• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHPOffice / PHPWord / 13107856838

03 Feb 2025 06:41AM UTC coverage: 96.823% (+0.08%) from 96.748%
13107856838

Pull #2737

github

web-flow
Merge 456ede69a into fd06f9633
Pull Request #2737: Reader HTML: Support font styles for h1/h6

10 of 10 new or added lines in 1 file covered. (100.0%)

21 existing lines in 1 file now uncovered.

12222 of 12623 relevant lines covered (96.82%)

34.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.39
/src/PhpWord/Shared/Html.php
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

19
namespace PhpOffice\PhpWord\Shared;
20

21
use DOMAttr;
22
use DOMDocument;
23
use DOMNode;
24
use DOMXPath;
25
use Exception;
26
use PhpOffice\PhpWord\ComplexType\RubyProperties;
27
use PhpOffice\PhpWord\Element\AbstractContainer;
28
use PhpOffice\PhpWord\Element\Row;
29
use PhpOffice\PhpWord\Element\Table;
30
use PhpOffice\PhpWord\Element\TextRun;
31
use PhpOffice\PhpWord\Settings;
32
use PhpOffice\PhpWord\SimpleType\Jc;
33
use PhpOffice\PhpWord\SimpleType\NumberFormat;
34
use PhpOffice\PhpWord\Style\Paragraph;
35

36
/**
37
 * Common Html functions.
38
 *
39
 * @SuppressWarnings(PHPMD.UnusedPrivateMethod) For readWPNode
40
 */
41
class 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;
62✔
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);
62✔
79
        $html = str_replace(['&lt;', '&gt;', '&amp;', '&quot;'], ['_lt_', '_gt_', '_amp_', '_quot_'], $html);
62✔
80
        $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
62✔
81
        $html = str_replace('&', '&amp;', $html);
62✔
82
        $html = str_replace(['_lt_', '_gt_', '_amp_', '_quot_'], ['&lt;', '&gt;', '&amp;', '&quot;'], $html);
62✔
83

84
        if (false === $fullHTML) {
62✔
85
            $html = '<body>' . $html . '</body>';
60✔
86
        }
87

88
        // Load DOM
89
        if (\PHP_VERSION_ID < 80000) {
62✔
90
            $orignalLibEntityLoader = libxml_disable_entity_loader(true);
62✔
91
        }
92
        $dom = new DOMDocument();
62✔
93
        $dom->preserveWhiteSpace = $preserveWhiteSpace;
62✔
94
        $dom->loadXML($html);
62✔
95
        static::$xpath = new DOMXPath($dom);
62✔
96
        $node = $dom->getElementsByTagName('body');
62✔
97

98
        static::parseNode($node->item(0), $element);
62✔
99
        if (\PHP_VERSION_ID < 80000) {
61✔
100
            libxml_disable_entity_loader($orignalLibEntityLoader);
61✔
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) {
60✔
115
            $attributes = $node->attributes; // get all the attributes(eg: id, class)
60✔
116

117
            $attributeDir = $attributes->getNamedItem('dir');
60✔
118
            $attributeDirValue = $attributeDir ? $attributeDir->nodeValue : '';
60✔
119
            $bidi = $attributeDirValue === 'rtl';
60✔
120
            foreach ($attributes as $attribute) {
60✔
121
                $val = $attribute->value;
43✔
122
                switch (strtolower($attribute->name)) {
43✔
123
                    case 'align':
43✔
124
                        $styles['alignment'] = self::mapAlign(trim($val), $bidi);
2✔
125

126
                        break;
2✔
127
                    case 'lang':
43✔
128
                        $styles['lang'] = $val;
1✔
129

130
                        break;
1✔
131
                    case 'width':
42✔
132
                        // tables, cells
133
                        $val = $val === 'auto' ? '100%' : $val;
9✔
134
                        if (false !== strpos($val, '%')) {
9✔
135
                            // e.g. <table width="100%"> or <td width="50%">
136
                            $styles['width'] = (int) $val * 50;
5✔
137
                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
5✔
138
                        } else {
139
                            // e.g. <table width="250> where "250" = 250px (always pixels)
140
                            $styles['width'] = Converter::pixelToTwip(self::convertHtmlSize($val));
4✔
141
                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
4✔
142
                        }
143

144
                        break;
9✔
145
                    case 'cellspacing':
37✔
146
                        // tables e.g. <table cellspacing="2">,  where "2" = 2px (always pixels)
147
                        $styles['cellSpacing'] = Converter::pixelToTwip(self::convertHtmlSize($val));
1✔
148

149
                        break;
1✔
150
                    case 'bgcolor':
37✔
151
                        // tables, rows, cells e.g. <tr bgColor="#FF0000">
152
                        $styles['bgColor'] = self::convertRgb($val);
3✔
153

154
                        break;
3✔
155
                    case 'valign':
36✔
156
                        // cells e.g. <td valign="middle">
157
                        if (preg_match('#(?:top|bottom|middle|baseline)#i', $val, $matches)) {
1✔
158
                            $styles['valign'] = self::mapAlignVertical($matches[0]);
1✔
159
                        }
160

161
                        break;
1✔
162
                }
163
            }
164

165
            $attributeIdentifier = $attributes->getNamedItem('id');
60✔
166
            if ($attributeIdentifier && self::$css) {
60✔
167
                $styles = self::parseStyleDeclarations(self::$css->getStyle('#' . $attributeIdentifier->nodeValue), $styles);
×
168
            }
169

170
            $attributeClass = $attributes->getNamedItem('class');
60✔
171
            if ($attributeClass) {
60✔
172
                if (self::$css) {
2✔
173
                    $styles = self::parseStyleDeclarations(self::$css->getStyle('.' . $attributeClass->nodeValue), $styles);
2✔
174
                }
175
                $styles['className'] = $attributeClass->nodeValue;
2✔
176
            }
177

178
            $attributeStyle = $attributes->getNamedItem('style');
60✔
179
            if ($attributeStyle) {
60✔
180
                $styles = self::parseStyle($attributeStyle, $styles);
31✔
181
            }
182
        }
183

184
        return $styles;
60✔
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') {
62✔
198
            self::$css = new Css($node->textContent);
2✔
199
            self::$css->process();
2✔
200

201
            return;
2✔
202
        }
203

204
        // Populate styles array
