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

PHPOffice / PHPWord / 12945196840

24 Jan 2025 07:27AM UTC coverage: 96.961% (+0.04%) from 96.921%
12945196840

Pull #2728

github

web-flow
Merge 5b97bab8f into 10ae49963
Pull Request #2728: Proper css inline border #1670

142 of 143 new or added lines in 2 files covered. (99.3%)

9 existing lines in 1 file now uncovered.

12155 of 12536 relevant lines covered (96.96%)

33.84 hits per line

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

96.79
/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\Element\AbstractContainer;
27
use PhpOffice\PhpWord\Element\Row;
28
use PhpOffice\PhpWord\Element\Table;
29
use PhpOffice\PhpWord\Settings;
30
use PhpOffice\PhpWord\SimpleType\Jc;
31
use PhpOffice\PhpWord\SimpleType\NumberFormat;
32
use PhpOffice\PhpWord\Style\Paragraph;
33

34
/**
35
 * Common Html functions.
36
 *
37
 * @SuppressWarnings(PHPMD.UnusedPrivateMethod) For readWPNode
38
 */
39
class Html
40
{
41
    private const RGB_REGEXP = '/^\s*rgb\s*[(]\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*[)]\s*$/';
42

43
    protected static $listIndex = 0;
44

45
    protected static $xpath;
46

47
    protected static $options;
48

49
    /**
50
     * @var Css
51
     */
52
    protected static $css;
53

54
    /**
55
     * Add HTML parts.
56
     *
57
     * Note: $stylesheet parameter is removed to avoid PHPMD error for unused parameter
58
     * Warning: Do not pass user-generated HTML here, as that would allow an attacker to read arbitrary
59
     * files or perform server-side request forgery by passing local file paths or URLs in <img>.
60
     *
61
     * @param AbstractContainer $element Where the parts need to be added
62
     * @param string $html The code to parse
63
     * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag
64
     * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed
65
     */
66
    public static function addHtml($element, $html, $fullHTML = false, $preserveWhiteSpace = true, $options = null): void
67
    {
68
        /*
69
         * @todo parse $stylesheet for default styles.  Should result in an array based on id, class and element,
70
         * which could be applied when such an element occurs in the parseNode function.
71
         */
72
        static::$options = $options;
59✔
73

74
        // Preprocess: remove all line ends, decode HTML entity,
75
        // fix ampersand and angle brackets and add body tag for HTML fragments
76
        $html = str_replace(["\n", "\r"], '', $html);
59✔
77
        $html = str_replace(['&lt;', '&gt;', '&amp;', '&quot;'], ['_lt_', '_gt_', '_amp_', '_quot_'], $html);
59✔
78
        $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
59✔
79
        $html = str_replace('&', '&amp;', $html);
59✔
80
        $html = str_replace(['_lt_', '_gt_', '_amp_', '_quot_'], ['&lt;', '&gt;', '&amp;', '&quot;'], $html);
59✔
81

82
        if (false === $fullHTML) {
59✔
83
            $html = '<body>' . $html . '</body>';
57✔
84
        }
85

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

96
        static::parseNode($node->item(0), $element);
59✔
97
        if (\PHP_VERSION_ID < 80000) {
58✔
98
            libxml_disable_entity_loader($orignalLibEntityLoader);
58✔
99
        }
100
    }
101

102
    /**
103
     * parse Inline style of a node.
104
     *
105
     * @param DOMNode $node Node to check on attributes and to compile a style array
106
     * @param array<string, mixed> $styles is supplied, the inline style attributes are added to the already existing style
107
     *
108
     * @return array
109
     */
110
    protected static function parseInlineStyle($node, $styles = [])
111
    {
112
        if (XML_ELEMENT_NODE == $node->nodeType) {
58✔
113
            $attributes = $node->attributes; // get all the attributes(eg: id, class)
58✔
114

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

124
                        break;
2✔
125
                    case 'lang':
42✔
126
                        $styles['lang'] = $val;
1✔
127

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

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

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

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

159
                        break;
1✔
160
                }
161
            }
162

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

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

176
            $attributeStyle = $attributes->getNamedItem('style');
58✔
177
            if ($attributeStyle) {
58✔
178
                $styles = self::parseStyle($attributeStyle, $styles);
30✔
179
            }
180
        }
181

182
        return $styles;
58✔
183
    }
184

185
    /**
186
     * Parse a node and add a corresponding element to the parent element.
187
     *
188
     * @param DOMNode $node node to parse
189
     * @param AbstractContainer $element object to add an element corresponding with the node
190
     * @param array $styles Array with all styles
191
     * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems
192
     */
193
    protected static function parseNode($node, $element, $styles = [], $data = []): void
194
    {
195
        if ($node->nodeName == 'style') {
59✔
196
            self::$css = new Css($node->textContent);
2✔
197
            self::$css->process();
2✔
198

199
            return;
2✔
200
        }
201

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

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

244
        $newElement = null;
59✔
245
        $keys = ['node', 'element', 'styles', 'data', 'argument1', 'argument2'];
59✔
246

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

261
            // Retrieve back variables from arguments
262
            foreach ($keys as $key) {
59✔
263
                if (array_key_exists($key, $arguments)) {
59✔
264
                    $$key = $arguments[$key];
59✔
265
                }
266
            }
267
        }
