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

PHPOffice / PHPWord / 11711423367

06 Nov 2024 08:32PM UTC coverage: 96.91% (-0.008%) from 96.918%
11711423367

Pull #2694

github

web-flow
Merge 0096e59b4 into feadceb1f
Pull Request #2694: Bump symfony/process from 5.4.34 to 5.4.46

11885 of 12264 relevant lines covered (96.91%)

33.23 hits per line

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

96.37
/src/PhpWord/Shared/Html.php
1
<?php
2
/**
3
 * This file is part of PHPWord - A pure PHP library for reading and writing
4
 * word processing documents.
5
 *
6
 * PHPWord is free software distributed under the terms of the GNU Lesser
7
 * General Public License version 3 as published by the Free Software Foundation.
8
 *
9
 * For the full copyright and license information, please read the LICENSE
10
 * file that was distributed with this source code. For the full list of
11
 * contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
12
 *
13
 * @see         https://github.com/PHPOffice/PHPWord
14
 *
15
 * @license     http://www.gnu.org/licenses/lgpl.txt LGPL version 3
16
 */
17

18
namespace PhpOffice\PhpWord\Shared;
19

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

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

42
    protected static $listIndex = 0;
43

44
    protected static $xpath;
45

46
    protected static $options;
47

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

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

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

81
        if (false === $fullHTML) {
53✔
82
            $html = '<body>' . $html . '</body>';
51✔
83
        }
84

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

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

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

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

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

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

140
                        break;
4✔
141
                    case 'cellspacing':
35✔
142
                        // tables e.g. <table cellspacing="2">,  where "2" = 2px (always pixels)
143
                        $val = (int) $val . 'px';
1✔
144
                        $styles['cellSpacing'] = Converter::cssToTwip($val);
1✔
145

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

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

158
                        break;
1✔
159
                }
160
            }
161

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

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

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

181
        return $styles;
52✔
182
    }
183

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

198
            return;
2✔
199
        }
200

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

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

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

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

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

268
        if ($newElement === null) {
53✔
269
            $newElement = $element;
53✔
270
        }
271

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

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

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

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

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

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

337
                break;
1✔
338
        }
339
    }
340

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

358
        return $newElement;
2✔
359
    }
360

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

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

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

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

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

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

420
        $newElement = $element->addTable($elementStyles);
10✔
421

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

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

433
        return $newElement;
10✔
434
    }
435

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

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

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

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

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

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

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

486
        return $cell;
3✔
487
    }
488

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

501
        return true;
10✔
502
    }
503

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

521
        return $style;
52✔
522
    }
523

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

548
        $styles = array_diff_key($styles, array_flip($nonInheritedStyles));
52✔
549

550
        return $styles;
52✔
551
    }
552

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

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

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

583
                        break;
1✔
584
                }
585
            }
586

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

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

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

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

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

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

683
        return self::parseStyleDeclarations($selectors, $styles);
29✔
684
    }
685

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

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

700
                            break;
1✔
701
                    }
702

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

805
                    break;
2✔
806
                case 'border-color':
8✔
807
                    self::mapBorderColor($styles, $value);
1✔
808

809
                    break;
1✔
810
                case 'border-width':
8✔
811
                    $styles['borderSize'] = Converter::cssToPoint($value);
1✔
812

813
                    break;
1✔
814
                case 'border-style':
8✔
815
                    $styles['borderStyle'] = self::mapBorderStyle($value);
1✔
816

817
                    break;
1✔
818
                case 'width':
8✔
819
                    if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) {
3✔
820
                        $styles['width'] = Converter::cssToTwip($matches[1]);
2✔
821
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
2✔
822
                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
3✔
823
                        $styles['width'] = $matches[1] * 50;
3✔
824
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
3✔
825
                    } elseif (preg_match('/([0-9]+)/', $value, $matches)) {
1✔
826
                        $styles['width'] = $matches[1];
1✔
827
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO;
1✔
828
                    }
829

830
                    break;
3✔
831
                case 'height':
7✔
832
                    $styles['height'] = Converter::cssToTwip($value);
1✔
833
                    $styles['exactHeight'] = true;
1✔
834

835
                    break;
1✔
836
                case 'border':
6✔
837
                case 'border-top':
4✔
838
                case 'border-bottom':
4✔
839
                case 'border-right':