205
        $styleTypes = ['font', 'paragraph', 'list', 'table', 'row', 'cell'];
62✔
206
        foreach ($styleTypes as $styleType) {
62✔
207
            if (!isset($styles[$styleType])) {
62✔
208
                $styles[$styleType] = [];
62✔
209
            }
210
        }
211

212
        // Node mapping table
213
        $nodes = [
62✔
214
            // $method               $node   $element    $styles     $data   $argument1      $argument2
215
            'p' => ['Paragraph',     $node,  $element,   $styles,    null,   null,           null],
62✔
216
            'h1' => ['Heading',      $node,  $element,   $styles,    null,   'Heading1',     null],
62✔
217
            'h2' => ['Heading',      $node,  $element,   $styles,    null,   'Heading2',     null],
62✔
218
            'h3' => ['Heading',      $node,  $element,   $styles,    null,   'Heading3',     null],
62✔
219
            'h4' => ['Heading',      $node,  $element,   $styles,    null,   'Heading4',     null],
62✔
220
            'h5' => ['Heading',      $node,  $element,   $styles,    null,   'Heading5',     null],
62✔
221
            'h6' => ['Heading',      $node,  $element,   $styles,    null,   'Heading6',     null],
62✔
222
            '#text' => ['Text',      $node,  $element,   $styles,    null,   null,           null],
62✔
223
            'strong' => ['Property', null,   null,       $styles,    null,   'bold',         true],
62✔
224
            'b' => ['Property',    null,   null,       $styles,    null,   'bold',         true],
62✔
225
            'em' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
62✔
226
            'i' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
62✔
227
            'u' => ['Property',    null,   null,       $styles,    null,   'underline',    'single'],
62✔
228
            'sup' => ['Property',    null,   null,       $styles,    null,   'superScript',  true],
62✔
229
            'sub' => ['Property',    null,   null,       $styles,    null,   'subScript',    true],
62✔
230
            'span' => ['Span',        $node,  null,       $styles,    null,   null,           null],
62✔
231
            'font' => ['Span',        $node,  null,       $styles,    null,   null,           null],
62✔
232
            'table' => ['Table',       $node,  $element,   $styles,    null,   null,           null],
62✔
233
            'tr' => ['Row',         $node,  $element,   $styles,    null,   null,           null],
62✔
234
            'td' => ['Cell',        $node,  $element,   $styles,    null,   null,           null],
62✔
235
            'th' => ['Cell',        $node,  $element,   $styles,    null,   null,           null],
62✔
236
            'ul' => ['List',        $node,  $element,   $styles,    $data,  null,           null],
62✔
237
            'ol' => ['List',        $node,  $element,   $styles,    $data,  null,           null],
62✔
238
            'li' => ['ListItem',    $node,  $element,   $styles,    $data,  null,           null],
62✔
239
            'img' => ['Image',       $node,  $element,   $styles,    null,   null,           null],
62✔
240
            'br' => ['LineBreak',   null,   $element,   $styles,    null,   null,           null],
62✔
241
            'a' => ['Link',        $node,  $element,   $styles,    null,   null,           null],
62✔
242
            'input' => ['Input',       $node,  $element,   $styles,    null,   null,           null],
62✔
243
            'hr' => ['HorizRule',   $node,  $element,   $styles,    null,   null,           null],
62✔
244
            'ruby' => ['Ruby',   $node,  $element,   $styles,    null,   null,           null],
62✔
245
        ];
62✔
246

247
        $newElement = null;
62✔
248
        $keys = ['node', 'element', 'styles', 'data', 'argument1', 'argument2'];
62✔
249

250
        if (isset($nodes[$node->nodeName])) {
62✔
251
            // Execute method based on node mapping table and return $newElement or null
252
            // Arguments are passed by reference
253
            $arguments = [];
62✔
254
            $args = [];
62✔
255
            [$method, $args[0], $args[1], $args[2], $args[3], $args[4], $args[5]] = $nodes[$node->nodeName];
62✔
256
            for ($i = 0; $i <= 5; ++$i) {
62✔
257
                if ($args[$i] !== null) {
62✔
258
                    $arguments[$keys[$i]] = &$args[$i];
62✔
259
                }
260
            }
261
            $method = "parse{$method}";
62✔
262
            $newElement = call_user_func_array(['PhpOffice\PhpWord\Shared\Html', $method], array_values($arguments));
62✔
263

264
            // Retrieve back variables from arguments
265
            foreach ($keys as $key) {
62✔
266
                if (array_key_exists($key, $arguments)) {
62✔
267
                    $$key = $arguments[$key];
62✔
268
                }
269
            }
270
        }
271

272
        if ($newElement === null) {
62✔
273
            $newElement = $element;
62✔
274
        }
275

276
        static::parseChildNodes($node, $newElement, $styles, $data);
