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