3✔
840
                case 'border-left':
3✔
841
                    // must have exact order [width color style], e.g. "1px #0011CC solid" or "2pt green solid"
842
                    // Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC
843
                    if (preg_match('/([0-9]+[^0-9]*)\s+(\#[a-fA-F0-9]+|[a-zA-Z]+)\s+([a-z]+)/', $value, $matches)) {
4✔
844
                        if (false !== strpos($property, '-')) {
4✔
845
                            $tmp = explode('-', $property);
1✔
846
                            $which = $tmp[1];
1✔
847
                            $which = ucfirst($which); // e.g. bottom -> Bottom
1✔
848
                        } else {
849
                            $which = '';
3✔
850
                        }
851
                        // Note - border width normalization:
852
                        // Width of border in Word is calculated differently than HTML borders, usually showing up too bold.
853
                        // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips.
854
                        // Therefore we need to normalize converted twip value to cca 1/2 of value.
855
                        // This may be adjusted, if better ratio or formula found.
856
                        // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size)
857
                        $size = Converter::cssToTwip($matches[1]);
4✔
858
                        $size = (int) ($size / 2);
4✔
859
                        // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc ..
860
                        $styles["border{$which}Size"] = $size; // twips
4✔
861
                        $styles["border{$which}Color"] = trim($matches[2], '#');
4✔
862
                        $styles["border{$which}Style"] = self::mapBorderStyle($matches[3]);
4✔
863
                    }
864

865
                    break;
4✔
866
                case 'vertical-align':
3✔
867
                    // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align
868
                    if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) {
1✔
869
                        $styles['valign'] = self::mapAlignVertical($matches[0]);
1✔
870
                    }
871

872
                    break;
1✔
873
                case 'page-break-after':
2✔
874
                    if ($value == 'always') {
1✔
875
                        $styles['isPageBreak'] = true;
1✔
876
                    }
877

878
                    break;
1✔
879
            }
880
        }
881

882
        return $styles;
31✔
883
    }
884

885
    /**
886
     * Parse image node.
887
     *
888
     * @param DOMNode $node
889
     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
890
     *
891
     * @return \PhpOffice\PhpWord\Element\Image
892
     */
893
    protected static function parseImage($node, $element)
894
    {
895
        $style = [];
9✔
896
        $src = null;
9✔
897
        foreach ($node->attributes as $attribute) {
9✔
898
            switch ($attribute->name) {
9✔
899
                case 'src':
9✔
900
                    $src = $attribute->value;
9✔
901

902
                    break;
9✔
903
                case 'width':
9✔
904
                    $width = $attribute->value;
9✔
905

906
                    // pt
907
                    if (false !== strpos($width, 'pt')) {
9✔
908
                        $width = Converter::pointToPixel((float) str_replace('pt', '', $width));
1✔
909
                    }
910

911
                    // px
912
                    if (false !== strpos($width, 'px')) {
9✔
913
                        $width = str_replace('px', '', $width);
1✔
914
                    }
915

916
                    $style['width'] = $width;
9✔
917
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
918

919
                    break;
9✔
920
                case 'height':
9✔
921
                    $height = $attribute->value;
9✔
922

923
                    // pt
924
                    if (false !== strpos($height, 'pt')) {
9✔
925
                        $height = Converter::pointToPixel((float) str_replace('pt', '', $height));
1✔
926
                    }
927

928
                    // px
929
                    if (false !== strpos($height, 'px')) {
9✔
930
                        $height = str_replace('px', '', $height);
1✔
931
                    }
932

933
                    $style['height'] = $height;
9✔
934
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
935

936
                    break;
9✔
937
                case 'style':
6✔
938
                    $styleattr = explode(';', $attribute->value);
5✔
939
                    foreach ($styleattr as $attr) {
5✔
940
                        if (strpos($attr, ':')) {
5✔
941
                            [$k, $v] = explode(':', $attr);
5✔
942
                            switch ($k) {
943
                                case 'float':
5✔
944
                                    if (trim($v) == 'right') {
5✔
945
                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT;
5✔
946
                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
5✔
947
                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
5✔
948
                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
5✔
949
                                        $style['overlap'] = true;
5✔
950
                                    }
951
                                    if (trim($v) == 'left') {
5✔
952
                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT;
3✔
953
                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
3✔
954
                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
3✔
955
                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
3✔
956
                                        $style['overlap'] = true;
3✔
957
                                    }
958

959
                                    break;
5✔
960
                            }
961
                        }
962
                    }
963

964
                    break;
5✔
965
            }
966
        }
967
        $originSrc = $src;
9✔
968
        if (strpos($src, 'data:image') !== false) {
9✔
969
            $tmpDir = Settings::getTempDir() . '/';
1✔
970

971
            $match = [];
1✔
972
            preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match);
1✔
973

974
            $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1];
