Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 723
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 / 723
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 / 175
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 / 12
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            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        gc_collect_cycles();
221        if ($has_top) {
222            $top_mask = imagecreatefrompng($top_mask_path);
223            $this->generalUtils()->checkNotFalse($top_mask, "renderSingle: Could not create top_mask");
224            imagecopy($img, $top_mask, 0, 0, 0, 0, $wid, $hei);
225            gc_collect_cycles();
226        }
227
228        // Association
229        if ($association_file) {
230            $association_mask = imagecreatefrompng($association_mask_path);
231            $this->generalUtils()->checkNotFalse($association_mask, "renderSingle: Could not create association_mask");
232            imagecopy($img, $association_mask, 0, 0, 0, 0, $wid, $hei);
233            gc_collect_cycles();
234
235            $offset = intval(round(($wid + $hei) * 0.01) - 1);
236            $size = max(1, intval(round(($wid + $hei) * 0.09) + 1));
237
238            $flag_mask = imagecreatefrompng($flag_mask_path);
239            $this->generalUtils()->checkNotFalse($flag_mask, "renderSingle: Could not create flag_mask");
240
241            $association_img = imagecreatetruecolor($size, $size);
242            $this->generalUtils()->checkNotFalse($association_img, "renderSingle: Could not create association_img");
243            $association_img_orig = imagecreatefromjpeg($association_img_orig_path);
244            $this->generalUtils()->checkNotFalse($association_img_orig, "renderSingle: Could not read association_img_orig");
245            imagecopyresampled(
246                $association_img,
247                $association_img_orig,
248                0,
249                0,
250                0,
251                0,
252                $size,
253                $size,
254                imagesx($association_img_orig),
255                imagesy($association_img_orig),
256            );
257            gc_collect_cycles();
258
259            for ($x = 0; $x < $size; $x++) {
260                for ($y = 0; $y < $size; $y++) {
261                    $flag_color_at = imagecolorat($flag_mask, $x + $offset, $y + $offset);
262                    $this->generalUtils()->checkNotFalse($flag_color_at, "renderSingle: Could not get flag_color_at");
263                    $mask = imagecolorsforindex($flag_mask, $flag_color_at);
264                    if ($mask['red'] > 0) {
265                        $ratio = floatval($mask['red']) / 255.0;
266                        $association_color_at = imagecolorat($association_img, $x, $y);
267                        $this->generalUtils()->checkNotFalse($association_color_at, "renderSingle: Could not get association_color_at");
268                        $src = imagecolorsforindex($association_img, $association_color_at);
269                        $src_r = floatval($src['red']);
270                        $src_g = floatval($src['green']);
271                        $src_b = floatval($src['blue']);
272                        $color_at = imagecolorat($img, $x + $offset, $y + $offset);
273                        $this->generalUtils()->checkNotFalse($color_at, "renderSingle: Could not get color_at");
274                        $dst = imagecolorsforindex($img, $color_at);
275                        $dst_r = floatval($dst['red']);
276                        $dst_g = floatval($dst['green']);
277                        $dst_b = floatval($dst['blue']);
278                        $color = imagecolorallocate(
279                            $img,
280                            max(0, min(255, intval($src_r * $ratio + $dst_r * (1 - $ratio)))),
281                            max(0, min(255, intval($src_g * $ratio + $dst_g * (1 - $ratio)))),
282                            max(0, min(255, intval($src_b * $ratio + $dst_b * (1 - $ratio)))),
283                        );
284                        $this->generalUtils()->checkNotFalse($color, "renderSingle: Could not allocate color");
285                        imagesetpixel($img, $x + $offset, $y + $offset, $color);
286                        imagecolordeallocate($img, $color);
287                    }
288                }
289            }
290            gc_collect_cycles();
291        }
292
293        // Text
294        $size = ($hei + ($is_landscape ? $wid : $hei)) * 0.018;
295        $yellow = imagecolorallocate($img, 255, 255, 0);
296        $this->generalUtils()->checkNotFalse($yellow, "renderSingle: Could not create yellow");
297        $shady = imagecolorallocatealpha($img, 0, 0, 0, 64);
298        $this->generalUtils()->checkNotFalse($shady, "renderSingle: Could not create shady");
299        $shoff = $size / 10;
300        $font_path = "{$panini_path}fonts/OpenSans/OpenSans-SemiBold.ttf";
301        $box = imagettfbbox($size, 0, $font_path, $line1);
302        $textwid = $box[2] ?? 0;
303        $x = ($line2 ? $wid * 0.95 - $textwid : $wid / 2 - $textwid / 2);
304        $y = $hei * ($is_landscape ? 0.95 : ($line2 ? 0.915 : 0.945));
305        $this->drawText($img, $size, 0, $x + $shoff, $y + $shoff, $shady, $font_path, $line1);
306        $this->drawText($img, $size, 0, $x, $y, $yellow, $font_path, $line1);
307        if ($line2) {
308            $box = imagettfbbox($size, 0, $font_path, $line2);
309            $textwid = $box[2] ?? 0;
310            $x = $wid * 0.95 - $textwid;
311            $y = $hei * 0.975;
312            $this->drawText($img, $size, 0, $x + $shoff, $y + $shoff, $shady, $font_path, $line2);
313            $this->drawText($img, $size, 0, $x, $y, $yellow, $font_path, $line2);
314        }
315        gc_collect_cycles();
316
317        ob_start();
318        imagejpeg($img, null, 90);
319        $image_data = ob_get_contents();
320        ob_end_clean();
321        $this->generalUtils()->checkNotFalse($image_data, "Could not retrieve image data");
322        file_put_contents($cache_file, $image_data);
323        $this->log()->info("Written to cache: {$id}-{$md5}.jpg");
324        return $image_data;
325    }
326
327    /**
328     * @param array<array{ids?: array<int>}> $pages
329     * @param array{grid?: bool}             $options
330     */
331    public function render3x5Pages(array $pages, array $options): string {
332        if (!$this->authUtils()->hasPermission('panini2024')) {
333            throw new NotFoundHttpException();
334        }
335
336        $grid = (bool) ($options['grid'] ?? false);
337        $x_step = 70;
338        $x_margin = 5.5;
339        $x_offset = 0;
340        $y_step = 50.8;
341        $y_offset = 21.5;
342        $y_margin = 4;
343
344        foreach ($pages as $page) {
345            $ids = $page['ids'] ?? [];
346            foreach ($ids as $id) {
347                $this->cachePictureId($id);
348            }
349        }
350
351        $pdf = new \TCPDF('P', 'mm', 'A4');
352        $pdf->setAutoPageBreak(false, 0);
353        $pdf->setPrintHeader(false);
354        $pdf->setPrintFooter(false);
355        foreach ($pages as $page) {
356            $ids = $page['ids'] ?? [];
357            $pdf->AddPage();
358
359            if ($grid) {
360                $pdf->SetDrawColor(0, 0, 0);
361                $pdf->SetLineWidth(0.1);
362                for ($x = 1; $x < 3; $x++) {
363                    $pdf->Line(
364                        $x_offset + $x_step * $x,
365                        $y_offset + $y_step * 0,
366                        $x_offset + $x_step * $x,
367                        $y_offset + $y_step * 5,
368                    );
369                }
370                for ($y = 0; $y < 6; $y++) {
371                    $pdf->Line(
372                        $x_offset + $x_step * 0,
373                        $y_offset + $y_step * $y,
374                        $x_offset + $x_step * 3,
375                        $y_offset + $y_step * $y,
376                    );
377                }
378            }
379
380            $index = 0;
381            for ($y = 0; $y < 5; $y++) {
382                for ($x = 0; $x < 3; $x++) {
383                    if ($y !== 2) {
384                        $id = $ids[$index] ?? 0;
385                        $temp_file_path = $this->getCachePathForPictureId($id);
386                        if ($temp_file_path) {
387                            $pdf->Image(
388                                $temp_file_path,
389                                $x_offset + $x_margin + $x_step * $x,
390                                $y_offset + $y_margin + $y_step * $y,
391                                $x_step - $x_margin * 2,
392                            );
393                        }
394                        $index++;
395                    }
396                }
397            }
398        }
399        return $pdf->Output('3x5.pdf', 'S');
400    }
401
402    /** @param array{grid?: bool} $options */
403    public function render4x4Zip(array $options): string {
404        $grid_or_empty = ($options['grid'] ?? false) ? '-grid' : '';
405        $ids = $this->getAllEntries();
406
407        $panini_utils = $this->paniniUtils();
408        $zip = new \ZipArchive();
409        $zip_path = $panini_utils->getCachePathForZip("duplicates{$grid_or_empty}");
410        if ($zip->open($zip_path, \ZipArchive::CREATE) !== true) {
411            throw new \Exception("Could not open Zip");
412        }
413        foreach ($ids as $id) {
414            $spec = "duplicate-{$id}{$grid_or_empty}";
415            $pdf_out = null;
416            [$pages, $options] = $this->parseSpec($spec, /* num_per_page= */ 16);
417            $pdf_out = $panini_utils->render4x4Pages($pages, $options);
418            if (!$pdf_out) {
419                throw new \Exception("PDF generation failed for ID: {$id}");
420            }
421            $zip->addFromString("{$spec}.pdf", $pdf_out);
422            gc_collect_cycles();
423        }
424        $zip->close();
425
426        $content = file_get_contents($zip_path) ?: '';
427        @unlink($zip_path);
428        return $content;
429    }
430
431    /** @return array<int> */
432    private function getAllEntries(): array {
433        $ids = [];
434
435        $db = $this->dbUtils()->getDb();
436        $result_olz = $db->query("SELECT id FROM panini24 ORDER BY id ASC");
437        // @phpstan-ignore-next-line
438        for ($i = 0; $i < $result_olz->num_rows; $i++) {
439            // @phpstan-ignore-next-line
440            $row_olz = $result_olz->fetch_assoc();
441            // @phpstan-ignore-next-line
442            $ids[] = intval($row_olz['id']);
443        }
444        return $ids;
445    }
446
447    /**
448     * @param array<array{ids?: array<int>}> $pages
449     * @param array{grid?: bool}             $options
450     */
451    public function render4x4Pages(array $pages, array $options): string {
452        if (!$this->authUtils()->hasPermission('panini2024')) {
453            throw new NotFoundHttpException();
454        }
455
456        $grid = (bool) ($options['grid'] ?? false);
457        $x_step = 48;
458        $x_margin = 2;
459        $x_offset = 9;
460        $y_step = 68;
461        $y_offset = 12.5;
462        $y_margin = 2;
463
464        foreach ($pages as $page) {
465            $ids = $page['ids'] ?? [];
466            foreach (array_unique($ids) as $id) {
467                $this->cachePictureId($id);
468            }
469        }
470
471        $pdf = new \TCPDF('P', 'mm', 'A4');
472        $pdf->setAutoPageBreak(false, 0);
473        $pdf->setPrintHeader(false);
474        $pdf->setPrintFooter(false);
475        foreach ($pages as $page) {
476            $ids = $page['ids'] ?? [];
477            $pdf->AddPage();
478
479            if ($grid) {
480                $pdf->SetDrawColor(0, 0, 0);
481                $pdf->SetLineWidth(0.1);
482                for ($x = 0; $x < 5; $x++) {
483                    $line_x = $x_offset + $x_step * $x;
484                    $pdf->Line($line_x, 0, $line_x, $y_offset - 1);
485                    $pdf->Line($line_x, 297, $line_x, 297 - $y_offset + 1);
486                }
487                for ($y = 0; $y < 5; $y++) {
488                    $line_y = $y_offset + $y_step * $y;
489                    $pdf->Line(0, $line_y, $x_offset - 1, $line_y);
490                    $pdf->Line(210, $line_y, 210 - $x_offset + 1, $line_y);
491                }
492            }
493
494            $index = 0;
495            for ($y = 0; $y < 4; $y++) {
496                for ($x = 0; $x < 4; $x++) {
497                    $id = $ids[$index] ?? 0;
498                    $temp_file_path = $this->getCachePathForPictureId($id);
499                    if ($temp_file_path) {
500                        $pdf->Image(
501                            $temp_file_path,
502                            $x_offset + $x_margin + $x_step * $x,
503                            $y_offset + $y_margin + $y_step * $y,
504                            $x_step - $x_margin * 2,
505                        );
506                    }
507                    $index++;
508                }
509            }
510        }
511        return $pdf->Output('4x4.pdf', 'S');
512    }
513
514    private function cachePictureId(int $id): void {
515        if ($id === 0) {
516            return;
517        }
518        $temp_file_path = $this->getCachePathForPictureId($id);
519        $img = imagecreatefromstring($this->renderSingle($id));
520        $this->generalUtils()->checkNotFalse($img, "cachePictureId: Could create image from string (ID:{$id})");
521        $wid = imagesx($img);
522        $hei = imagesy($img);
523        if ($hei < $wid) {
524            $img = imagerotate($img, 90, 0);
525            $this->generalUtils()->checkNotFalse($img, "cachePictureId: Failed rotating");
526        }
527        imagejpeg($img, $temp_file_path);
528        gc_collect_cycles();
529    }
530
531    private function getCachePathForPictureId(int $id): ?string {
532        if ($id === 0) {
533            return null;
534        }
535        $data_path = $this->envUtils()->getDataPath();
536        $temp_path = "{$data_path}temp/";
537        if (!is_dir($temp_path)) {
538            mkdir($temp_path, 0o777, true);
539        }
540        return "{$temp_path}paninipdf-{$id}.jpg";
541    }
542
543    public function getCachePathForZip(string $ident): string {
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}paninizip-{$ident}.zip";
550    }
551
552    // --- BOOK ------------------------------------------------------------------------------------
553
554    private function getBookPdf(): \TCPDF {
555        $data_path = $this->envUtils()->getDataPath();
556        $panini_path = "{$data_path}panini_data/";
557
558        $pdf = new \TCPDF('P', 'mm', 'A4');
559        $pdf->setAutoPageBreak(false, 0);
560        $pdf->setPrintHeader(false);
561        $pdf->setPrintFooter(false);
562
563        $font_path = "{$panini_path}fonts/OpenSans/OpenSans-SemiBold.ttf";
564        $fontname = \TCPDF_FONTS::addTTFfont($font_path, 'TrueTypeUnicode');
565        if (!$fontname) {
566            throw new \Exception("Error with font {$font_path}");
567        }
568        $pdf->SetFont($fontname);
569
570        return $pdf;
571    }
572
573    private function addBookPage(\TCPDF $pdf): void {
574        $pdf->AddPage();
575        $mainbg_path = __DIR__.'/../../../../assets/icns/mainbg.png';
576        $info = getimagesize($mainbg_path);
577        $wid_px = $info[0] ?? 0;
578        $hei_px = $info[1] ?? 0;
579        $dpi = 150;
580        $wid_mm = $wid_px / $dpi * self::MM_PER_INCH;
581        $hei_mm = $hei_px / $dpi * self::MM_PER_INCH;
582        for ($tile_x = 0; $tile_x < ceil(210 / $wid_mm); $tile_x++) {
583            for ($tile_y = 0; $tile_y < ceil(297 / $hei_mm); $tile_y++) {
584                $pdf->Image(
585                    $mainbg_path,
586                    $tile_x * $wid_mm,
587                    $tile_y * $hei_mm,
588                    $wid_mm,
589                );
590            }
591        }
592    }
593
594    /** @param array<string, mixed> $entry */
595    private function drawPlaceholder(
596        \TCPDF $pdf,
597        array $entry,
598        float $x,
599        float $y,
600        float $wid,
601        float $hei,
602    ): void {
603        $is_landcape = $wid > $hei;
604
605        $pdf->SetLineWidth(0.1);
606        $pdf->SetDrawColor(200, 200, 200);
607        $pdf->SetFillColor(255, 255, 255);
608        $pdf->Rect($x, $y, $wid, $hei, 'DF');
609
610        $pdf->SetTextColor(200, 200, 200);
611        $pdf->SetFontSize($is_landcape ? 7.75 : 8.75);
612        $line1 = $this->convertString($entry['line1']);
613        $line2 = $this->convertString($entry['line2']);
614        $has_line2 = $line2 !== '';
615        $align = $has_line2 ? 'R' : 'C';
616        $pdf->setXY($x, $y + $hei - ($has_line2 ? 10 : 8));
617        $pdf->Cell($wid, 5, $line1, 0, 0, $align);
618        if ($has_line2) {
619            $pdf->setXY($x, $y + $hei - 6);
620            $pdf->Cell($wid, 5, $line2, 0, 0, $align);
621        }
622    }
623
624    /** @param array<string, mixed> $entry */
625    private function drawEntryInfobox(
626        \TCPDF $pdf,
627        array $entry,
628        float $x,
629        float $y,
630        float $wid,
631        float $hei,
632    ): void {
633        $pdf->SetLineWidth(0.1);
634        $pdf->SetDrawColor(0, 117, 33);
635        $pdf->SetFillColor(212, 231, 206);
636        $pdf->Rect($x, $y, $wid, $hei, 'F');
637        $line_y = $y;
638        $pdf->Line($x, $line_y, $x + $wid, $line_y);
639        $line_y = $y + $hei;
640        $pdf->Line($x, $line_y, $x + $wid, $line_y);
641
642        $pdf->SetFontSize(11);
643        $birthday = $entry['birthdate'] ?? '';
644        if (substr($birthday, 4) === '-00-00') {
645            $birthday = substr($birthday, 0, 4);
646        }
647        if ($entry['birthdate'] === null) { // No information => ERROR!
648            $pdf->SetTextColor(255, 0, 0);
649            $birthday = '!!!';
650        } elseif (substr($birthday, 0, 4) === '0000') { // Year Zero => Do not show
651            $birthday = '';
652        } else {
653            $pdf->SetTextColor(0, 117, 33);
654            if (strlen($birthday) === 10) {
655                $birthday = date('d.m.Y', strtotime($birthday) ?: 0);
656            }
657        }
658        $pdf->setXY($x, $y + 1);
659        $pdf->Cell($wid * 0.7 - 2, 5, $birthday);
660
661        $pdf->SetFontSize(14);
662        $num_mispunch = strval($entry['num_mispunches']);
663        if ($entry['num_mispunches'] === null) { // No information => ERROR!
664            $pdf->SetTextColor(255, 0, 0);
665            $num_mispunch = '!!!';
666        } elseif (intval($entry['num_mispunches']) < 0) { // Negative count => Do not show
667            $num_mispunch = '';
668        } else {
669            $pdf->SetTextColor(0, 117, 33);
670        }
671        $pdf->setXY($x + $wid * 0.7, $y + 1);
672        $pdf->Cell($wid * 0.3, 5, $num_mispunch, 0, 0, 'R');
673
674        $pdf->SetTextColor(0, 117, 33);
675        $pdf->SetFontSize(8);
676        $infos = json_decode($entry['infos'], true) ?? [];
677        $favourite_map = $this->convertString($infos[0] ?? '');
678        $pdf->setXY($x, $y + 6);
679        $pdf->Cell($wid, 4, $favourite_map);
680        $since_when = $this->convertString($infos[3] ?? '');
681        $pdf->setXY($x, $y + 10);
682        $pdf->Cell($wid, 4, $since_when);
683        $motto = $this->convertString($infos[4] ?? '');
684        $pdf->setXY($x, $y + 14);
685        $pdf->Multicell($wid, 11, $motto, 0, 'L');
686    }
687
688    /** @param array{} $options */
689    private function drawText(
690        \GdImage $image,
691        float $size,
692        float $angle,
693        int|float $x,
694        int|float $y,
695        int $color,
696        string $font_filename,
697        string $text,
698        array $options = [],
699    ): void {
700        $x = intval(round($x));
701        $y = intval(round($y));
702        imagettftext($image, $size, $angle, $x, $y, $color, $font_filename, $text, $options);
703    }
704
705    private function convertString(?string $string): string {
706        return $string ?? '';
707    }
708
709    public function renderBookPages(): string {
710        if (!$this->authUtils()->hasPermission('panini2024')) {
711            throw new NotFoundHttpException();
712        }
713        $pdf = $this->getBookPdf();
714        $entries = $this->getBookEntries();
715
716        $x_step = 46;
717        $x_margin = 1;
718        $x_offset = 13;
719        $y_step = 92;
720        $y_offset = 10.5;
721        $y_margin = 1;
722        $y_box = 26;
723
724        $index = 0;
725        foreach ($entries as $entry) {
726            if (($index % 12) === 0) {
727                $this->addBookPage($pdf);
728            }
729            $x_index = $index % 4;
730            $y_index = floor($index / 4) % 3;
731
732            $x = $x_offset + $x_margin + $x_step * $x_index;
733            $y = $y_offset + $y_margin + $y_step * $y_index;
734            $wid = $x_step - $x_margin * 2;
735
736            $placeholder_hei = $y_step - $y_box - $y_margin * 2;
737            $this->drawPlaceholder($pdf, $entry, $x, $y, $wid, $placeholder_hei);
738
739            $box_y = $y + $y_step - $y_box - $y_margin;
740            $box_hei = $y_box - $y_margin;
741            $this->drawEntryInfobox($pdf, $entry, $x, $box_y, $wid, $box_hei);
742
743            $index++;
744        }
745        return $pdf->Output('book.pdf', 'S');
746    }
747
748    /** @return array<array<string, mixed>> */
749    private function getBookEntries(): array {
750        $entries = [];
751
752        $db = $this->dbUtils()->getDb();
753        $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");
754        $esc_associations = [];
755        // @phpstan-ignore-next-line
756        for ($i = 0; $i < $result_associations->num_rows; $i++) {
757            // @phpstan-ignore-next-line
758            $row_association = $result_associations->fetch_assoc();
759            $entries[] = $row_association;
760
761            if ($row_association['is_other'] ?? false) {
762                $sql = implode("', '", $esc_associations);
763                $result_portraits = $db->query("SELECT * FROM panini24 WHERE association NOT IN ('{$sql}') ORDER BY line2 ASC, line1 ASC");
764                // @phpstan-ignore-next-line
765                for ($j = 0; $j < $result_portraits->num_rows; $j++) {
766                    // @phpstan-ignore-next-line
767                    $row_portrait = $result_portraits->fetch_assoc();
768                    $entries[] = $row_portrait;
769                }
770            } else {
771                $line1 = $row_association['line1'] ?? '';
772                $esc_association = $db->real_escape_string("{$line1}");
773                $esc_associations[] = $esc_association;
774                $result_portraits = $db->query("SELECT * FROM panini24 WHERE association = '{$esc_association}' ORDER BY line2 ASC, line1 ASC");
775                // @phpstan-ignore-next-line
776                for ($j = 0; $j < $result_portraits->num_rows; $j++) {
777                    // @phpstan-ignore-next-line
778                    $row_portrait = $result_portraits->fetch_assoc();
779                    $entries[] = $row_portrait;
780                }
781            }
782        }
783        // @phpstan-ignore-next-line
784        return $entries;
785    }
786
787    public function renderOlzPages(): string {
788        if (!$this->authUtils()->hasPermission('panini2024')) {
789            throw new NotFoundHttpException();
790        }
791        $pdf = $this->getBookPdf();
792        $entries = $this->getOlzEntries();
793
794        $index = 0;
795        $last_page = 0;
796        foreach ($entries as $entry) {
797            [$page, $x, $y] = $this->getOlzPageXY($index);
798            for ($p = $last_page; $p < $page; $p++) {
799                $this->addBookPage($pdf);
800            }
801            $last_page = $page;
802
803            $short = 44;
804            $long = 64;
805            $wid = $entry['is_landscape'] ? $long : $short;
806            $hei = $entry['is_landscape'] ? $short : $long;
807
808            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
809
810            $index++;
811        }
812        return $pdf->Output('olz.pdf', 'S');
813    }
814
815    /** @return array<array<string, mixed>> */
816    private function getOlzEntries(): array {
817        $entries = [];
818
819        $db = $this->dbUtils()->getDb();
820        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 10 AND id < 20 ORDER BY id ASC");
821        // @phpstan-ignore-next-line
822        for ($i = 0; $i < $result_olz->num_rows; $i++) {
823            // @phpstan-ignore-next-line
824            $row_olz = $result_olz->fetch_assoc();
825            $entries[] = $row_olz;
826        }
827        // @phpstan-ignore-next-line
828        return $entries;
829    }
830
831    /** @return array{0: int, 1: float, 2: float} */
832    private function getOlzPageXY(int $index): array {
833        $a4_wid = 210;
834        $olz = [
835            [1, $a4_wid * 0.75, 10.5 + 22],
836            [1, $a4_wid * 0.25, 10.5 + 44 + 10.5 + 32],
837            [1, $a4_wid * 0.8, 10.5 + 44 + 10.5 + 32],
838            [1, $a4_wid * 0.2, 10.5 + 44 + 10.5 + 64 + 10.5 + 32],
839            [2, $a4_wid * 0.8, 10.5 + 44 + 10.5 + 64 + 10.5 + 64 + 10.5 + 32],
840            [2, $a4_wid * 0.25, 10.5 + 22],
841            [2, $a4_wid * 0.75, 10.5 + 44 + 10.5 + 32],
842            [2, $a4_wid * 0.2, 10.5 + 44 + 10.5 + 64 + 10.5 + 32],
843            [2, $a4_wid * 0.25, 10.5 + 44 + 10.5 + 64 + 10.5 + 64 + 10.5 + 32],
844        ];
845        return $olz[$index];
846    }
847
848    public function renderHistoryPages(): string {
849        if (!$this->authUtils()->hasPermission('panini2024')) {
850            throw new NotFoundHttpException();
851        }
852        $pdf = $this->getBookPdf();
853        $entries = $this->getHistoryEntries();
854
855        $index = 0;
856        $last_page = 0;
857        foreach ($entries as $entry) {
858            [$page, $x, $y] = $this->getHistoryPageXY($index);
859            for ($p = $last_page; $p < $page; $p++) {
860                $this->addBookPage($pdf);
861            }
862            $last_page = $page;
863
864            $short = 44;
865            $long = 64;
866            $wid = $entry['is_landscape'] ? $long : $short;
867            $hei = $entry['is_landscape'] ? $short : $long;
868
869            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
870
871            $index++;
872        }
873        return $pdf->Output('history.pdf', 'S');
874    }
875
876    /** @return array<array<string, mixed>> */
877    private function getHistoryEntries(): array {
878        $entries = [];
879
880        $db = $this->dbUtils()->getDb();
881        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 50 AND id < 100 ORDER BY id ASC");
882        // @phpstan-ignore-next-line
883        for ($i = 0; $i < $result_olz->num_rows; $i++) {
884            // @phpstan-ignore-next-line
885            $row_olz = $result_olz->fetch_assoc();
886            $entries[] = $row_olz;
887        }
888        // @phpstan-ignore-next-line
889        return $entries;
890    }
891
892    /** @return array{0: int, 1: float, 2: float} */
893    private function getHistoryPageXY(int $index): array {
894        $a4_wid = 210;
895        $olz = [
896            [1, $a4_wid * 0.75, 5.5 + 32 * 1],
897            [1, $a4_wid * 0.25, 5.5 + 32 * 2],
898            [1, $a4_wid * 0.75, 5.5 + 32 * 3],
899            [1, $a4_wid * 0.25, 5.5 + 32 * 4],
900            [1, $a4_wid * 0.75, 5.5 + 32 * 5],
901            [1, $a4_wid * 0.25, 5.5 + 32 * 6],
902            [1, $a4_wid * 0.75, 5.5 + 32 * 7],
903            [1, $a4_wid * 0.25, 5.5 + 32 * 8],
904            [2, $a4_wid * 0.75, 5.5 + 32 * 8],
905            [2, $a4_wid * 0.25, 5.5 + 32 * 7],
906            [2, $a4_wid * 0.75, 5.5 + 32 * 6],
907            [2, $a4_wid * 0.25, 5.5 + 32 * 5],
908            [2, $a4_wid * 0.75, 5.5 + 32 * 4],
909            [2, $a4_wid * 0.25, 5.5 + 32 * 3],
910            [2, $a4_wid * 0.75, 5.5 + 32 * 2],
911            [2, $a4_wid * 0.25, 5.5 + 32 * 1],
912        ];
913        return $olz[$index];
914    }
915
916    public function renderDressesPages(): string {
917        if (!$this->authUtils()->hasPermission('panini2024')) {
918            throw new NotFoundHttpException();
919        }
920        $pdf = $this->getBookPdf();
921        $entries = $this->getDressesEntries();
922
923        $index = 0;
924        $last_page = 0;
925        foreach ($entries as $entry) {
926            [$page, $x, $y] = $this->getDressesPageXY($index);
927            for ($p = $last_page; $p < $page; $p++) {
928                $this->addBookPage($pdf);
929            }
930            $last_page = $page;
931
932            $short = 44;
933            $long = 64;
934            $wid = $entry['is_landscape'] ? $long : $short;
935            $hei = $entry['is_landscape'] ? $short : $long;
936
937            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
938
939            $index++;
940        }
941        return $pdf->Output('dresses.pdf', 'S');
942    }
943
944    /** @return array<array<string, mixed>> */
945    private function getDressesEntries(): array {
946        $entries = [];
947
948        $db = $this->dbUtils()->getDb();
949        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 40 AND id < 50 ORDER BY id ASC");
950        // @phpstan-ignore-next-line
951        for ($i = 0; $i < $result_olz->num_rows; $i++) {
952            // @phpstan-ignore-next-line
953            $row_olz = $result_olz->fetch_assoc();
954            $entries[] = $row_olz;
955        }
956        // @phpstan-ignore-next-line
957        return $entries;
958    }
959
960    /** @return array{0: int, 1: float, 2: float} */
961    private function getDressesPageXY(int $index): array {
962        $a4_wid = 210;
963        $olz = [
964            [1, $a4_wid * 0.25, 42 + 22],
965            [1, $a4_wid * 0.75, 42 + 44 - 6 + 22],
966            [1, $a4_wid * 0.25, 42 + 44 - 6 + 44 - 6 + 22],
967            [1, $a4_wid * 0.75, 42 + 44 - 6 + 44 - 6 + 44 - 6 + 22],
968            [1, $a4_wid * 0.25, 42 + 44 - 6 + 44 - 6 + 44 - 6 + 44 - 6 + 22],
969            [1, $a4_wid * 0.75, 42 + 44 - 6 + 44 - 6 + 44 - 6 + 44 - 6 + 44 - 6 + 22],
970        ];
971        return $olz[$index];
972    }
973
974    public function renderMapsPages(): string {
975        if (!$this->authUtils()->hasPermission('panini2024')) {
976            throw new NotFoundHttpException();
977        }
978        $pdf = $this->getBookPdf();
979        $entries = $this->getMapsEntries();
980
981        $index = 0;
982        $last_page = 0;
983        foreach ($entries as $entry) {
984            [$page, $x, $y] = $this->getMapsPageXY($index);
985            for ($p = $last_page; $p < $page; $p++) {
986                $this->addBookPage($pdf);
987            }
988            $last_page = $page;
989
990            $short = 44;
991            $long = 64;
992            $wid = $entry['is_landscape'] ? $long : $short;
993            $hei = $entry['is_landscape'] ? $short : $long;
994
995            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
996
997            $this->drawEntryInfobox($pdf, $entry, $x - $wid / 2, $y + $hei / 2 + 1, $wid, 25);
998
999            $index++;
1000        }
1001        return $pdf->Output('maps.pdf', 'S');
1002    }
1003
1004    /** @return array<array<string, mixed>> */
1005    private function getMapsEntries(): array {
1006        $entries = [];
1007
1008        $db = $this->dbUtils()->getDb();
1009        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 100 AND id < 150 ORDER BY line1 ASC");
1010        // @phpstan-ignore-next-line
1011        for ($i = 0; $i < $result_olz->num_rows; $i++) {
1012            // @phpstan-ignore-next-line
1013            $row_olz = $result_olz->fetch_assoc();
1014            $entries[] = $row_olz;
1015        }
1016        // @phpstan-ignore-next-line
1017        return $entries;
1018    }
1019
1020    /** @return array{0: int, 1: float, 2: float} */
1021    private function getMapsPageXY(int $index): array {
1022        $x_step = 46;
1023        $x_offset = 13;
1024        $y_step = 92;
1025        $y_offset = 10.5;
1026        $y_box = 26;
1027
1028        $col1 = $x_offset + $x_step * 1 / 2;
1029        $col2 = $x_offset + $x_step * 3 / 2;
1030        $col3 = $x_offset + $x_step * 5 / 2;
1031        $col4 = $x_offset + $x_step * 7 / 2;
1032
1033        $row1 = $y_offset + $y_step * 1 / 2 - $y_box / 2;
1034        $row2 = $y_offset + $y_step * 3 / 2 - $y_box / 2;
1035        $row3 = $y_offset + $y_step * 5 / 2 - $y_box / 2;
1036
1037        $olz = [
1038            [1, $col1, $row1],
1039            // [1, $col2, $row1],
1040            [1, $col3, $row1],
1041            [1, $col4, $row1],
1042            [1, $col1, $row2],
1043            // [1, $col2, $row2],
1044            // [1, $col3, $row2],
1045            [1, $col4, $row2],
1046            [1, $col1, $row3],
1047            [1, $col2, $row3],
1048            [1, $col3, $row3],
1049            [1, $col4, $row3],
1050            [2, $col1, $row1],
1051            [2, $col2, $row1],
1052            [2, $col3, $row1],
1053            [2, $col4, $row1],
1054            [2, $col1, $row2],
1055            [2, $col2, $row2],
1056            [2, $col3, $row2],
1057            [2, $col4, $row2],
1058            [2, $col1, $row3],
1059            [2, $col2, $row3],
1060            [2, $col3, $row3],
1061            [2, $col4, $row3],
1062        ];
1063        return $olz[$index];
1064    }
1065
1066    public function renderBackPages(): string {
1067        if (!$this->authUtils()->hasPermission('panini2024')) {
1068            throw new NotFoundHttpException();
1069        }
1070        $pdf = $this->getBookPdf();
1071        $entries = $this->getBackEntries();
1072
1073        $index = 0;
1074        $last_page = 0;
1075        foreach ($entries as $entry) {
1076            [$page, $x, $y] = $this->getBackPageXY($index);
1077            for ($p = $last_page; $p < $page; $p++) {
1078                $this->addBookPage($pdf);
1079            }
1080            $last_page = $page;
1081
1082            $short = 44;
1083            $long = 64;
1084            $wid = $entry['is_landscape'] ? $long : $short;
1085            $hei = $entry['is_landscape'] ? $short : $long;
1086
1087            $this->drawPlaceholder($pdf, $entry, $x - $wid / 2, $y - $hei / 2, $wid, $hei);
1088
1089            $index++;
1090        }
1091        return $pdf->Output('back.pdf', 'S');
1092    }
1093
1094    /** @return array<array<string, mixed>> */
1095    private function getBackEntries(): array {
1096        $entries = [];
1097
1098        $db = $this->dbUtils()->getDb();
1099        $result_olz = $db->query("SELECT * FROM panini24 WHERE id >= 20 AND id < 40 ORDER BY id ASC");
1100        // @phpstan-ignore-next-line
1101        for ($i = 0; $i < $result_olz->num_rows; $i++) {
1102            // @phpstan-ignore-next-line
1103            $row_olz = $result_olz->fetch_assoc();
1104            $entries[] = $row_olz;
1105        }
1106        // @phpstan-ignore-next-line
1107        return $entries;
1108    }
1109
1110    /** @return array{0: int, 1: float, 2: float} */
1111    private function getBackPageXY(int $index): array {
1112        $a4_wid = 210;
1113        $olz = [
1114            [1, $a4_wid * 0.75, 297 - 10.5 - 22],
1115            [2, $a4_wid * 0.75, 297 - 10.5 - 22],
1116        ];
1117        return $olz[$index];
1118    }
1119
1120    public static function fromEnv(): self {
1121        return new self();
1122    }
1123}