268

269
        if ($newElement === null) {
59✔
270
            $newElement = $element;
59✔
271
        }
272

273
        static::parseChildNodes($node, $newElement, $styles, $data);
59✔
274
    }
275

276
    /**
277
     * Parse child nodes.
278
     *
279
     * @param DOMNode $node
280
     * @param AbstractContainer|Row|Table $element
281
     * @param array $styles
282
     * @param array $data
283
     */
284
    protected static function parseChildNodes($node, $element, $styles, $data): void
285
    {
286
        if ('li' != $node->nodeName) {
59✔
287
            $cNodes = $node->childNodes;
59✔
288
            if (!empty($cNodes)) {
59✔
289
                foreach ($cNodes as $cNode) {
59✔
290
                    if ($element instanceof AbstractContainer || $element instanceof Table || $element instanceof Row) {
59✔
291
                        self::parseNode($cNode, $element, $styles, $data);
59✔
292
                    }
293
                }
294
            }
295
        }
296
    }
297

298
    /**
299
     * Parse paragraph node.
300
     *
301
     * @param DOMNode $node
302
     * @param AbstractContainer $element
303
     * @param array &$styles
304
     *
305
     * @return \PhpOffice\PhpWord\Element\PageBreak|\PhpOffice\PhpWord\Element\TextRun
306
     */
307
    protected static function parseParagraph($node, $element, &$styles)
308
    {
309
        $styles['paragraph'] = self::recursiveParseStylesInHierarchy($node, $styles['paragraph']);
31✔
310
        if (isset($styles['paragraph']['isPageBreak']) && $styles['paragraph']['isPageBreak']) {
31✔
311
            return $element->addPageBreak();
1✔
312
        }
313

314
        return $element->addTextRun($styles['paragraph']);
30✔
315
    }
316

317
    /**
318
     * Parse input node.
319
     *
320
     * @param DOMNode $node
321
     * @param AbstractContainer $element
322
     * @param array &$styles
323
     */
324
    protected static function parseInput($node, $element, &$styles): void
325
    {
326
        $attributes = $node->attributes;
1✔
327
        if (null === $attributes->getNamedItem('type')) {
1✔
328
            return;
×
329
        }
330

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

338
                break;
1✔
339
        }
340
    }
341

342
    /**
343
     * Parse heading node.
344
     *
345
     * @param AbstractContainer $element
346
     * @param array &$styles
347
     * @param string $argument1 Name of heading style
348
     *
349
     * @return \PhpOffice\PhpWord\Element\TextRun
350
     *
351
     * @todo Think of a clever way of defining header styles, now it is only based on the assumption, that
352
     * Heading1 - Heading6 are already defined somewhere
353
     */
354
    protected static function parseHeading($element, &$styles, $argument1)
355
    {
356
        $styles['paragraph'] = $argument1;
2✔
357
        $newElement = $element->addTextRun($styles['paragraph']);
2✔
358

359
        return $newElement;
2✔
360
    }
361

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

373
        //alignment applies on paragraph, not on font. Let's copy it there
374
        if (isset($styles['font']['alignment']) && is_array($styles['paragraph'])) {
44✔
375
            $styles['paragraph']['alignment'] = $styles['font']['alignment'];
8✔
376
        }
377

378
        if (is_callable([$element, 'addText'])) {
44✔
379
            $element->addText($node->nodeValue, $styles['font'], $styles['paragraph']);
44✔
380
        }
381
    }
382

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

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

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

421
        $newElement = $element->addTable($elementStyles);
16✔
422

423
        // Add style name from CSS Class
424
        if (isset($elementStyles['className'])) {
16✔
425
            $newElement->getStyle()->setStyleName($elementStyles['className']);
1✔
426
        }
427

428
        $attributes = $node->attributes;
16✔
429
        if ($attributes->getNamedItem('border')) {
16✔
430
            $border = (int) $attributes->getNamedItem('border')->nodeValue;
1✔
431
            $newElement->getStyle()->setBorderSize(Converter::pixelToTwip($border));
1✔
432
        }
433

434
        return $newElement;
16✔
435
    }
436

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

453
        // set cell height to control row heights
454
        $height = $rowStyles['height'] ?? null;
16✔
455
        unset($rowStyles['height']); // would not apply
16✔
456

457
        return $element->addRow($height, $rowStyles);
16✔
458
    }
459

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

473
        $colspan = $node->getAttribute('colspan');
16✔
474
        if (!empty($colspan)) {
16✔
475
            $cellStyles['gridSpan'] = $colspan - 0;
2✔
476
        }
477

478
        // set cell width to control column widths
479
        $width = $cellStyles['width'] ?? null;
16✔
480
        unset($cellStyles['width']); // would not apply
16✔
481
        $cell = $element->addCell($width, $cellStyles);
16✔
482