1✔
975

976
            $ifp = fopen($imgFile, 'wb');
1✔
977

978
            if ($ifp !== false) {
1✔
979
                fwrite($ifp, base64_decode($match[2]));
1✔
980
                fclose($ifp);
1✔
981
            }
982
        }
983
        $src = urldecode($src);
9✔
984

985
        if (!is_file($src)
9✔
986
            && null !== self::$options
9✔
987
            && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'])
9✔
988
        ) {
989
            $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src);
1✔
990
        }
991

992
        if (!is_file($src)) {
9✔
993
            if ($imgBlob = @file_get_contents($src)) {
3✔
994
                $tmpDir = Settings::getTempDir() . '/';
3✔
995
                $match = [];
3✔
996
                preg_match('/.+\.(\w+)$/', $src, $match);
3✔
997
                $src = $tmpDir . uniqid();
3✔
998
                if (isset($match[1])) {
3✔
999
                    $src .= '.' . $match[1];
2✔
1000
                }
1001

1002
                $ifp = fopen($src, 'wb');
3✔
1003

1004
                if ($ifp !== false) {
3✔
1005
                    fwrite($ifp, $imgBlob);
3✔
1006
                    fclose($ifp);
3✔
1007
                }
1008
            }
1009
        }
1010

1011
        if (is_file($src)) {
9✔
1012
            $newElement = $element->addImage($src, $style);
9✔
1013
        } else {
1014
            throw new Exception("Could not load image $originSrc");
×
1015
        }
1016

1017
        return $newElement;
8✔
1018
    }
1019

1020
    /**
1021
     * Transforms a CSS border style into a word border style.
1022
     *
1023
     * @param string $cssBorderStyle
1024
     *
1025
     * @return null|string
1026
     */
1027
    protected static function mapBorderStyle($cssBorderStyle)
1028
    {
1029
        switch ($cssBorderStyle) {
1030
            case 'none':
4✔
1031
            case 'dashed':
4✔
1032
            case 'dotted':
4✔
1033
            case 'double':
4✔
1034
                return $cssBorderStyle;
2✔
1035
            default:
1036
                return 'single';
3✔
1037
        }
1038
    }
1039

1040
    protected static function mapBorderColor(&$styles, $cssBorderColor): void
1041
    {
1042
        $numColors = substr_count($cssBorderColor, '#');
1✔
1043
        if ($numColors === 1) {
1✔
1044
            $styles['borderColor'] = trim($cssBorderColor, '#');
1✔
1045
        } elseif ($numColors > 1) {
1✔
1046
            $colors = explode(' ', $cssBorderColor);
1✔
1047
            $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'];
1✔
1048
            for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) {
1✔
1049
                $styles[$borders[$i]] = trim($colors[$i], '#');
1✔
1050
            }
1051
        }
1052
    }
1053

1054
    /**
1055
     * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1056
     *
1057
     * @param string $cssAlignment
1058
     * @param bool $bidi
1059
     *
1060
     * @return null|string
1061
     */
1062
    protected static function mapAlign($cssAlignment, $bidi)
1063
    {
1064
        switch ($cssAlignment) {
1065
            case 'right':
8✔
1066
                return $bidi ? Jc::START : Jc::END;
1✔
1067
            case 'center':
8✔
1068
                return Jc::CENTER;
6✔
1069
            case 'justify':
4✔
1070
                return Jc::BOTH;
1✔
1071
            default:
1072
                return $bidi ? Jc::END : Jc::START;
4✔
1073
        }
1074
    }
1075

1076
    /**
1077
     * Transforms a HTML/CSS vertical alignment.
1078
     *
1079
     * @param string $alignment
1080
     *
1081
     * @return null|string
1082
     */
1083
    protected static function mapAlignVertical($alignment)