62✔
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) {
62✔
290
            $cNodes = $node->childNodes;
62✔
291
            if (!empty($cNodes)) {
62✔
292
                foreach ($cNodes as $cNode) {
62✔
293
                    if ($element instanceof AbstractContainer || $element instanceof Table || $element instanceof Row) {
62✔
294
                        self::parseNode($cNode, $element, $styles, $data);
62✔
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']);
31✔
313
        if (isset($styles['paragraph']['isPageBreak']) && $styles['paragraph']['isPageBreak']) {
31✔
314
            return $element->addPageBreak();
1✔
315
        }
316

317
        return $element->addTextRun($styles['paragraph']);
30✔
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;
1✔
330
        if (null === $attributes->getNamedItem('type')) {
1✔
331
            return;
×
332
        }
333

334
        $inputType = $attributes->getNamedItem('type')->nodeValue;
1✔
335
        switch ($inputType) {
336
            case 'checkbox':
1✔
337
                $checked = ($checked = $attributes->getNamedItem('checked')) && $checked->nodeValue === 'true' ? true : false;
1✔
338
                $textrun = $element->addTextRun($styles['paragraph']);
1✔
339
                $textrun->addFormField('checkbox')->setValue($checked);
1✔
340

341
                break;
1✔
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(DOMNode $node, AbstractContainer $element, array &$styles, string $argument1): TextRun
358
    {   
359
        $style = new Paragraph();
4✔
360
        $style->setStyleName($argument1);
4✔
361
        $style->setStyleByArray(self::parseInlineStyle($node, $styles['paragraph']));
4✔
362

363
        return $element->addTextRun($style);
4✔
364
    }
365

366
    /**
367
     * Parse text node.
368
     *
369
     * @param DOMNode $node
370
     * @param AbstractContainer $element
371
     * @param array &$styles
372
     */
373
    protected static function parseText($node, $element, &$styles): void
374
    {
375
        $styles['font'] = self::recursiveParseStylesInHierarchy($node, $styles['font']);
46✔
376

377
        //alignment applies on paragraph, not on font. Let's copy it there
378
        if (isset($styles['font']['alignment']) && is_array($styles['paragraph'])) {
46✔
379
            $styles['paragraph']['alignment'] = $styles['font']['alignment'];
9✔
380
        }
381

382
        if (is_callable([$element, 'addText'])) {
46✔
383
            $element->addText($node->nodeValue, $styles['font'], $styles['paragraph']);
46✔
384
        }
385
    }
386

387
    /**
388
     * Parse property node.
389
     *
390
     * @param array &$styles
391
     * @param string $argument1 Style name
392
     * @param string $argument2 Style value
393
     */
394
    protected static function parseProperty(&$styles, $argument1, $argument2): void
395
    {
396
        $styles['font'][$argument1] = $argument2;
6✔
397
    }
398

399
    /**
400
     * Parse span node.
401
     *
402
     * @param DOMNode $node
403
     * @param array &$styles
404
     */
405
    protected static function parseSpan($node, &$styles): void
406
    {
407
        self::parseInlineStyle($node, $styles['font']);
12✔
408
    }
409

410
    /**
411
     * Parse table node.
412
     *
413
     * @param DOMNode $node
414
     * @param AbstractContainer $element
415
     * @param array &$styles
416
     *
417
     * @return Table $element
418
     *
419
     * @todo As soon as TableItem, RowItem and CellItem support relative width and height
420
     */
421
    protected static function parseTable($node, $element, &$styles)
422
    {
423
        $elementStyles = self::parseInlineStyle($node, $styles['table']);
16✔
424

425
        $newElement = $element->addTable($elementStyles);
16✔
426

427
        // Add style name from CSS Class
428
        if (isset($elementStyles['className'])) {
16✔
429
            $newElement->getStyle()->setStyleName($elementStyles['className']);
1✔
430
        }
431

432
        $attributes = $node->attributes;
16✔
433
        if ($attributes->getNamedItem('border')) {
16✔
434
            $border = (int) $attributes->getNamedItem('border')->nodeValue;
1✔
435
            $newElement->getStyle()->setBorderSize(Converter::pixelToTwip($border));
1✔
436
        }
437

438
        return $newElement;
16✔
439
    }
440

441
    /**
442
     * Parse a table row.
443
     *
444
     * @param DOMNode $node
445
     * @param Table $element
446
     * @param array &$styles
447
     *
448
     * @return Row $element
449
     */
450
    protected static function parseRow($node, $element, &$styles)
451
    {
452
        $rowStyles = self::parseInlineStyle($node, $styles['row']);
16✔
453
        if ($node->parentNode->nodeName == 'thead') {
16✔
454
            $rowStyles['tblHeader'] = true;
2✔
455
        }
456

457
        // set cell height to control row heights
458
        $height = $rowStyles['height'] ?? null;
16✔
459
        unset($rowStyles['height']); // would not apply
16✔
460

461
        return $element->addRow($height, $rowStyles);
16✔
462
    }
463

464
    /**
465
     * Parse table cell.
466
     *
467
     * @param DOMNode $node
468
     * @param Table $element
469
     * @param array &$styles
470
     *
471
     * @return \PhpOffice\PhpWord\Element\Cell|TextRun $element
472
     */
473
    protected static function parseCell($node, $element, &$styles)
474
    {
475
        $cellStyles = self::recursiveParseStylesInHierarchy($node, $styles['cell']);
16✔
476

477
        $colspan = $node->getAttribute('colspan');
16✔
478
        if (!empty($colspan)) {
16✔
479
            $cellStyles['gridSpan'] = $colspan - 0;
2✔
480
        }
481

482
        // set cell width to control column widths
483
        $width = $cellStyles['width'] ?? null;
16✔
484
        unset($cellStyles['width']); // would not apply
16✔
485
        $cell = $element->addCell($width, $cellStyles);
16✔
486

487
        if (self::shouldAddTextRun($node)) {
16✔
488
            return $cell->addTextRun(self::filterOutNonInheritedStyles(self::parseInlineStyle($node, $styles['paragraph'])));
16✔
489
        }
490

491
        return $cell;
3✔
492
    }
493

494
    /**
495
     * Checks if $node contains an HTML element that cannot be added to TextRun.
496
     *
497
     * @return bool Returns true if the node contains an HTML element that cannot be added to TextRun
498
     */
499
    protected static function shouldAddTextRun(DOMNode $node)
500
    {
501
        $containsBlockElement = self::$xpath->query('.//table|./p|./ul|./ol|./h1|./h2|./h3|./h4|./h5|./h6', $node)->length > 0;
16✔
502
        if ($containsBlockElement) {
16✔
503
            return false;
3✔
504
        }
505

506
        return true;
16✔
507
    }
508

509
    /**
510
     * Recursively parses styles on parent nodes
511
     * TODO if too slow, add caching of parent nodes, !! everything is static here so watch out for concurrency !!
512
     */
513
    protected static function recursiveParseStylesInHierarchy(DOMNode $node, array $style)
514
    {
515
        $parentStyle = [];
60✔
516
        if ($node->parentNode != null && XML_ELEMENT_NODE == $node->parentNode->nodeType) {
60✔
517
            $parentStyle = self::recursiveParseStylesInHierarchy($node->parentNode, []);
60✔
518
        }
519
        if ($node->nodeName === '#text') {
60✔
520
            $parentStyle = array_merge($parentStyle, $style);
46✔
521
        } else {
522
            $parentStyle = self::filterOutNonInheritedStyles($parentStyle);
60✔
523
        }
524
        $style = self::parseInlineStyle($node, $parentStyle);
60✔
525

526
        return $style;
60✔
527
    }
528

529
    /**
530
     * Removes non-inherited styles from array.
531
     */
532
    protected static function filterOutNonInheritedStyles(array $styles)
533
    {
534
        $nonInheritedStyles = [
60✔
535
            'borderSize',
60✔
536
            'borderTopSize',
60✔
537
            'borderRightSize',
60✔
538
            'borderBottomSize',
60✔
539
            'borderLeftSize',
60✔
540
            'borderColor',
60✔
541
            'borderTopColor',
60✔
542
            'borderRightColor',
60✔
543
            'borderBottomColor',
60✔
544
            'borderLeftColor',
60✔
545
            'borderStyle',
60✔
546
            'spaceAfter',
60✔
547
            'spaceBefore',
60✔
548
            'underline',
60✔
549
            'strikethrough',
60✔
550
            'hidden',
60✔
551
        ];
60✔
552

553
        $styles = array_diff_key($styles, array_flip($nonInheritedStyles));
60✔
554

555
        return $styles;
60✔
556
    }
557

558
    /**
559
     * Parse list node.
560
     *
561
     * @param DOMNode $node
562
     * @param AbstractContainer $element
563
     * @param array &$styles
564
     * @param array &$data
565
     */
566
    protected static function parseList($node, $element, &$styles, &$data)
567
    {
568
        $isOrderedList = $node->nodeName === 'ol';
7✔
569
        if (isset($data['listdepth'])) {
7✔
570
            ++$data['listdepth'];
3✔
571
        } else {
572
            $data['listdepth'] = 0;
7✔
573
            $styles['list'] = 'listStyle_' . self::$listIndex++;
7✔
574
            $style = $element->getPhpWord()->addNumberingStyle($styles['list'], self::getListStyle($isOrderedList));
7✔
575

576
            // extract attributes start & type e.g. <ol type="A" start="3">
577
            $start = 0;
7✔
578
            $type = '';
7✔
579
            foreach ($node->attributes as $attribute) {
7✔
580
                switch ($attribute->name) {
1✔
581
                    case 'start':
1✔
582
                        $start = (int) $attribute->value;
1✔
583

584
                        break;
1✔
585
                    case 'type':
1✔
586
                        $type = $attribute->value;
1✔
587

588
                        break;
1✔
589
                }
590
            }
591

592
            $levels = $style->getLevels();
7✔
593
            /** @var \PhpOffice\PhpWord\Style\NumberingLevel */
594
            $level = $levels[0];
7✔
595
            if ($start > 0) {
7✔
596
                $level->setStart($start);
1✔
597
            }
598
            $type = $type ? self::mapListType($type) : null;
7✔
599
            if ($type) {
7✔
600
                $level->setFormat($type);
1✔
601
            }
602
        }
603
        if ($node->parentNode->nodeName === 'li') {
7✔
604
            return $element->getParent();
1✔
605
        }
606
    }
607

608
    /**
609
     * @param bool $isOrderedList
610
     *
611
     * @return array
612
     */
613
    protected static function getListStyle($isOrderedList)
614
    {
615
        if ($isOrderedList) {
7✔
616
            return [
5✔
617
                'type' => 'multilevel',
5✔
618
                'levels' => [
5✔
619
                    ['format' => NumberFormat::DECIMAL,      'text' => '%1.', 'alignment' => 'left',  'tabPos' => 720,  'left' => 720,  'hanging' => 360],
5✔
620
                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%2.', 'alignment' => 'left',  'tabPos' => 1440, 'left' => 1440, 'hanging' => 360],
5✔
621
                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%3.', 'alignment' => 'right', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 180],
5✔
622
                    ['format' => NumberFormat::DECIMAL,      'text' => '%4.', 'alignment' => 'left',  'tabPos' => 2880, 'left' => 2880, 'hanging' => 360],
5✔
623
                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%5.', 'alignment' => 'left',  'tabPos' => 3600, 'left' => 3600, 'hanging' => 360],
5✔
624
                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%6.', 'alignment' => 'right', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 180],
5✔
625
                    ['format' => NumberFormat::DECIMAL,      'text' => '%7.', 'alignment' => 'left',  'tabPos' => 5040, 'left' => 5040, 'hanging' => 360],
5✔
626
                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%8.', 'alignment' => 'left',  'tabPos' => 5760, 'left' => 5760, 'hanging' => 360],
5✔
627
                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%9.', 'alignment' => 'right', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 180],
5✔
628
                ],
5✔
629
            ];
5✔
630
        }
631

632
        return [
4✔
633
            'type' => 'hybridMultilevel',
4✔
634
            'levels' => [
4✔
635
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 720,  'left' => 720,  'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
4✔
636
                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
4✔
637
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
4✔
638
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
4✔
639
                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
4✔
640
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
4✔
641
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
4✔
642
                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
4✔
643
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
4✔
644
            ],
4✔
645
        ];
4✔
646
    }
647

648
    /**
649
     * Parse list item node.
650
     *
651
     * @param DOMNode $node
652
     * @param AbstractContainer $element
653
     * @param array &$styles
654
     * @param array $data
655
     *
656
     * @todo This function is almost the same like `parseChildNodes`. Merged?
657
     * @todo As soon as ListItem inherits from AbstractContainer or TextRun delete parsing part of childNodes
658
     */
659
    protected static function parseListItem($node, $element, &$styles, $data): void
660
    {
661
        $cNodes = $node->childNodes;
7✔
662
        if (!empty($cNodes)) {
7✔
663
            $listRun = $element->addListItemRun($data['listdepth'], $styles['list'], $styles['paragraph']);
7✔
664
            foreach ($cNodes as $cNode) {
7✔
665
                self::parseNode($cNode, $listRun, $styles, $data);
7✔
666
            }
667
        }
668
    }
669

670
    /**
671
     * Parse style.
672
     *
673
     * @param DOMAttr $attribute
674
     * @param array $styles
675
     *
676
     * @return array
677
     */
678
    protected static function parseStyle($attribute, $styles)
679
    {
680
        $properties = explode(';', trim($attribute->value, " \t\n\r\0\x0B;"));
32✔
681

682
        $selectors = [];
32✔
683
        foreach ($properties as $property) {
32✔
684
            [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null);
32✔
685
            $selectors[strtolower(trim($cKey))] = trim($cValue ?? '');
32✔
686
        }
687

688
        return self::parseStyleDeclarations($selectors, $styles);
32✔
689
    }
690

691
    protected static function parseStyleDeclarations(array $selectors, array $styles)
692
    {
693
        $bidi = ($selectors['direction'] ?? '') === 'rtl';
34✔
694
        foreach ($selectors as $property => $value) {
34✔
695
            switch ($property) {
696
                case 'text-decoration':
34✔
697
                    switch ($value) {
698
                        case 'underline':
4✔
699
                            $styles['underline'] = 'single';
3✔
700

701
                            break;
3✔
702
                        case 'line-through':
1✔
703
                            $styles['strikethrough'] = true;
1✔
704

705
                            break;
1✔
706
                    }
707

708
                    break;
4✔
709
                case 'text-align':
32✔
710
                    $styles['alignment'] = self::mapAlign($value, $bidi);
8✔
711

712
                    break;
8✔
713
                case 'ruby-align':
31✔
714
                    $styles['rubyAlignment'] = self::mapRubyAlign($value);
1✔
715

716
                    break;
1✔
717
                case 'display':
31✔
718
                    $styles['hidden'] = $value === 'none' || $value === 'hidden';
1✔
719

720
                    break;
1✔
721
                case 'direction':
30✔
722
                    $styles['rtl'] = $value === 'rtl';
3✔
723
                    $styles['bidi'] = $value === 'rtl';
3✔
724

725
                    break;
3✔
726
                case 'font-size':
27✔
727
                    $styles['size'] = Converter::cssToPoint($value);
6✔
728

729
                    break;
6✔
730
                case 'font-family':
24✔
731
                    $value = array_map('trim', explode(',', $value));
5✔
732
                    $styles['name'] = ucwords($value[0]);
5✔
733

734
                    break;
5✔
735
                case 'color':
20✔
736
                    $styles['color'] = self::convertRgb($value);
5✔
737

738
                    break;
5✔
739
                case 'background-color':
18✔
740
                    $styles['bgColor'] = self::convertRgb($value);
5✔
741

742
                    break;
5✔
743
                case 'line-height':
17✔
744
                    $matches = [];
2✔
745
                    if ($value === 'normal' || $value === 'inherit') {
2✔
746
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
747
                        $spacing = 0;
1✔
748
                    } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) {
2✔
749
                        //matches number with a unit, e.g. 12px, 15pt, 20mm, ...
750
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::EXACT;
2✔
751
                        $spacing = Converter::cssToTwip($matches[1]);
2✔
752
                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
1✔
753
                        //matches percentages
754
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
755
                        //we are subtracting 1 line height because the Spacing writer is adding one line
756
                        $spacing = ((((int) $matches[1]) / 100) * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT;
1✔
757
                    } else {
758
                        //any other, wich is a multiplier. E.g. 1.2
759
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
760
                        //we are subtracting 1 line height because the Spacing writer is adding one line
761
                        $spacing = ($value * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT;
1✔
762
                    }
763
                    $styles['spacingLineRule'] = $spacingLineRule;
2✔
764
                    $styles['line-spacing'] = $spacing;
2✔
765

766
                    break;
2✔
767
                case 'letter-spacing':
15✔
768
                    $styles['letter-spacing'] = Converter::cssToTwip($value);
1✔
769

770
                    break;
1✔
771
                case 'text-indent':
14✔
772
                    $styles['indentation']['firstLine'] = Converter::cssToTwip($value);
1✔
773

774
                    break;
1✔
775
                case 'font-weight':
13✔
776
                    $tValue = false;
3✔
777
                    if (preg_match('#bold#', $value)) {
3✔
778
                        $tValue = true; // also match bolder
3✔
779
                    }
780
                    $styles['bold'] = $tValue;
3✔
781

782
                    break;
3✔
783
                case 'font-style':
12✔
784
                    $tValue = false;
1✔
785
                    if (preg_match('#(?:italic|oblique)#', $value)) {
1✔
786
                        $tValue = true;
1✔
787
                    }
788
                    $styles['italic'] = $tValue;
1✔
789

790
                    break;
1✔
791
                case 'font-variant':
11✔
792
                    $tValue = false;
1✔
793
                    if (preg_match('#small-caps#', $value)) {
1✔
794
                        $tValue = true;
1✔
795
                    }
796
                    $styles['smallCaps'] = $tValue;
1✔
797

798
                    break;
1✔
799
                case 'margin':
10✔
800
                    $value = Converter::cssToTwip($value);
×
UNCOV
801
                    $styles['spaceBefore'] = $value;
×
UNCOV
802
                    $styles['spaceAfter'] = $value;
×
803

UNCOV
804
                    break;
×
805
                case 'margin-top':
10✔
806
                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
807
                    $styles['spaceBefore'] = Converter::cssToTwip($value);
2✔
808

809
                    break;
2✔
810
                case 'margin-bottom':
10✔
811
                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
812
                    $styles['spaceAfter'] = Converter::cssToTwip($value);
2✔
813

814
                    break;
2✔
815

816
                case 'padding':
9✔
817
                    $valueTop = $valueRight = $valueBottom = $valueLeft = null;
1✔
818
                    $cValue = preg_replace('# +#', ' ', trim($value));
1✔
819
                    $paddingArr = explode(' ', $cValue);
1✔
820
                    $countParams = count($paddingArr);
1✔
821
                    if ($countParams == 1) {
1✔
UNCOV
822
                        $valueTop = $valueRight = $valueBottom = $valueLeft = $paddingArr[0];
×
823
                    } elseif ($countParams == 2) {
1✔
824
                        $valueTop = $valueBottom = $paddingArr[0];
×
825
                        $valueRight = $valueLeft = $paddingArr[1];
×
826
                    } elseif ($countParams == 3) {
1✔
UNCOV
827
                        $valueTop = $paddingArr[0];
×
UNCOV
828
                        $valueRight = $valueLeft = $paddingArr[1];
×
UNCOV
829
                        $valueBottom = $paddingArr[2];
×
830
                    } elseif ($countParams == 4) {
1✔
831
                        $valueTop = $paddingArr[0];
1✔
832
                        $valueRight = $paddingArr[1];
1✔
833
                        $valueBottom = $paddingArr[2];
1✔
834
                        $valueLeft = $paddingArr[3];
1✔
835
                    }
836
                    if ($valueTop !== null) {
1✔
837
                        $styles['paddingTop'] = Converter::cssToTwip($valueTop);
1✔
838
                    }
839
                    if ($valueRight !== null) {
1✔
840
                        $styles['paddingRight'] = Converter::cssToTwip($valueRight);
1✔
841
                    }
842
                    if ($valueBottom !== null) {
1✔
843
                        $styles['paddingBottom'] = Converter::cssToTwip($valueBottom);
1✔
844
                    }
845
                    if ($valueLeft !== null) {
1✔
846
                        $styles['paddingLeft'] = Converter::cssToTwip($valueLeft);
1✔
847
                    }
848

849
                    break;
1✔
850
                case 'padding-top':
9✔
851
                    $styles['paddingTop'] = Converter::cssToTwip($value);
1✔
852

853
                    break;
1✔
854
                case 'padding-right':
9✔
855
                    $styles['paddingRight'] = Converter::cssToTwip($value);
1✔
856

857
                    break;
1✔
858
                case 'padding-bottom':
9✔
859
                    $styles['paddingBottom'] = Converter::cssToTwip($value);
1✔
860

861
                    break;
1✔
862
                case 'padding-left':
9✔
863
                    $styles['paddingLeft'] = Converter::cssToTwip($value);
1✔
864

865
                    break;
1✔
866

867
                case 'border-color':
8✔
868
                    self::mapBorderColor($styles, $value);
1✔
869

870
                    break;
1✔
871
                case 'border-width':
8✔
872
                    $styles['borderSize'] = Converter::cssToPoint($value);
1✔
873

874
                    break;
1✔
875
                case 'border-style':
8✔
876
                    $styles['borderStyle'] = self::mapBorderStyle($value);
1✔
877

878
                    break;
1✔
879
                case 'width':
8✔
880
                    if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) {
3✔
881
                        $styles['width'] = Converter::cssToTwip($matches[1]);
2✔
882
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
2✔
883
                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
3✔
884
                        $styles['width'] = $matches[1] * 50;
3✔
885
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
3✔
886
                    } elseif (preg_match('/([0-9]+)/', $value, $matches)) {
1✔
887
                        $styles['width'] = $matches[1];
1✔
888
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO;
1✔
889
                    }
890

891
                    break;
3✔
892
                case 'height':
7✔
893
                    $styles['height'] = Converter::cssToTwip($value);
1✔
894
                    $styles['exactHeight'] = true;
1✔
895

896
                    break;
1✔
897
                case 'border':
6✔
898
                case 'border-top':
4✔
899
                case 'border-bottom':
4✔
900
                case 'border-right':
3✔
901
                case 'border-left':
3✔
902
                    // must have exact order [width color style], e.g. "1px #0011CC solid" or "2pt green solid"
903
                    // Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC
904
                    if (preg_match('/([0-9]+[^0-9]*)\s+(\#[a-fA-F0-9]+|[a-zA-Z]+)\s+([a-z]+)/', $value, $matches)) {
4✔
905
                        if (false !== strpos($property, '-')) {
4✔
906
                            $tmp = explode('-', $property);
1✔
907
                            $which = $tmp[1];
1✔
908
                            $which = ucfirst($which); // e.g. bottom -> Bottom
1✔
909
                        } else {
910
                            $which = '';
3✔
911
                        }
912
                        // Note - border width normalization:
913
                        // Width of border in Word is calculated differently than HTML borders, usually showing up too bold.
914
                        // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips.
915
                        // Therefore we need to normalize converted twip value to cca 1/2 of value.
916
                        // This may be adjusted, if better ratio or formula found.
917
                        // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size)
918
                        $size = Converter::cssToTwip($matches[1]);
4✔
919
                        $size = (int) ($size / 2);
4✔
920
                        // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc ..
921
                        $styles["border{$which}Size"] = $size; // twips
4✔
922
                        $styles["border{$which}Color"] = trim($matches[2], '#');
4✔
923
                        $styles["border{$which}Style"] = self::mapBorderStyle($matches[3]);
4✔
924
                    }
925

926
                    break;
4✔
927
                case 'vertical-align':
3✔
928
                    // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align
929
                    if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) {
1✔
930
                        $styles['valign'] = self::mapAlignVertical($matches[0]);
1✔
931
                    }
932

933
                    break;
1✔
934
                case 'page-break-after':
2✔
935
                    if ($value == 'always') {
1✔
936
                        $styles['isPageBreak'] = true;
1✔
937
                    }
938

939
                    break;
1✔
940
            }
941
        }
942

943
        return $styles;
34✔
944
    }
945

946
    /**
947
     * Parse image node.
948
     *
949
     * @param DOMNode $node
950
     * @param AbstractContainer $element
951
     *
952
     * @return \PhpOffice\PhpWord\Element\Image
953
     */
954
    protected static function parseImage($node, $element)
955
    {
956
        $style = [];
9✔
957
        $src = null;
9✔
958
        foreach ($node->attributes as $attribute) {
9✔
959
            switch ($attribute->name) {
9✔
960
                case 'src':
9✔
961
                    $src = $attribute->value;
9✔
962

963
                    break;
9✔
964
                case 'width':
9✔
965
                    $style['width'] = self::convertHtmlSize($attribute->value);
9✔
966
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
967

968
                    break;
9✔
969
                case 'height':
9✔
970
                    $style['height'] = self::convertHtmlSize($attribute->value);
9✔
971
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
972

973
                    break;
9✔
974
                case 'style':
6✔
975
                    $styleattr = explode(';', $attribute->value);
5✔
976
                    foreach ($styleattr as $attr) {
5✔
977
                        if (strpos($attr, ':')) {
5✔
978
                            [$k, $v] = explode(':', $attr);
5✔
979
                            switch ($k) {
980
                                case 'float':
5✔
981
                                    if (trim($v) == 'right') {
5✔
982
                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT;
5✔
983
                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
5✔
984
                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
5✔
985
                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
5✔
986
                                        $style['overlap'] = true;
5✔
987
                                    }
988
                                    if (trim($v) == 'left') {
5✔
989
                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT;
3✔
990
                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
3✔
991
                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
3✔
992
                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
3✔
993
                                        $style['overlap'] = true;
3✔
994
                                    }
995

996
                                    break;
5✔
997
                            }
998
                        }
999
                    }
1000

1001
                    break;
5✔
1002
            }
1003
        }
1004
        $originSrc = $src;
9✔
1005
        if (strpos($src, 'data:image') !== false) {
9✔
1006
            $tmpDir = Settings::getTempDir() . '/';
1✔
1007

1008
            $match = [];
1✔
1009
            preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match);
1✔
1010
            if (!empty($match)) {
1✔
1011
                $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1];
1✔
1012

1013
                $ifp = fopen($imgFile, 'wb');
1✔
1014

1015
                if ($ifp !== false) {
1✔
1016
                    fwrite($ifp, base64_decode($match[2]));
1✔
1017
                    fclose($ifp);
1✔
1018
                }
1019
            }
1020
        }
1021
        $src = urldecode($src);
9✔
1022

1023
        if (!is_file($src)
9✔
1024
            && null !== self::$options
9✔
1025
            && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'])
9✔
1026
        ) {
1027
            $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src);
1✔
1028
        }
1029

1030
        if (!is_file($src)) {
9✔
1031
            if ($imgBlob = @file_get_contents($src)) {
3✔
1032
                $tmpDir = Settings::getTempDir() . '/';
3✔
1033
                $match = [];
3✔
1034
                preg_match('/.+\.(\w+)$/', $src, $match);
3✔
1035
                $src = $tmpDir . uniqid();
3✔
1036
                if (isset($match[1])) {
3✔
1037
                    $src .= '.' . $match[1];
2✔
1038
                }
1039

1040
                $ifp = fopen($src, 'wb');
3✔
1041

1042
                if ($ifp !== false) {
3✔
1043
                    fwrite($ifp, $imgBlob);
3✔
1044
                    fclose($ifp);
3✔
1045
                }
1046
            }
1047
        }
1048

1049
        if (is_file($src)) {
9✔
1050
            $newElement = $element->addImage($src, $style);
9✔
1051
        } else {
UNCOV
1052
            throw new Exception("Could not load image $originSrc");
×
1053
        }
1054

1055
        return $newElement;
8✔
1056
    }
1057

1058
    /**
1059
     * Transforms a CSS border style into a word border style.
1060
     *
1061
     * @param string $cssBorderStyle
1062
     *
1063
     * @return null|string
1064
     */
1065
    protected static function mapBorderStyle($cssBorderStyle)
1066
    {
1067
        switch ($cssBorderStyle) {
1068
            case 'none':
4✔
1069
            case 'dashed':
4✔
1070
            case 'dotted':
4✔
1071
            case 'double':
4✔
1072
                return $cssBorderStyle;
2✔
1073
            default:
1074
                return 'single';
3✔
1075
        }
1076
    }
1077

1078
    protected static function mapBorderColor(&$styles, $cssBorderColor): void
1079
    {
1080
        $numColors = substr_count($cssBorderColor, '#');
1✔
1081
        if ($numColors === 1) {
1✔
1082
            $styles['borderColor'] = trim($cssBorderColor, '#');
1✔
1083
        } elseif ($numColors > 1) {
1✔
1084
            $colors = explode(' ', $cssBorderColor);
1✔
1085
            $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'];
1✔
1086
            for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) {
1✔
1087
                $styles[$borders[$i]] = trim($colors[$i], '#');
1✔
1088
            }
1089
        }
1090
    }
