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