Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.10% covered (success)
95.10%
602 / 633
76.47% covered (warning)
76.47%
26 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
Html
95.10% covered (success)
95.10%
602 / 633
76.47% covered (warning)
76.47%
26 / 34
239
0.00% covered (danger)
0.00%
0 / 1
 addHtml
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
4.02
 parseInlineStyle
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
18
 parseNode
100.00% covered (success)
100.00%
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.59% covered (success)
98.59%
70 / 71
0.00% covered (danger)
0.00%
0 / 1
23
 mapBorderStyle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 mapBorderColor
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 mapAlign
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 mapRubyAlign
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
14.11
 mapAlignVertical
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
16.96
 mapListType
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
13.12
 parseLineBreak
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseLink
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 parseHorizRule
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 parseRuby
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
16
 convertRgb
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 convertHtmlSize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * This file is part of PHPWord - A pure PHP library for reading and writing
5 * word processing documents.
6 *
7 * PHPWord is free software distributed under the terms of the GNU Lesser
8 * General Public License version 3 as published by the Free Software Foundation.
9 *
10 * For the full copyright and license information, please read the LICENSE
11 * file that was distributed with this source code. For the full list of
12 * contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
13 *
14 * @see         https://github.com/PHPOffice/PHPWord
15 *
16 * @license     http://www.gnu.org/licenses/lgpl.txt LGPL version 3
17 */
18
19namespace PhpOffice\PhpWord\Shared;
20
21use DOMDocument;
22use DOMNode;
23use DOMXPath;
24use Exception;
25use PhpOffice\PhpWord\ComplexType\RubyProperties;
26use PhpOffice\PhpWord\Element\AbstractContainer;
27use PhpOffice\PhpWord\Element\Row;
28use PhpOffice\PhpWord\Element\Table;
29use PhpOffice\PhpWord\Element\TextRun;
30use PhpOffice\PhpWord\Settings;
31use PhpOffice\PhpWord\SimpleType\Jc;
32use PhpOffice\PhpWord\SimpleType\NumberFormat;
33use PhpOffice\PhpWord\Style\Paragraph;
34
35/**
36 * Common Html functions.
37 *
38 * @SuppressWarnings("PHPMD.UnusedPrivateMethod") For readWPNode
39 */
40class Html
41{
42    private const RGB_REGEXP = '/^\s*rgb\s*[(]\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*[)]\s*$/';
43
44    protected static $listIndex = 0;
45
46    protected static $xpath;
47
48    protected static $options;
49
50    /**
51     * @var Css
52     */
53    protected static $css;
54
55    /**
56     * Add HTML parts.
57     *
58     * Note: $stylesheet parameter is removed to avoid PHPMD error for unused parameter
59     * Warning: Do not pass user-generated HTML here, as that would allow an attacker to read arbitrary
60     * files or perform server-side request forgery by passing local file paths or URLs in <img>.
61     *
62     * @param AbstractContainer $element Where the parts need to be added
63     * @param string $html The code to parse
64     * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag
65     * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed
66     */
67    public static function addHtml($element, $html, $fullHTML = false, $preserveWhiteSpace = true, $options = null): void
68    {
69        /*
70         * @todo parse $stylesheet for default styles.  Should result in an array based on id, class and element,
71         * which could be applied when such an element occurs in the parseNode function.
72         */
73        static::$options = $options;
74
75        // Preprocess: remove all line ends, decode HTML entity,
76        // fix ampersand and angle brackets and add body tag for HTML fragments
77        $html = str_replace(["\n", "\r"], '', $html);
78        $html = str_replace(['&lt;', '&gt;', '&amp;', '&quot;'], ['_lt_', '_gt_', '_amp_', '_quot_'], $html);
79        $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
80        $html = str_replace('&', '&amp;', $html);
81        $html = str_replace(['_lt_', '_gt_', '_amp_', '_quot_'], ['&lt;', '&gt;', '&amp;', '&quot;'], $html);
82
83        if (false === $fullHTML) {
84            $html = '<body>' . $html . '</body>';
85        }
86
87        // Load DOM
88        if (\PHP_VERSION_ID < 80000) {
89            $orignalLibEntityLoader = libxml_disable_entity_loader(true);
90        }
91        $dom = new DOMDocument();
92        $dom->preserveWhiteSpace = $preserveWhiteSpace;
93        $dom->loadXML($html);
94        static::$xpath = new DOMXPath($dom);
95        $node = $dom->getElementsByTagName('body');
96
97        static::parseNode($node->item(0), $element);
98        if (\PHP_VERSION_ID < 80000) {
99            libxml_disable_entity_loader($orignalLibEntityLoader);
100        }
101    }
102
103    /**
104     * parse Inline style of a node.
105     *
106     * @param DOMNode $node Node to check on attributes and to compile a style array
107     * @param array<string, mixed> $styles is supplied, the inline style attributes are added to the already existing style
108     *
109     * @return array
110     */
111    protected static function parseInlineStyle($node, $styles = [])
112    {
113        if (XML_ELEMENT_NODE == $node->nodeType) {
114            $attributes = $node->attributes; // get all the attributes(eg: id, class)
115
116            $attributeDir = $attributes->getNamedItem('dir');
117            $attributeDirValue = $attributeDir ? $attributeDir->nodeValue : '';
118            $bidi = $attributeDirValue === 'rtl';
119            foreach ($attributes as $attribute) {
120                $val = $attribute->value;
121                switch (strtolower($attribute->name)) {
122                    case 'align':
123                        $styles['alignment'] = self::mapAlign(trim($val), $bidi);
124
125                        break;
126                    case 'lang':
127                        $styles['lang'] = $val;
128
129                        break;
130                    case 'width':
131                        // tables, cells
132                        $val = $val === 'auto' ? '100%' : $val;
133                        if (false !== strpos($val, '%')) {
134                            // e.g. <table width="100%"> or <td width="50%">
135                            $styles['width'] = (int) $val * 50;
136                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
137                        } else {
138                            // e.g. <table width="250> where "250" = 250px (always pixels)
139                            $styles['width'] = Converter::pixelToTwip(self::convertHtmlSize($val));
140                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
141                        }
142
143                        break;
144                    case 'cellspacing':
145                        // tables e.g. <table cellspacing="2">,  where "2" = 2px (always pixels)
146                        $styles['cellSpacing'] = Converter::pixelToTwip(self::convertHtmlSize($val));
147
148                        break;
149                    case 'bgcolor':
150                        // tables, rows, cells e.g. <tr bgColor="#FF0000">
151                        $styles['bgColor'] = self::convertRgb($val);
152
153                        break;
154                    case 'valign':
155                        // cells e.g. <td valign="middle">
156                        if (preg_match('#(?:top|bottom|middle|baseline)#i', $val, $matches)) {
157                            $styles['valign'] = self::mapAlignVertical($matches[0]);
158                        }
159
160                        break;
161                }
162            }
163
164            $attributeIdentifier = $attributes->getNamedItem('id');
165            if ($attributeIdentifier && self::$css) {
166                $styles = self::parseStyleDeclarations(self::$css->getStyle('#' . $attributeIdentifier->nodeValue), $styles);
167            }
168
169            $attributeClass = $attributes->getNamedItem('class');
170            if ($attributeClass) {
171                if (self::$css) {
172                    $styles = self::parseStyleDeclarations(self::$css->getStyle('.' . $attributeClass->nodeValue), $styles);
173                }
174                $styles['className'] = $attributeClass->nodeValue;
175            }
176
177            $attributeStyle = $attributes->getNamedItem('style');
178            if ($attributeStyle) {
179                $styles = self::parseStyle($attributeStyle, $styles);
180            }
181        }
182
183        return $styles;
184    }
185
186    /**
187     * Parse a node and add a corresponding element to the parent element.
188     *
189     * @param DOMNode $node node to parse
190     * @param AbstractContainer $element object to add an element corresponding with the node
191     * @param array $styles Array with all styles
192     * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems
193     */
194    protected static function parseNode($node, $element, $styles = [], $data = []): void
195    {
196        if ($node->nodeName == 'style') {
197            self::$css = new Css($node->textContent);
198            self::$css->process();
199
200            return;
201        }
202
203        // Populate styles array
204        $styleTypes = ['font', 'paragraph', 'list', 'table', 'row', 'cell'];
205        foreach ($styleTypes as $styleType) {
206            if (!isset($styles[$styleType])) {
207                $styles[$styleType] = [];
208            }
209        }
210
211        // Node mapping table
212        $nodes = [
213            // $method               $node   $element    $styles     $data   $argument1      $argument2
214            'p' => ['Paragraph',     $node,  $element,   $styles,    null,   null,           null],
215            'h1' => ['Heading',      $node,  $element,   $styles,    null,   'Heading1',     null],
216            'h2' => ['Heading',      $node,  $element,   $styles,    null,   'Heading2',     null],
217            'h3' => ['Heading',      $node,  $element,   $styles,    null,   'Heading3',     null],
218            'h4' => ['Heading',      $node,  $element,   $styles,    null,   'Heading4',     null],
219            'h5' => ['Heading',      $node,  $element,   $styles,    null,   'Heading5',     null],
220            'h6' => ['Heading',      $node,  $element,   $styles,    null,   'Heading6',     null],
221            '#text' => ['Text',      $node,  $element,   $styles,    null,   null,           null],
222            'strong' => ['Property', null,   null,       $styles,    null,   'bold',         true],
223            'b' => ['Property',    null,   null,       $styles,    null,   'bold',         true],
224            'em' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
225            'i' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
226            'u' => ['Property',    null,   null,       $styles,    null,   'underline',    'single'],
227            'sup' => ['Property',    null,   null,       $styles,    null,   'superScript',  true],
228            'sub' => ['Property',    null,   null,       $styles,    null,   'subScript',    true],
229            '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        $altText = null;
951        foreach ($node->attributes as $attribute) {
952            switch ($attribute->name) {
953                case 'src':
954                    $src = $attribute->value;
955
956                    break;
957                case 'alt':
958                    $altText = $attribute->value;
959
960                    break;
961                case 'width':
962                    $style['width'] = self::convertHtmlSize($attribute->value);
963                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
964
965                    break;
966                case 'height':
967                    $style['height'] = self::convertHtmlSize($attribute->value);
968                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
969
970                    break;
971                case 'style':
972                    $styleattr = explode(';', $attribute->value);
973                    foreach ($styleattr as $attr) {
974                        if (strpos($attr, ':')) {
975                            [$k, $v] = explode(':', $attr);
976                            switch ($k) {
977                                case 'float':
978                                    if (trim($v) == 'right') {
979                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT;
980                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
981                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
982                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
983                                        $style['overlap'] = true;
984                                    }
985                                    if (trim($v) == 'left') {
986                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT;
987                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
988                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
989                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
990                                        $style['overlap'] = true;
991                                    }
992
993                                    break;
994                            }
995                        }
996                    }
997
998                    break;
999            }
1000        }
1001        $originSrc = $src;
1002        if (strpos($src, 'data:image') !== false) {
1003            $tmpDir = Settings::getTempDir() . '/';
1004
1005            $match = [];
1006            preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match);
1007            if (!empty($match)) {
1008                $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1];
1009
1010                $ifp = fopen($imgFile, 'wb');
1011
1012                if ($ifp !== false) {
1013                    fwrite($ifp, base64_decode($match[2]));
1014                    fclose($ifp);
1015                }
1016            }
1017        }
1018        $src = urldecode($src);
1019
1020        if (!is_file($src)
1021            && null !== self::$options
1022            && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'])
1023        ) {
1024            $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src);
1025        }
1026
1027        if (!is_file($src)) {
1028            if ($imgBlob = @file_get_contents($src)) {
1029                $tmpDir = Settings::getTempDir() . '/';
1030                $match = [];
1031                preg_match('/.+\.(\w+)$/', $src, $match);
1032                $src = $tmpDir . uniqid();
1033                if (isset($match[1])) {
1034                    $src .= '.' . $match[1];
1035                }
1036
1037                $ifp = fopen($src, 'wb');
1038
1039                if ($ifp !== false) {
1040                    fwrite($ifp, $imgBlob);
1041                    fclose($ifp);
1042                }
1043            }
1044        }
1045
1046        if (is_file($src)) {
1047            $newElement = $element->addImage($src, $style, false, null, $altText);
1048        } else {
1049            throw new Exception("Could not load image $originSrc");
1050        }
1051
1052        return $newElement;
1053    }
1054
1055    /**
1056     * Transforms a CSS border style into a word border style.
1057     *
1058     * @param string $cssBorderStyle
1059     *
1060     * @return null|string
1061     */
1062    protected static function mapBorderStyle($cssBorderStyle)
1063    {
1064        switch ($cssBorderStyle) {
1065            case 'none':
1066            case 'dashed':
1067            case 'dotted':
1068            case 'double':
1069                return $cssBorderStyle;
1070            default:
1071                return 'single';
1072        }
1073    }
1074
1075    protected static function mapBorderColor(&$styles, $cssBorderColor): void
1076    {
1077        $numColors = substr_count($cssBorderColor, '#');
1078        if ($numColors === 1) {
1079            $styles['borderColor'] = trim($cssBorderColor, '#');
1080        } elseif ($numColors > 1) {
1081            $colors = explode(' ', $cssBorderColor);
1082            $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'];
1083            for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) {
1084                $styles[$borders[$i]] = trim($colors[$i], '#');
1085            }
1086        }
1087    }
1088
1089    /**
1090     * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1091     *
1092     * @param string $cssAlignment
1093     * @param bool $bidi
1094     *
1095     * @return null|string
1096     */
1097    protected static function mapAlign($cssAlignment, $bidi)
1098    {
1099        switch ($cssAlignment) {
1100            case 'right':
1101                return $bidi ? Jc::START : Jc::END;
1102            case 'center':
1103                return Jc::CENTER;
1104            case 'justify':
1105                return Jc::BOTH;
1106            default:
1107                return $bidi ? Jc::END : Jc::START;
1108        }
1109    }
1110
1111    /**
1112     * Transforms a HTML/CSS ruby alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1113     */
1114    protected static function mapRubyAlign(string $cssRubyAlignment): string
1115    {
1116        switch ($cssRubyAlignment) {
1117            case 'center':
1118                return RubyProperties::ALIGNMENT_CENTER;
1119            case 'start':
1120                return RubyProperties::ALIGNMENT_LEFT;
1121            case 'space-between':
1122                return RubyProperties::ALIGNMENT_DISTRIBUTE_SPACE;
1123            default:
1124                return '';
1125        }
1126    }
1127
1128    /**
1129     * Transforms a HTML/CSS vertical alignment.
1130     *
1131     * @param string $alignment
1132     *
1133     * @return null|string
1134     */
1135    protected static function mapAlignVertical($alignment)
1136    {
1137        $alignment = strtolower($alignment);
1138        switch ($alignment) {
1139            case 'top':
1140            case 'baseline':
1141            case 'bottom':
1142                return $alignment;
1143            case 'middle':
1144                return 'center';
1145            case 'sub':
1146                return 'bottom';
1147            case 'text-top':
1148            case 'baseline':
1149                return 'top';
1150            default:
1151                // @discuss - which one should apply:
1152                // - Word uses default vert. alignment: top
1153                // - all browsers use default vert. alignment: middle
1154                // Returning empty string means attribute wont be set so use Word default (top).
1155                return '';
1156        }
1157    }
1158
1159    /**
1160     * Map list style for ordered list.
1161     *
1162     * @param string $cssListType
1163     */
1164    protected static function mapListType($cssListType)
1165    {
1166        switch ($cssListType) {
1167            case 'a':
1168                return NumberFormat::LOWER_LETTER; // a, b, c, ..
1169            case 'A':
1170                return NumberFormat::UPPER_LETTER; // A, B, C, ..
1171            case 'i':
1172                return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, ..
1173            case 'I':
1174                return NumberFormat::UPPER_ROMAN; // I, II, III, IV, ..
1175            case '1':
1176            default:
1177                return NumberFormat::DECIMAL; // 1, 2, 3, ..
1178        }
1179    }
1180
1181    /**
1182     * Parse line break.
1183     *
1184     * @param AbstractContainer $element
1185     */
1186    protected static function parseLineBreak($element): void
1187    {
1188        $element->addTextBreak();
1189    }
1190
1191    /**
1192     * Parse link node.
1193     *
1194     * @param DOMNode $node
1195     * @param AbstractContainer $element
1196     * @param array $styles
1197     */
1198    protected static function parseLink($node, $element, &$styles)
1199    {
1200        $target = null;
1201        foreach ($node->attributes as $attribute) {
1202            switch ($attribute->name) {
1203                case 'href':
1204                    $target = $attribute->value;
1205
1206                    break;
1207            }
1208        }
1209        $styles['font'] = self::parseInlineStyle($node, $styles['font']);
1210
1211        if (empty($target)) {
1212            $target = '#';
1213        }
1214
1215        if (strpos($target, '#') === 0 && strlen($target) > 1) {
1216            return $element->addLink(substr($target, 1), $node->textContent, $styles['font'], $styles['paragraph'], true);
1217        }
1218
1219        return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']);
1220    }
1221
1222    /**
1223     * Render horizontal rule
1224     * Note: Word rule is not the same as HTML's <hr> since it does not support width and thus neither alignment.
1225     *
1226     * @param DOMNode $node
1227     * @param AbstractContainer $element
1228     */
1229    protected static function parseHorizRule($node, $element): void
1230    {
1231        $styles = self::parseInlineStyle($node);
1232
1233        // <hr> is implemented as an empty paragraph - extending 100% inside the section
1234        // Some properties may be controlled, e.g. <hr style="border-bottom: 3px #DDDDDD solid; margin-bottom: 0;">
1235
1236        $fontStyle = $styles + ['size' => 3];
1237
1238        $paragraphStyle = $styles + [
1239            'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc
1240            'spacing' => 0, // twip
1241            'spaceBefore' => 120, // twip, 240/2 (default line height)
1242            'spaceAfter' => 120, // twip
1243            'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'],
1244            'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'],
1245            'borderBottomStyle' => 'single', // same as "solid"
1246        ];
1247
1248        $element->addText('', $fontStyle, $paragraphStyle);
1249
1250        // Notes: <hr/> cannot be:
1251        // - table - throws error "cannot be inside textruns", e.g. lists
1252        // - line - that is a shape, has different behaviour
1253        // - repeated text, e.g. underline "_", because of unpredictable line wrapping
1254    }
1255
1256    /**
1257     * Parse ruby node.
1258     *
1259     * @param DOMNode $node
1260     * @param AbstractContainer $element
1261     * @param array $styles
1262     */
1263    protected static function parseRuby($node, $element, &$styles)
1264    {
1265        $rubyProperties = new RubyProperties();
1266        $baseTextRun = new TextRun($styles['paragraph']);
1267        $rubyTextRun = new TextRun(null);
1268        if ($node->hasAttributes()) {
1269            $langAttr = $node->attributes->getNamedItem('lang');
1270            if ($langAttr !== null) {
1271                $rubyProperties->setLanguageId($langAttr->textContent);
1272            }
1273            $styleAttr = $node->attributes->getNamedItem('style');
1274            if ($styleAttr !== null) {
1275                $styles = self::parseStyle($styleAttr, $styles['paragraph']);
1276                if (isset($styles['rubyAlignment']) && $styles['rubyAlignment'] !== '') {
1277                    $rubyProperties->setAlignment($styles['rubyAlignment']);
1278                }
1279                if (isset($styles['size']) && $styles['size'] !== '') {
1280                    $rubyProperties->setFontSizeForBaseText($styles['size']);
1281                }
1282                $baseTextRun->setParagraphStyle($styles);
1283            }
1284        }
1285        foreach ($node->childNodes as $child) {
1286            if ($child->nodeName === '#text') {
1287                $content = trim($child->textContent);
1288                if ($content !== '') {
1289                    $baseTextRun->addText($content);
1290                }
1291            } elseif ($child->nodeName === 'rt') {
1292                $rubyTextRun->addText(trim($child->textContent));
1293                if ($child->hasAttributes()) {
1294                    $styleAttr = $child->attributes->getNamedItem('style');
1295                    if ($styleAttr !== null) {
1296                        $styles = self::parseStyle($styleAttr, []);
1297                        if (isset($styles['size']) && $styles['size'] !== '') {
1298                            $rubyProperties->setFontFaceSize($styles['size']);
1299                        }
1300                        $rubyTextRun->setParagraphStyle($styles);
1301                    }
1302                }
1303            }
1304        }
1305
1306        return $element->addRuby($baseTextRun, $rubyTextRun, $rubyProperties);
1307    }
1308
1309    private static function convertRgb(string $rgb): string
1310    {
1311        if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) {
1312            return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]);
1313        }
1314
1315        return trim($rgb, '# ');
1316    }
1317
1318    /**
1319     * Transform HTML sizes (pt, px) in pixels.
1320     */
1321    protected static function convertHtmlSize(string $size): float
1322    {
1323        // pt
1324        if (false !== strpos($size, 'pt')) {
1325            return Converter::pointToPixel((float) str_replace('pt', '', $size));
1326        }
1327
1328        // px
1329        if (false !== strpos($size, 'px')) {
1330            return (float) str_replace('px', '', $size);
1331        }
1332
1333        return (float) $size;
1334    }
1335}