1091

1092
    /**
1093
     * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1094
     *
1095
     * @param string $cssAlignment
1096
     * @param bool $bidi
1097
     *
1098
     * @return null|string
1099
     */
1100
    protected static function mapAlign($cssAlignment, $bidi)
1101
    {
1102
        switch ($cssAlignment) {
1103
            case 'right':
9✔
1104
                return $bidi ? Jc::START : Jc::END;
1✔
1105
            case 'center':
9✔
1106
                return Jc::CENTER;
7✔
1107
            case 'justify':
4✔
1108
                return Jc::BOTH;
1✔
1109
            default:
1110
                return $bidi ? Jc::END : Jc::START;
4✔
1111
        }
1112
    }
1113

1114
    /**
1115
     * Transforms a HTML/CSS ruby alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1116
     */
1117
    protected static function mapRubyAlign(string $cssRubyAlignment): string
1118
    {
1119
        switch ($cssRubyAlignment) {
1120
            case 'center':
1✔
1121
                return RubyProperties::ALIGNMENT_CENTER;
1✔
UNCOV
1122
            case 'start':
×
1123
                return RubyProperties::ALIGNMENT_LEFT;
×
UNCOV
1124
            case 'space-between':
×
UNCOV
1125
                return RubyProperties::ALIGNMENT_DISTRIBUTE_SPACE;
×
1126
            default:
UNCOV
1127
                return '';
×
1128
        }
1129
    }
