Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.55% covered (success)
96.55%
532 / 551
80.65% covered (warning)
80.65%
25 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Html
96.55% covered (success)
96.55%
532 / 551
80.65% covered (warning)
80.65%
25 / 31
201
0.00% covered (danger)
0.00%
0 / 1
 addHtml
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
4.02
 parseInlineStyle
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
17
 parseNode
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
10
 parseChildNodes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 parseParagraph
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parseInput
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 parseHeading
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseText
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 parseProperty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseSpan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseTable
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 parseRow
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseCell
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 shouldAddTextRun
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 recursiveParseStylesInHierarchy
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 filterOutNonInheritedStyles
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 parseList
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 getListStyle
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 parseListItem
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parseStyle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseStyleDeclarations
97.01% covered (success)
97.01%
130 / 134
0.00% covered (danger)
0.00%
0 / 1
47
 parseImage
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
1 / 1
25
 mapBorderStyle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 mapBorderColor
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 mapAlign
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 mapAlignVertical
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
16.96
 mapListType
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
13.12
 parseLineBreak
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseLink
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 parseHorizRule
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 convertRgb
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * This file is part of PHPWord - A pure PHP library for reading and writing
4 * word processing documents.
5 *
6 * PHPWord is free software distributed under the terms of the GNU Lesser
7 * General Public License version 3 as published by the Free Software Foundation.
8 *
9 * For the full copyright and license information, please read the LICENSE
10 * file that was distributed with this source code. For the full list of
11 * contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
12 *
13 * @see         https://github.com/PHPOffice/PHPWord
14 *
15 * @license     http://www.gnu.org/licenses/lgpl.txt LGPL version 3
16 */
17
18namespace PhpOffice\PhpWord\Shared;
19
20use DOMAttr;
21use DOMDocument;
22use DOMNode;
23use DOMXPath;
24use Exception;
25use PhpOffice\PhpWord\Element\AbstractContainer;
26use PhpOffice\PhpWord\Element\Row;
27use PhpOffice\PhpWord\Element\Table;
28use PhpOffice\PhpWord\Settings;
29use PhpOffice\PhpWord\SimpleType\Jc;
30use PhpOffice\PhpWord\SimpleType\NumberFormat;
31use PhpOffice\PhpWord\Style\Paragraph;
32
33/**
34 * Common Html functions.
35 *
36 * @SuppressWarnings(PHPMD.UnusedPrivateMethod) For readWPNode
37 */
38class Html
39{
40    private const RGB_REGEXP = '/^\s*rgb\s*[(]\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*[)]\s*$/';
41
42    protected static $listIndex = 0;
43
44    protected static $xpath;
45
46    protected static $options;
47
48    /**
49     * @var Css
50     */
51    protected static $css;
52
53    /**
54     * Add HTML parts.
55     *
56     * Note: $stylesheet parameter is removed to avoid PHPMD error for unused parameter
57     * Warning: Do not pass user-generated HTML here, as that would allow an attacker to read arbitrary
58     * files or perform server-side request forgery by passing local file paths or URLs in <img>.
59     *
60     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element Where the parts need to be added
61     * @param string $html The code to parse
62     * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag
63     * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed
64     */
65    public static function addHtml($element, $html, $fullHTML = false, $preserveWhiteSpace = true, $options = null): void
66    {
67        /*
68         * @todo parse $stylesheet for default styles.  Should result in an array based on id, class and element,
69         * which could be applied when such an element occurs in the parseNode function.
70         */
71        static::$options = $options;
72
73        // Preprocess: remove all line ends, decode HTML entity,
74        // fix ampersand and angle brackets and add body tag for HTML fragments
75        $html = str_replace(["\n", "\r"], '', $html);
76        $html = str_replace(['&lt;', '&gt;', '&amp;', '&quot;'], ['_lt_', '_gt_', '_amp_', '_quot_'], $html);
77        $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
78        $html = str_replace('&', '&amp;', $html);
79        $html = str_replace(['_lt_', '_gt_', '_amp_', '_quot_'], ['&lt;', '&gt;', '&amp;', '&quot;'], $html);
80
81        if (false === $fullHTML) {
82            $html = '<body>' . $html . '</body>';
83        }
84
85        // Load DOM
86        if (\PHP_VERSION_ID < 80000) {
87            $orignalLibEntityLoader = libxml_disable_entity_loader(true);
88        }
89        $dom = new DOMDocument();
90        $dom->preserveWhiteSpace = $preserveWhiteSpace;
91        $dom->loadXML($html);
92        static::$xpath = new DOMXPath($dom);
93        $node = $dom->getElementsByTagName('body');
94
95        static::parseNode($node->item(0), $element);
96        if (\PHP_VERSION_ID < 80000) {
97            libxml_disable_entity_loader($orignalLibEntityLoader);
98        }
99    }
100
101    /**
102     * parse Inline style of a node.
103     *
104     * @param DOMNode $node Node to check on attributes and to compile a style array
105     * @param array<string, mixed> $styles is supplied, the inline style attributes are added to the already existing style
106     *
107     * @return array
108     */
109    protected static function parseInlineStyle($node, $styles = [])
110    {
111        if (XML_ELEMENT_NODE == $node->nodeType) {
112            $attributes = $node->attributes; // get all the attributes(eg: id, class)
113
114            $attributeDir = $attributes->getNamedItem('dir');
115            $attributeDirValue = $attributeDir ? $attributeDir->nodeValue : '';
116            $bidi = $attributeDirValue === 'rtl';
117            foreach ($attributes as $attribute) {
118                $val = $attribute->value;
119                switch (strtolower($attribute->name)) {
120                    case 'align':
121                        $styles['alignment'] = self::mapAlign(trim($val), $bidi);
122
123                        break;
124                    case 'lang':
125                        $styles['lang'] = $val;
126
127                        break;
128                    case 'width':
129                        // tables, cells
130                        if (false !== strpos($val, '%')) {
131                            // e.g. <table width="100%"> or <td width="50%">
132                            $styles['width'] = (int) $val * 50;
133                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
134                        } else {
135                            // e.g. <table width="250> where "250" = 250px (always pixels)
136                            $styles['width'] = Converter::pixelToTwip($val);
137                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
138                        }
139
140                        break;
141                    case 'cellspacing':
142                        // tables e.g. <table cellspacing="2">,  where "2" = 2px (always pixels)
143                        $val = (int) $val . 'px';
144                        $styles['cellSpacing'] = Converter::cssToTwip($val);
145
146                        break;
147                    case 'bgcolor':
148                        // tables, rows, cells e.g. <tr bgColor="#FF0000">
149                        $styles['bgColor'] = self::convertRgb($val);
150
151                        break;
152                    case 'valign':
153                        // cells e.g. <td valign="middle">
154                        if (preg_match('#(?:top|bottom|middle|baseline)#i', $val, $matches)) {
155                            $styles['valign'] = self::mapAlignVertical($matches[0]);
156                        }
157
158                        break;
159                }
160            }
161
162            $attributeIdentifier = $attributes->getNamedItem('id');
163            if ($attributeIdentifier && self::$css) {
164                $styles = self::parseStyleDeclarations(self::$css->getStyle('#' . $attributeIdentifier->nodeValue), $styles);
165            }
166
167            $attributeClass = $attributes->getNamedItem('class');
168            if ($attributeClass) {
169                if (self::$css) {
170                    $styles = self::parseStyleDeclarations(self::$css->getStyle('.' . $attributeClass->nodeValue), $styles);
171                }
172                $styles['className'] = $attributeClass->nodeValue;
173            }
174
175            $attributeStyle = $attributes->getNamedItem('style');
176            if ($attributeStyle) {
177                $styles = self::parseStyle($attributeStyle, $styles);
178            }
179        }
180
181        return $styles;
182    }
183
184    /**
185     * Parse a node and add a corresponding element to the parent element.
186     *
187     * @param DOMNode $node node to parse
188     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element object to add an element corresponding with the node
189     * @param array $styles Array with all styles
190     * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems
191     */
192    protected static function parseNode($node, $element, $styles = [], $data = []): void
193    {
194        if ($node->nodeName == 'style') {
195            self::$css = new Css($node->textContent);
196            self::$css->process();
197
198            return;
199        }
200
201        // Populate styles array
202        $styleTypes = ['font', 'paragraph', 'list', 'table', 'row', 'cell'];
203        foreach ($styleTypes as $styleType) {
204            if (!isset($styles[$styleType])) {
205                $styles[$styleType] = [];
206            }
207        }
208
209        // Node mapping table
210        $nodes = [
211            // $method        $node   $element    $styles     $data   $argument1      $argument2
212            'p' => ['Paragraph',   $node,  $element,   $styles,    null,   null,           null],
213            'h1' => ['Heading',     null,   $element,   $styles,    null,   'Heading1',     null],
214            'h2' => ['Heading',     null,   $element,   $styles,    null,   'Heading2',     null],
215            'h3' => ['Heading',     null,   $element,   $styles,    null,   'Heading3',     null],
216            'h4' => ['Heading',     null,   $element,   $styles,    null,   'Heading4',     null],
217            'h5' => ['Heading',     null,   $element,   $styles,    null,   'Heading5',     null],
218            'h6' => ['Heading',     null,   $element,   $styles,    null,   'Heading6',     null],
219            '#text' => ['Text',        $node,  $element,   $styles,    null,   null,           null],
220            'strong' => ['Property',    null,   null,       $styles,    null,   'bold',         true],
221            'b' => ['Property',    null,   null,       $styles,    null,   'bold',         true],
222            'em' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
223            'i' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
224            'u' => ['Property',    null,   null,       $styles,    null,   'underline',    'single'],
225            'sup' => ['Property',    null,   null,       $styles,    null,   'superScript',  true],
226            'sub' => ['Property',    null,   null,       $styles,    null,   'subScript',    true],
227            'span' => ['Span',        $node,  null,       $styles,    null,   null,           null],
228            'font' => ['Span',        $node,  null,       $styles,    null,   null,           null],
229            'table' => ['Table',       $node,  $element,   $styles,    null,   null,           null],
230            'tr' => ['Row',         $node,  $element,   $styles,    null,   null,           null],
231            'td' => ['Cell',        $node,  $element,   $styles,    null,   null,           null],
232            'th' => ['Cell',        $node,  $element,   $styles,    null,   null,           null],
233            'ul' => ['List',        $node,  $element,   $styles,    $data,  null,           null],
234            'ol' => ['List',        $node,  $element,   $styles,    $data,  null,           null],
235            'li' => ['ListItem',    $node,  $element,   $styles,    $data,  null,           null],
236            'img' => ['Image',       $node,  $element,   $styles,    null,   null,           null],
237            'br' => ['LineBreak',   null,   $element,   $styles,    null,   null,           null],
238            'a' => ['Link',        $node,  $element,   $styles,    null,   null,           null],
239            'input' => ['Input',       $node,  $element,   $styles,    null,   null,           null],
240            'hr' => ['HorizRule',   $node,  $element,   $styles,    null,   null,           null],
241        ];
242
243        $newElement = null;
244        $keys = ['node', 'element', 'styles', 'data', 'argument1', 'argument2'];
245
246        if (isset($nodes[$node->nodeName])) {
247            // Execute method based on node mapping table and return $newElement or null
248            // Arguments are passed by reference
249            $arguments = [];
250            $args = [];
251            [$method, $args[0], $args[1], $args[2], $args[3], $args[4], $args[5]] = $nodes[$node->nodeName];
252            for ($i = 0; $i <= 5; ++$i) {
253                if ($args[$i] !== null) {
254                    $arguments[$keys[$i]] = &$args[$i];
255                }
256            }
257            $method = "parse{$method}";
258            $newElement = call_user_func_array(['PhpOffice\PhpWord\Shared\Html', $method], array_values($arguments));
259
260            // Retrieve back variables from arguments
261            foreach ($keys as $key) {
262                if (array_key_exists($key, $arguments)) {
263                    $$key = $arguments[$key];
264                }
265            }
266        }
267
268        if ($newElement === null) {
269            $newElement = $element;
270        }
271
272        static::parseChildNodes($node, $newElement, $styles, $data);
273    }
274
275    /**
276     * Parse child nodes.
277     *
278     * @param DOMNode $node
279     * @param \PhpOffice\PhpWord\Element\AbstractContainer|Row|Table $element
280     * @param array $styles
281     * @param array $data
282     */
283    protected static function parseChildNodes($node, $element, $styles, $data): void
284    {
285        if ('li' != $node->nodeName) {
286            $cNodes = $node->childNodes;
287            if (!empty($cNodes)) {
288                foreach ($cNodes as $cNode) {
289                    if ($element instanceof AbstractContainer || $element instanceof Table || $element instanceof Row) {
290                        self::parseNode($cNode, $element, $styles, $data);
291                    }
292                }
293            }
294        }
295    }
296
297    /**
298     * Parse paragraph node.
299     *
300     * @param DOMNode $node
301     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
302     * @param array &$styles
303     *
304     * @return \PhpOffice\PhpWord\Element\PageBreak|\PhpOffice\PhpWord\Element\TextRun
305     */
306    protected static function parseParagraph($node, $element, &$styles)
307    {
308        $styles['paragraph'] = self::recursiveParseStylesInHierarchy($node, $styles['paragraph']);
309        if (isset($styles['paragraph']['isPageBreak']) && $styles['paragraph']['isPageBreak']) {
310            return $element->addPageBreak();
311        }
312
313        return $element->addTextRun($styles['paragraph']);
314    }
315
316    /**
317     * Parse input node.
318     *
319     * @param DOMNode $node
320     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
321     * @param array &$styles
322     */
323    protected static function parseInput($node, $element, &$styles): void
324    {
325        $attributes = $node->attributes;
326        if (null === $attributes->getNamedItem('type')) {
327            return;
328        }
329
330        $inputType = $attributes->getNamedItem('type')->nodeValue;
331        switch ($inputType) {
332            case 'checkbox':
333                $checked = ($checked = $attributes->getNamedItem('checked')) && $checked->nodeValue === 'true' ? true : false;
334                $textrun = $element->addTextRun($styles['paragraph']);
335                $textrun->addFormField('checkbox')->setValue($checked);
336
337                break;
338        }
339    }
340
341    /**
342     * Parse heading node.
343     *
344     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
345     * @param array &$styles
346     * @param string $argument1 Name of heading style
347     *
348     * @return \PhpOffice\PhpWord\Element\TextRun
349     *
350     * @todo Think of a clever way of defining header styles, now it is only based on the assumption, that
351     * Heading1 - Heading6 are already defined somewhere
352     */
353    protected static function parseHeading($element, &$styles, $argument1)
354    {
355        $styles['paragraph'] = $argument1;
356        $newElement = $element->addTextRun($styles['paragraph']);
357
358        return $newElement;
359    }
360
361    /**
362     * Parse text node.
363     *
364     * @param DOMNode $node
365     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
366     * @param array &$styles
367     */
368    protected static function parseText($node, $element, &$styles): void
369    {
370        $styles['font'] = self::recursiveParseStylesInHierarchy($node, $styles['font']);
371
372        //alignment applies on paragraph, not on font. Let's copy it there
373        if (isset($styles['font']['alignment']) && is_array($styles['paragraph'])) {
374            $styles['paragraph']['alignment'] = $styles['font']['alignment'];
375        }
376
377        if (is_callable([$element, 'addText'])) {
378            $element->addText($node->nodeValue, $styles['font'], $styles['paragraph']);
379        }
380    }
381
382    /**
383     * Parse property node.
384     *
385     * @param array &$styles
386     * @param string $argument1 Style name
387     * @param string $argument2 Style value
388     */
389    protected static function parseProperty(&$styles, $argument1, $argument2): void
390    {
391        $styles['font'][$argument1] = $argument2;
392    }
393
394    /**
395     * Parse span node.
396     *
397     * @param DOMNode $node
398     * @param array &$styles
399     */
400    protected static function parseSpan($node, &$styles): void
401    {
402        self::parseInlineStyle($node, $styles['font']);
403    }
404
405    /**
406     * Parse table node.
407     *
408     * @param DOMNode $node
409     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
410     * @param array &$styles
411     *
412     * @return Table $element
413     *
414     * @todo As soon as TableItem, RowItem and CellItem support relative width and height
415     */
416    protected static function parseTable($node, $element, &$styles)
417    {
418        $elementStyles = self::parseInlineStyle($node, $styles['table']);
419
420        $newElement = $element->addTable($elementStyles);
421
422        // Add style name from CSS Class
423        if (isset($elementStyles['className'])) {
424            $newElement->getStyle()->setStyleName($elementStyles['className']);
425        }
426
427        $attributes = $node->attributes;
428        if ($attributes->getNamedItem('border')) {
429            $border = (int) $attributes->getNamedItem('border')->nodeValue;
430            $newElement->getStyle()->setBorderSize(Converter::pixelToTwip($border));
431        }
432
433        return $newElement;
434    }
435
436    /**
437     * Parse a table row.
438     *
439     * @param DOMNode $node
440     * @param \PhpOffice\PhpWord\Element\Table $element
441     * @param array &$styles
442     *
443     * @return Row $element
444     */
445    protected static function parseRow($node, $element, &$styles)
446    {
447        $rowStyles = self::parseInlineStyle($node, $styles['row']);
448        if ($node->parentNode->nodeName == 'thead') {
449            $rowStyles['tblHeader'] = true;
450        }
451
452        // set cell height to control row heights
453        $height = $rowStyles['height'] ?? null;
454        unset($rowStyles['height']); // would not apply
455
456        return $element->addRow($height, $rowStyles);
457    }
458
459    /**
460     * Parse table cell.
461     *
462     * @param DOMNode $node
463     * @param \PhpOffice\PhpWord\Element\Table $element
464     * @param array &$styles
465     *
466     * @return \PhpOffice\PhpWord\Element\Cell|\PhpOffice\PhpWord\Element\TextRun $element
467     */
468    protected static function parseCell($node, $element, &$styles)
469    {
470        $cellStyles = self::recursiveParseStylesInHierarchy($node, $styles['cell']);
471
472        $colspan = $node->getAttribute('colspan');
473        if (!empty($colspan)) {
474            $cellStyles['gridSpan'] = $colspan - 0;
475        }
476
477        // set cell width to control column widths
478        $width = $cellStyles['width'] ?? null;
479        unset($cellStyles['width']); // would not apply
480        $cell = $element->addCell($width, $cellStyles);
481
482        if (self::shouldAddTextRun($node)) {
483            return $cell->addTextRun(self::filterOutNonInheritedStyles(self::parseInlineStyle($node, $styles['paragraph'])));
484        }
485
486        return $cell;
487    }
488
489    /**
490     * Checks if $node contains an HTML element that cannot be added to TextRun.
491     *
492     * @return bool Returns true if the node contains an HTML element that cannot be added to TextRun
493     */
494    protected static function shouldAddTextRun(DOMNode $node)
495    {
496        $containsBlockElement = self::$xpath->query('.//table|./p|./ul|./ol|./h1|./h2|./h3|./h4|./h5|./h6', $node)->length > 0;
497        if ($containsBlockElement) {
498            return false;
499        }
500
501        return true;
502    }
503
504    /**
505     * Recursively parses styles on parent nodes
506     * TODO if too slow, add caching of parent nodes, !! everything is static here so watch out for concurrency !!
507     */
508    protected static function recursiveParseStylesInHierarchy(DOMNode $node, array $style)
509    {
510        $parentStyle = [];
511        if ($node->parentNode != null && XML_ELEMENT_NODE == $node->parentNode->nodeType) {
512            $parentStyle = self::recursiveParseStylesInHierarchy($node->parentNode, []);
513        }
514        if ($node->nodeName === '#text') {
515            $parentStyle = array_merge($parentStyle, $style);
516        } else {
517            $parentStyle = self::filterOutNonInheritedStyles($parentStyle);
518        }
519        $style = self::parseInlineStyle($node, $parentStyle);
520
521        return $style;
522    }
523
524    /**
525     * Removes non-inherited styles from array.
526     */
527    protected static function filterOutNonInheritedStyles(array $styles)
528    {
529        $nonInheritedStyles = [
530            'borderSize',
531            'borderTopSize',
532            'borderRightSize',
533            'borderBottomSize',
534            'borderLeftSize',
535            'borderColor',
536            'borderTopColor',
537            'borderRightColor',
538            'borderBottomColor',
539            'borderLeftColor',
540            'borderStyle',
541            'spaceAfter',
542            'spaceBefore',
543            'underline',
544            'strikethrough',
545            'hidden',
546        ];
547
548        $styles = array_diff_key($styles, array_flip($nonInheritedStyles));
549
550        return $styles;
551    }
552
553    /**
554     * Parse list node.
555     *
556     * @param DOMNode $node
557     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
558     * @param array &$styles
559     * @param array &$data
560     */
561    protected static function parseList($node, $element, &$styles, &$data)
562    {
563        $isOrderedList = $node->nodeName === 'ol';
564        if (isset($data['listdepth'])) {
565            ++$data['listdepth'];
566        } else {
567            $data['listdepth'] = 0;
568            $styles['list'] = 'listStyle_' . self::$listIndex++;
569            $style = $element->getPhpWord()->addNumberingStyle($styles['list'], self::getListStyle($isOrderedList));
570
571            // extract attributes start & type e.g. <ol type="A" start="3">
572            $start = 0;
573            $type = '';
574            foreach ($node->attributes as $attribute) {
575                switch ($attribute->name) {
576                    case 'start':
577                        $start = (int) $attribute->value;
578
579                        break;
580                    case 'type':
581                        $type = $attribute->value;
582
583                        break;
584                }
585            }
586
587            $levels = $style->getLevels();
588            /** @var \PhpOffice\PhpWord\Style\NumberingLevel */
589            $level = $levels[0];
590            if ($start > 0) {
591                $level->setStart($start);
592            }
593            $type = $type ? self::mapListType($type) : null;
594            if ($type) {
595                $level->setFormat($type);
596            }
597        }
598        if ($node->parentNode->nodeName === 'li') {
599            return $element->getParent();
600        }
601    }
602
603    /**
604     * @param bool $isOrderedList
605     *
606     * @return array
607     */
608    protected static function getListStyle($isOrderedList)
609    {
610        if ($isOrderedList) {
611            return [
612                'type' => 'multilevel',
613                'levels' => [
614                    ['format' => NumberFormat::DECIMAL,      'text' => '%1.', 'alignment' => 'left',  'tabPos' => 720,  'left' => 720,  'hanging' => 360],
615                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%2.', 'alignment' => 'left',  'tabPos' => 1440, 'left' => 1440, 'hanging' => 360],
616                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%3.', 'alignment' => 'right', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 180],
617                    ['format' => NumberFormat::DECIMAL,      'text' => '%4.', 'alignment' => 'left',  'tabPos' => 2880, 'left' => 2880, 'hanging' => 360],
618                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%5.', 'alignment' => 'left',  'tabPos' => 3600, 'left' => 3600, 'hanging' => 360],
619                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%6.', 'alignment' => 'right', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 180],
620                    ['format' => NumberFormat::DECIMAL,      'text' => '%7.', 'alignment' => 'left',  'tabPos' => 5040, 'left' => 5040, 'hanging' => 360],
621                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%8.', 'alignment' => 'left',  'tabPos' => 5760, 'left' => 5760, 'hanging' => 360],
622                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%9.', 'alignment' => 'right', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 180],
623                ],
624            ];
625        }
626
627        return [
628            'type' => 'hybridMultilevel',
629            'levels' => [
630                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 720,  'left' => 720,  'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
631                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
632                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
633                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
634                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
635                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
636                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
637                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
638                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
639            ],
640        ];
641    }
642
643    /**
644     * Parse list item node.
645     *
646     * @param DOMNode $node
647     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
648     * @param array &$styles
649     * @param array $data
650     *
651     * @todo This function is almost the same like `parseChildNodes`. Merged?
652     * @todo As soon as ListItem inherits from AbstractContainer or TextRun delete parsing part of childNodes
653     */
654    protected static function parseListItem($node, $element, &$styles, $data): void
655    {
656        $cNodes = $node->childNodes;
657        if (!empty($cNodes)) {
658            $listRun = $element->addListItemRun($data['listdepth'], $styles['list'], $styles['paragraph']);
659            foreach ($cNodes as $cNode) {
660                self::parseNode($cNode, $listRun, $styles, $data);
661            }
662        }
663    }
664
665    /**
666     * Parse style.
667     *
668     * @param DOMAttr $attribute
669     * @param array $styles
670     *
671     * @return array
672     */
673    protected static function parseStyle($attribute, $styles)
674    {
675        $properties = explode(';', trim($attribute->value, " \t\n\r\0\x0B;"));
676
677        $selectors = [];
678        foreach ($properties as $property) {
679            [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null);
680            $selectors[strtolower(trim($cKey))] = trim($cValue ?? '');
681        }
682
683        return self::parseStyleDeclarations($selectors, $styles);
684    }
685
686    protected static function parseStyleDeclarations(array $selectors, array $styles)
687    {
688        $bidi = ($selectors['direction'] ?? '') === 'rtl';
689        foreach ($selectors as $property => $value) {
690            switch ($property) {
691                case 'text-decoration':
692                    switch ($value) {
693                        case 'underline':
694                            $styles['underline'] = 'single';
695
696                            break;
697                        case 'line-through':
698                            $styles['strikethrough'] = true;
699
700                            break;
701                    }
702
703                    break;
704                case 'text-align':
705                    $styles['alignment'] = self::mapAlign($value, $bidi);
706
707                    break;
708                case 'display':
709                    $styles['hidden'] = $value === 'none' || $value === 'hidden';
710
711                    break;
712                case 'direction':
713                    $styles['rtl'] = $value === 'rtl';
714                    $styles['bidi'] = $value === 'rtl';
715
716                    break;
717                case 'font-size':
718                    $styles['size'] = Converter::cssToPoint($value);
719
720                    break;
721                case 'font-family':
722                    $value = array_map('trim', explode(',', $value));
723                    $styles['name'] = ucwords($value[0]);
724
725                    break;
726                case 'color':
727                    $styles['color'] = self::convertRgb($value);
728
729                    break;
730                case 'background-color':
731                    $styles['bgColor'] = self::convertRgb($value);
732
733                    break;
734                case 'line-height':
735                    $matches = [];
736                    if ($value === 'normal') {
737                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
738                        $spacing = 0;
739                    } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) {
740                        //matches number with a unit, e.g. 12px, 15pt, 20mm, ...
741                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::EXACT;
742                        $spacing = Converter::cssToTwip($matches[1]);
743                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
744                        //matches percentages
745                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
746                        //we are subtracting 1 line height because the Spacing writer is adding one line
747                        $spacing = ((((int) $matches[1]) / 100) * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT;
748                    } else {
749                        //any other, wich is a multiplier. E.g. 1.2
750                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
751                        //we are subtracting 1 line height because the Spacing writer is adding one line
752                        $spacing = ($value * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT;
753                    }
754                    $styles['spacingLineRule'] = $spacingLineRule;
755                    $styles['line-spacing'] = $spacing;
756
757                    break;
758                case 'letter-spacing':
759                    $styles['letter-spacing'] = Converter::cssToTwip($value);
760
761                    break;
762                case 'text-indent':
763                    $styles['indentation']['firstLine'] = Converter::cssToTwip($value);
764
765                    break;
766                case 'font-weight':
767                    $tValue = false;
768                    if (preg_match('#bold#', $value)) {
769                        $tValue = true; // also match bolder
770                    }
771                    $styles['bold'] = $tValue;
772
773                    break;
774                case 'font-style':
775                    $tValue = false;
776                    if (preg_match('#(?:italic|oblique)#', $value)) {
777                        $tValue = true;
778                    }
779                    $styles['italic'] = $tValue;
780
781                    break;
782                case 'font-variant':
783                    $tValue = false;
784                    if (preg_match('#small-caps#', $value)) {
785                        $tValue = true;
786                    }
787                    $styles['smallCaps'] = $tValue;
788
789                    break;
790                case 'margin':
791                    $value = Converter::cssToTwip($value);
792                    $styles['spaceBefore'] = $value;
793                    $styles['spaceAfter'] = $value;
794
795                    break;
796                case 'margin-top':
797                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
798                    $styles['spaceBefore'] = Converter::cssToTwip($value);
799
800                    break;
801                case 'margin-bottom':
802                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
803                    $styles['spaceAfter'] = Converter::cssToTwip($value);
804
805                    break;
806                case 'border-color':
807                    self::mapBorderColor($styles, $value);
808
809                    break;
810                case 'border-width':
811                    $styles['borderSize'] = Converter::cssToPoint($value);
812
813                    break;
814                case 'border-style':
815                    $styles['borderStyle'] = self::mapBorderStyle($value);
816
817                    break;
818                case 'width':
819                    if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) {
820                        $styles['width'] = Converter::cssToTwip($matches[1]);
821                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
822                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
823                        $styles['width'] = $matches[1] * 50;
824                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
825                    } elseif (preg_match('/([0-9]+)/', $value, $matches)) {
826                        $styles['width'] = $matches[1];
827                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO;
828                    }
829
830                    break;
831                case 'height':
832                    $styles['height'] = Converter::cssToTwip($value);
833                    $styles['exactHeight'] = true;
834
835                    break;
836                case 'border':
837                case 'border-top':
838                case 'border-bottom':
839                case 'border-right':
840                case 'border-left':
841                    // must have exact order [width color style], e.g. "1px #0011CC solid" or "2pt green solid"
842                    // Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC
843                    if (preg_match('/([0-9]+[^0-9]*)\s+(\#[a-fA-F0-9]+|[a-zA-Z]+)\s+([a-z]+)/', $value, $matches)) {
844                        if (false !== strpos($property, '-')) {
845                            $tmp = explode('-', $property);
846                            $which = $tmp[1];
847                            $which = ucfirst($which); // e.g. bottom -> Bottom
848                        } else {
849                            $which = '';
850                        }
851                        // Note - border width normalization:
852                        // Width of border in Word is calculated differently than HTML borders, usually showing up too bold.
853                        // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips.
854                        // Therefore we need to normalize converted twip value to cca 1/2 of value.
855                        // This may be adjusted, if better ratio or formula found.
856                        // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size)
857                        $size = Converter::cssToTwip($matches[1]);
858                        $size = (int) ($size / 2);
859                        // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc ..
860                        $styles["border{$which}Size"] = $size; // twips
861                        $styles["border{$which}Color"] = trim($matches[2], '#');
862                        $styles["border{$which}Style"] = self::mapBorderStyle($matches[3]);
863                    }
864
865                    break;
866                case 'vertical-align':
867                    // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align
868                    if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) {
869                        $styles['valign'] = self::mapAlignVertical($matches[0]);
870                    }
871
872                    break;
873                case 'page-break-after':
874                    if ($value == 'always') {
875                        $styles['isPageBreak'] = true;
876                    }
877
878                    break;
879            }
880        }
881
882        return $styles;
883    }
884
885    /**
886     * Parse image node.
887     *
888     * @param DOMNode $node
889     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
890     *
891     * @return \PhpOffice\PhpWord\Element\Image
892     */
893    protected static function parseImage($node, $element)
894    {
895        $style = [];
896        $src = null;
897        foreach ($node->attributes as $attribute) {
898            switch ($attribute->name) {
899                case 'src':
900                    $src = $attribute->value;
901
902                    break;
903                case 'width':
904                    $width = $attribute->value;
905
906                    // pt
907                    if (false !== strpos($width, 'pt')) {
908                        $width = Converter::pointToPixel((float) str_replace('pt', '', $width));
909                    }
910
911                    // px
912                    if (false !== strpos($width, 'px')) {
913                        $width = str_replace('px', '', $width);
914                    }
915
916                    $style['width'] = $width;
917                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
918
919                    break;
920                case 'height':
921                    $height = $attribute->value;
922
923                    // pt
924                    if (false !== strpos($height, 'pt')) {
925                        $height = Converter::pointToPixel((float) str_replace('pt', '', $height));
926                    }
927
928                    // px
929                    if (false !== strpos($height, 'px')) {
930                        $height = str_replace('px', '', $height);
931                    }
932
933                    $style['height'] = $height;
934                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
935
936                    break;
937                case 'style':
938                    $styleattr = explode(';', $attribute->value);
939                    foreach ($styleattr as $attr) {
940                        if (strpos($attr, ':')) {
941                            [$k, $v] = explode(':', $attr);
942                            switch ($k) {
943                                case 'float':
944                                    if (trim($v) == 'right') {
945                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT;
946                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
947                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
948                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
949                                        $style['overlap'] = true;
950                                    }
951                                    if (trim($v) == 'left') {
952                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT;
953                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
954                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
955                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
956                                        $style['overlap'] = true;
957                                    }
958
959                                    break;
960                            }
961                        }
962                    }
963
964                    break;
965            }
966        }
967        $originSrc = $src;
968        if (strpos($src, 'data:image') !== false) {
969            $tmpDir = Settings::getTempDir() . '/';
970
971            $match = [];
972            preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match);
973
974            $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1];
975
976            $ifp = fopen($imgFile, 'wb');
977
978            if ($ifp !== false) {
979                fwrite($ifp, base64_decode($match[2]));
980                fclose($ifp);
981            }
982        }
983        $src = urldecode($src);
984
985        if (!is_file($src)
986            && null !== self::$options
987            && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'])
988        ) {
989            $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src);
990        }
991
992        if (!is_file($src)) {
993            if ($imgBlob = @file_get_contents($src)) {
994                $tmpDir = Settings::getTempDir() . '/';
995                $match = [];
996                preg_match('/.+\.(\w+)$/', $src, $match);
997                $src = $tmpDir . uniqid();
998                if (isset($match[1])) {
999                    $src .= '.' . $match[1];
1000                }
1001
1002                $ifp = fopen($src, 'wb');
1003
1004                if ($ifp !== false) {
1005                    fwrite($ifp, $imgBlob);
1006                    fclose($ifp);
1007                }
1008            }
1009        }
1010
1011        if (is_file($src)) {
1012            $newElement = $element->addImage($src, $style);
1013        } else {
1014            throw new Exception("Could not load image $originSrc");
1015        }
1016
1017        return $newElement;
1018    }
1019
1020    /**
1021     * Transforms a CSS border style into a word border style.
1022     *
1023     * @param string $cssBorderStyle
1024     *
1025     * @return null|string
1026     */
1027    protected static function mapBorderStyle($cssBorderStyle)
1028    {
1029        switch ($cssBorderStyle) {
1030            case 'none':
1031            case 'dashed':
1032            case 'dotted':
1033            case 'double':
1034                return $cssBorderStyle;
1035            default:
1036                return 'single';
1037        }
1038    }
1039
1040    protected static function mapBorderColor(&$styles, $cssBorderColor): void
1041    {
1042        $numColors = substr_count($cssBorderColor, '#');
1043        if ($numColors === 1) {
1044            $styles['borderColor'] = trim($cssBorderColor, '#');
1045        } elseif ($numColors > 1) {
1046            $colors = explode(' ', $cssBorderColor);
1047            $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'];
1048            for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) {
1049                $styles[$borders[$i]] = trim($colors[$i], '#');
1050            }
1051        }
1052    }
1053
1054    /**
1055     * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1056     *
1057     * @param string $cssAlignment
1058     * @param bool $bidi
1059     *
1060     * @return null|string
1061     */
1062    protected static function mapAlign($cssAlignment, $bidi)
1063    {
1064        switch ($cssAlignment) {
1065            case 'right':
1066                return $bidi ? Jc::START : Jc::END;
1067            case 'center':
1068                return Jc::CENTER;
1069            case 'justify':
1070                return Jc::BOTH;
1071            default:
1072                return $bidi ? Jc::END : Jc::START;
1073        }
1074    }
1075
1076    /**
1077     * Transforms a HTML/CSS vertical alignment.
1078     *
1079     * @param string $alignment
1080     *
1081     * @return null|string
1082     */
1083    protected static function mapAlignVertical($alignment)
1084    {
1085        $alignment = strtolower($alignment);
1086        switch ($alignment) {
1087            case 'top':
1088            case 'baseline':
1089            case 'bottom':
1090                return $alignment;
1091            case 'middle':
1092                return 'center';
1093            case 'sub':
1094                return 'bottom';
1095            case 'text-top':
1096            case 'baseline':
1097                return 'top';
1098            default:
1099                // @discuss - which one should apply:
1100                // - Word uses default vert. alignment: top
1101                // - all browsers use default vert. alignment: middle
1102                // Returning empty string means attribute wont be set so use Word default (top).
1103                return '';
1104        }
1105    }
1106
1107    /**
1108     * Map list style for ordered list.
1109     *
1110     * @param string $cssListType
1111     */
1112    protected static function mapListType($cssListType)
1113    {
1114        switch ($cssListType) {
1115            case 'a':
1116                return NumberFormat::LOWER_LETTER; // a, b, c, ..
1117            case 'A':
1118                return NumberFormat::UPPER_LETTER; // A, B, C, ..
1119            case 'i':
1120                return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, ..
1121            case 'I':
1122                return NumberFormat::UPPER_ROMAN; // I, II, III, IV, ..
1123            case '1':
1124            default:
1125                return NumberFormat::DECIMAL; // 1, 2, 3, ..
1126        }
1127    }
1128
1129    /**
1130     * Parse line break.
1131     *
1132     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
1133     */
1134    protected static function parseLineBreak($element): void
1135    {
1136        $element->addTextBreak();
1137    }
1138
1139    /**
1140     * Parse link node.
1141     *
1142     * @param DOMNode $node
1143     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
1144     * @param array $styles
1145     */
1146    protected static function parseLink($node, $element, &$styles)
1147    {
1148        $target = null;
1149        foreach ($node->attributes as $attribute) {
1150            switch ($attribute->name) {
1151                case 'href':
1152                    $target = $attribute->value;
1153
1154                    break;
1155            }
1156        }
1157        $styles['font'] = self::parseInlineStyle($node, $styles['font']);
1158
1159        if (empty($target)) {
1160            $target = '#';
1161        }
1162
1163        if (strpos($target, '#') === 0 && strlen($target) > 1) {
1164            return $element->addLink(substr($target, 1), $node->textContent, $styles['font'], $styles['paragraph'], true);
1165        }
1166
1167        return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']);
1168    }
1169
1170    /**
1171     * Render horizontal rule
1172     * Note: Word rule is not the same as HTML's <hr> since it does not support width and thus neither alignment.
1173     *
1174     * @param DOMNode $node
1175     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
1176     */
1177    protected static function parseHorizRule($node, $element): void
1178    {
1179        $styles = self::parseInlineStyle($node);
1180
1181        // <hr> is implemented as an empty paragraph - extending 100% inside the section
1182        // Some properties may be controlled, e.g. <hr style="border-bottom: 3px #DDDDDD solid; margin-bottom: 0;">
1183
1184        $fontStyle = $styles + ['size' => 3];
1185
1186        $paragraphStyle = $styles + [
1187            'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc
1188            'spacing' => 0, // twip
1189            'spaceBefore' => 120, // twip, 240/2 (default line height)
1190            'spaceAfter' => 120, // twip
1191            'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'],
1192            'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'],
1193            'borderBottomStyle' => 'single', // same as "solid"
1194        ];
1195
1196        $element->addText('', $fontStyle, $paragraphStyle);
1197
1198        // Notes: <hr/> cannot be:
1199        // - table - throws error "cannot be inside textruns", e.g. lists
1200        // - line - that is a shape, has different behaviour
1201        // - repeated text, e.g. underline "_", because of unpredictable line wrapping
1202    }
1203
1204    private static function convertRgb(string $rgb): string
1205    {
1206        if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) {
1207            return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]);
1208        }
1209
1210        return trim($rgb, '# ');
1211    }
1212}