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