483
        if (self::shouldAddTextRun($node)) {
16✔
484
            return $cell->addTextRun(self::filterOutNonInheritedStyles(self::parseInlineStyle($node, $styles['paragraph'])));
16✔
485
        }
486

487
        return $cell;
3✔
488
    }
489

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

502
        return true;
16✔
503
    }
504

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

522
        return $style;
58✔
523
    }
524

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

549
        $styles = array_diff_key($styles, array_flip($nonInheritedStyles));
58✔
550

551
        return $styles;
58✔
552
    }
553

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

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

580
                        break;
1✔
581
                    case 'type':
1✔
582
                        $type = $attribute->value;
1✔
583

584
                        break;
1✔
585
                }
586
            }
587

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

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

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

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

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

678
        $selectors = [];
30✔
679
        foreach ($properties as $property) {
30✔
680
            [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null);
30✔
681
            $selectors[strtolower(trim($cKey))] = trim($cValue ?? '');
30✔
682
        }
683

684
        return self::parseStyleDeclarations($selectors, $styles);
30✔
685
    }
686

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

697
                            break;
3✔
698
                        case 'line-through':
1✔
699
                            $styles['strikethrough'] = true;
1✔
700

701
                            break;
1✔
702
                    }
703

704
                    break;
4✔
705
                case 'text-align':
30✔
706
                    $styles['alignment'] = self::mapAlign($value, $bidi);
7✔
707

708
                    break;
7✔
709
                case 'display':
29✔
710
                    $styles['hidden'] = $value === 'none' || $value === 'hidden';
1✔
711

712
                    break;
1✔
713
                case 'direction':
28✔
714
                    $styles['rtl'] = $value === 'rtl';
3✔
715
                    $styles['bidi'] = $value === 'rtl';
3✔
716

717
                    break;
3✔
718
                case 'font-size':
25✔
719
                    $styles['size'] = Converter::cssToPoint($value);
5✔
720

721
                    break;
5✔
722
                case 'font-family':
22✔
723
                    $value = array_map('trim', explode(',', $value));
5✔
724
                    $styles['name'] = ucwords($value[0]);
5✔
725

726
                    break;
5✔
727
                case 'color':
18✔
728
                    $styles['color'] = self::convertRgb($value);
4✔
729

730
                    break;
4✔
731
                case 'background-color':
17✔
732
                    $styles['bgColor'] = self::convertRgb($value);
5✔
733

734
                    break;
5✔
735
                case 'line-height':
16✔
736
                    $matches = [];
1✔
737
                    if ($value === 'normal') {
1✔
738
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
739
                        $spacing = 0;
1✔
740
                    } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) {
1✔
741
                        //matches number with a unit, e.g. 12px, 15pt, 20mm, ...
742
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::EXACT;
1✔
743
                        $spacing = Converter::cssToTwip($matches[1]);
1✔
744
                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
1✔
745
                        //matches percentages
746
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
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;
1✔
749
                    } else {
750
                        //any other, wich is a multiplier. E.g. 1.2
751
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
752
                        //we are subtracting 1 line height because the Spacing writer is adding one line
753
                        $spacing = ($value * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT;
1✔
754
                    }
755
                    $styles['spacingLineRule'] = $spacingLineRule;
1✔
756
                    $styles['line-spacing'] = $spacing;
1✔
757

758
                    break;
1✔
759
                case 'letter-spacing':
15✔
760
                    $styles['letter-spacing'] = Converter::cssToTwip($value);
1✔
761

762
                    break;
1✔
763
                case 'text-indent':
14✔
764
                    $styles['indentation']['firstLine'] = Converter::cssToTwip($value);
1✔
765

766
                    break;
1✔
767
                case 'font-weight':
13✔
768
                    $tValue = false;
3✔
769
                    if (preg_match('#bold#', $value)) {
3✔
770
                        $tValue = true; // also match bolder
3✔
771
                    }
772
                    $styles['bold'] = $tValue;
3✔
773

774
                    break;
3✔
775
                case 'font-style':
12✔
776
                    $tValue = false;
1✔
777
                    if (preg_match('#(?:italic|oblique)#', $value)) {
1✔
778
                        $tValue = true;
1✔
779
                    }
780
                    $styles['italic'] = $tValue;
1✔
781

782
                    break;
1✔
783
                case 'font-variant':
11✔
784
                    $tValue = false;
1✔
785
                    if (preg_match('#small-caps#', $value)) {
1✔
786
                        $tValue = true;
1✔
787
                    }
788
                    $styles['smallCaps'] = $tValue;
1✔
789

790
                    break;
1✔
791
                case 'margin':
10✔
792
                    $value = Converter::cssToTwip($value);
×
793
                    $styles['spaceBefore'] = $value;
×
794
                    $styles['spaceAfter'] = $value;
×
795

796
                    break;
×
797
                case 'margin-top':
10✔
798
                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
799
                    $styles['spaceBefore'] = Converter::cssToTwip($value);
2✔
800

801
                    break;
2✔
802
                case 'margin-bottom':
10✔
803
                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
804
                    $styles['spaceAfter'] = Converter::cssToTwip($value);
2✔
805

806
                    break;
2✔
807

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

841
                    break;
1✔
842
                case 'padding-top':
9✔
843
                    $styles['paddingTop'] = Converter::cssToTwip($value);
1✔
844

845
                    break;
1✔
846
                case 'padding-right':
9✔
847
                    $styles['paddingRight'] = Converter::cssToTwip($value);
1✔
848

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

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

857
                    break;
1✔
858

859
                case 'border-color':
8✔
860
                    self::mapBorderColor($styles, $value);
1✔
861

862
                    break;
1✔
863
                case 'border-width':
8✔
864
                    $styles['borderSize'] = Converter::cssToPoint($value);
1✔
865

866
                    break;
1✔
867
                case 'border-style':
8✔
868
                    $styles['borderStyle'] = self::mapBorderStyle($value);
1✔
869

870
                    break;
1✔
871
                case 'width':
8✔
872
                    if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) {
3✔
873
                        $styles['width'] = Converter::cssToTwip($matches[1]);
2✔
874
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
2✔
875
                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
3✔
876
                        $styles['width'] = $matches[1] * 50;
3✔
877
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
3✔
878
                    } elseif (preg_match('/([0-9]+)/', $value, $matches)) {
1✔
879
                        $styles['width'] = $matches[1];
1✔
880
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO;
1✔
881
                    }
