Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 732
0.00% covered (danger)
0.00%
0 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
Panini2024Utils
0.00% covered (danger)
0.00%
0 / 732
0.00% covered (danger)
0.00%
0 / 33
22350
0.00% covered (danger)
0.00%
0 / 1
 parseSpec
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
56
 renderSingle
0.00% covered (danger)
0.00%
0 / 183
0.00% covered (danger)
0.00%
0 / 1
812
 render3x5Pages
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
156
 render4x4Zip
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 getAllEntries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 render4x4Pages
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
132
 cachePictureId
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getCachePathForPictureId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getCachePathForZip
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getBookPdf
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 addBookPage
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 drawPlaceholder
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 drawEntryInfobox
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
72
 drawText
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 convertString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderBookPages
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 getBookEntries
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 renderOlzPages
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getOlzEntries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getOlzPageXY
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 renderHistoryPages
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getHistoryEntries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getHistoryPageXY
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 renderDressesPages
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getDressesEntries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getDressesPageXY
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 renderMapsPages
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 getMapsEntries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getMapsPageXY
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
2
 renderBackPages
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getBackEntries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getBackPageXY
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 fromEnv
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Olz\Apps\Panini2024\Utils;
4
5use Olz\Entity\Panini2024\Panini2024Picture;
6use Olz\Utils\WithUtilsTrait;
7use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
8use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
9
10class Panini2024Utils {
11    use Panini2024UtilsTrait;
12    use WithUtilsTrait;
13
14    public const DPI = 900;
15    public const MM_PER_INCH = 25.4;
16
17    // This is the version with the self-printed foldable label paper:
18    // public const PANINI_SHORT = 42.8; // mm (50.8mm, 4mm margin)
19    // public const PANINI_LONG = 59; // mm (70mm, 5.5mm margin)
20
21    // This is the version with 4x4 pictures per A4, which need to be cut:
22    public const PANINI_SHORT = 45; // mm
23    public const PANINI_LONG = 65; // mm
24
25    public const ASSOCIATION_MAP = [
26        'Au ZH' => 'wappen/waedenswil.jpg',
27        'Adliswil' => 'wappen/adliswil.jpg',
28        'Hirzel' => 'wappen/hirzel.jpg',
29        'Horgen' => 'wappen/horgen.jpg',
30        'Kilchberg' => 'wappen/kilchberg.jpg',
31        'Langnau am Albis' => 'wappen/langnau_am_albis.jpg',
32        'Oberrieden' => 'wappen/oberrieden.jpg',
33        'Richterswil' => 'wappen/richterswil.jpg',
34        'Rüschlikon' => 'wappen/rueschlikon.jpg',
35        'Samstagern' => 'wappen/richterswil.jpg',
36        'Schönenberg' => 'wappen/waedenswil.jpg',
37        'Thalwil' => 'wappen/thalwil.jpg',
38        'Wädenswil' => 'wappen/waedenswil.jpg',
39        'Zürich' => 'wappen/zuerich.jpg',
40
41        'Einsiedeln' => 'wappen/einsiedeln.jpg',
42        'Küsnacht ZH' => 'wappen/kuesnacht_zh.jpg',
43        'Oberwil' => 'wappen/daegerlen.jpg',
44        'Maur' => 'wappen/maur.jpg',
45        'Niederurnen GL' => 'wappen/niederurnen.jpg',
46        'Pfäffikon ZH' => 'wappen/pfaeffikon_zh.jpg',
47        'Basel' => 'wappen/basel.jpg',
48        'Hausen am Albis' => 'wappen/hausen_am_albis.jpg',
49        'Wollerau' => 'wappen/wollerau.jpg',
50        'Riedikon' => 'wappen/uster.jpg',
51        'Winterthur' => 'wappen/winterthur.jpg',
52        'Landquart GR' => 'wappen/landquart_gr.jpg',
53        'Wolhusen LU' => 'wappen/wolhusen_lu.jpg',
54        'Wetzikon' => 'wappen/wetzikon.jpg',
55        'Affoltern am Albis' => 'wappen/affoltern_am_albis.jpg',
56        'Greifensee' => 'wappen/greifensee.jpg',
57        'St. Gallen' => 'wappen/st_gallen.jpg',
58        'Bern' => 'wappen/bern.jpg',
59        'Seewen SZ' => 'wappen/seewen_sz.jpg',
60    ];
61
62    /** @return array{0: array<array{ids?: array<int>}>, 1: array{grid?: bool}} */
63    public function parseSpec(string $spec, int $num_per_page): array {
64        $random_res = preg_match('/^random-([0-9]+)(-grid)?$/i', $spec, $random_matches);
65        if ($random_res) {
66            $num = intval($random_matches[1]);
67            $options = [
68                'grid' => ($random_matches[2] ?? '') === '-grid',
69            ];
70            $panini_repo = $this->entityManager()->getRepository(Panini2024Picture::class);
71            $all_ids = array_map(function ($picture) {
72                return $picture->getId();
73            }, $panini_repo->findAll());
74            $ids_len = count($all_ids);
75            assert($ids_len > 0);
76            $pages = [];
77            for ($p = 0; $p < $num; $p++) {
78                $ids = [];
79                for ($i = 0; $i < 16; $i++) {
80                    $id = $all_ids[random_int(0, $ids_len - 1)];
81                    $this->generalUtils()->checkNotNull($id, "ID was null");
82                    $ids[] = $id;
83                }
84                $pages[] = ['ids' => $ids];
85            }
86            return [$pages, $options];
87        }
88        $duplicate_res = preg_match('/^duplicate-([0-9]+)(-grid)?$/i', $spec, $duplicate_matches);
89        if ($duplicate_res) {
90            $id = intval($duplicate_matches[1]);
91            $ids = [];
92            for ($i = 0; $i < $num_per_page; $i++) {
93                $ids[] = $id;
94            }
95            $options = [
96                'grid' => ($duplicate_matches[2] ?? '') === '-grid',
97            ];
98            $pages = [
99                ['ids' => $ids],
100            ];
101            return [$pages, $options];
102        }
103        $pattern_param = $num_per_page - 1;
104        $pattern = "/^((?:[0-9]+,){{$pattern_param}}[0-9]+)(-grid)?$/i";
105        $list_res = preg_match($pattern, $spec, $list_matches);
106        if ($list_res) {
107            $ids = array_map(function ($idstr) {
108                return intval($idstr);
109            }, explode(',', $list_matches[1]));
110            $options = [
111                'grid' => ($list_matches[2] ?? '') === '-grid',
112            ];
113            $pages = [
114                ['ids' => $ids],
115            ];
116            return [$pages, $options];
117        }
118        throw new NotFoundHttpException("Invalid spec: {$spec} ({$pattern})");
119    }
120
121    public function renderSingle(int $id): string {
122        $entity_manager = $this->dbUtils()->getEntityManager();
123        $data_path = $this->envUtils()->getDataPath();
124        $panini_path = "{$data_path}panini_data/";
125        $masks_path = "{$panini_path}masks/";
126
127        $panini_repo = $entity_manager->getRepository(Panini2024Picture::class);
128        $picture = $panini_repo->findOneBy(['id' => $id]);
129        if (!$picture) {
130            throw new NotFoundHttpException("Kein solches Panini vorhanden");
131        }
132        $owner = $picture->getOwnerUser();
133        $is_landscape = $picture->getIsLandscape();
134        $has_top = $picture->getHasTop();
135        $lp_suffix = $is_landscape ? 'L' : 'P';
136        $img_src = $picture->getImgSrc();
137        $img_style = $picture->getImgStyle();
138        $line1 = $picture->getLine1();
139        $line2 = $picture->getLine2();
140        $association = $picture->getAssociation();
141        $res_wid_percent = preg_match('/width\:\s*([\-0-9\.]+)%/i', $img_style, $matches);
142        $img_wid_percent = floatval($res_wid_percent ? $matches[1] : 100);
143        $res_top_percent = preg_match('/top\:\s*([\-0-9\.]+)%/i', $img_style, $matches);
144        $img_top_percent = floatval($res_top_percent ? $matches[1] : 100);
145        $res_left_percent = preg_match('/left\:\s*([\-0-9\.]+)%/i', $img_style, $matches);
146        $img_left_percent = floatval($res_left_percent ? $matches[1] : 100);
147
148        $has_panini = $this->authUtils()->hasPermission('panini2024');
149        $current_user = $this->authUtils()->getCurrentUser();
150        $is_mine = $owner && $current_user && ($owner->getId() === $current_user->getId());
151        if (!$has_panini && !$is_mine) {
152            throw new AccessDeniedHttpException("Kein Zugriff");
153        }
154
155        $wid = max(1, intval(round(($is_landscape ? self::PANINI_LONG : self::PANINI_SHORT)
156        * self::DPI / self::MM_PER_INCH)));
157        $hei = max(1, intval(round(($is_landscape ? self::PANINI_SHORT : self::PANINI_LONG)
158            * self::DPI / self::MM_PER_INCH)));
159        $suffix = "{$lp_suffix}_{$wid}x{$hei}";
160        $payload_folder = (intval($id) >= 1000) ? "portraits/{$id}/" : '';
161        $payload_path = "{$panini_path}{$payload_folder}{$img_src}";
162        $bottom_mask_path = "{$masks_path}bottom{$suffix}.png";
163        $top_mask_path = "{$masks_path}top{$suffix}.png";
164        $association_mask_path = "{$masks_path}association{$suffix}.png";
165        $flag_mask_path = "{$masks_path}associationStencil{$suffix}.png";
166        $association_file = self::ASSOCIATION_MAP[$association] ?? null;
167        $association_img_orig_path = "{$panini_path}{$association_file}";
168
169        $ident = json_encode([
170            $is_landscape,
171            $has_top,
172            $img_src,
173            $img_style,
174            $line1,
175            $line2,
176            $association,
177            filemtime($payload_path),
178            filemtime($bottom_mask_path),
179            filemtime($top_mask_path),
180            filemtime($association_mask_path),
181            filemtime($flag_mask_path),
182            filemtime($association_img_orig_path),
183            md5(file_get_contents(__FILE__) ?: ''),
184        ]) ?: '[]';
185        $md5 = md5($ident);
186        $cache_file = "{$panini_path}cache/{$id}-{$md5}.jpg";
187        if (is_file($cache_file)) {
188            $this->log()->info("Read from cache: {$id}-{$md5}.jpg");
189            return file_get_contents($cache_file) ?: '';
190        }
191
192        $img = imagecreatetruecolor($wid, $hei);
193        $this->generalUtils()->checkNotFalse($img, "renderSingle: Could not create img");
194        gc_collect_cycles();
195
196        // Payload
197        $payload_img = imagecreatefromjpeg($payload_path);
198        if ($payload_img) {
199            $payload_wid = imagesx($payload_img);
200            $payload_hei = imagesy($payload_img);
201            imagecopyresampled(
202                $img,
203                $payload_img,
204                intval(round($img_left_percent * $wid / 100)),
205                intval(round($img_top_percent * $hei / 100)),
206                0,
207                0,
208                intval(round($wid * $img_wid_percent / 100)),
209                intval(round($wid * $img_wid_percent * $payload_hei / $payload_wid / 100)),
210                $payload_wid,
211                $payload_hei,
212            );
213            imagedestroy($payload_img);
214            gc_collect_cycles();
215        }
216
217        // Masks
218        $bottom_mask = imagecreatefrompng($bottom_mask_path);
219        $this->generalUtils()->checkNotFalse($bottom_mask, "renderSingle: Could not read bottom_mask");
220        imagecopy($img, $bottom_mask, 0, 0, 0, 0, $wid, $hei);
221        imagedestroy($bottom_mask);
222        gc_collect_cycles();
223        if ($has_top) {
224            $top_mask = imagecreatefrompng($top_mask_path);
225            $this->generalUtils()->checkNotFalse($top_mask, "renderSingle: Could not create top_mask");
226            imagecopy($img, $top_mask, 0, 0, 0, 0, $wid, $hei);
227            imagedestroy($top_mask);
228            gc_collect_cycles();
229        }
230
231        // Association
232        if ($association_file) {
233            $association_mask = imagecreatefrompng($association_mask_path);
234            $this->generalUtils()->checkNotFalse($association_mask, "renderSingle: Could not create association_mask");
235            imagecopy($img, $association_mask, 0, 0, 0, 0, $wid, $hei);
236            imagedestroy($association_mask);
237            gc_collect_cycles();
238
239            $offset = intval(round(($wid + $hei) * 0.01) - 1);
240            $size = max(1, intval(round(($wid + $hei) * 0.09) + 1));
241
242            $flag_mask = imagecreatefrompng($flag_mask_path);
243            $this->generalUtils()->checkNotFalse($flag_mask, "renderSingle: Could not create flag_mask");
244
245            $association_img = imagecreatetruecolor($size, $size);
246            $this->generalUtils()->checkNotFalse($association_img, "renderSingle: Could not create association_img");
247            $association_img_orig = imagecreatefromjpeg($association_img_orig_path);
248            $this->generalUtils()->checkNotFalse($association_img_orig, "renderSingle: Could not read association_img_orig");
249            imagecopyresampled(
250                $association_img,
251                $association_img_orig,
252                0,
253                0,
254                0,
255                0,
256                $size,
257                $size,
258                imagesx($association_img_orig),
259                imagesy($association_img_orig),
260            );
261            imagedestroy($association_img_orig);
262            gc_collect_cycles();
263
264            for ($x = 0; $x < $size; $x++) {
265                for ($y = 0; $y < $size; $y++) {
266                    $flag_color_at = imagecolorat($flag_mask, $x + $offset, $y + $offset);
267                    $this->generalUtils()->checkNotFalse($flag_color_at, "renderSingle: Could not get flag_color_at");
268                    $mask = imagecolorsforindex($flag_mask, $flag_color_at);
269                    if ($mask['red'] > 0) {
270                        $ratio = floatval($mask['red']) / 255.0;
271                        $association_color_at = imagecolorat($association_img, $x, $y);
272                        $this->generalUtils()->checkNotFalse($association_color_at, "renderSingle: Could not get association_color_at");
273                        $src = imagecolorsforindex($association_img, $association_color_at);
274                        $src_r = floatval($src['red']);
275                        $src_g = floatval($src['green']);
276                        $src_b = floatval($src['blue']);
277                        $color_at = imagecolorat($img, $x + $offset, $y + $offset);
278                        $this->generalUtils()->checkNotFalse($color_at, "renderSingle: Could not get color_at");
279                        $dst = imagecolorsforindex($img, $color_at);
280                        $dst_r = floatval($dst['red']);
281                        $dst_g = floatval($dst['green']);
282                        $dst_b = floatval($dst['blue']);
283                        $color = imagecolorallocate(
284                            $img,
285                            max(0, min(255, intval($src_r * $ratio + $dst_r * (1 - $ratio)))),
286                            max(0, min(255, intval($src_g * $ratio + $dst_g * (1 - $ratio)))),
287                            max(0, min(255, intval($src_b * $ratio + $dst_b * (1 - $ratio)))),
288                        );
289                        $this->generalUtils()->checkNotFalse($color, "renderSingle: Could not allocate color");
290                        imagesetpixel($img, $x + $offset, $y + $offset, $color);
291                        imagecolordeallocate($img, $color);
292                    }
293                }
294            }
295            imagedestroy($flag_mask);
296            imagedestroy($association_img);
297            gc_collect_cycles();
298        }
299
300        // Text
301        $size = ($hei + ($is_landscape ? $wid : $hei)) * 0.018;
302        $yellow = imagecolorallocate($img, 255, 255, 0);
303        $this->generalUtils()->checkNotFalse($yellow, "renderSingle: Could not create yellow");
304        $shady = imagecolorallocatealpha($img, 0, 0, 0, 64);
305        $this->generalUtils()->checkNotFalse($shady, "renderSingle: Could not create shady");
306        $shoff = $size / 10;
307        $font_path = "{$panini_path}fonts/OpenSans/OpenSans-SemiBold.ttf";
308        $box = imagettfbbox($size, 0, $font_path, $line1);
309        $textwid = $box[2] ?? 0;
310        $x = ($line2 ? $wid * 0.95 - $textwid : $wid / 2 - $textwid / 2);
311        $y = $hei * ($is_landscape ? 0.95 : ($line2 ? 0.915 : 0.945));
312        $this->drawText($img, $size, 0, $x + $shoff, $y + $shoff, $shady, $font_path, $line1);
313        $this->drawText($img, $size, 0, $x, $y, $yellow, $font_path, $line1);
314        if ($line2) {
315            $box = imagettfbbox($size, 0, $font_path, $line2);
316            $textwid = $box[2] ?? 0;
317            $x = $wid * 0.95 - $textwid;
318            $y = $hei * 0.975;
319            $this->drawText($img, $size, 0, $x + $shoff, $y + $shoff, $shady, $font_path, $line2);
320            $this->drawText($img, $size, 0, $x, $y, $yellow, $font_path, $line2);
321        }
322        gc_collect_cycles();
323
324        ob_start();
325        imagejpeg($img, null, 90);
326        $image_data = ob_get_contents();
327        ob_end_clean();
328        imagedestroy($img);
329        $this->generalUtils()->checkNotFalse($image_data, "Could not retrieve image data");
330        file_put_contents($cache_file, $image_data);
331        $this->log()->info("Written to cache: {$id}-{$md5}.jpg");
332        return $image_data;
333    }
334
335    /**
336     * @param array<array{ids?: array<int>}> $pages
337     * @param array{grid?: bool}             $options
338     */
339    public function render3x5Pages(array $pages, array $options): string {
340        if (!$this->authUtils()->hasPermission('panini2024')) {
341            throw new NotFoundHttpException();
342        }
343
344        $grid = (bool) ($options['grid'] ?? false);
345        $x_step = 70;
346        $x_margin = 5.5;
347        $x_offset = 0;
348        $y_step = 50.8;
349        $y_offset = 21.5;
350        $y_margin = 4;
351
352        foreach ($pages as $page) {
353            $ids = $page['ids'] ?? [];
354            foreach ($ids as $id) {
355                $this->cachePictureId($id);
356            }
357        }
358
359        $pdf = new \TCPDF('P', 'mm', 'A4');
360        $pdf->setAutoPageBreak(false, 0);
361        $pdf->setPrintHeader(false);
362        $pdf->setPrintFooter(false);
363        foreach ($pages as $page) {
364            $ids = $page['ids'] ?? [];
365            $pdf->AddPage();
366
367            if ($grid) {
368                $pdf->SetDrawColor(0, 0, 0);
369                $pdf->SetLineWidth(0.1);
370                for ($x = 1; $x < 3; $x++) {
371                    $pdf->Line(
372                        $x_offset + $x_step * $x,
373                        $y_offset + $y_step * 0,
374                        $x_offset + $x_step * $x,
375                        $y_offset + $y_step * 5,
376                    );
377                }
378                for ($y = 0; $y < 6; $y++) {
379                    $pdf->Line(
380                        $x_offset + $x_step * 0,
381                        $y_offset + $y_step * $y,
382                        $x_offset + $x_step * 3,
383                        $y_offset + $y_step * $y,
384                    );
385                }
386            }
387
388            $index = 0;
389            for ($y = 0; $y < 5; $y++) {
390                for ($x = 0; $x < 3; $x++) {
391                    if ($y !== 2) {
392                        $id = $ids[$index] ?? 0;
393                        $temp_file_path = $this->getCachePathForPictureId($id);
394                        if ($temp_file_path) {
395                            $pdf->Image(
396                                $temp_file_path,
397                                $x_offset + $x_margin + $x_step * $x,
398                                $y_offset + $y_margin + $y_step * $y,
399                                $x_step - $x_margin * 2,
400                            );
401                        }
402                        $index++;
403                    }
404                }
405            }
406        }
407        return $pdf->Output('3x5.pdf', 'S');
408    }
409
410    /** @param array{grid?: bool} $options */
411    public function render4x4Zip(array $options): string {
412        $grid_or_empty = ($options['grid'] ?? false) ? '-grid' : '';
413        $ids = $this->getAllEntries();
414
415        $panini_utils = $this->paniniUtils();
416        $zip = new \ZipArchive();
417        $zip_path = $panini_utils->getCachePathForZip("duplicates{$grid_or_empty}");
418        if ($zip->open($zip_path, \ZipArchive::CREATE) !== true) {
419            throw new \Exception("Could not open Zip");
420        }
421        foreach ($ids as $id) {
422            $spec = "duplicate-{$id}{$grid_or_empty}";
423            $pdf_out = null;
424            [$pages, $options] = $this->parseSpec($spec, /* num_per_page= */ 16);
425            $pdf_out = $panini_utils->render4x4Pages($pages, $options);
426            if (!$pdf_out) {
427                throw new \Exception("PDF generation failed for ID: {$id}");
428            }
429            $zip->addFromString("{$spec}.pdf", $pdf_out);
430            gc_collect_cycles();
431        }
432        $zip->close();
433
434        $content = file_get_contents($zip_path) ?: '';
435        @unlink($zip_path);
436        return $content;
437    }
438
439    /** @return array<int> */
440    private function getAllEntries(): array {
441        $ids = [];
442
443        $db = $this->dbUtils()->getDb();
444        $result_olz = $db->query("SELECT id FROM panini24 ORDER BY id ASC");
445        // @phpstan-ignore-next-line
446        for ($i = 0; $i < $result_olz->num_rows; $i++) {
447            // @phpstan-ignore-next-line
448            $row_olz = $result_olz->fetch_assoc();
449            // @phpstan-ignore-next-line
450            $ids[] = intval($row_olz['id']);
451        }
452        return $ids;
453    }
454
455    /**
456     * @param array<array{ids?: array<int>}> $pages
457     * @param array{grid?: bool}             $options
458     */
459    public function render4x4Pages(array $pages, array $options): string {
460        if (!$this->authUtils()->hasPermission('panini2024')) {
461            throw new NotFoundHttpException();
462        }
463
464        $grid = (bool) ($options['grid'] ?? false);
465        $x_step = 48;
466        $x_margin = 2;
467        $x_offset = 9;
468        $y_step = 68;
469        $y_offset = 12.5;
470        $y_margin = 2;
471
472        foreach ($pages as $page) {
473            $ids = $page['ids'] ?? [];
474            foreach (array_unique($ids) as $id) {
475                $this->cachePictureId($id);
476            }
477        }
478
479        $pdf = new \TCPDF('P', 'mm', 'A4');
480        $pdf->setAutoPageBreak(false, 0);
481        $pdf->setPrintHeader(false);
482        $pdf->setPrintFooter(false);
483        foreach ($pages as $page) {
484            $ids = $page['ids'] ?? [];
485            $pdf->AddPage();
486
487            if ($grid) {
488                $pdf->SetDrawColor(0, 0, 0);
489                $pdf->SetLineWidth(0.1);
490                for ($x = 0; $x < 5; $x++) {
491                    $line_x = $x_offset + $x_step * $x;
492                    $pdf->Line($line_x, 0, $line_x, $y_offset - 1);
493                    $pdf->Line($line_x, 297, $line_x, 297 - $y_offset + 1);
494                }
495                for ($y = 0; $y < 5; $y++) {
496                    $line_y = $y_offset + $y_step * $y;
497                    $pdf->Line(0, $line_y, $x_offset - 1, $line_y);
498                    $pdf->Line(210, $line_y, 210 - $x_offset + 1, $line_y);
499                }
500            }
501
502            $index = 0;
503            for ($y = 0; $y < 4; $y++) {
504                for ($x = 0; $x < 4; $x++) {
505                    $id = $ids[$index] ?? 0;
506                    $temp_file_path = $this->getCachePathForPictureId($id);
507                    if ($temp_file_path) {
508                        $pdf->Image(
509                            $temp_file_path,
510                            $x_offset + $x_margin + $x_step * $x,
511                            $y_offset + $y_margin + $y_step * $y,
512                            $x_step - $x_margin * 2,
513                        );
514                    }
515                    $index++;
516                }
517            }
518        }
519        return $pdf->Output('4x4.pdf', 'S');
520    }
521
522    private function cachePictureId(int $id): void {
523        if ($id === 0) {
524            return;
525        }
526        $temp_file_path = $this->getCachePathForPictureId($id);
527        $img = imagecreatefromstring($this->renderSingle($id));
528        $this->generalUtils()->checkNotFalse($img, "cachePictureId: Could create image from string (ID:{$id})");
529        $wid = imagesx($img);
530        $hei = imagesy($img);
531        if ($hei < $wid) {
532            $img = imagerotate($img, 90, 0);
533            $this->generalUtils()->checkNotFalse($img, "cachePictureId: Failed rotating");
534        }
535        imagejpeg($img, $temp_file_path);
536        imagedestroy($img);
537        gc_collect_cycles();
538    }
539
540    private function getCachePathForPictureId(int $id): ?string {
541        if ($id === 0) {
542            return null;
543        }
544        $data_path = $this->envUtils()->getDataPath();
545        $temp_path = "{$data_path}temp/";
546        if (!is_dir($temp_path)) {
547            mkdir($temp_path, 0o777, true);
548        }
549        return "{$temp_path}paninipdf-{$id}.jpg";
550    }
551
552    public function getCachePathForZip(string $ident): string {
553        $data_path = $this->envUtils()->getDataPath();
554        $temp_path = "{$data_path}temp/";
555        if (!is_dir($temp_path)) {
556            mkdir($temp_path, 0o777, true);
557        }
558        return "{$temp_path}paninizip-{$ident}.zip";
559    }
560
561    // --- BOOK ------------------------------------------------------------------------------------
562
563    private function getBookPdf(): \TCPDF {
564        $data_path = $this->envUtils()->getDataPath();
565        $panini_path = "{$data_path}panini_data/";
566
567        $pdf = new \TCPDF('P', 'mm', 'A4');
568        $pdf->setAutoPageBreak(false, 0);
569        $pdf->setPrintHeader(false);
570        $pdf->setPrintFooter(false);
571
572        $font_path = "{$panini_path}fonts/OpenSans/OpenSans-SemiBold.ttf";
573        $fontname = \TCPDF_FONTS::addTTFfont($font_path, 'TrueTypeUnicode');
574        if (!$fontname) {
575            throw new \Exception("Error with font {$font_path}");
576        }
577        $pdf->SetFont($fontname);
578
579        return $pdf;
580    }
581
582    private function addBookPage(\TCPDF $pdf): void {
583        $pdf->AddPage();
584        $mainbg_path = __DIR__.'/../../../../assets/icns/mainbg.png';
585        $info = getimagesize($mainbg_path);
586        $wid_px = $info[0] ?? 0;
587        $hei_px = $info[1] ?? 0;
588        $dpi = 150;
589        $wid_mm = $wid_px / $dpi * self::MM_PER_INCH;
590        $hei_mm = $hei_px / $dpi * self::MM_PER_INCH;
591        for ($tile_x = 0; $tile_x < ceil(210 / $wid_mm); $tile_x++) {
592            for ($tile_y = 0; $tile_y < ceil(297 / $hei_mm); $tile_y++) {
593                $pdf->Image(
594                    $mainbg_path,
595                    $tile_x * $wid_mm,
596                    $tile_y * $hei_mm,
597                    $wid_mm,
598                );
599            }
600        }
601    }
602
603    /** @param array<string, mixed> $entry */
604    private function drawPlaceholder(
605        \TCPDF $pdf,
606        array $entry,
607        float $x,
608        float $y,
609        float $wid,
610        float $hei,
611    ): void {
612        $is_landcape = $wid > $hei;
613
614        $pdf->SetLineWidth(0.1);
615        $pdf->SetDrawColor(200, 200, 200);
616        $pdf->SetFillColor(255, 255, 255);
617        $pdf->Rect($x, $y, $wid, $hei, 'DF');
618
619        $pdf->SetTextColor(200, 200, 200);
620        $pdf->SetFontSize($is_landcape ? 7.75 : 8.75);
621        $line1 = $this->convertString($entry['line1']);
622        $line2 = $this->convertString($entry['line2']);
623        $has_line2 = $line2 !== '';
624        $align = $has_line2 ? 'R' : 'C';
625        $pdf->setXY($x, $y + $hei - ($has_line2 ? 10 : 8));
626        $pdf->Cell($wid, 5, $line1, 0, 0, $align);
627        if ($has_line2) {
628            $pdf->setXY($x, $y + $hei - 6);
629            $pdf->Cell($wid, 5, $line2, 0, 0, $align);
630        }
631    }
632
633    /** @param array<string, mixed> $entry */
634    private function drawEntryInfobox(
635        \TCPDF $pdf,
636        array $entry,
637        float $x,
638        float $y,
639        float $wid,
640        float $hei,
641    ): void {
642        $pdf->SetLineWidth(0.1);
643        $pdf->SetDrawColor(0, 117, 33);
644        $pdf->SetFillColor(212, 231, 206);
645        $pdf->Rect($x, $y, $wid, $hei, 'F');
646        $line_y = $y;
647        $pdf->Line($x, $line_y, $x + $wid, $line_y);
648        $line_y = $y + $hei;
649        $pdf->Line($x, $line_y, $x + $wid, $line_y);
650
651        $pdf->SetFontSize(11);
652        $birthday = $entry['birthdate'] ?? '';
653        if (substr($birthday, 4) === '-00-00') {
654            $birthday = substr($birthday, 0, 4);
655        }
656        if ($entry['birthdate'] === null) { // No information => ERROR!
657            $pdf->SetTextColor(255, 0, 0);
658            $birthday = '!!!';
659        } elseif (substr($birthday, 0, 4) === '0000') { // Year Zero => Do not show
660            $birthday = '';
661        } else {
662            $pdf->SetTextColor(0, 117, 33);
663            if (strlen($birthday) === 10) {
664                $birthday = date('d.m.Y', strtotime($birthday) ?: 0);
665            }
666        }
667        $pdf->setXY($x, $y + 1);
668        $pdf->Cell($wid * 0.7 - 2, 5, $birthday);
669
670        $pdf->SetFontSize(14);
671        $num_mispunch = strval($entry['num_mispunches']);
672        if ($entry['num_mispunches'] === null) { // No information => ERROR!
673            $pdf->SetTextColor(255, 0, 0);
674            $num_mispunch = '!!!';
675        } elseif (intval($entry['num_mispunches']) < 0) { // Negative count => Do not show
676            $num_mispunch = '';
677        } else {
678            $pdf->SetTextColor(0, 117, 33);
679        }
680        $pdf->setXY($x + $wid * 0.7, $y + 1);
681        $pdf->Cell($wid * 0.3, 5, $num_mispunch, 0, 0, 'R');
682
683        $pdf->SetTextColor(0, 117, 33);
684        $pdf->SetFontSize(8);
685        $infos = json_decode($entry['infos'], true) ?? [];
686        $favourite_map = $this->convertString($infos[0] ?? '');
687        $pdf->setXY($x, $y + 6);
688        $pdf->Cell($wid, 4, $favourite_map);
689        $since_when = $this->convertString($infos[3] ?? '');
690        $pdf->setXY($x, $y + 10);
691        $pdf->Cell($wid, 4, $since_when);
692        $motto = $this->convertString($infos[4] ?? '');
693        $pdf->setXY($x, $y + 14);
694        $pdf->Multicell($wid, 11, $motto, 0, 'L');
695    }
696
697    /** @param array{} $options */
698    private function drawText(
699        \GdImage $image,
700        float $size,
701        float $angle,
702        int|float $x,
703        int|float $y,
704        int $color,
705        string $font_filename,
706        string $text,
707        array $options = [],
708    ): void {
709        $x = intval(round($x));
710        $y = intval(round($y));
711        imagettftext($image, $size, $angle, $x, $y, $color, $font_filename, $text, $options);
712    }
713
714    private function convertString(?string $string): string {
715        return $string ?? '';
716    }
717
718    public function renderBookPages(): string {
719        if (!$this->authUtils()->hasPermission('panini2024')) {
720            throw new NotFoundHttpException();
721        }
722        $pdf = $this->getBookPdf();
723        $entries = $this->getBookEntries();
724
725        $x_step = 46;
726        $x_margin = 1;
727        $x_offset = 13;
728        $y_step = 92;
729        $y_offset = 10.5;
730        $y_margin = 1;
731        $y_box = 26;
732
733        $index = 0;
734        foreach ($entries as $entry) {
735            if (($index % 12) === 0) {
736                $this->addBookPage($pdf);
737            }
738            $x_index = $index % 4;
739            $y_index = floor($index / 4) % 3;
740
741            $x = $x_offset + $x_margin + $x_step * $x_index;
742            $y = $y_offset + $y_margin + $y_step * $y_index;
743            $wid = $x_step - $x_margin * 2;
744
745            $placeholder_hei = $y_step - $y_box - $y_margin * 2;
746            $this->drawPlaceholder($pdf, $entry, $x, $y, $wid, $placeholder_hei);
747
748            $box_y = $y + $y_step - $y_box - $y_margin;
749            $box_hei = $y_box - $y_margin;
750            $this->drawEntryInfobox($pdf, $entry, $x, $box_y, $wid, $box_hei);
751
752            $index++;
753        }
754        return $pdf->Output('book.pdf', 'S');
755    }
756
757    /** @return array<array<string, mixed>> */
758    private function getBookEntries(): array {
759        $entries = [];
760
761        $db = $this->dbUtils()->getDb();
762        $result_associations = $db->query("SELECT *, (img_src = 'wappen/other.jpg') AS is_other FROM panini24 WHERE img_src LIKE 'wappen/%' ORDER BY is_other ASC, line1 ASC");
763        $esc_associations = [];
764        // @phpstan-ignore-next-line
765        for ($i = 0; $i < $result_associations->num_rows; $i++) {
766            // @phpstan-ignore-next-line
767            $row_association = $result_associations->fetch_assoc();
768            $entries[] = $row_association;
769
770            if ($row_association['is_other'] ?? false) {
771                $sql = implode("', '", $esc_associations);
772                $result_portraits = $db->query("SELECT * FROM panini24 WHERE association NOT IN ('{$sql}') ORDER BY line2 ASC, line1 ASC");
773                // @phpstan-ignore-next-line
774                for ($j = 0; $j < $result_portraits->num_rows; $j++) {
775                    // @phpstan-ignore-next-line
776                    $row_portrait = $result_portraits->fetch_assoc();
777                    $entries[] = $row_portrait;
778                }
779            } else {
780                $line1 = $row_association['line1'] ?? '';
781                $esc_association = $db->real_escape_string("{$line1}");
782                $esc_associations[] = $esc_association;
783                $result_portraits = $db->query("SELECT * FROM panini24 WHERE association = '{$esc_association}' ORDER BY line2 ASC, line1 ASC");
784                // @phpstan-ignore-next-line
785                for ($j = 0; $j < $result_portraits->num_rows; $j++) {
786                    // @phpstan-ignore-next-line
787                    $row_portrait = $result_portraits->fetch_assoc();
788                    $entries[] = $row_portrait;
789                }
790            }
791        }
792        // @phpstan-ignore-next-line
793        return $entries;
794    }
795
796    public function renderOlzPages(): string {
797        if (!$this->authUtils()->hasPermission('panini2024')) {
798            throw new NotFoundHttpException();
799        }
800        $pdf = $this->getBookPdf();
801        $entries = $this->getOlzEntries();
802
803        $index = 0;
804        $last_page = 0;
805        foreach ($entries as $entry) {
806            [$page, $x, $y] = $this->getOlzPageXY($index);
807            for ($p = $last_page; $p < $page; $p++) {
808                $this->addBookPage($pdf);
809            }
810            $last_page = $page;
811
812            $short = 44;
813            $long = 64;
814            $wid = $entry['is_landscape'] ? $long : $short;
815            $hei = $entry['is_landscape'] ? $short : $long;
816
817            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
818
819            $index++;
820        }
821        return $pdf->Output('olz.pdf', 'S');
822    }
823
824    /** @return array<array<string, mixed>> */
825    private function getOlzEntries(): array {
826        $entries = [];
827
828        $db = $this->dbUtils()->getDb();
829        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 10 AND id < 20 ORDER BY id ASC");
830        // @phpstan-ignore-next-line
831        for ($i = 0; $i < $result_olz->num_rows; $i++) {
832            // @phpstan-ignore-next-line
833            $row_olz = $result_olz->fetch_assoc();
834            $entries[] = $row_olz;
835        }
836        // @phpstan-ignore-next-line
837        return $entries;
838    }
839
840    /** @return array{0: int, 1: float, 2: float} */
841    private function getOlzPageXY(int $index): array {
842        $a4_wid = 210;
843        $olz = [
844            [1, $a4_wid * 0.75, 10.5 + 22],
845            [1, $a4_wid * 0.25, 10.5 + 44 + 10.5 + 32],
846            [1, $a4_wid * 0.8, 10.5 + 44 + 10.5 + 32],
847            [1, $a4_wid * 0.2, 10.5 + 44 + 10.5 + 64 + 10.5 + 32],
848            [2, $a4_wid * 0.8, 10.5 + 44 + 10.5 + 64 + 10.5 + 64 + 10.5 + 32],
849            [2, $a4_wid * 0.25, 10.5 + 22],
850            [2, $a4_wid * 0.75, 10.5 + 44 + 10.5 + 32],
851            [2, $a4_wid * 0.2, 10.5 + 44 + 10.5 + 64 + 10.5 + 32],
852            [2, $a4_wid * 0.25, 10.5 + 44 + 10.5 + 64 + 10.5 + 64 + 10.5 + 32],
853        ];
854        return $olz[$index];
855    }
856
857    public function renderHistoryPages(): string {
858        if (!$this->authUtils()->hasPermission('panini2024')) {
859            throw new NotFoundHttpException();
860        }
861        $pdf = $this->getBookPdf();
862        $entries = $this->getHistoryEntries();
863
864        $index = 0;
865        $last_page = 0;
866        foreach ($entries as $entry) {
867            [$page, $x, $y] = $this->getHistoryPageXY($index);
868            for ($p = $last_page; $p < $page; $p++) {
869                $this->addBookPage($pdf);
870            }
871            $last_page = $page;
872
873            $short = 44;
874            $long = 64;
875            $wid = $entry['is_landscape'] ? $long : $short;
876            $hei = $entry['is_landscape'] ? $short : $long;
877
878            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
879
880            $index++;
881        }
882        return $pdf->Output('history.pdf', 'S');
883    }
884
885    /** @return array<array<string, mixed>> */
886    private function getHistoryEntries(): array {
887        $entries = [];
888
889        $db = $this->dbUtils()->getDb();
890        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 50 AND id < 100 ORDER BY id ASC");
891        // @phpstan-ignore-next-line
892        for ($i = 0; $i < $result_olz->num_rows; $i++) {
893            // @phpstan-ignore-next-line
894            $row_olz = $result_olz->fetch_assoc();
895            $entries[] = $row_olz;
896        }
897        // @phpstan-ignore-next-line
898        return $entries;
899    }
900
901    /** @return array{0: int, 1: float, 2: float} */
902    private function getHistoryPageXY(int $index): array {
903        $a4_wid = 210;
904        $olz = [
905            [1, $a4_wid * 0.75, 5.5 + 32 * 1],
906            [1, $a4_wid * 0.25, 5.5 + 32 * 2],
907            [1, $a4_wid * 0.75, 5.5 + 32 * 3],
908            [1, $a4_wid * 0.25, 5.5 + 32 * 4],
909            [1, $a4_wid * 0.75, 5.5 + 32 * 5],
910            [1, $a4_wid * 0.25, 5.5 + 32 * 6],
911            [1, $a4_wid * 0.75, 5.5 + 32 * 7],
912            [1, $a4_wid * 0.25, 5.5 + 32 * 8],
913            [2, $a4_wid * 0.75, 5.5 + 32 * 8],
914            [2, $a4_wid * 0.25, 5.5 + 32 * 7],
915            [2, $a4_wid * 0.75, 5.5 + 32 * 6],
916            [2, $a4_wid * 0.25, 5.5 + 32 * 5],
917            [2, $a4_wid * 0.75, 5.5 + 32 * 4],
918            [2, $a4_wid * 0.25, 5.5 + 32 * 3],
919            [2, $a4_wid * 0.75, 5.5 + 32 * 2],
920            [2, $a4_wid * 0.25, 5.5 + 32 * 1],
921        ];
922        return $olz[$index];
923    }
924
925    public function renderDressesPages(): string {
926        if (!$this->authUtils()->hasPermission('panini2024')) {
927            throw new NotFoundHttpException();
928        }
929        $pdf = $this->getBookPdf();
930        $entries = $this->getDressesEntries();
931
932        $index = 0;
933        $last_page = 0;
934        foreach ($entries as $entry) {
935            [$page, $x, $y] = $this->getDressesPageXY($index);
936            for ($p = $last_page; $p < $page; $p++) {
937                $this->addBookPage($pdf);
938            }
939            $last_page = $page;
940
941            $short = 44;
942            $long = 64;
943            $wid = $entry['is_landscape'] ? $long : $short;
944            $hei = $entry['is_landscape'] ? $short : $long;
945
946            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
947
948            $index++;
949        }
950        return $pdf->Output('dresses.pdf', 'S');
951    }
952
953    /** @return array<array<string, mixed>> */
954    private function getDressesEntries(): array {
955        $entries = [];
956
957        $db = $this->dbUtils()->getDb();
958        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 40 AND id < 50 ORDER BY id ASC");
959        // @phpstan-ignore-next-line
960        for ($i = 0; $i < $result_olz->num_rows; $i++) {
961            // @phpstan-ignore-next-line
962            $row_olz = $result_olz->fetch_assoc();
963            $entries[] = $row_olz;
964        }
965        // @phpstan-ignore-next-line
966        return $entries;
967    }
968
969    /** @return array{0: int, 1: float, 2: float} */
970    private function getDressesPageXY(int $index): array {
971        $a4_wid = 210;
972        $olz = [
973            [1, $a4_wid * 0.25, 42 + 22],
974            [1, $a4_wid * 0.75, 42 + 44 - 6 + 22],
975            [1, $a4_wid * 0.25, 42 + 44 - 6 + 44 - 6 + 22],
976            [1, $a4_wid * 0.75, 42 + 44 - 6 + 44 - 6 + 44 - 6 + 22],
977            [1, $a4_wid * 0.25, 42 + 44 - 6 + 44 - 6 + 44 - 6 + 44 - 6 + 22],
978            [1, $a4_wid * 0.75, 42 + 44 - 6 + 44 - 6 + 44 - 6 + 44 - 6 + 44 - 6 + 22],
979        ];
980        return $olz[$index];
981    }
982
983    public function renderMapsPages(): string {
984        if (!$this->authUtils()->hasPermission('panini2024')) {
985            throw new NotFoundHttpException();
986        }
987        $pdf = $this->getBookPdf();
988        $entries = $this->getMapsEntries();
989
990        $index = 0;
991        $last_page = 0;
992        foreach ($entries as $entry) {
993            [$page, $x, $y] = $this->getMapsPageXY($index);
994            for ($p = $last_page; $p < $page; $p++) {
995                $this->addBookPage($pdf);
996            }
997            $last_page = $page;
998
999            $short = 44;
1000            $long = 64;
1001            $wid = $entry['is_landscape'] ? $long : $short;
1002            $hei = $entry['is_landscape'] ? $short : $long;
1003
1004            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
1005
1006            $this->drawEntryInfobox($pdf, $entry, $x - $wid / 2, $y + $hei / 2 + 1, $wid, 25);
1007
1008            $index++;
1009        }
1010        return $pdf->Output('maps.pdf', 'S');
1011    }
1012
1013    /** @return array<array<string, mixed>> */
1014    private function getMapsEntries(): array {
1015        $entries = [];
1016
1017        $db = $this->dbUtils()->getDb();
1018        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 100 AND id < 150 ORDER BY line1 ASC");
1019        // @phpstan-ignore-next-line
1020        for ($i = 0; $i < $result_olz->num_rows; $i++) {
1021            // @phpstan-ignore-next-line
1022            $row_olz = $result_olz->fetch_assoc();
1023            $entries[] = $row_olz;
1024        }
1025        // @phpstan-ignore-next-line
1026        return $entries;
1027    }
1028
1029    /** @return array{0: int, 1: float, 2: float} */
1030    private function getMapsPageXY(int $index): array {
1031        $x_step = 46;
1032        $x_offset = 13;
1033        $y_step = 92;
1034        $y_offset = 10.5;
1035        $y_box = 26;
1036
1037        $col1 = $x_offset + $x_step * 1 / 2;
1038        $col2 = $x_offset + $x_step * 3 / 2;
1039        $col3 = $x_offset + $x_step * 5 / 2;
1040        $col4 = $x_offset + $x_step * 7 / 2;
1041
1042        $row1 = $y_offset + $y_step * 1 / 2 - $y_box / 2;
1043        $row2 = $y_offset + $y_step * 3 / 2 - $y_box / 2;
1044        $row3 = $y_offset + $y_step * 5 / 2 - $y_box / 2;
1045
1046        $olz = [
1047            [1, $col1, $row1],
1048            // [1, $col2, $row1],
1049            [1, $col3, $row1],
1050            [1, $col4, $row1],
1051            [1, $col1, $row2],
1052            // [1, $col2, $row2],
1053            // [1, $col3, $row2],
1054            [1, $col4, $row2],
1055            [1, $col1, $row3],
1056            [1, $col2, $row3],
1057            [1, $col3, $row3],
1058            [1, $col4, $row3],
1059            [2, $col1, $row1],
1060            [2, $col2, $row1],
1061            [2, $col3, $row1],
1062            [2, $col4, $row1],
1063            [2, $col1, $row2],
1064            [2, $col2, $row2],
1065            [2, $col3, $row2],
1066            [2, $col4, $row2],
1067            [2, $col1, $row3],
1068            [2, $col2, $row3],
1069            [2, $col3, $row3],
1070            [2, $col4, $row3],
1071        ];
1072        return $olz[$index];
1073    }
1074
1075    public function renderBackPages(): string {
1076        if (!$this->authUtils()->hasPermission('panini2024')) {
1077            throw new NotFoundHttpException();
1078        }
1079        $pdf = $this->getBookPdf();
1080        $entries = $this->getBackEntries();
1081
1082        $index = 0;
1083        $last_page = 0;
1084        foreach ($entries as $entry) {
1085            [$page, $x, $y] = $this->getBackPageXY($index);
1086            for ($p = $last_page; $p < $page; $p++) {
1087                $this->addBookPage($pdf);
1088            }
1089            $last_page = $page;
1090
1091            $short = 44;
1092            $long = 64;
1093            $wid = $entry['is_landscape'] ? $long : $short;
1094            $hei = $entry['is_landscape'] ? $short : $long;
1095
1096            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
1097
1098            $index++;
1099        }
1100        return $pdf->Output('back.pdf', 'S');
1101    }
1102
1103    /** @return array<array<string, mixed>> */
1104    private function getBackEntries(): array {
1105        $entries = [];
1106
1107        $db = $this->dbUtils()->getDb();
1108        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 20 AND id < 40 ORDER BY id ASC");
1109        // @phpstan-ignore-next-line
1110        for ($i = 0; $i < $result_olz->num_rows; $i++) {
1111            // @phpstan-ignore-next-line
1112            $row_olz = $result_olz->fetch_assoc();
1113            $entries[] = $row_olz;
1114        }
1115        // @phpstan-ignore-next-line
1116        return $entries;
1117    }
1118
1119    /** @return array{0: int, 1: float, 2: float} */
1120    private function getBackPageXY(int $index): array {
1121        $a4_wid = 210;
1122        $olz = [
1123            [1, $a4_wid * 0.75, 297 - 10.5 - 22],
1124            [2, $a4_wid * 0.75, 297 - 10.5 - 22],
1125        ];
1126        return $olz[$index];
1127    }
1128
1129    public static function fromEnv(): self {
1130        return new self();
1131    }
1132}