1130

1131
    /**
1132
     * Transforms a HTML/CSS vertical alignment.
1133
     *
1134
     * @param string $alignment
1135
     *
1136
     * @return null|string
1137
     */
1138
    protected static function mapAlignVertical($alignment)
1139
    {
1140
        $alignment = strtolower($alignment);
1✔
1141
        switch ($alignment) {
1142
            case 'top':
1✔
1143
            case 'baseline':
1✔
1144
            case 'bottom':
1✔
1145
                return $alignment;
1✔
1146
            case 'middle':
1✔
1147
                return 'center';
1✔
1148
            case 'sub':
×
UNCOV
1149
                return 'bottom';
×
UNCOV
1150
            case 'text-top':
×
UNCOV
1151
            case 'baseline':
×
UNCOV
1152
                return 'top';
×
1153
            default:
1154
                // @discuss - which one should apply:
1155
                // - Word uses default vert. alignment: top
1156
                // - all browsers use default vert. alignment: middle
1157
                // Returning empty string means attribute wont be set so use Word default (top).
UNCOV
1158
                return '';
×
1159
        }
1160
    }
1161

1162
    /**
1163
     * Map list style for ordered list.
1164
     *
1165
     * @param string $cssListType
1166
     */
1167
    protected static function mapListType($cssListType)