882

883
                    break;
3✔
884
                case 'height':
7✔
885
                    $styles['height'] = Converter::cssToTwip($value);
1✔
886
                    $styles['exactHeight'] = true;
1✔
887

888
                    break;
1✔
889
                case 'border':
6✔
890
                case 'border-top':
4✔
891
                case 'border-bottom':
4✔
892
                case 'border-right':
3✔
893
                case 'border-left':
3✔
894
                    $value = preg_replace('/ +/', ' ', trim($value));
4✔
895
                    $valueArr = explode(' ', strtolower($value));
4✔
896

897
                    $size = $color = $style = null;
4✔
898
                    // must have exact order of one of the options
899
                    //  [width color style], e.g. "1px #0011CC solid" or "2pt green solid"
900
                    //  [width style color], e.g. "1px solid #0011CC" or "2pt solid green"
901
                    // Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC
902
                    // we determine the order of color and style by checking value of the color.
903
                    $namedColors = self::mapNamedBorderColor();
4✔
904
                    foreach ($valueArr as $tmpValue) {
4✔
905
                        if (strpos($tmpValue, '#') === 0 || isset($namedColors[$tmpValue])) {
4✔
906
                            $color = trim($tmpValue, '#');
4✔
907
                        } elseif (preg_match('/[\-\d]+(px|pt|cm|mm|in|pc)/', $tmpValue) === 1) {
4✔
908
                            $size = Converter::cssToTwip($tmpValue);
4✔
909
                        } else {
910
                            $style = self::mapBorderStyle($valueArr[2]);
4✔
911
                        }
912
                    }
913

914
                    if ($size !== null && $color !== null && $style !== null) {
4✔
915
                        if (false !== strpos($property, '-')) {
4✔
916
                            $tmp = explode('-', $property);
1✔
917
                            $which = $tmp[1];
1✔
918
                            $which = ucfirst($which); // e.g. bottom -> Bottom
1✔
919
                        } else {
920
                            $which = '';
3✔
921
                        }
922
                        // Note - border width normalization:
923
                        // Width of border in Word is calculated differently than HTML borders, usually showing up too bold.
924
                        // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips.
925
                        // Therefore we need to normalize converted twip value to cca 1/2 of value.
926
                        // This may be adjusted, if better ratio or formula found.
927
                        // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size)
928
                        $size = (int) ($size / 2);
4✔
929
                        // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc ..
930
                        $styles["border{$which}Size"] = $size; // twips
4✔
931
                        $styles["border{$which}Color"] = $color;
4✔
932
                        $styles["border{$which}Style"] = $style;
4✔
933
                    }
934

935
                    break;
4✔
936
                case 'vertical-align':
3✔
937
                    // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align
938
                    if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) {
1✔
939
                        $styles['valign'] = self::mapAlignVertical($matches[0]);
1✔
940
                    }
941

942
                    break;
1✔
943
                case 'page-break-after':
2✔
944
                    if ($value == 'always') {
1✔
945
                        $styles['isPageBreak'] = true;
1✔
946
                    }
947

948
                    break;
1✔
949
            }
950
        }
951

952
        return $styles;
32✔
953
    }
954

955
    /**
956
     * Parse image node.
957
     *
958
     * @param DOMNode $node
959
     * @param AbstractContainer $element
960
     *
961
     * @return \PhpOffice\PhpWord\Element\Image
962
     */
963
    protected static function parseImage($node, $element)
