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