1168
    {
1169
        switch ($cssListType) {
1170
            case 'a':
1✔
UNCOV
1171
                return NumberFormat::LOWER_LETTER; // a, b, c, ..
×
1172
            case 'A':
1✔
1173
                return NumberFormat::UPPER_LETTER; // A, B, C, ..
1✔
1174
            case 'i':
1✔
1175
                return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, ..
1✔
1176
            case 'I':
×
UNCOV
1177
                return NumberFormat::UPPER_ROMAN; // I, II, III, IV, ..
×
UNCOV
1178
            case '1':
×
1179
            default:
UNCOV
1180
                return NumberFormat::DECIMAL; // 1, 2, 3, ..
×
1181
        }
1182
    }
1183

1184
    /**
1185
     * Parse line break.
1186
     *
1187
     * @param AbstractContainer $element
1188
     */
1189
    protected static function parseLineBreak($element): void
1190
    {
1191
        $element->addTextBreak();
2✔
1192
    }
1193

1194
    /**
1195
     * Parse link node.
1196
     *
1197
     * @param DOMNode $node
1198
     * @param AbstractContainer $element
1199
     * @param array $styles
1200
     */
1201
    protected static function parseLink($node, $element, &$styles)
1202
    {
1203
        $target = null;
3✔
1204
        foreach ($node->attributes as $attribute) {
3✔
1205
            switch ($attribute->name) {
3✔
1206
                case 'href':
3✔
1207
                    $target = $attribute->value;
3✔
1208

1209
                    break;
3✔
1210
            }
1211
        }
1212
        $styles['font'] = self::parseInlineStyle($node, $styles['font']);
3✔
1213

1214
        if (empty($target)) {
3✔
1215
            $target = '#';
1✔
1216
        }
1217

1218
        if (strpos($target, '#') === 0 && strlen($target) > 1) {
3✔
1219
            return $element->addLink(substr($target, 1), $node->textContent, $styles['font'], $styles['paragraph'], true);
1✔
1220
        }
1221

1222
        return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']);
2✔
1223
    }