964
    {
965
        $style = [];
9✔
966
        $src = null;
9✔
967
        foreach ($node->attributes as $attribute) {
9✔
968
            switch ($attribute->name) {
9✔
969
                case 'src':
9✔
970
                    $src = $attribute->value;
9✔
971

972
                    break;
9✔
973
                case 'width':
9✔
974
                    $style['width'] = self::convertHtmlSize($attribute->value);
9✔
975
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
976

977
                    break;
9✔
978
                case 'height':
9✔
979
                    $style['height'] = self::convertHtmlSize($attribute->value);
9✔
980
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
981

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

1005
                                    break;
5✔
1006
                            }
1007
                        }
1008
                    }
1009

1010
                    break;
5✔
1011
            }
1012
        }
1013
        $originSrc = $src;
9✔
1014
        if (strpos($src, 'data:image') !== false) {
9✔
1015
            $tmpDir = Settings::getTempDir() . '/';
1✔
1016

1017
            $match = [];
1✔
1018
            preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match);
1✔
1019
            if (!empty($match)) {
1✔
1020
                $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1];
1✔
1021

1022
                $ifp = fopen($imgFile, 'wb');
1✔
1023

1024
                if ($ifp !== false) {
1✔
1025
                    fwrite($ifp, base64_decode($match[2]));
1✔
1026
                    fclose($ifp);
1✔
1027
                }
1028
            }
1029
        }
1030
        $src = urldecode($src);
9✔
1031

1032
        if (!is_file($src)
9✔
1033
            && null !== self::$options
9✔
1034
            && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'])
9✔
1035
        ) {
1036
            $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src);
1✔
1037
        }
1038

1039
        if (!is_file($src)) {
9✔
1040
            if ($imgBlob = @file_get_contents($src)) {
3✔
1041
                $tmpDir = Settings::getTempDir() . '/';
3✔
1042
                $match = [];
3✔
1043
                preg_match('/.+\.(\w+)$/', $src, $match);
3✔
1044
                $src = $tmpDir . uniqid();
3✔
1045
                if (isset($match[1])) {
3✔
1046
                    $src .= '.' . $match[1];
2✔
1047
                }
1048

1049
                $ifp = fopen($src, 'wb');
3✔
1050

1051
                if ($ifp !== false) {
3✔
1052
                    fwrite($ifp, $imgBlob);
3✔
1053
                    fclose($ifp);
3✔
1054
                }
1055
            }
1056
        }
1057

1058
        if (is_file($src)) {
9✔
1059
            $newElement = $element->addImage($src, $style);
9✔
1060
        } else {
NEW
UNCOV
1061
            throw new Exception("Could not load image $originSrc");
×
1062
        }
1063

1064
        return $newElement;
8✔
1065
    }
1066

1067
    /**
1068
     * Transforms a CSS border style into a word border style.
1069
     *
1070
     * @param string $cssBorderStyle
1071
     *
1072
     * @return null|string
1073
     */
1074
    protected static function mapBorderStyle($cssBorderStyle)
1075
    {
1076
        switch ($cssBorderStyle) {
1077
            case 'none':
4✔
1078
            case 'dashed':
4✔
1079
            case 'dotted':
4✔
1080
            case 'double':
4✔
1081
            case 'inset':
3✔
1082
            case 'outset':
3✔
1083
                return $cssBorderStyle;
2✔
1084
            default:
1085
                return 'single';
3✔
1086
        }
1087
    }
1088

1089
    protected static function mapNamedBorderColor(): array
