Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.99% covered (success)
96.99%
515 / 531
96.30% covered (success)
96.30%
52 / 54
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
96.99% covered (success)
96.99%
515 / 531
96.30% covered (success)
96.30%
52 / 54
219
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 parseString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseUri
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setDocument
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseHtml
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 extractStyleBlocks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 normalizeHtmlEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseHtmlFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseHtmlUri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseCss
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseCssFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseCssUri
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDocument
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 document
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPageSize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getPageSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPageMargins
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setPageTopMargin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPageRightMargin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPageBottomMargin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPageLeftMargin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDefaultStyle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 parseCssSizeToInt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setX
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setY
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setYOverride
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPageMargins
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageTopMargin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageRightMargin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageBottomMargin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageLeftMargin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getX
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getY
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 drawBox
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 getDefaultStyle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultStyles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCss
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
11
 process
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 addNodeToDocument
100.00% covered (success)
100.00%
136 / 136
100.00% covered (success)
100.00%
1 / 1
42
 addNestedInlineText
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 parseCssColorToRgbArray
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 toRgbColor
38.46% covered (warning)
38.46%
5 / 13
0.00% covered (danger)
0.00%
0 / 1
59.68
 createDefaultStyles
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
9
 prepareNodeStyles
93.04% covered (success)
93.04%
107 / 115
0.00% covered (danger)
0.00%
0 / 1
40.54
 applySelectorProperties
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
24
 getCurrentX
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentY
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 resetY
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 newPage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getStringLines
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Pdf\Build\Html;
16
17use Pop\Css;
18use Pop\Color\Color;
19use Pop\Dom\Child;
20use Pop\Pdf\Build\Html\Form;
21use Pop\Pdf\Build\Html\Table;
22use Pop\Pdf\Document;
23
24/**
25 * Pdf HTML parser class
26 *
27 * @category   Pop
28 * @package    Pop\Pdf
29 * @author     Nick Sagona, III <nick@popphp.org>
30 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
31 * @license    https://www.popphp.org/license     New BSD License
32 * @version    6.2.0
33 */
34class Parser
35{
36
37    /**
38     * PDF document
39     * @var Document
40     */
41    protected Document $document;
42
43    /**
44     * HTML object or array of HTML objects
45     * @var Child|array|null
46     */
47    protected Child|array|null $html = null;
48
49    /**
50     * CSS object
51     * @var ?Css\Css
52     */
53    protected ?Css\Css $css = null;
54
55    /**
56     * Page size
57     * @var string|array
58     */
59    protected string|array $pageSize = 'LETTER';
60
61    /**
62     * Page margins
63     * @var array
64     */
65    protected array $pageMargins = [
66        'top'    => 80,
67        'right'  => 60,
68        'bottom' => 60,
69        'left'   => 60
70    ];
71
72    /**
73     * Default styles
74     * @var array
75     */
76    protected array $defaultStyles = [
77        'font-family' => 'Arial',
78        'font-size'   => 10,
79        'font-weight' => 'normal',
80        'color'       => [0, 0, 0],
81        'line-height' => 14
82    ];
83
84    /**
85     * Current x-position
86     * @var int
87     */
88    protected int $x = 0;
89
90    /**
91     * Current y-position
92     * @var int
93     */
94    protected int $y = 0;
95
96    /**
97     * Current page object
98     * @var ?Document\Page
99     */
100    protected ?Document\Page $page = null;
101
102    /**
103     * HTML file directory
104     * @var ?string
105     */
106    protected ?string $fileDir = null;
107
108    /**
109     * Text wrap object
110     * @var ?Document\Page\Text\Wrap
111     */
112    protected ?Document\Page\Text\Wrap $textWrap = null;
113
114    /**
115     * Y-override
116     * @var ?int
117     */
118    protected ?int $yOverride = null;
119
120    /**
121     * Constructor
122     *
123     * Instantiate the HTML parser object
124     *
125     * @param Document          $document
126     * @param string|array|null $pageSize
127     */
128    public function __construct(Document $document = new Document(), string|array|null $pageSize = null)
129    {
130        $this->setDocument($document);
131        $this->createDefaultStyles();
132        if ($pageSize !== null) {
133            if (is_array($pageSize) && (count($pageSize) == 2)) {
134                $this->setPageSize($pageSize[0], $pageSize[1]);
135            } else {
136                $this->setPageSize($pageSize);
137            }
138        }
139    }
140
141    /**
142     * Parse HTML string
143     *
144     * @param  string            $htmlString
145     * @param  Document          $document
146     * @param  string|array|null $pageSize
147     * @return Parser
148     */
149    public static function parseString(
150        string $htmlString, Document $document = new Document(), string|array|null $pageSize = null
151    ): Parser
152    {
153        $html = new self($document, $pageSize);
154        $html->parseHtml($htmlString);
155
156        return $html;
157    }
158
159    /**
160     * Parse $html from file
161     *
162     * @param  string            $htmlFile
163     * @param  Document          $document
164     * @param  string|array|null $pageSize
165     * @throws Exception
166     * @return Parser
167     */
168    public static function parseFile(
169        string $htmlFile, Document $document = new Document(), string|array|null $pageSize = null
170    ): Parser
171    {
172        $html = new self($document, $pageSize);
173        $html->parseHtmlFile($htmlFile);
174
175        return $html;
176    }
177
178    /**
179     * Parse $html from URI
180     *
181     * @param string            $htmlUri
182     * @param Document          $document
183     * @param string|array|null $pageSize
184     * @return Parser
185     */
186    public static function parseUri(
187        string $htmlUri, Document $document = new Document(), string|array|null $pageSize = null
188    ): Parser
189    {
190        $html = new self($document, $pageSize);
191        $html->parseHtmlUri($htmlUri);
192
193        return $html;
194    }
195
196    /**
197     * Set document
198     *
199     * @param  Document $document
200     * @return Parser
201     */
202    public function setDocument(Document $document): Parser
203    {
204        $this->document = $document;
205        return $this;
206    }
207
208    /**
209     * Parse HTML string
210     *
211     * @param  string  $htmlString
212     * @param  ?string $basePath
213     * @return Parser
214     */
215    public function parseHtml(string $htmlString, ?string $basePath = null): Parser
216    {
217        if ($basePath !== null) {
218            $this->fileDir = $basePath;
219        }
220        $htmlString  = $this->extractStyleBlocks($htmlString);
221        $this->html  = Child::parseString($this->normalizeHtmlEncoding($htmlString));
222        return $this;
223    }
224
225    /**
226     * Extract <style> blocks from raw HTML and feed them into the CSS engine
227     *
228     * Pop\Dom\Child::parseString() builds its tree from a DOMDocument, and
229     * libxml's HTML parser stores <style>/<script> content as a CDATA
230     * section rather than a text node - a node type Child::parseString()
231     * doesn't walk, so a <style> block's CSS text is unreachable once the
232     * string has been handed off to the DOM. Pulling it out of the raw
233     * string first (and removing the tag so it never reaches the DOM, where
234     * it would otherwise render as literal text) sidesteps that entirely.
235     *
236     * @param  string $htmlString
237     * @return string
238     */
239    protected function extractStyleBlocks(string $htmlString): string
240    {
241        return (string)preg_replace_callback('/<style\b[^>]*>(.*?)<\/style>/is', function ($matches) {
242            $this->css->parseCss($matches[1]);
243            return '';
244        }, $htmlString);
245    }
246
247    /**
248     * Normalize HTML string encoding
249     *
250     * Pop\Dom\Child::parseString() loads the string into a DOMDocument with
251     * no charset hint, and DOMDocument::loadHTML() assumes ISO-8859-1 for
252     * any markup that doesn't declare its own charset - misinterpreting
253     * multi-byte UTF-8 (e.g. non-Latin scripts) and corrupting it into
254     * mojibake. Converting non-ASCII characters to numeric HTML entities
255     * beforehand sidesteps that assumption, since entities are plain ASCII
256     * and decode correctly regardless of which encoding libxml assumes.
257     *
258     * @param  string $htmlString
259     * @return string
260     */
261    protected function normalizeHtmlEncoding(string $htmlString): string
262    {
263        return mb_encode_numericentity($htmlString, [0x80, 0x10FFFF, 0, 0x10FFFF], 'UTF-8');
264    }
265
266    /**
267     * Parse HTML string from file
268     *
269     * @param  string $htmlFile
270     * @throws Exception
271     * @return Parser
272     */
273    public function parseHtmlFile(string $htmlFile): Parser
274    {
275        if (!file_exists($htmlFile)) {
276            throw new Exception('Error: That file does not exist.');
277        }
278        return $this->parseHtml(file_get_contents($htmlFile), dirname(realpath($htmlFile)));
279    }
280
281    /**
282     * Parse HTML string from URI
283     *
284     * @param  string $htmlUri
285     * @return Parser
286     */
287    public function parseHtmlUri(string $htmlUri): Parser
288    {
289        return $this->parseHtml(file_get_contents($htmlUri));
290    }
291
292    /**
293     * Parse CSS string
294     *
295     * @param  string $cssString
296     * @return Parser
297     */
298    public function parseCss(string $cssString): Parser
299    {
300        // $this->css is never null at this point - the constructor always
301        // calls createDefaultStyles(), which unconditionally initializes it.
302        $this->css->parseCss($cssString);
303        return $this;
304    }
305
306    /**
307     * Parse CSS file
308     *
309     * @param  string $cssFile
310     * @throws \Pop\Css\Exception
311     * @return Parser
312     */
313    public function parseCssFile(string $cssFile): Parser
314    {
315        // $this->css is never null here - see parseCss()'s comment.
316        $this->css->parseCssFile($cssFile);
317        return $this;
318    }
319
320    /**
321     * Parse CSS URI
322     *
323     * @param  string $cssUri
324     * @return Parser
325     */
326    public function parseCssUri(string $cssUri): Parser
327    {
328        // $this->css is never null here - see parseCss()'s comment.
329        $this->css->parseCssUri($cssUri);
330        return $this;
331    }
332
333    /**
334     * Get document
335     *
336     * @return Document
337     */
338    public function getDocument(): Document
339    {
340        return $this->document;
341    }
342
343    /**
344     * Get document (alias)
345     *
346     * @return Document
347     */
348    public function document(): Document
349    {
350        return $this->document;
351    }
352
353    /**
354     * Get the current page
355     *
356     * @return ?Document\Page
357     */
358    public function getPage(): ?Document\Page
359    {
360        return $this->page;
361    }
362
363    /**
364     * Set page size
365     *
366     * @param  mixed $size
367     * @param  mixed $height
368     * @return Parser
369     */
370    public function setPageSize(mixed $size, mixed $height = null): Parser
371    {
372        $this->pageSize = ($height !== null) ? ['width' => $size, 'height' => $height] : $size;
373
374        return $this;
375    }
376
377    /**
378     * Get page size
379     *
380     * @return string|array
381     */
382    public function getPageSize(): string|array
383    {
384        return $this->pageSize;
385    }
386
387    /**
388     * Set page margins
389     *
390     * @param  int $top
391     * @param  int $right
392     * @param  int $bottom
393     * @param  int $left
394     * @return Parser
395     */
396    public function setPageMargins(int $top, int $right, int $bottom, int $left): Parser
397    {
398        $this->pageMargins['top']    = $top;
399        $this->pageMargins['right']  = $right;
400        $this->pageMargins['bottom'] = $bottom;
401        $this->pageMargins['left']   = $left;
402        return $this;
403    }
404
405    /**
406     * Set page top margin
407     *
408     * @param  int $margin
409     * @return Parser
410     */
411    public function setPageTopMargin(int $margin): Parser
412    {
413        $this->pageMargins['top'] = $margin;
414        return $this;
415    }
416
417    /**
418     * Set page right margin
419     *
420     * @param  int $margin
421     * @return Parser
422     */
423    public function setPageRightMargin(int $margin): Parser
424    {
425        $this->pageMargins['right'] = $margin;
426        return $this;
427    }
428
429    /**
430     * Set page bottom margin
431     *
432     * @param  int $margin
433     * @return Parser
434     */
435    public function setPageBottomMargin(int $margin): Parser
436    {
437        $this->pageMargins['bottom'] = $margin;
438        return $this;
439    }
440
441    /**
442     * Set page left margin
443     *
444     * @param  int $margin
445     * @return Parser
446     */
447    public function setPageLeftMargin(int $margin): Parser
448    {
449        $this->pageMargins['left'] = $margin;
450        return $this;
451    }
452
453    /**
454     * Set a default style
455     *
456     * @param  string $property
457     * @param  string $value
458     * @throws Exception
459     * @return Parser
460     */
461    public function setDefaultStyle(string $property, string $value): Parser
462    {
463        $this->defaultStyles[$property] = match ($property) {
464            'font-size', 'line-height' => $this->parseCssSizeToInt($property, $value),
465            'color'                    => $this->parseCssColorToRgbArray($value),
466            default                    => $value,
467        };
468
469        return $this;
470    }
471
472    /**
473     * Parse a CSS size string (e.g. "26" or "26px") into an int
474     *
475     * @param  string $property
476     * @param  string $value
477     * @throws Exception
478     * @return int
479     */
480    protected function parseCssSizeToInt(string $property, string $value): int
481    {
482        if (!preg_match('/^-?\d+(\.\d+)?/', trim($value))) {
483            throw new Exception("Error: The value for '" . $property . "' must be numeric.");
484        }
485
486        return (int)$value;
487    }
488
489    /**
490     * Set x-position
491     *
492     * @param  int $x
493     * @return Parser
494     */
495    public function setX(int $x): Parser
496    {
497        $this->x = $x;
498        return $this;
499    }
500
501    /**
502     * Set y-position
503     *
504     * @param  int $y
505     * @return Parser
506     */
507    public function setY(int $y): Parser
508    {
509        $this->y = $y;
510        return $this;
511    }
512
513    /**
514     * Set the y-position override for the next node (mirrors the mechanism
515     * already used internally for multi-page paragraph overflow)
516     *
517     * @param  ?int $y
518     * @return Parser
519     */
520    public function setYOverride(?int $y): Parser
521    {
522        $this->yOverride = $y;
523        return $this;
524    }
525
526    /**
527     * Get page margins
528     *
529     * @return array
530     */
531    public function getPageMargins(): array
532    {
533        return $this->pageMargins;
534    }
535
536    /**
537     * Get page top margin
538     *
539     * @return int
540     */
541    public function getPageTopMargin(): int
542    {
543        return $this->pageMargins['top'];
544    }
545
546    /**
547     * Get page right margin
548     *
549     * @return int
550     */
551    public function getPageRightMargin(): int
552    {
553        return $this->pageMargins['right'];
554    }
555
556    /**
557     * Get page bottom margin
558     *
559     * @return int
560     */
561    public function getPageBottomMargin(): int
562    {
563        return $this->pageMargins['bottom'];
564    }
565
566    /**
567     * Get page left margin
568     *
569     * @return int
570     */
571    public function getPageLeftMargin(): int
572    {
573        return $this->pageMargins['left'];
574    }
575
576    /**
577     * Get x-position
578     *
579     * @return int
580     */
581    public function getX(): int
582    {
583        return $this->x;
584    }
585
586    /**
587     * Get y-position
588     *
589     * @return int
590     */
591    public function getY(): int
592    {
593        return $this->y;
594    }
595
596    /**
597     * Draw a background-filled and/or bordered box
598     *
599     * @param  float $x
600     * @param  float $topY
601     * @param  float $width
602     * @param  float $height
603     * @param  array $styles
604     * @return void
605     */
606    public function drawBox(float $x, float $topY, float $width, float $height, array $styles): void
607    {
608        if (empty($styles['backgroundColor']) && empty($styles['borderWidth'])) {
609            return;
610        }
611
612        if (!empty($styles['backgroundColor'])) {
613            $path = new Document\Page\Path(Document\Page\Path::FILL);
614            $path->setFillColor(new Color\Rgb($styles['backgroundColor'][0], $styles['backgroundColor'][1], $styles['backgroundColor'][2]));
615            $path->drawRectangle($x, $topY - $height, $width, $height);
616            $this->page->addPath($path);
617        }
618
619        if (!empty($styles['borderWidth'])) {
620            $borderColor = $styles['borderColor'] ?? [0, 0, 0];
621            $path = new Document\Page\Path(Document\Page\Path::STROKE);
622            $path->setStrokeColor(new Color\Rgb($borderColor[0], $borderColor[1], $borderColor[2]));
623            $path->setStroke($styles['borderWidth']);
624            $path->drawRectangle($x, $topY - $height, $width, $height);
625            $this->page->addPath($path);
626        }
627    }
628
629    /**
630     * Get a default style
631     *
632     * font-size/line-height are stored as int and color as an [r, g, b]
633     * array (see setDefaultStyle()), so this can't be narrowed to ?string.
634     *
635     * @param  string $property
636     * @return string|int|array|null
637     */
638    public function getDefaultStyle(string $property): string|int|array|null
639    {
640        return $this->defaultStyles[$property] ?? null;
641    }
642
643    /**
644     * Get default styles
645     *
646     * @return array
647     */
648    public function getDefaultStyles(): array
649    {
650        return $this->defaultStyles;
651    }
652
653    /**
654     * Get styles
655     *
656     * @return ?Css\Css
657     */
658    public function getCss(): ?Css\Css
659    {
660        return $this->css;
661    }
662
663    /**
664     * Get HTML nodes
665     *
666     * @return array|Child|null
667     */
668    public function getHtml(): array|Child|null
669    {
670        return $this->html;
671    }
672
673    /**
674     * Prepare for conversion of HTML into PDF objects
675     *
676     * @return array|Child|null
677     */
678    public function prepare(): array|Child|null
679    {
680        $htmlNodes = null;
681        if ($this->html instanceof Child) {
682            foreach ($this->html->getChildNodes() as $child) {
683                if ($child->getNodeName() == 'head') {
684                    foreach ($child->getChildNodes() as $c) {
685                        if (($c->getNodeName() == 'link') && ($c->hasAttribute('href')) &&
686                            ($c->hasAttribute('type')) && ($c->getAttribute('type') == 'text/css')) {
687                            // $this->css is never null here - see
688                            // parseCss()'s comment.
689                            $this->css->parseCssFile($this->fileDir . '/' . $c->getAttribute('href'));
690                        }
691                    }
692                } else if ($child->getNodeName() == 'body') {
693                    $htmlNodes = $child;
694                }
695            }
696        }
697
698        if ($htmlNodes === null) {
699            $htmlNodes = $this->html;
700        }
701
702        return $htmlNodes;
703    }
704
705    /**
706     * Process conversion of HTML into PDF objects
707     *
708     * @throws Exception
709     * @return Document
710     */
711    public function process(): Document
712    {
713        $htmlNodes = $this->prepare();
714
715        if ($htmlNodes instanceof Child) {
716            foreach ($htmlNodes->getChildNodes() as $i => $child) {
717                $this->addNodeToDocument($child, $i);
718            }
719        } else {
720            foreach ($htmlNodes as $i => $child) {
721                $this->addNodeToDocument($child, $i);
722            }
723        }
724
725        return $this->document;
726    }
727
728    /**
729     * Add node to document
730     *
731     * @param  Child $child
732     * @param  int   $i
733     * @throws Exception|\Pop\Pdf\Exception
734     * @return void
735     */
736    protected function addNodeToDocument(Child $child, int $i = 0): void
737    {
738        $styles   = $this->prepareNodeStyles($child->getNodeName(), $child->getAttributes());
739        $currentX = $this->getCurrentX();
740
741        if ($this->yOverride !== null) {
742            $currentY        = $this->yOverride;
743            $this->yOverride = null;
744            // $this->y was left at whatever the last newPage() inside the
745            // orphan loop set it to (0) - not where $currentY (the override)
746            // actually is on the page. Resync it now, so the NODE AFTER this
747            // one (which reads $this->y, not $currentY) advances from the
748            // real position instead of an assumed-zero baseline.
749            if ($this->page !== null) {
750                $this->y = (int)round($this->page->getHeight() - $this->pageMargins['top'] - $currentY);
751            }
752        } else {
753            $currentY = $this->getCurrentY(($i != 0) ? $styles['marginBottom'] ?? 0 : 0);
754        }
755
756        $wrapLength = ($this->x > $this->pageMargins['left']) ?
757            $this->page->getWidth() - $this->pageMargins['right'] - $this->x :
758            $this->page->getWidth() - $this->pageMargins['right'] - $this->pageMargins['left'];
759
760        // Image node
761        if ($child->getNodeName() == 'img') {
762            $image = Document\Page\Image::createImageFromFile($this->fileDir . '/' . $child->getAttribute('src'));
763            $width = null;
764            $height = null;
765            $align = null;
766
767            if ($child->hasAttribute('width')) {
768                $width = (strpos($child->getAttribute('width'), '%')) ?
769                    $this->page->getWidth() * ((int)$child->getAttribute('width') / 100) : (int)$child->getAttribute('width');
770            } else if ($child->hasAttribute('height')) {
771                $height = (strpos($child->getAttribute('height'), '%')) ?
772                    $this->page->getHeight() * ((int)$child->getAttribute('height') / 100) : (int)$child->getAttribute('height');
773            } else if ($styles['width'] !== null) {
774                $width = (strpos($styles['width'], '%')) ?
775                    $this->page->getWidth() * ((int)$styles['width'] / 100) : (int)$styles['width'];
776            } else if ($styles['height'] !== null) {
777                $height = (strpos($styles['height'], '%')) ?
778                    $this->page->getHeight() * ((int)$styles['height'] / 100) : (int)$styles['height'];
779            }
780
781            if ($width !== null) {
782                $image->resizeToWidth($width);
783            } else if ($height !== null) {
784                $image->resizeToHeight($height);
785            }
786
787            if ($height === null) {
788                $height = ($image->getResizedHeight() !== null) ? $image->getResizedHeight() : $image->getHeight();
789            }
790
791            if ($child->hasAttribute('align')) {
792                $align = strtoupper($child->getAttribute('align'));
793            } else if (isset($styles['float'])) {
794                $align = strtoupper($styles['float']);
795            }
796
797            if ($align == 'LEFT') {
798                $box = [
799                    'left' => $currentX,
800                    'right' => $currentX + $width + ($styles['marginRight'] ?? 0),
801                    'top' => $currentY,
802                    'bottom' => $currentY - $height - ($styles['marginBottom'] ?? 0)
803                ];
804                $this->textWrap = new Document\Page\Text\Wrap('RIGHT', $this->pageMargins['left'], $this->page->getWidth() - $this->pageMargins['right'], $box);
805            } else if ($align == 'RIGHT') {
806                $box = [
807                    'left' => $this->page->getWidth() - $this->pageMargins['right'] - $width - ($styles['marginLeft'] ?? 0),
808                    'right' => $this->page->getWidth() - $this->pageMargins['right'],
809                    'top' => $currentY,
810                    'bottom' => $currentY - $height - ($styles['marginBottom'] ?? 0)
811                ];
812                $this->textWrap = new Document\Page\Text\Wrap('LEFT', $this->pageMargins['left'], $this->page->getWidth() - $this->pageMargins['right'], $box);
813            }
814
815            if ($this->textWrap !== null) {
816                $newY = $currentY - (($image->getResizedHeight() !== null) ? $image->getResizedHeight() : $image->getHeight());
817                if ($align == 'RIGHT') {
818                    $this->page->addImage($image, ($this->page->getWidth() - $this->pageMargins['right'] - $width), $newY);
819                } else {
820                    $this->page->addImage($image, $currentX, $newY);
821                }
822                $currentY -= $styles['lineHeight'];
823                $this->y += (int)round($styles['lineHeight']);
824            } else {
825                $currentY -= ($image->getResizedHeight() !== null) ? $image->getResizedHeight() : $image->getHeight();
826                $this->y += (int)round(($image->getResizedHeight() !== null) ? $image->getResizedHeight() : $image->getHeight());
827                $this->page->addImage($image, $currentX, $currentY);
828                $currentY -= $styles['lineHeight'];
829                $this->y += (int)round($styles['lineHeight']);
830            }
831        // Table node
832        } else if ($child->getNodeName() == 'table') {
833            $tableWidth = $this->page->getWidth() - $this->pageMargins['left'] - $this->pageMargins['right'];
834            Table\Layout::render($this, $child, $styles, $this->pageMargins['left'], (int) $tableWidth, (float) $currentY);
835
836        // Form node
837        } else if ($child->getNodeName() == 'form') {
838            Form\Layout::render($this, $child, $styles, $currentX, (float) $currentY);
839
840        // Bare form control node (no <form> ancestor)
841        } else if (in_array($child->getNodeName(), ['input', 'select', 'textarea', 'button'])) {
842            Form\Layout::renderBareControl($this, $child, $currentX, (float) $currentY);
843
844        // Text node
845        } else {
846            if (!empty($styles['backgroundColor']) || !empty($styles['borderWidth'])) {
847                $boxWidth  = $wrapLength;
848                $boxText   = trim((string)$child->getNodeValue());
849                if ($boxText !== '') {
850                    $fontObject = $this->document->getFont($styles['currentFont']);
851                    $lines      = $this->getStringLines($boxText, $styles['fontSize'], $boxWidth, $fontObject);
852                    $lineCount  = max(1, count($lines));
853                } else {
854                    $lineCount = 1;
855                }
856                $boxHeight = ($lineCount * $styles['lineHeight']) + $styles['paddingTop'] + $styles['paddingBottom'];
857                $this->drawBox($currentX, $currentY + $styles['paddingTop'], $boxWidth, $boxHeight, $styles);
858            }
859
860            if ($this->textWrap !== null) {
861                $box = $this->textWrap->getBox();
862                if ($this->textWrap->isRight()) {
863                    $startX = $box['right'];
864                    $startY = $currentY;
865                    $edgeX  = $wrapLength;
866                    $edgeY  = $box['bottom'];
867                } else {
868                    $startX = $currentX;
869                    $startY = $currentY;
870                    $edgeX  = $box['left'] - 40;
871                    $edgeY  = $box['bottom'];
872                }
873            } else {
874                $startX = $currentX;
875                $startY = $currentY;
876                $edgeX  = $wrapLength;
877                $edgeY  = $this->pageMargins['bottom'];
878            }
879
880            $textStream = new Document\Page\Text\Stream($startX, $startY, $edgeX, $edgeY);
881            $textStream->setCurrentStyle(
882                $styles['currentFont'],
883                $styles['fontSize'],
884                new Color\Rgb($styles['color'][0], $styles['color'][1], $styles['color'][2]),
885                $styles['textAlign']
886            );
887            $streamY = $styles['lineHeight'] ?? null;
888            if (!empty($child->getNodeValue())) {
889                $textStream->addText($child->getNodeValue(), $streamY);
890            }
891
892            if ($child->hasChildNodes()) {
893                $this->addNestedInlineText($textStream, $child->getChildNodes(), $styles);
894            }
895
896            $this->page->addTextStream($textStream);
897
898            $orphanStream = clone $textStream;
899            $hasOrphans   = false;
900            $previousOrphanContent = null;
901
902            while ($orphanStream->hasOrphans($this->document->getFonts())) {
903                $orphanStream = $orphanStream->getOrphanStream();
904
905                // A single line whose own height (e.g. an oversized CSS
906                // line-height) or a single word wider than the page can
907                // never fit no matter how many fresh pages we try - detected
908                // here as two consecutive iterations producing identical
909                // remaining content (getOrphanStream() trimmed nothing both
910                // times). Stop retrying rather than looping forever creating
911                // empty pages: the previous iteration's best-effort draw
912                // already happened, matching this project's established
913                // no-splitting-when-it-can't-fit approach.
914                $currentOrphanContent = implode('|', array_column($orphanStream->getTextStreams(), 'string'));
915                if ($currentOrphanContent === $previousOrphanContent) {
916                    break;
917                }
918                $previousOrphanContent = $currentOrphanContent;
919
920                if ($orphanStream->getCurrentY() <= $this->pageMargins['bottom']) {
921                    $currentY = $this->newPage();
922                    $orphanStream->setCurrentY($currentY);
923                }
924                // A float's box (if any) doesn't carry across a page break -
925                // once we've moved to a fresh page, the continuation always
926                // gets the full page width/height, matching what happens
927                // when no new page was needed above. Without this reset,
928                // the OLD float-derived edgeX/edgeY/startX kept constraining
929                // the stream on every subsequent page too.
930                $orphanStream->setStartX($this->pageMargins['left']);
931                $orphanStream->setEdgeX($wrapLength);
932                $orphanStream->setEdgeY($this->pageMargins['bottom']);
933                $orphanStream->setStartY($orphanStream->getCurrentY());
934
935                $orphanStream->setCurrentX($currentX);
936                $this->page->addTextStream($orphanStream);
937
938                $orphanStream = clone $orphanStream;
939                $hasOrphans = true;
940            }
941
942            if ($hasOrphans) {
943                $this->yOverride = $orphanStream->getCurrentY();
944            } else {
945                $this->yOverride = null;
946                $this->y += (int)round($textStream->measureHeight($this->document->getFonts())
947                    + ((!empty($styles['marginBottom'])) ? $styles['marginBottom'] : 0));
948            }
949        }
950    }
951
952    /**
953     * Recursively walk a node's descendants, adding each one's own text to the stream
954     *
955     * A text node's value attaches only to its immediate parent element, which can sit
956     * arbitrarily deep below the block element addNodeToDocument() is rendering (e.g.
957     * <p><b><i>text</i></b></p> - the text belongs to <i>, two levels below <b>). Only
958     * descending into the immediate child, as opposed to recursing all the way down,
959     * would silently drop that text.
960     *
961     * @param  Document\Page\Text\Stream $textStream
962     * @param  array                     $nodes
963     * @param  array                     $parentStyles
964     * @return void
965     */
966    protected function addNestedInlineText(Document\Page\Text\Stream $textStream, array $nodes, array $parentStyles): void
967    {
968        foreach ($nodes as $node) {
969            $styles = $this->prepareNodeStyles($node->getNodeName(), $node->getAttributes(), $parentStyles);
970            $textStream->setCurrentStyle(
971                $styles['currentFont'],
972                $styles['fontSize'],
973                new Color\Rgb($styles['color'][0], $styles['color'][1], $styles['color'][2])
974            );
975            $streamY = $styles['lineHeight'] ?? null;
976            if (!empty($node->getNodeValue())) {
977                $textStream->addText($node->getNodeValue(), $streamY, ($node->getNodeName() == 'br'));
978            }
979
980            if ($node->hasChildNodes()) {
981                $this->addNestedInlineText($textStream, $node->getChildNodes(), $styles);
982            }
983        }
984    }
985
986    /**
987     * Parse a CSS color string into an RGB array, tolerating a value that Color::parse()
988     * already resolves to Color\Rgb (which has no toRgb() method to convert from itself)
989     *
990     * @param  string $colorString
991     * @throws Exception
992     * @return array
993     */
994    protected function parseCssColorToRgbArray(string $colorString): array
995    {
996        try {
997            $rgbColor = $this->toRgbColor(Color::parse($colorString));
998        } catch (Color\Exception $exception) {
999            throw new Exception("Error: The color value '" . $colorString . "' is not in a supported CSS color format.");
1000        }
1001
1002        return $rgbColor->toArray(false);
1003    }
1004
1005    /**
1006     * Normalize any Color\ColorInterface value to Color\Rgb
1007     *
1008     * ColorInterface doesn't declare toRgb() (Color\Rgb has no such method to
1009     * convert from itself), so every other concrete color type it might
1010     * resolve to is matched explicitly here.
1011     *
1012     * @param  Color\ColorInterface $color
1013     * @return Color\Rgb
1014     */
1015    protected function toRgbColor(Color\ColorInterface $color): Color\Rgb
1016    {
1017        return match (true) {
1018            $color instanceof Color\Rgb      => $color,
1019            $color instanceof Color\Cmyk     => $color->toRgb(),
1020            $color instanceof Color\Grayscale => $color->toRgb(),
1021            $color instanceof Color\Hex      => $color->toRgb(),
1022            $color instanceof Color\Hsb      => $color->toRgb(),
1023            $color instanceof Color\Hsl      => $color->toRgb(),
1024            $color instanceof Color\Hsv      => $color->toRgb(),
1025            $color instanceof Color\Hwb      => $color->toRgb(),
1026            $color instanceof Color\Lab      => $color->toRgb(),
1027            $color instanceof Color\Lch      => $color->toRgb(),
1028            $color instanceof Color\Oklab    => $color->toRgb(),
1029            $color instanceof Color\Oklch    => $color->toRgb(),
1030            default => throw new Exception('Error: That CSS color type is not supported.'),
1031        };
1032    }
1033
1034    /**
1035     * Create default styles
1036     *
1037     * @return void
1038     */
1039    protected function createDefaultStyles(): void
1040    {
1041        $h1 = new Css\Selector('h1');
1042        $h1['margin-bottom'] = '18px';
1043        $h1['font-size']     = '32px';
1044        $h1['font-weight']   = 'bold';
1045
1046        $h2 = new Css\Selector('h2');
1047        $h2['margin-bottom'] = '18px';
1048        $h2['font-size']     = '28px';
1049        $h2['font-weight']   = 'bold';
1050
1051        $h3 = new Css\Selector('h3');
1052        $h3['margin-bottom'] = '16px';
1053        $h3['font-size']     = '24px';
1054        $h3['font-weight']   = 'bold';
1055
1056        $h4 = new Css\Selector('h4');
1057        $h4['margin-bottom'] = '14px';
1058        $h4['font-size']     = '20px';
1059        $h4['font-weight']   = 'bold';
1060
1061        $h5 = new Css\Selector('h5');
1062        $h5['margin-bottom'] = '12px';
1063        $h5['font-size']     = '16px';
1064        $h5['font-weight']   = 'bold';
1065
1066        $h6 = new Css\Selector('h6');
1067        $h6['margin-bottom'] = '10px';
1068        $h6['font-size']     = '14px';
1069        $h6['font-weight']   = 'bold';
1070
1071        $p = new Css\Selector('p');
1072        $p['margin-bottom'] = '24px';
1073        $p['font-size']     = '12px';
1074
1075        $a = new Css\Selector('a');
1076        $a['color'] = new Color\Rgb(0, 0, 255);
1077
1078        $strong = new Css\Selector('strong');
1079        $strong['font-weight'] = 'bold';
1080
1081        $em = new Css\Selector('em');
1082        $em['font-style'] = 'italic';
1083
1084        $this->css = new Css\Css();
1085        $this->css->addSelectors([$h1, $h2, $h3, $h4, $h5, $h6, $p, $a, $strong, $em]);
1086
1087        if (!($this->document->hasFont('Arial'))) {
1088            $this->document->addFont(new Document\Font('Arial'));
1089        }
1090        if (!($this->document->hasFont('Arial,Bold'))) {
1091            $this->document->addFont(new Document\Font('Arial,Bold'));
1092        }
1093        if (!($this->document->hasFont('Arial,Italic'))) {
1094            $this->document->addFont(new Document\Font('Arial,Italic'));
1095        }
1096        if (!($this->document->hasFont('Arial,BoldItalic'))) {
1097            $this->document->addFont(new Document\Font('Arial,BoldItalic'));
1098        }
1099        if (!($this->document->hasFont('TimesNewRoman'))) {
1100            $this->document->addFont(new Document\Font('TimesNewRoman'));
1101        }
1102        if (!($this->document->hasFont('TimesNewRoman,Bold'))) {
1103            $this->document->addFont(new Document\Font('TimesNewRoman,Bold'));
1104        }
1105        if (!($this->document->hasFont('TimesNewRoman,Italic'))) {
1106            $this->document->addFont(new Document\Font('TimesNewRoman,Italic'));
1107        }
1108        if (!($this->document->hasFont('TimesNewRoman,BoldItalic'))) {
1109            $this->document->addFont(new Document\Font('TimesNewRoman,BoldItalic'));
1110        }
1111    }
1112
1113    /**
1114     * Prepare node styles
1115     *
1116     * @param  string $name
1117     * @param  array  $attribs
1118     * @param  array  $currentStyles
1119     * @throws Exception
1120     * @return array
1121     */
1122    public function prepareNodeStyles(string $name, array $attribs = [], array $currentStyles = []): array
1123    {
1124        $styles = [
1125            'currentFont'   => null,
1126            'fontFamily'    => $currentStyles['fontFamily'] ?? $this->defaultStyles['font-family'],
1127            'fontSize'      => $currentStyles['fontSize'] ?? $this->defaultStyles['font-size'],
1128            'fontWeight'    => $currentStyles['fontWeight'] ?? $this->defaultStyles['font-weight'],
1129            'float'         => null,
1130            'width'         => null,
1131            'height'        => null,
1132            'color'         => $currentStyles['color'] ?? $this->defaultStyles['color'],
1133            'lineHeight'    => $currentStyles['lineHeight'] ?? $this->defaultStyles['line-height'],
1134            'marginTop'     => 0,
1135            'paddingTop'    => 0,
1136            'marginRight'   => 0,
1137            'paddingRight'  => 0,
1138            'marginBottom'  => 0,
1139            'paddingBottom' => 0,
1140            'marginLeft'      => 0,
1141            'paddingLeft'     => 0,
1142            'textAlign'       => null,
1143            'borderWidth'     => 0,
1144            'borderColor'     => [0, 0, 0],
1145            'backgroundColor' => null
1146        ];
1147
1148        if (in_array($name, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])) {
1149            switch ($name) {
1150                case 'h1':
1151                    $styles['fontSize']   = round($styles['fontSize'] * 2.67); // 32
1152                    $styles['fontWeight'] = 'bold';
1153                    break;
1154                case 'h2':
1155                    $styles['fontSize']   = round($styles['fontSize'] * 2.33);  // 28
1156                    $styles['fontWeight'] = 'bold';
1157                    break;
1158                case 'h3':
1159                    $styles['fontSize']   = $styles['fontSize'] * 2;  // 24
1160                    $styles['fontWeight'] = 'bold';
1161                    break;
1162                case 'h4':
1163                    $styles['fontSize']   = round($styles['fontSize'] * 1.67); // 20
1164                    $styles['fontWeight'] = 'bold';
1165                    break;
1166                case 'h5':
1167                    $styles['fontSize']   = round($styles['fontSize'] * 1.33);  // 16
1168                    $styles['fontWeight'] = 'bold';
1169                    break;
1170                case 'h6':
1171                    $styles['fontWeight'] = 'bold';
1172                    break;
1173            }
1174        }
1175
1176        if ($this->css->hasSelector($name)) {
1177            $styles = $this->applySelectorProperties($styles, $this->css[$name]);
1178        }
1179
1180        if (isset($attribs['id']) && $this->css->hasSelector('#' . $attribs['id'])) {
1181            $styles = $this->applySelectorProperties($styles, $this->css['#' . $attribs['id']]);
1182        }
1183
1184        if (isset($attribs['class']) && $this->css->hasSelector('.' . $attribs['class'])) {
1185            $styles = $this->applySelectorProperties($styles, $this->css['.' . $attribs['class']]);
1186        }
1187
1188        // Inline style="..." takes precedence over tag/#id/.class selectors,
1189        // matching normal CSS cascade/specificity - applied last so it wins.
1190        if (!empty($attribs['style'])) {
1191            $inlineSelector = Css\Css::parseString('.__inline__{' . $attribs['style'] . '}')->getSelector('.__inline__');
1192            if ($inlineSelector !== null) {
1193                $styles = $this->applySelectorProperties($styles, $inlineSelector);
1194            }
1195        }
1196
1197        if (str_contains($styles['fontFamily'], ',')) {
1198            $fonts = explode(',', $styles['fontFamily']);
1199            foreach ($fonts as $font) {
1200                $font = trim($font);
1201                if ($this->document->hasFont($font)) {
1202                    $styles['currentFont'] = $font;
1203                    break;
1204                } else if ($this->document->hasFont(str_replace(' ', '-', $font))) {
1205                    $styles['currentFont'] = str_replace(' ', '-', $font);
1206                } else if ($this->document->hasFont(str_replace(' ', ',', $font))) {
1207                    $styles['currentFont'] = str_replace(' ', ',', $font);
1208                } else if ($this->document->hasFont(str_replace(' ', '', $font))) {
1209                    $styles['currentFont'] = str_replace(' ', '', $font);
1210                }
1211            }
1212        } else {
1213            $styles['currentFont'] = $styles['fontFamily'];
1214        }
1215
1216        if ($styles['currentFont'] === null) {
1217            throw new Exception('Error: No available font has been detected.');
1218        } else if ($styles['currentFont'] == 'sans-serif') {
1219            $styles['currentFont'] = 'Arial';
1220        } else if ($styles['currentFont'] == 'serif') {
1221            $styles['currentFont'] = 'TimesNewRoman';
1222        }
1223
1224        if ($styles['fontWeight'] == 'bold') {
1225            if ($this->document->hasFont($styles['currentFont'] . 'Bold')) {
1226                $styles['currentFont'] .= 'Bold';
1227            } else if ($this->document->hasFont($styles['currentFont'] . '-Bold')) {
1228                $styles['currentFont'] .= '-Bold';
1229            } else if ($this->document->hasFont($styles['currentFont'] . ',Bold')) {
1230                $styles['currentFont'] .= ',Bold';
1231            }
1232        }
1233
1234        if (!($this->document->hasFont($styles['currentFont']))) {
1235            $standardFonts = Document\Font::standardFonts();
1236            if (in_array($styles['currentFont'], $standardFonts)) {
1237                $this->document->addFont(new Document\Font($styles['currentFont']));
1238            } else if (in_array(str_replace(' ', '-', $styles['currentFont']), $standardFonts)) {
1239                $styles['currentFont'] = str_replace(' ', '-', $styles['currentFont']);
1240                $this->document->addFont(new Document\Font($styles['currentFont']));
1241            } else if (in_array(str_replace(' ', ',', $styles['currentFont']), $standardFonts)) {
1242                $styles['currentFont'] = str_replace(' ', ',', $styles['currentFont']);
1243                $this->document->addFont(new Document\Font($styles['currentFont']));
1244            } else if (in_array(str_replace(' ', '', $styles['currentFont']), $standardFonts)) {
1245                $styles['currentFont'] = str_replace(' ', '', $styles['currentFont']);
1246                $this->document->addFont(new Document\Font($styles['currentFont']));
1247            } else {
1248                throw new Exception('Error: The current font has not been added to the document.');
1249            }
1250
1251            if ($styles['fontWeight'] == 'bold') {
1252                if ($this->document->hasFont($styles['currentFont'] . 'Bold')) {
1253                    $styles['currentFont'] .= 'Bold';
1254                } else if ($this->document->hasFont($styles['currentFont'] . '-Bold')) {
1255                    $styles['currentFont'] .= '-Bold';
1256                } else if ($this->document->hasFont($styles['currentFont'] . ',Bold')) {
1257                    $styles['currentFont'] .= ',Bold';
1258                } else if (in_array($styles['currentFont'] . 'Bold', $standardFonts)) {
1259                    $styles['currentFont'] .= 'Bold';
1260                    $this->document->addFont(new Document\Font($styles['currentFont']));
1261                } else if (in_array($styles['currentFont'] . '-Bold', $standardFonts)) {
1262                    $styles['currentFont'] .= '-Bold';
1263                    $this->document->addFont(new Document\Font($styles['currentFont']));
1264                } else if (in_array($styles['currentFont'] . ',Bold', $standardFonts)) {
1265                    $styles['currentFont'] .= ',Bold';
1266                    $this->document->addFont(new Document\Font($styles['currentFont']));
1267                }
1268            }
1269        }
1270
1271        return $styles;
1272    }
1273
1274    /**
1275     * Apply a CSS selector's properties onto a styles array
1276     *
1277     * Shared by the tag/#id/.class selector matches and by an inline
1278     * style="..." attribute (parsed into a one-off Selector) in
1279     * prepareNodeStyles(), so all four sources read the same property set.
1280     *
1281     * @param  array         $styles
1282     * @param  Css\Selector  $selector
1283     * @return array
1284     */
1285    protected function applySelectorProperties(array $styles, Css\Selector $selector): array
1286    {
1287        if ($selector->hasProperty('font-family')) {
1288            $styles['fontFamily'] = str_replace('"', '', $selector['font-family']);
1289        }
1290        if ($selector->hasProperty('font-size')) {
1291            $styles['fontSize'] = (int)$selector['font-size'];
1292        }
1293        if ($selector->hasProperty('font-weight')) {
1294            $styles['fontWeight'] = $selector['font-weight'];
1295        }
1296        if ($selector->hasProperty('color')) {
1297            $styles['color'] = $selector['color'];
1298            if (is_string($styles['color'])) {
1299                $styles['color'] = $this->parseCssColorToRgbArray($styles['color']);
1300            }
1301        }
1302        if ($selector->hasProperty('float')) {
1303            $styles['float'] = $selector['float'];
1304        }
1305        if ($selector->hasProperty('width')) {
1306            $styles['width'] = $selector['width'];
1307        }
1308        if ($selector->hasProperty('height')) {
1309            $styles['height'] = $selector['height'];
1310        }
1311        if ($selector->hasProperty('line-height')) {
1312            $styles['lineHeight'] = (int)$selector['line-height'];
1313        }
1314        if ((int)$selector['margin-top'] > 0) {
1315            $styles['marginTop'] = (int)$selector['margin-top'];
1316        }
1317        if ((int)$selector['padding-top'] > 0) {
1318            $styles['paddingTop'] = (int)$selector['padding-top'];
1319        }
1320        if ((int)$selector['margin-right'] > 0) {
1321            $styles['marginRight'] = (int)$selector['margin-right'];
1322        }
1323        if ((int)$selector['padding-right'] > 0) {
1324            $styles['paddingRight'] = (int)$selector['padding-right'];
1325        }
1326        if ((int)$selector['margin-bottom'] > 0) {
1327            $styles['marginBottom'] = (int)$selector['margin-bottom'];
1328        }
1329        if ((int)$selector['padding-bottom'] > 0) {
1330            $styles['paddingBottom'] = (int)$selector['padding-bottom'];
1331        }
1332        if ((int)$selector['margin-left'] > 0) {
1333            $styles['marginLeft'] = (int)$selector['margin-left'];
1334        }
1335        if ((int)$selector['padding-left'] > 0) {
1336            $styles['paddingLeft'] = (int)$selector['padding-left'];
1337        }
1338        if ($selector->hasProperty('text-align')) {
1339            $styles['textAlign'] = $selector['text-align'];
1340        }
1341        if ($selector->hasProperty('border-width')) {
1342            $styles['borderWidth'] = (int)$selector['border-width'];
1343        }
1344        if ($selector->hasProperty('border-color')) {
1345            $borderColor = $selector['border-color'];
1346            if (is_string($borderColor)) {
1347                $borderColor = $this->parseCssColorToRgbArray($borderColor);
1348            }
1349            $styles['borderColor'] = $borderColor;
1350        }
1351        if ($selector->hasProperty('background-color')) {
1352            $bgColor = $selector['background-color'];
1353            if (is_string($bgColor)) {
1354                $bgColor = $this->parseCssColorToRgbArray($bgColor);
1355            }
1356            $styles['backgroundColor'] = $bgColor;
1357        }
1358
1359        return $styles;
1360    }
1361
1362    /**
1363     * Get current X-position
1364     *
1365     * @return int
1366     */
1367    protected function getCurrentX(): int
1368    {
1369        if ($this->x < $this->pageMargins['left']) {
1370            $this->x = $this->pageMargins['left'];
1371        }
1372        return $this->x;
1373    }
1374
1375    /**
1376     * Get current Y-position
1377     *
1378     * @param  int $marginBottom
1379     * @return int
1380     */
1381    public function getCurrentY($marginBottom = 0): int
1382    {
1383        if (!($this->document->hasPages())) {
1384            $this->page = (is_array($this->pageSize)) ?
1385                new Document\Page($this->pageSize['width'], $this->pageSize['height']) : new Document\Page($this->pageSize);
1386            $this->document->addPage($this->page);
1387        } else {
1388            $this->page = $this->document->getPage($this->document->getCurrentPage());
1389        }
1390
1391        $currentY = $this->page->getHeight() - $this->pageMargins['top'] - $this->y - $marginBottom;
1392
1393        if ($currentY <= $this->pageMargins['bottom']) {
1394            $this->page = (is_array($this->pageSize)) ?
1395                new Document\Page($this->pageSize['width'], $this->pageSize['height']) : new Document\Page($this->pageSize);
1396            $this->document->addPage($this->page);
1397            $currentY = $this->resetY();
1398        }
1399
1400        return $currentY;
1401    }
1402
1403    /**
1404     * Reset Y-position
1405     *
1406     * @return int
1407     */
1408    protected function resetY(): int
1409    {
1410        // resetY()'s only call site is inside getCurrentY(), reached after
1411        // getCurrentY() has already created or fetched a page - the document
1412        // always has pages by the time resetY() runs.
1413        $this->page = $this->document->getPage($this->document->getCurrentPage());
1414
1415        $this->y  = 0;
1416
1417        return $this->page->getHeight() - $this->pageMargins['top'];
1418    }
1419
1420    /**
1421     * Create new page
1422     *
1423     * @return int
1424     */
1425    public function newPage(): int
1426    {
1427        $this->page = (is_array($this->pageSize)) ?
1428            new Document\Page($this->pageSize['width'], $this->pageSize['height']) : new Document\Page($this->pageSize);
1429        $this->document->addPage($this->page);
1430        $this->y = 0;
1431        return $this->page->getHeight() - $this->pageMargins['top'] - $this->y;
1432    }
1433
1434    /**
1435     * Get string lines
1436     *
1437     * @param  string        $string
1438     * @param  int           $fontSize
1439     * @param  int           $wrapLength
1440     * @param  Document\Font $fontObject
1441     * @return array
1442     */
1443    public function getStringLines(string $string, int $fontSize, int $wrapLength, Document\Font $fontObject): array
1444    {
1445        $strings   = [];
1446        $curString = '';
1447        $words     = explode(' ', $string);
1448
1449        foreach ($words as $word) {
1450            $newString = ($curString != '') ? $curString . ' ' . $word : $word;
1451            if ($fontObject->getStringWidth($newString, $fontSize) <= $wrapLength) {
1452                $curString = $newString;
1453            } else {
1454                $strings[] = $curString;
1455                $curString = $word;
1456            }
1457        }
1458        if (!empty($curString)) {
1459            $strings[] = $curString;
1460        }
1461
1462        return $strings;
1463    }
1464
1465}