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