1090
    {
1091
        $colors = [];
4✔
1092
        $colors['aliceblue'] = '#f0f8ff';
4✔
1093
        $colors['antiquewhite'] = '#faebd7';
4✔
1094
        $colors['aqua'] = '#00ffff';
4✔
1095
        $colors['aquamarine'] = '#7fffd4';
4✔
1096
        $colors['azure'] = '#f0ffff';
4✔
1097
        $colors['beige'] = '#f5f5dc';
4✔
1098
        $colors['bisque'] = '#ffe4c4';
4✔
1099
        $colors['black'] = '#000000';
4✔
1100
        $colors['blanchedalmond'] = '#ffebcd';
4✔
1101
        $colors['blue'] = '#0000ff';
4✔
1102
        $colors['blueviolet'] = '#8a2be2';
4✔
1103
        $colors['brown'] = '#a52a2a';
4✔
1104
        $colors['burlywood'] = '#deb887';
4✔
1105
        $colors['cadetblue'] = '#5f9ea0';
4✔
1106
        $colors['chartreuse'] = '#7fff00';
4✔
1107
        $colors['chocolate'] = '#d2691e';
4✔
1108
        $colors['coral'] = '#ff7f50';
4✔
1109
        $colors['cornflowerblue'] = '#6495ed';
4✔
1110
        $colors['cornsilk'] = '#fff8dc';
4✔
1111
        $colors['crimson'] = '#dc143c';
4✔
1112
        $colors['cyan'] = '#00ffff';
4✔
1113
        $colors['darkblue'] = '#00008b';
4✔
1114
        $colors['darkcyan'] = '#008b8b';
4✔
1115
        $colors['darkgoldenrod'] = '#b8860b';
4✔
1116
        $colors['darkgray'] = '#a9a9a9';
4✔
1117
        $colors['darkgrey'] = '#a9a9a9';
4✔
1118
        $colors['darkgreen'] = '#006400';
4✔
1119
        $colors['darkkhaki'] = '#bdb76b';
4✔
1120
        $colors['darkmagenta'] = '#8b008b';
4✔
1121
        $colors['darkolivegreen'] = '#556b2f';
4✔
1122
        $colors['darkorange'] = '#ff8c00';
4✔
1123
        $colors['darkorchid'] = '#9932cc';
4✔
1124
        $colors['darkred'] = '#8b0000';
4✔
1125
        $colors['darksalmon'] = '#e9967a';
4✔
1126
        $colors['darkseagreen'] = '#8fbc8f';
4✔
1127
        $colors['darkslateblue'] = '#483d8b';
4✔
1128
        $colors['darkslategray'] = '#2f4f4f';
4✔
1129
        $colors['darkslategrey'] = '#2f4f4f';
4✔
1130
        $colors['darkturquoise'] = '#00ced1';
4✔
1131
        $colors['darkviolet'] = '#9400d3';
4✔
1132
        $colors['deeppink'] = '#ff1493';
4✔
1133
        $colors['deepskyblue'] = '#00bfff';
4✔
1134
        $colors['dimgray'] = '#696969';
4✔
1135
        $colors['dimgrey'] = '#696969';
4✔
1136
        $colors['dodgerblue'] = '#1e90ff';
4✔
1137
        $colors['firebrick'] = '#b22222';
4✔
1138
        $colors['floralwhite'] = '#fffaf0';
4✔
1139
        $colors['forestgreen'] = '#228b22';
4✔
1140
        $colors['fuchsia'] = '#ff00ff';
4✔
1141
        $colors['gainsboro'] = '#dcdcdc';
4✔
1142
        $colors['ghostwhite'] = '#f8f8ff';
4✔
1143
        $colors['gold'] = '#ffd700';
4✔
1144
        $colors['goldenrod'] = '#daa520';
4✔
1145
        $colors['gray'] = '#808080';
4✔
1146
        $colors['grey'] = '#808080';
4✔
1147
        $colors['green'] = '#008000';
4✔
1148
        $colors['greenyellow'] = '#adff2f';
4✔
1149
        $colors['honeydew'] = '#f0fff0';
4✔
1150
        $colors['hotpink'] = '#ff69b4';
4✔
1151
        $colors['indianred'] = '#cd5c5c';
4✔
1152
        $colors['indigo'] = '#4b0082';
4✔
1153
        $colors['ivory'] = '#fffff0';
4✔
1154
        $colors['khaki'] = '#f0e68c';
4✔
1155
        $colors['lavender'] = '#e6e6fa';
4✔
1156
        $colors['lavenderblush'] = '#fff0f5';
4✔
1157
        $colors['lawngreen'] = '#7cfc00';
4✔
1158
        $colors['lemonchiffon'] = '#fffacd';
4✔
1159
        $colors['lightblue'] = '#add8e6';
4✔
1160
        $colors['lightcoral'] = '#f08080';
4✔
1161
        $colors['lightcyan'] = '#e0ffff';
4✔
1162
        $colors['lightgoldenrodyellow'] = '#fafad2';
4✔
1163
        $colors['lightgray'] = '#d3d3d3';
4✔
1164
        $colors['lightgrey'] = '#d3d3d3';
4✔
1165
        $colors['lightgreen'] = '#90ee90';
4✔
1166
        $colors['lightpink'] = '#ffb6c1';
4✔
1167
        $colors['lightsalmon'] = '#ffa07a';
4✔
1168
        $colors['lightseagreen'] = '#20b2aa';
4✔
1169
        $colors['lightskyblue'] = '#87cefa';
4✔
1170
        $colors['lightslategray'] = '#778899';
4✔
1171
        $colors['lightslategrey'] = '#778899';
4✔
1172
        $colors['lightsteelblue'] = '#b0c4de';
4✔
1173
        $colors['lightyellow'] = '#ffffe0';
4✔
1174
        $colors['lime'] = '#00ff00';
4✔
1175
        $colors['limegreen'] = '#32cd32';
4✔
1176
        $colors['linen'] = '#faf0e6';
4✔
1177
        $colors['magenta'] = '#ff00ff';
4✔
1178
        $colors['maroon'] = '#800000';
4✔
1179
        $colors['mediumaquamarine'] = '#66cdaa';
4✔
1180
        $colors['mediumblue'] = '#0000cd';
4✔
1181
        $colors['mediumorchid'] = '#ba55d3';
4✔
1182
        $colors['mediumpurple'] = '#9370db';
4✔
1183
        $colors['mediumseagreen'] = '#3cb371';
4✔
1184
        $colors['mediumslateblue'] = '#7b68ee';
4✔
1185
        $colors['mediumspringgreen'] = '#00fa9a';
4✔
1186
        $colors['mediumturquoise'] = '#48d1cc';
4✔
1187
        $colors['mediumvioletred'] = '#c71585';
4✔
1188
        $colors['midnightblue'] = '#191970';
4✔
1189
        $colors['mintcream'] = '#f5fffa';
4✔
1190
        $colors['mistyrose'] = '#ffe4e1';
4✔
1191
        $colors['moccasin'] = '#ffe4b5';
4✔
1192
        $colors['navajowhite'] = '#ffdead';
4✔
1193
        $colors['navy'] = '#000080';
4✔
1194
        $colors['oldlace'] = '#fdf5e6';
4✔
1195
        $colors['olive'] = '#808000';
4✔
1196
        $colors['olivedrab'] = '#6b8e23';
4✔
1197
        $colors['orange'] = '#ffa500';
4✔
1198
        $colors['orangered'] = '#ff4500';
4✔
1199
        $colors['orchid'] = '#da70d6';
4✔
1200
        $colors['palegoldenrod'] = '#eee8aa';
4✔
1201
        $colors['palegreen'] = '#98fb98';
4✔
1202
        $colors['paleturquoise'] = '#afeeee';
4✔
1203
        $colors['palevioletred'] = '#db7093';
4✔
1204
        $colors['papayawhip'] = '#ffefd5';
4✔
1205
        $colors['peachpuff'] = '#ffdab9';
4✔
1206
        $colors['peru'] = '#cd853f';
4✔
1207
        $colors['pink'] = '#ffc0cb';
4✔
1208
        $colors['plum'] = '#dda0dd';
4✔
1209
        $colors['powderblue'] = '#b0e0e6';
4✔
1210
        $colors['purple'] = '#800080';
4✔
1211
        $colors['rebeccapurple'] = '#663399';
4✔
1212
        $colors['red'] = '#ff0000';
4✔
1213
        $colors['rosybrown'] = '#bc8f8f';
4✔
1214
        $colors['royalblue'] = '#4169e1';
4✔
1215
        $colors['saddlebrown'] = '#8b4513';
4✔
1216
        $colors['salmon'] = '#fa8072';
4✔
1217
        $colors['sandybrown'] = '#f4a460';
4✔
1218
        $colors['seagreen'] = '#2e8b57';
4✔
1219
        $colors['seashell'] = '#fff5ee';
4✔
1220
        $colors['sienna'] = '#a0522d';
4✔
1221
        $colors['silver'] = '#c0c0c0';
4✔
1222
        $colors['skyblue'] = '#87ceeb';
4✔
1223
        $colors['slateblue'] = '#6a5acd';
4✔
1224
        $colors['slategray'] = '#708090';
4✔
1225
        $colors['slategrey'] = '#708090';
4✔
1226
        $colors['snow'] = '#fffafa';
4✔
1227
        $colors['springgreen'] = '#00ff7f';
4✔
1228
        $colors['steelblue'] = '#4682b4';
4✔
1229
        $colors['tan'] = '#d2b48c';
4✔
1230
        $colors['teal'] = '#008080';
4✔
1231
        $colors['thistle'] = '#d8bfd8';
4✔
1232
        $colors['tomato'] = '#ff6347';
4✔
1233
        $colors['turquoise'] = '#40e0d0';
4✔
1234
        $colors['violet'] = '#ee82ee';
4✔
1235
        $colors['wheat'] = '#f5deb3';
4✔
1236
        $colors['white'] = '#ffffff';
4✔
1237
        $colors['whitesmoke'] = '#f5f5f5';
4✔
1238
        $colors['yellow'] = '#ffff00';
4✔
1239
        $colors['yellowgreen'] = '#9acd32';
4✔
1240

1241
        return $colors;
4✔
1242
    }
