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