1084
    {
1085
        $alignment = strtolower($alignment);
1✔
1086
        switch ($alignment) {
1087
            case 'top':
1✔
1088
            case 'baseline':
1✔
1089
            case 'bottom':
1✔
1090
                return $alignment;
1✔
1091
            case 'middle':
1✔
1092
                return 'center';
1✔
1093
            case 'sub':
×
1094
                return 'bottom';
×
1095
            case 'text-top':
×
1096
            case 'baseline':
×
1097
                return 'top';
×
1098
            default:
1099
                // @discuss - which one should apply:
1100
                // - Word uses default vert. alignment: top
1101
                // - all browsers use default vert. alignment: middle
1102
                // Returning empty string means attribute wont be set so use Word default (top).
1103
                return '';
×
1104
        }
1105
    }
1106

1107
    /**
1108
     * Map list style for ordered list.
1109
     *
1110
     * @param string $cssListType
1111
     */
1112
    protected static function mapListType($cssListType)
1113
    {
1114
        switch ($cssListType) {
1115
            case 'a':
1✔
1116
                return NumberFormat::LOWER_LETTER; // a, b, c, ..
×
1117
            case 'A':
1✔
1118
                return NumberFormat::UPPER_LETTER; // A, B, C, ..
1✔
1119
            case 'i':
1✔
1120
                return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, ..
1✔
1121
            case 'I':
×
1122
                return NumberFormat::UPPER_ROMAN; // I, II, III, IV, ..
×
1123
            case '1':
×
1124
            default:
1125
                return NumberFormat::DECIMAL; // 1, 2, 3, ..
×
1126
        }
1127
    }
1128

1129
    /**
1130
     * Parse line break.
1131
     *
1132
     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
1133
     */
1134
    protected static function parseLineBreak($element): void
1135
    {
1136
        $element->addTextBreak();
2✔
1137
    }
1138

1139
    /**
1140
     * Parse link node.
1141
     *
1142
     * @param DOMNode $node
1143
     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
1144
     * @param array $styles
1145
     */
1146
    protected static function parseLink($node, $element, &$styles)
1147
    {
1148
        $target = null;
3✔
1149
        foreach ($node->attributes as $attribute) {
3✔
1150
            switch ($attribute->name) {
3✔
1151
                case 'href':
3✔
1152
                    $target = $attribute->value;
3✔
1153

1154
                    break;
3✔
1155
            }
1156
        }
1157
        $styles['font'] = self::parseInlineStyle($node, $styles['font']);
3✔
1158

1159
        if (empty($target)) {
3✔
1160
            $target = '#';
1✔
1161
        }
1162

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

1167
        return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']);
2✔
1168
    }
1169

1170
    /**
1171
     * Render horizontal rule
1172
     * Note: Word rule is not the same as HTML's <hr> since it does not support width and thus neither alignment.
1173
     *
1174
     * @param DOMNode $node
1175
     * @param \PhpOffice\PhpWord\Element\AbstractContainer $element
1176
     */
1177
    protected static function parseHorizRule($node, $element): void
1178
    {
1179
        $styles = self::parseInlineStyle($node);
1✔
1180

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

1184
        $fontStyle = $styles + ['size' => 3];
1✔
1185

1186
        $paragraphStyle = $styles + [
1✔
1187
            'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc
1✔
1188
            'spacing' => 0, // twip
1✔
1189
            'spaceBefore' => 120, // twip, 240/2 (default line height)
1✔
1190
            'spaceAfter' => 120, // twip
1✔
1191
            'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'],
1✔
1192
            'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'],
1✔
1193
            'borderBottomStyle' => 'single', // same as "solid"
1✔
1194
        ];
1✔
1195

1196
        $element->addText('', $fontStyle, $paragraphStyle);
1✔
1197

1198
        // Notes: <hr/> cannot be:
1199
        // - table - throws error "cannot be inside textruns", e.g. lists
1200
        // - line - that is a shape, has different behaviour
1201
        // - repeated text, e.g. underline "_", because of unpredictable line wrapping
1202
    }
1203

1204
    private static function convertRgb(string $rgb): string
1205
    {
1206
        if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) {
8✔
1207
            return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]);
1✔
1208
        }
1209

1210
        return trim($rgb, '# ');
8✔
1211
    }
1212
}
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

© 2025 Coveralls, Inc