1243

1244
    protected static function mapBorderColor(&$styles, $cssBorderColor): void
1245
    {
1246
        $numColors = substr_count($cssBorderColor, '#');
1✔
1247
        if ($numColors === 1) {
1✔
1248
            $styles['borderColor'] = trim($cssBorderColor, '#');
1✔
1249
        } elseif ($numColors > 1) {
1✔
1250
            $colors = explode(' ', $cssBorderColor);
1✔
1251
            $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'];
1✔
1252
            for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) {
1✔
1253
                $styles[$borders[$i]] = trim($colors[$i], '#');
1✔
1254
            }
1255
        }
1256
    }
1257

1258
    /**
1259
     * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1260
     *
1261
     * @param string $cssAlignment
1262
     * @param bool $bidi
1263
     *
1264
     * @return null|string
1265
     */
1266
    protected static function mapAlign($cssAlignment, $bidi)
1267
    {
1268
        switch ($cssAlignment) {
1269
            case 'right':
8✔
1270
                return $bidi ? Jc::START : Jc::END;
1✔
1271
            case 'center':
8✔
1272
                return Jc::CENTER;
6✔
1273
            case 'justify':
4✔
1274
                return Jc::BOTH;
1✔
1275
            default:
1276
                return $bidi ? Jc::END : Jc::START;
4✔
1277
        }
1278
    }
1279

1280
    /**
1281
     * Transforms a HTML/CSS vertical alignment.
1282
     *
1283
     * @param string $alignment
1284
     *
1285
     * @return null|string
1286
     */
1287
    protected static function mapAlignVertical($alignment)
1288
    {
1289
        $alignment = strtolower($alignment);
1✔
1290
        switch ($alignment) {
1291
            case 'top':
1✔
1292
            case 'baseline':
1✔
1293
            case 'bottom':
1✔
1294
                return $alignment;
1✔
1295
            case 'middle':
1✔
1296
                return 'center';
1✔
1297
            case 'sub':
×
1298
                return 'bottom';
×
UNCOV
1299
            case 'text-top':
×
UNCOV
1300
            case 'baseline':
×
UNCOV
1301
                return 'top';
×
1302
            default:
1303
                // @discuss - which one should apply:
1304
                // - Word uses default vert. alignment: top
1305
                // - all browsers use default vert. alignment: middle
1306
                // Returning empty string means attribute wont be set so use Word default (top).
UNCOV
1307
                return '';
×
1308
        }
1309
    }
1310

1311
    /**
1312
     * Map list style for ordered list.
1313
     *
1314
     * @param string $cssListType
1315
     */
1316
    protected static function mapListType($cssListType)
1317
    {
1318
        switch ($cssListType) {
1319
            case 'a':
1✔
UNCOV
1320
                return NumberFormat::LOWER_LETTER; // a, b, c, ..
×
1321
            case 'A':
1✔
1322
                return NumberFormat::UPPER_LETTER; // A, B, C, ..
1✔
1323
            case 'i':
1✔
1324
                return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, ..
1✔
UNCOV
1325
            case 'I':
×
1326
                return NumberFormat::UPPER_ROMAN; // I, II, III, IV, ..
×
UNCOV
1327
            case '1':
×
1328
            default:
UNCOV
1329
                return NumberFormat::DECIMAL; // 1, 2, 3, ..
×
1330
        }
1331
    }
1332

1333
    /**
1334
     * Parse line break.
1335
     *
1336
     * @param AbstractContainer $element
1337
     */
1338
    protected static function parseLineBreak($element): void
1339
    {
1340
        $element->addTextBreak();
2✔
1341
    }
1342

1343
    /**
1344
     * Parse link node.
1345
     *
1346
     * @param DOMNode $node
1347
     * @param AbstractContainer $element
1348
     * @param array $styles
1349
     */
1350
    protected static function parseLink($node, $element, &$styles)
1351
    {
1352
        $target = null;
3✔
1353
        foreach ($node->attributes as $attribute) {
3✔
1354
            switch ($attribute->name) {
3✔
1355
                case 'href':
3✔
1356
                    $target = $attribute->value;
3✔
1357

1358
                    break;
3✔
1359
            }
1360
        }
1361
        $styles['font'] = self::parseInlineStyle($node, $styles['font']);
3✔
1362

1363
        if (empty($target)) {
3✔
1364
            $target = '#';
1✔
1365
        }
1366

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

1371
        return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']);
2✔
1372
    }
1373

1374
    /**
1375
     * Render horizontal rule
1376
     * Note: Word rule is not the same as HTML's <hr> since it does not support width and thus neither alignment.
1377
     *
1378
     * @param DOMNode $node
1379
     * @param AbstractContainer $element
1380
     */
1381
    protected static function parseHorizRule($node, $element): void
1382
    {
1383
        $styles = self::parseInlineStyle($node);
1✔
1384

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

1388
        $fontStyle = $styles + ['size' => 3];
1✔
1389

1390
        $paragraphStyle = $styles + [
1✔
1391
            'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc
1✔
1392
            'spacing' => 0, // twip
1✔
1393
            'spaceBefore' => 120, // twip, 240/2 (default line height)
1✔
1394
            'spaceAfter' => 120, // twip
1✔
1395
            'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'],
1✔
1396
            'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'],
1✔
1397
            'borderBottomStyle' => 'single', // same as "solid"
1✔
1398
        ];
1✔
1399

1400
        $element->addText('', $fontStyle, $paragraphStyle);
1✔
1401

1402
        // Notes: <hr/> cannot be:
1403
        // - table - throws error "cannot be inside textruns", e.g. lists
1404
        // - line - that is a shape, has different behaviour
1405
        // - repeated text, e.g. underline "_", because of unpredictable line wrapping
1406
    }
1407

1408
    private static function convertRgb(string $rgb): string
1409
    {
1410
        if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) {
8✔
1411
            return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]);
1✔
1412
        }
1413

1414
        return trim($rgb, '# ');
8✔
1415
    }
1416

1417
    /**
1418
     * Transform HTML sizes (pt, px) in pixels.
1419
     */
1420
    protected static function convertHtmlSize(string $size): float
1421
    {
1422
        // pt
1423
        if (false !== strpos($size, 'pt')) {
14✔
1424
            return Converter::pointToPixel((float) str_replace('pt', '', $size));
2✔
1425
        }
1426

1427
        // px
1428
        if (false !== strpos($size, 'px')) {
12✔
1429
            return (float) str_replace('px', '', $size);
2✔
1430
        }
1431

1432
        return (float) $size;
10✔
1433
    }
1434
}
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