1224

1225
    /**
1226
     * Render horizontal rule
1227
     * Note: Word rule is not the same as HTML's <hr> since it does not support width and thus neither alignment.
1228
     *
1229
     * @param DOMNode $node
1230
     * @param AbstractContainer $element
1231
     */
1232
    protected static function parseHorizRule($node, $element): void
1233
    {
1234
        $styles = self::parseInlineStyle($node);
1✔
1235

1236
        // <hr> is implemented as an empty paragraph - extending 100% inside the section
1237
        // Some properties may be controlled, e.g. <hr style="border-bottom: 3px #DDDDDD solid; margin-bottom: 0;">
1238

1239
        $fontStyle = $styles + ['size' => 3];
1✔
1240

1241
        $paragraphStyle = $styles + [
1✔
1242
            'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc
1✔
1243
            'spacing' => 0, // twip
1✔
1244
            'spaceBefore' => 120, // twip, 240/2 (default line height)
1✔
1245
            'spaceAfter' => 120, // twip
1✔
1246
            'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'],
1✔
1247
            'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'],
1✔
1248
            'borderBottomStyle' => 'single', // same as "solid"
1✔
1249
        ];
1✔
1250

1251
        $element->addText('', $fontStyle, $paragraphStyle);
1✔
1252

1253
        // Notes: <hr/> cannot be:
