Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.50% covered (success)
91.50%
280 / 306
63.16% covered (warning)
63.16%
24 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wrapper
91.50% covered (success)
91.50%
280 / 306
63.16% covered (warning)
63.16%
24 / 38
124.25
0.00% covered (danger)
0.00%
0 / 1
 config
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 Open
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 Output
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 AddPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPrintHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 SetDisplayMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 SetMargins
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 SetHeaderMargin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 SetFooterMargin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 SetCreator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 SetTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 SetAuthor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 SetFont
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 Ln
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 Write
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 Bookmark
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCategory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setQuestion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCategories
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderHeader
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
3
 setCustomHeader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 renderFooter
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
7
 setCustomFooter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addFaqToc
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentFont
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFaq
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveImage
84.00% covered (success)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
18.18
 inlineLocalImages
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
12
 checkBase64Image
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 concatenatePaths
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 isWithinRoot
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 convertExternalImagesToBase64
82.93% covered (success)
82.93%
34 / 41
0.00% covered (danger)
0.00%
0 / 1
18.44
 fetchExternalImage
83.33% covered (success)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
4.07
 validateImageData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 getImageMimeType
88.24% covered (success)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
10.16
 WriteHTML
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Main PDF class for phpMyFAQ which "just" extends the TCPDF library.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @author    Peter Beauvain <pbeauvain@web.de>
13 * @author    Krzysztof Kruszynski <thywolf@wolf.homelinux.net>
14 * @copyright 2004-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2004-11-21
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ\Export\Pdf;
23
24use Exception;
25use phpMyFAQ\Configuration;
26use phpMyFAQ\Date;
27use phpMyFAQ\Export\Pdf\Engine\PdfEngineInterface;
28use phpMyFAQ\Export\Pdf\Engine\TcpdfEngine;
29use phpMyFAQ\Link\Util\TitleSlugifier;
30use phpMyFAQ\Strings;
31use phpMyFAQ\Translation;
32
33/**
34 * Class Wrapper
35 *
36 * @package phpMyFAQ\Export\Pdf
37 */
38/* @mago-ignore lint:too-many-methods */
39class Wrapper
40{
41    /**
42     * Default left page margin in mm, mirroring the engine's left-margin default.
43     */
44    private const float MARGIN_LEFT = 15;
45
46    /**
47     * Default right page margin in mm, mirroring the engine's right-margin default.
48     */
49    private const float MARGIN_RIGHT = 15;
50
51    /**
52     * With or without bookmarks.
53     */
54    public bool $enableBookmarks = false;
55
56    /**
57     * Full export from admin backend?
58     */
59    public bool $isFullExport = false;
60
61    /**
62     * Categories.
63     *
64     * @var array<int, array<string, mixed>>
65     */
66    public array $categories = [];
67
68    /**
69     * The current category.
70     */
71    public int $category;
72
73    /**
74     * The current faq.
75     *
76     * @var array<string, mixed>
77     */
78    public array $faq = [];
79
80    /**
81     * Configuration. Optional until injected via setConfig(); rendering requires it.
82     */
83    protected ?Configuration $config = null;
84
85    /**
86     * Returns the configuration or fails loudly when setConfig() was not called
87     * before rendering started.
88     */
89    private function config(): Configuration
90    {
91        return (
92            $this->config ?? throw new \LogicException('Wrapper::setConfig() must be called before rendering a PDF.')
93        );
94    }
95
96    /**
97     * Question.
98     */
99    private string $question = '';
100
101    /**
102     * Font files.
103     */
104    private array $fontFiles = [
105        'zh' => 'cid0cs',
106        'zh_tw' => 'cid0ct',
107        'ja' => 'cid0jp',
108        'ko' => 'cid0kr',
109        'cs' => 'dejavusans',
110        'sk' => 'dejavusans',
111        'el' => 'dejavusans',
112        'he' => 'dejavusans',
113        'tr' => 'dejavusans',
114        'default' => 'dejavusans',
115    ];
116
117    /**
118     * Current font.
119     */
120    private string $currentFont = 'dejavusans';
121
122    private string $customHeader = '';
123
124    private string $customFooter = '';
125
126    private readonly PdfEngineInterface $engine;
127
128    /**
129     * Constructor.
130     */
131    public function __construct(?PdfEngineInterface $engine = null)
132    {
133        $this->engine = $engine ?? new TcpdfEngine();
134
135        // Check on RTL
136        if ('rtl' === Translation::get(key: 'direction')) {
137            $this->engine->setRtl(true);
138        }
139
140        // Set font
141        $metaLanguage = Translation::getString(key: 'metaLanguage');
142        if ($metaLanguage !== '' && array_key_exists($metaLanguage, $this->fontFiles)) {
143            $this->currentFont = (string) $this->fontFiles[$metaLanguage];
144        }
145
146        // Register render-time callbacks so the engine's Header/Footer hooks
147        // call back into this renderer's domain logic.
148        $this->engine->onHeader($this->renderHeader(...));
149        $this->engine->onFooter($this->renderFooter(...));
150    }
151
152    public function Open(): void
153    {
154        $this->engine->open();
155    }
156
157    public function Output(string $name, string $dest): string
158    {
159        return $this->engine->output($name, $dest);
160    }
161
162    public function AddPage(): void
163    {
164        $this->engine->addPage();
165    }
166
167    public function setPrintHeader(bool $val = true): void
168    {
169        $this->engine->setPrintHeader($val);
170    }
171
172    public function SetDisplayMode(mixed $zoom): void
173    {
174        $this->engine->setDisplayMode($zoom);
175    }
176
177    public function SetMargins(float $left, float $top, float $right = -1): void
178    {
179        $this->engine->setMargins($left, $top, $right);
180    }
181
182    public function SetHeaderMargin(float $margin): void
183    {
184        $this->engine->setHeaderMargin($margin);
185    }
186
187    public function SetFooterMargin(float $margin): void
188    {
189        $this->engine->setFooterMargin($margin);
190    }
191
192    public function SetCreator(string $creator): void
193    {
194        $this->engine->setCreator($creator);
195    }
196
197    public function SetTitle(string $title): void
198    {
199        $this->engine->setTitle($title);
200    }
201
202    public function SetAuthor(string $author): void
203    {
204        $this->engine->setAuthor($author);
205    }
206
207    public function SetFont(string $family, string $style = '', float $size = 0): void
208    {
209        $this->engine->setFont($family, $style, $size);
210    }
211
212    public function Ln(?float $h = null): void
213    {
214        $this->engine->ln($h);
215    }
216
217    public function Write(float $h, string $txt): void
218    {
219        $this->engine->write($h, $txt);
220    }
221
222    public function Bookmark(string $txt, int $level = 0, float $y = -1): void
223    {
224        $this->engine->bookmark($txt, $level, $y);
225    }
226
227    /**
228     * Setter for the category name.
229     *
230     * @param int $category Entity name
231     */
232    public function setCategory(int $category): void
233    {
234        $this->category = $category;
235    }
236
237    /**
238     * Setter for the question.
239     *
240     * @param string $question Question
241     */
242    public function setQuestion(string $question = ''): void
243    {
244        $this->question = $question;
245    }
246
247    /**
248     * Setter for a category array.
249     *
250     * @param array<int, array<string, mixed>> $categories Categories
251     */
252    public function setCategories(array $categories): void
253    {
254        $this->categories = $categories;
255    }
256
257    public function setConfig(Configuration $configuration): void
258    {
259        $this->config = $configuration;
260    }
261
262    /**
263     * The header of the PDF file.
264     */
265    public function renderHeader(): void
266    {
267        // Set a custom header and footer
268        $this->setCustomHeader();
269
270        $title = array_key_exists($this->category, $this->categories)
271            ? (string) ($this->categories[$this->category]['name'] ?? '')
272            : '';
273
274        $this->engine->setTextColor(0, 0, 0);
275        $this->engine->setFont($this->currentFont, 'B', 14);
276
277        if (0 < Strings::strlen($this->customHeader)) {
278            $this->engine->writeHtmlCell(w: 0, h: 0, x: 0, y: 0, html: $this->customHeader);
279            $this->engine->ln();
280            $this->engine->writeHtmlCell(
281                w: 0,
282                h: 0,
283                x: 0,
284                y: 0,
285                html: html_entity_decode((string) $title, ENT_QUOTES, encoding: 'utf-8'),
286                border: 0,
287                ln: 0,
288                fill: false,
289                reseth: true,
290                align: 'C',
291            );
292            return;
293        }
294
295        $this->engine->multiCell(
296            w: 0,
297            h: 10,
298            txt: html_entity_decode((string) $title, ENT_QUOTES, encoding: 'utf-8'),
299            border: 0,
300            align: 'C',
301        );
302        $this->engine->setMargins(self::MARGIN_LEFT, $this->engine->getLastH() + 5, self::MARGIN_RIGHT);
303    }
304
305    /**
306     * Sets custom header.
307     */
308    public function setCustomHeader(): void
309    {
310        $this->customHeader = $this->inlineLocalImages(html_entity_decode(
311            (string) $this->config()->get(item: 'main.customPdfHeader'),
312            ENT_QUOTES,
313            encoding: 'utf-8',
314        ));
315    }
316
317    /**
318     * The footer of the PDF file.
319     * @throws Exception
320     */
321    public function renderFooter(): void
322    {
323        // Set a custom footer
324        $this->setCustomFooter();
325
326        $date = new Date($this->config());
327
328        $footer = sprintf(
329            $this->config()->get(item: 'spam.mailAddressInExport') ? '© %d %s <%s> | %s' : '© %d %s %s| %s',
330            date(format: 'Y'),
331            (string) $this->config()->get(item: 'main.metaPublisher'),
332            $this->config()->get(item: 'spam.mailAddressInExport') ? $this->config()->getAdminEmail() : '',
333            $date->format(date(format: 'Y-m-d H:i')),
334        );
335
336        if (0 < Strings::strlen($this->customFooter)) {
337            $this->engine->writeHtmlCell(w: 0, h: 0, x: null, y: null, html: $this->customFooter);
338        }
339
340        $this->engine->setTextColor(0, 0, 0);
341        $this->engine->setY(-25);
342        $this->engine->setFont($this->currentFont, '', 10);
343        $this->engine->cell(
344            w: 0,
345            h: 10,
346            txt: Translation::getString(key: 'ad_gen_page') . ' ' . $this->engine->getAliasNumPage() . ' / '
347                . $this->engine->getAliasNbPages(),
348            border: 0,
349            ln: 0,
350            align: 'C',
351        );
352        $this->engine->setY(-20);
353        $this->engine->setFont($this->currentFont, 'B', 8);
354        $this->engine->cell(w: 0, h: 10, txt: $footer, border: 0, ln: 1, align: 'C');
355        if (!$this->enableBookmarks) {
356            $this->engine->setY(-15);
357            $this->engine->setFont($this->currentFont, '', 8);
358            $baseUrl = $this->config()->getDefaultUrl() . 'content';
359            if ($this->faq !== []) {
360                if (array_key_exists($this->category, $this->categories)) {
361                    $baseUrl .= '/' . (int) ($this->categories[$this->category]['id'] ?? 0);
362                }
363
364                $baseUrl .= '/' . (string) ($this->faq['id'] ?? '');
365                $baseUrl .= '/' . (string) ($this->faq['lang'] ?? '');
366                $baseUrl .= '/' . TitleSlugifier::slug($this->question) . '.html';
367            }
368
369            $this->engine->cell(
370                w: 0,
371                h: 10,
372                txt: 'URL: ' . $baseUrl,
373                border: 0,
374                ln: 1,
375                align: 'C',
376                fill: false,
377                link: $baseUrl,
378            );
379        }
380    }
381
382    /**
383     * Sets custom footer.
384     */
385    public function setCustomFooter(): void
386    {
387        $this->customFooter = $this->inlineLocalImages(
388            (string) ($this->config()->get(item: 'main.customPdfFooter') ?? ''),
389        );
390    }
391
392    /**
393     * Adds a table of content for exports of the complete FAQ.
394     */
395    public function addFaqToc(): void
396    {
397        $this->engine->addTocPage();
398
399        // Title
400        $this->engine->setFont($this->currentFont, 'B', 24);
401        $this->engine->multiCell(w: 0, h: 0, txt: $this->config()->getTitle(), border: 0, align: 'C');
402        $this->engine->ln();
403
404        // TOC
405        $this->engine->setFont($this->currentFont, 'B', 16);
406        $this->engine->multiCell(w: 0, h: 0, txt: Translation::getString('msgTableOfContent'), border: 0, align: 'C');
407        $this->engine->ln();
408        $this->engine->setFont($this->currentFont, '', 12);
409
410        // Render TOC
411        $this->engine->addToc(
412            page: 1,
413            numbersfont: $this->currentFont,
414            filler: '.',
415            tocName: Translation::getString('msgTableOfContent'),
416            style: 'B',
417            color: [128, 0, 0],
418        );
419        $this->engine->endTocPage();
420    }
421
422    /**
423     * Returns the current font for PDF export.
424     */
425    public function getCurrentFont(): string
426    {
427        return $this->currentFont;
428    }
429
430    /**
431     * Sets the FAQ array.
432     *
433     * @param array<string, mixed> $faq
434     */
435    public function setFaq(array $faq): void
436    {
437        $this->faq = $faq;
438    }
439
440    /**
441     * Resolves an image source for the engine to draw, converting local and data-URI
442     * images to embedded base64 data where possible. This is necessary as the
443     * underlying PDF library does not support external images from self-signed
444     * certificates.
445     *
446     * Returns [resolvedFile, resolvedType] to draw, or null to skip the image.
447     *
448     * @param string $file Name of the file containing the image or a '@' character followed by the image data
449     *                     string. To link an image without embedding it on the document, set an asterisk
450     *                     character before the URL (i.e.: '*http://www.example.com/image.jpg').
451     * @param string $type Image format inferred from the file extension if not specified.
452     * @return array{0: string, 1: string}|null
453     */
454    private function resolveImage(string $file, string $type): ?array
455    {
456        // Pass through raw image data ('@' prefix) and non-embedded links ('*' prefix)
457        // without filesystem lookup.
458        if ($file !== '' && ($file[0] === '@' || $file[0] === '*')) {
459            return [$file, $type];
460        }
461
462        if (str_starts_with($file, 'data:')) {
463            $matches = [];
464            if (preg_match('#^data:[^;]+;base64,(.+)$#', $file, $matches)) {
465                $decoded = base64_decode($matches[1], strict: true);
466                if ($decoded !== false && $this->checkBase64Image($decoded)) {
467                    return ['@' . $decoded, $type];
468                }
469            }
470
471            return null;
472        }
473
474        $path = parse_url($file, PHP_URL_PATH);
475        if ($path === false || $path === null || $path === '') {
476            return null;
477        }
478
479        // URL-decode the file path to handle filenames with spaces and other special characters
480        $path = urldecode($path);
481
482        $type = pathinfo($path, PATHINFO_EXTENSION);
483        $resolvedPath = $this->concatenatePaths((string) PMF_ROOT_DIR, $path);
484        if ($resolvedPath === '' || !$this->isWithinRoot($resolvedPath)) {
485            return null;
486        }
487
488        if (!is_file($resolvedPath) || !is_readable($resolvedPath)) {
489            return null;
490        }
491
492        $data = file_get_contents($resolvedPath);
493        if ($data === false) {
494            return null;
495        }
496
497        if ($this->checkBase64Image($data)) {
498            return ['@' . $data, $type];
499        }
500
501        return [$path, $type];
502    }
503
504    /**
505     * Rewrites every <img> source to inline '@'-base64 data (or a resolvable
506     * local path) via resolveImage(), and removes images that cannot be
507     * resolved. Runs before the HTML reaches the PDF engine, because the
508     * modern engine renders HTML images directly without calling back into
509     * the Image() hook.
510     */
511    private function inlineLocalImages(string $html): string
512    {
513        return (string) preg_replace_callback(
514            '/<img\b[^>]*>/i',
515            /** @param array<array-key, string> $matches */
516            function (array $matches): string {
517                [$tag] = $matches;
518
519                // The engine's attribute parser is last-wins, so a tag with
520                // more than one src attribute could smuggle an unpoliced
521                // source past a first-wins rewrite. Drop such malformed tags.
522                $srcCount = preg_match_all('/(?<![\w-])src\s*=/i', $tag);
523                if ($srcCount === false || $srcCount > 1) {
524                    return '';
525                }
526
527                $srcMatch = [];
528                $found = preg_match(
529                    '/(?<![\w-])src\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'=<>`]+))/i',
530                    $tag,
531                    $srcMatch,
532                    PREG_OFFSET_CAPTURE,
533                );
534                if ($found !== 1) {
535                    return '';
536                }
537
538                /* @mago-expect analysis:docblock-type-mismatch - PREG_OFFSET_CAPTURE turns each $srcMatch element into a [text, offset] pair; mago's stub does not model the flag */
539                /** @var array<array-key, array{0: string, 1: int}> $srcMatch */
540
541                $src = '';
542                foreach ([1, 2, 3] as $group) {
543                    if (!array_key_exists($group, $srcMatch) || $srcMatch[$group][1] === -1) {
544                        continue;
545                    }
546
547                    [$src] = $srcMatch[$group];
548                    break;
549                }
550
551                if ($src === '') {
552                    return '';
553                }
554
555                if (str_starts_with($src, '@') || str_starts_with($src, '*')) {
556                    return $tag;
557                }
558
559                $resolved = $this->resolveImage($src, pathinfo($src, PATHINFO_EXTENSION));
560                if ($resolved === null) {
561                    return '';
562                }
563
564                [$file] = $resolved;
565                $newSrc = str_starts_with($file, '@') ? '@' . base64_encode(substr($file, offset: 1)) : $file;
566
567                [$attr, $attrOffset] = $srcMatch[0];
568                return substr_replace($tag, 'src="' . $newSrc . '"', $attrOffset, strlen($attr));
569            },
570            $html,
571        );
572    }
573
574    private function checkBase64Image(string $base64): bool
575    {
576        set_error_handler(static fn(): bool => true, E_WARNING);
577        try {
578            $img = imagecreatefromstring($base64);
579            if (!$img) {
580                return false;
581            }
582
583            $info = getimagesizefromstring($base64);
584        } finally {
585            restore_error_handler();
586        }
587
588        return $info && $info[0] > 0 && $info[1] > 0 && array_key_exists('mime', $info);
589    }
590
591    public function concatenatePaths(string $path, string $file): string
592    {
593        $trimmedPath = rtrim(str_replace(search: '\\', replace: '/', subject: $path), characters: '/');
594        $trimmedFile = ltrim(str_replace(search: '\\', replace: '/', subject: $file), characters: '/');
595
596        $pos = strpos($trimmedFile, needle: 'content/');
597        if ($pos === false) {
598            return '';
599        }
600
601        $relativePath = substr($trimmedFile, $pos);
602
603        return $trimmedPath . DIRECTORY_SEPARATOR . $relativePath;
604    }
605
606    /**
607     * Ensures a resolved filesystem path stays inside the phpMyFAQ web root,
608     * preventing path traversal (e.g. "../../../etc/passwd") in image sources.
609     */
610    private function isWithinRoot(string $resolvedPath): bool
611    {
612        $realPath = realpath($resolvedPath);
613        $realRoot = realpath((string) PMF_ROOT_DIR);
614
615        if ($realPath === false || $realRoot === false) {
616            return false;
617        }
618
619        return $realPath === $realRoot || str_starts_with($realPath, $realRoot . DIRECTORY_SEPARATOR);
620    }
621
622    /**
623     * Converts external images from allowed hosts to base64 data URIs in HTML content.
624     * This enables TCPDF to display external images that would otherwise fail due to SSL/certificate issues.
625     *
626     * @param string $html The HTML content to process
627     * @return string The processed HTML content with external images converted to base64
628     */
629    public function convertExternalImagesToBase64(string $html): string
630    {
631        if (!$this->config instanceof Configuration) {
632            return $html;
633        }
634
635        $allowedHosts = $this->config->getAllowedMediaHosts();
636        if ($allowedHosts === [] || count($allowedHosts) === 1 && trim($allowedHosts[0]) === '') {
637            return $html;
638        }
639
640        // Pattern to match img tags with src attributes
641        $pattern = '/<img\s+[^>]*src\s*=\s*["\']([^"\']+)["\'][^>]*>/i';
642        return preg_replace_callback(
643            $pattern,
644            function (array $matches) use ($allowedHosts): string {
645                $fullMatch = $matches[0];
646                $imageUrl = $matches[1];
647                // Parse the URL to get the host
648                $parsedUrl = parse_url($imageUrl);
649                if (!$parsedUrl || !array_key_exists('host', $parsedUrl)) {
650                    return $fullMatch; // Return original if URL is malformed
651                }
652
653                $host = $parsedUrl['host'];
654                // Check if the host is in the allowed list
655                $isAllowed = false;
656                foreach ($allowedHosts as $allowedHost) {
657                    $allowedHost = trim($allowedHost);
658                    if ($allowedHost === '') {
659                        continue;
660                    }
661
662                    if ($allowedHost === '0') {
663                        continue;
664                    }
665
666                    // Allow exact match or subdomain match
667                    if ($host === $allowedHost || str_ends_with($host, '.' . $allowedHost)) {
668                        $isAllowed = true;
669                        break;
670                    }
671                }
672
673                if (!$isAllowed) {
674                    return $fullMatch; // Return original if host not allowed
675                }
676
677                // Try to fetch the image and convert to base64
678                try {
679                    $imageData = $this->fetchExternalImage($imageUrl);
680                    if ($imageData !== false) {
681                        $base64Image = base64_encode($imageData);
682                        $mimeType = $this->getImageMimeType($imageData);
683                        if ($mimeType && $base64Image) {
684                            $fmt = 'data:%s;base64,%s';
685                            $dataUri = sprintf($fmt, $mimeType, $base64Image);
686                            return str_replace($imageUrl, $dataUri, $fullMatch);
687                        }
688                    }
689                } catch (Exception) {
690                    // If fetching fails, return the original
691                    return $fullMatch;
692                }
693
694                return $fullMatch;
695            },
696            $html,
697        ) ?? '';
698    }
699
700    /**
701     * Fetches an external image with the appropriate error handling.
702     *
703     * @param string $url The image URL to fetch
704     * @return string|false The image data or false on failure
705     */
706    private function fetchExternalImage(string $url): false|string
707    {
708        $context = stream_context_create([
709            'http' => [
710                'timeout' => 10, // 10-second timeout
711                'user_agent' => 'phpMyFAQ PDF Generator/1.0',
712                'follow_location' => true,
713                'max_redirects' => 3,
714            ],
715            'ssl' => [
716                'verify_peer' => false,
717                'verify_peer_name' => false,
718            ],
719        ]);
720
721        $imageData = file_get_contents($url, use_include_path: false, context: $context);
722
723        // Validate that we actually got image data
724        if ($imageData === false || $imageData === '') {
725            return false;
726        }
727
728        // Quick validation that this looks like image data
729        if (!$this->validateImageData($imageData)) {
730            return false;
731        }
732
733        return $imageData;
734    }
735
736    /**
737     * Validates that the given data appears to be a valid image.
738     *
739     * @param string $data The image data to validate
740     * @return bool True if data appears to be a valid image
741     */
742    private function validateImageData(string $data): bool
743    {
744        if (strlen($data) < 10) {
745            return false; // Too small to be a real image
746        }
747
748        // Check for common image file signatures
749        $signatures = [
750            'jpeg' => ["\xFF\xD8\xFF"],
751            'png' => ["\x89PNG\r\n\x1A\n"],
752            'gif' => ['GIF87a', 'GIF89a'],
753            'webp' => ['RIFF'],
754            'bmp' => ['BM'],
755        ];
756
757        foreach ($signatures as $signature) {
758            foreach ($signature as $sig) {
759                if (!str_starts_with($data, $sig)) {
760                    continue;
761                }
762
763                return true;
764            }
765        }
766
767        return false;
768    }
769
770    /**
771     * Determines the MIME type of image data.
772     *
773     * @param string $data The image data
774     * @return string|false The MIME type or false if not determined
775     */
776    private function getImageMimeType(string $data): string|false
777    {
778        $finfo = finfo_open(FILEINFO_MIME_TYPE);
779        if ($finfo === false) {
780            // Fallback to header-based detection
781            if (str_starts_with($data, "\xFF\xD8\xFF")) {
782                return 'image/jpeg';
783            }
784
785            if (str_starts_with($data, "\x89PNG\r\n\x1A\n")) {
786                return 'image/png';
787            }
788
789            if (str_starts_with($data, 'GIF87a') || str_starts_with($data, 'GIF89a')) {
790                return 'image/gif';
791            }
792
793            if (str_starts_with($data, 'RIFF')) {
794                return 'image/webp';
795            }
796
797            // Fallback to header-based detection
798            if (str_starts_with($data, 'BM')) {
799                return 'image/bmp';
800            }
801
802            return false;
803        }
804
805        $mimeType = finfo_buffer($finfo, $data);
806
807        // Ensure it's actually an image MIME type
808        if ($mimeType && str_starts_with($mimeType, 'image/')) {
809            return $mimeType;
810        }
811
812        return false;
813    }
814
815    /**
816     * Writes HTML content, pre-processing external images to base64 data URIs.
817     * This method converts external images from allowed hosts to base64 data URIs
818     * before passing the content to the engine for rendering.
819     *
820     * @param string $html HTML content to write
821     * @param bool $ln If true, the position after the call will be moved to the next line
822     * @param bool $fill Indicates if the background must be painted (true) or transparent (false)
823     * @param bool $reseth If true, reset the last cell height
824     * @param bool $cell If true, add the current left/right/top/bottom cell margins to the coordinates
825     * @param string $align Allows centering or align the image on the current line
826     */
827    public function WriteHTML(
828        string $html,
829        bool $ln = true,
830        bool $fill = false,
831        bool $reseth = false,
832        bool $cell = false,
833        string $align = '',
834    ): void {
835        // Pre-process HTML content to convert external images to base64, then delegate.
836        $html = $this->convertExternalImagesToBase64($html);
837        $this->engine->writeHtml($this->inlineLocalImages($html), $ln, $fill, $reseth, $cell, $align);
838    }
839}