1254
        // - table - throws error "cannot be inside textruns", e.g. lists
1255
        // - line - that is a shape, has different behaviour
1256
        // - repeated text, e.g. underline "_", because of unpredictable line wrapping
1257
    }
1258

1259
    /**
1260
     * Parse ruby node.
1261
     *
1262
     * @param DOMNode $node
1263
     * @param AbstractContainer $element
1264
     * @param array $styles
1265
     */
1266
    protected static function parseRuby($node, $element, &$styles)
1267
    {
1268
        $rubyProperties = new RubyProperties();
1✔
1269
        $baseTextRun = new TextRun($styles['paragraph']);
1✔
1270
        $rubyTextRun = new TextRun(null);
1✔
1271
        if ($node->hasAttributes()) {
1✔
1272
            $langAttr = $node->attributes->getNamedItem('lang');
1✔
1273
            if ($langAttr !== null) {
1✔
1274
                $rubyProperties->setLanguageId($langAttr->textContent);
1✔
1275
            }
1276
            $styleAttr = $node->attributes->getNamedItem('style');
1✔
1277
            if ($styleAttr !== null) {
1✔
1278
                $styles = self::parseStyle($styleAttr, $styles['paragraph']);
1✔
1279
                if (isset($styles['rubyAlignment']) && $styles['rubyAlignment'] !== '') {
1✔
1280
                    $rubyProperties->setAlignment($styles['rubyAlignment']);
1✔
1281
                }
1282
                if (isset($styles['size']) && $styles['size'] !== '') {
1✔
1283
                    $rubyProperties->setFontSizeForBaseText($styles['size']);
1✔
1284
                }
1285
                $baseTextRun->setParagraphStyle($styles);
1✔
1286
            }
1287
        }
1288
        foreach ($node->childNodes as $child) {
1✔
1289
            if ($child->nodeName === '#text') {
1✔
1290
                $content = trim($child->textContent);
1✔
1291
                if ($content !== '') {
1✔
1292
                    $baseTextRun->addText($content);
1✔
1293
                }
1294
            } elseif ($child->nodeName === 'rt') {
1✔
1295
                $rubyTextRun->addText(trim($child->textContent));
1✔
1296
                if ($child->hasAttributes()) {
1✔
1297
                    $styleAttr = $child->attributes->getNamedItem('style');
1✔
1298
                    if ($styleAttr !== null) {
1✔
1299
                        $styles = self::parseStyle($styleAttr, []);
1✔
1300
                        if (isset($styles['size']) && $styles['size'] !== '') {
1✔
1301
                            $rubyProperties->setFontFaceSize($styles['size']);
1✔
1302
                        }
1303
                        $rubyTextRun->setParagraphStyle($styles);
1✔
1304
                    }
1305
                }
1306
            }
1307
        }
1308

1309
        return $element->addRuby($baseTextRun, $rubyTextRun, $rubyProperties);
1✔
1310
    }
1311

1312
    private static function convertRgb(string $rgb): string
1313
    {
1314
        if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) {
9✔
1315
            return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]);
1✔
1316
        }
1317

1318
        return trim($rgb, '# ');
9✔
1319
    }
1320

1321
    /**
1322
     * Transform HTML sizes (pt, px) in pixels.
1323
     */
1324
    protected static function convertHtmlSize(string $size): float
1325
    {
1326
        // pt
1327
        if (false !== strpos($size, 'pt')) {
14✔
1328
            return Converter::pointToPixel((float) str_replace('pt', '', $size));
2✔
1329
        }
1330

1331
        // px
1332
        if (false !== strpos($size, 'px')) {
12✔
1333
            return (float) str_replace('px', '', $size);
2✔
1334
        }
1335

1336
        return (float) $size;
10✔
1337
    }
1338
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc