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

PHPOffice / PHPWord / 13461426829

21 Feb 2025 04:52PM UTC coverage: 96.905% (+0.1%) from 96.767%
13461426829

Pull #2567

github

web-flow
Merge e64a82db6 into 6ca8c9ff6
Pull Request #2567: WIP Do Not Install

300 of 307 new or added lines in 26 files covered. (97.72%)

161 existing lines in 21 files now uncovered.

12681 of 13086 relevant lines covered (96.91%)

37.43 hits per line

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

97.63
/src/PhpWord/Shared/Html.php
1
<?php
2

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

19
namespace PhpOffice\PhpWord\Shared;
20

21
use DOMAttr;
22
use DOMDocument;
23
use DOMNode;
24
use DOMXPath;
25
use Exception;
26
use PhpOffice\PhpWord\ComplexType\RubyProperties;
27
use PhpOffice\PhpWord\Element\AbstractContainer;
28
use PhpOffice\PhpWord\Element\Row;
29
use PhpOffice\PhpWord\Element\Table;
30
use PhpOffice\PhpWord\Element\TextRun;
31
use PhpOffice\PhpWord\Metadata\DocInfo;
32
use PhpOffice\PhpWord\PhpWord;
33
use PhpOffice\PhpWord\Settings;
34
use PhpOffice\PhpWord\SimpleType\Border;
35
use PhpOffice\PhpWord\SimpleType\Jc;
36
use PhpOffice\PhpWord\SimpleType\NumberFormat;
37
use PhpOffice\PhpWord\SimpleType\TextDirection;
38
use PhpOffice\PhpWord\Style\Paragraph;
39
use Throwable;
40

41
/**
42
 * Common Html functions.
43
 *
44
 * @SuppressWarnings(PHPMD.UnusedPrivateMethod) For readWPNode
45
 */
46
class Html
47
{
48
    private const SPECIAL_BORDER_WIDTHS = ['thin' => '0.5pt', 'thick' => '3.5pt', 'medium' => '2.0pt'];
49

50
    private const RGB_REGEXP = '/^\s*rgb\s*[(]\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*[)]\s*$/';
51

52
    private const DECLARES_CHARSET = '/ charset=/i';
53

54
    protected static $listIndex = 0;
55

56
    protected static $xpath;
57

58
    protected static $options;
59

60
    /** @var ?DocInfo */
61
    protected static $docInfo;
62

63
    /** @var bool */
64
    private static $addbody = false;
65

66
    /**
67
     * @var Css
68
     */
69
    protected static $css;
70

71
    /**
72
     * Add HTML parts.
73
     *
74
     * Note: $stylesheet parameter is removed to avoid PHPMD error for unused parameter
75
     * Warning: Do not pass user-generated HTML here, as that would allow an attacker to read arbitrary
76
     * files or perform server-side request forgery by passing local file paths or URLs in <img>.
77
     *
78
     * @param AbstractContainer $element Where the parts need to be added
79
     * @param string $html The code to parse
80
     * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag
81
     * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed
82
     */
83
    public static function addHtml($element, $html, $fullHTML = false, $preserveWhiteSpace = true, $options = null): void
84
    {
85
        /*
86
         * @todo parse $stylesheet for default styles.  Should result in an array based on id, class and element,
87
         * which could be applied when such an element occurs in the parseNode function.
88
         */
89
        static::$options = $options;
90✔
90
        static::$docInfo = null;
90✔
91
        if (method_exists($element, 'getPhpWord')) {
90✔
92
            /** @var ?PhpWord */
93
            $phpWord = $element->getPhpWord();
90✔
94
            if ($phpWord !== null) {
90✔
95
                static::$docInfo = $phpWord->getDocInfo();
89✔
96
            }
97
        }
98

99
        if (substr($html, 0, 2) === "\xfe\xff" || substr($html, 0, 2) === "\xff\xfe") {
90✔
100
            $html = mb_convert_encoding($html, 'UTF-8', 'UTF-16');
2✔
101
        }
102
        if (substr($html, 0, 3) === "\xEF\xBB\xBF") {
90✔
103
            $html = substr($html, 3);
1✔
104
        }
105
        if (self::$addbody && false === $fullHTML) {
90✔
106
            $html = '<body>' . $html . '</body>'; // @codeCoverageIgnore
107
        }
108

109
        // Load DOM
110
        if (\PHP_VERSION_ID < 80000) {
90✔
111
            $orignalLibEntityLoader = libxml_disable_entity_loader(true); // @codeCoverageIgnore
112
        }
113
        $dom = new DOMDocument();
90✔
114
        $html = self::replaceNonAsciiIfNeeded($html);
90✔
115
        $dom->preserveWhiteSpace = $preserveWhiteSpace;
90✔
116

117
        try {
118
            $result = @$dom->loadHTML($html);
90✔
119
            $exceptionMessage = 'DOM loadHTML failed';
90✔
NEW
120
        } catch (Throwable $e) {
×
NEW
121
            $result = false;
×
NEW
122
            $exceptionMessage = $e->getMessage();
×
123
        }
124
        if ($result === false) {
90✔
125
            throw new Exception($exceptionMessage);
1✔
126
        }
127
        self::removeAnnoyingWhitespaceTextNodes($dom);
89✔
128
        static::$xpath = new DOMXPath($dom);
89✔
129
        $node = $dom->getElementsByTagName('html');
89✔
130
        if (count($node) === 0 || $node->item(0) === null) {
89✔
131
            $node = $dom->getElementsByTagName('body'); // @codeCoverageIgnore
132
        }
133

134
        static::parseNode($node->item(0), $element);
89✔
135
        if (\PHP_VERSION_ID < 80000) {
88✔
136
            libxml_disable_entity_loader($orignalLibEntityLoader); // @codeCoverageIgnore
137
        }
138
    }
139

140
    // https://www.php.net/manual/en/domdocument.loadhtml.php
141
    private static function removeAnnoyingWhitespaceTextNodes(DOMNode $node): void
142
    {
143
        if ($node->hasChildNodes()) {
89✔
144
            for ($i = $node->childNodes->length - 1; $i >= 0; --$i) {
89✔
145
                self::removeAnnoyingWhitespaceTextNodes($node->childNodes->item($i));
89✔
146
            }
147
        }
148
        if ($node->nodeType === XML_TEXT_NODE && !$node->hasChildNodes() && !$node->hasAttributes() && empty(trim($node->textContent))) {
89✔
149
            $node->parentNode->removeChild($node);
35✔
150
        }
151
    }
152

153
    private static function replaceNonAscii(array $matches): string
154
    {
155
        return '&#' . mb_ord($matches[0], 'UTF-8') . ';';
7✔
156
    }
157

158
    private static function replaceNonAsciiIfNeeded(string $convert): ?string
159
    {
160
        if (preg_match(self::DECLARES_CHARSET, $convert) !== 1) {
90✔
161
            $lowend = "\u{80}";
81✔
162
            $highend = "\u{10ffff}";
81✔
163
            $regexp = "/[$lowend-$highend]/u";
81✔
164
            /** @var callable $callback */
165
            $callback = [self::class, 'replaceNonAscii'];
81✔
166
            $convert = preg_replace_callback($regexp, $callback, $convert);
81✔
167
        }
168

169
        return $convert;
90✔
170
    }
171

172
    /**
173
     * parse Inline style of a node.
174
     *
175
     * @param DOMNode $node Node to check on attributes and to compile a style array
176
     * @param array<string, mixed> $styles is supplied, the inline style attributes are added to the already existing style
177
     *
178
     * @return array
179
     */
180
    protected static function parseInlineStyle($node, &$styles)
181
    {
182
        if (XML_ELEMENT_NODE == $node->nodeType) {
87✔
183
            $attributes = $node->attributes; // get all the attributes(eg: id, class)
87✔
184

185
            $bidi = false;
87✔
186
            $attrDir = $attributes->getNamedItem('dir');
87✔
187
            $direction = isset($attrDir) ? $attrDir->nodeValue : '';
87✔
188
            if ($direction === 'rtl') {
87✔
189
                $bidi = $styles['bidi'] = $styles['rtl'] = true;
1✔
190
                $styles['textDirection'] = TextDirection::RLTB;
1✔
191
            } elseif ($direction === 'ltr') {
87✔
192
                $bidi = $styles['bidi'] = $styles['rtl'] = false;
1✔
193
                $styles['textDirection'] = TextDirection::LRTB;
1✔
194
            }
195
            foreach ($attributes as $attribute) {
87✔
196
                $val = $attribute->value;
66✔
197
                switch (strtolower($attribute->name)) {
66✔
198
                    case 'align':
66✔
199
                        $styles['alignment'] = self::mapAlign(trim($val), $bidi);
6✔
200

201
                        break;
6✔
202
                    case 'lang':
66✔
203
                        $styles['lang'] = $val;
9✔
204

205
                        break;
9✔
206
                    case 'width':
57✔
207
                        // tables, cells
208
                        $val = $val === 'auto' ? '100%' : $val;
11✔
209
                        if (false !== strpos($val, '%')) {
11✔
210
                            // e.g. <table width="100%"> or <td width="50%">
211
                            $styles['width'] = (int) $val * 50;
5✔
212
                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
5✔
213
                        } else {
214
                            // e.g. <table width="250> where "250" = 250px (always pixels)
215
                            $styles['width'] = Converter::pixelToTwip(self::convertHtmlSize($val));
6✔
216
                            $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
6✔
217
                        }
218

219
                        break;
11✔
220
                    case 'cellspacing':
50✔
221
                        // tables e.g. <table cellspacing="2">,  where "2" = 2px (always pixels)
222
                        $styles['cellSpacing'] = Converter::pixelToTwip(self::convertHtmlSize($val));
1✔
223

224
                        break;
1✔
225
                    case 'bgcolor':
50✔
226
                        // tables, rows, cells e.g. <tr bgColor="#FF0000">
227
                        HtmlColours::setArrayColour($styles, 'bgColor', self::convertRgb($val));
3✔
228

229
                        break;
3✔
230
                    case 'valign':
49✔
231
                        // cells e.g. <td valign="middle">
232
                        if (preg_match('#(?:top|bottom|middle|baseline)#i', $val, $matches)) {
1✔
233
                            $styles['valign'] = self::mapAlignVertical($matches[0]);
1✔
234
                        }
235

236
                        break;
1✔
237
                }
238
            }
239

240
            $attributeIdentifier = $attributes->getNamedItem('id');
87✔
241
            if ($attributeIdentifier && self::$css) {
87✔
242
                $styles = self::parseStyleDeclarations(self::$css->getStyle('#' . $attributeIdentifier->nodeValue), $styles);
1✔
243
            }
244

245
            $attributeClass = $attributes->getNamedItem('class');
87✔
246
            if ($attributeClass) {
87✔
247
                if (self::$css) {
5✔
248
                    $styles = self::parseStyleDeclarations(self::$css->getStyle('.' . $attributeClass->nodeValue), $styles);
5✔
249
                }
250
                $styles['className'] = $attributeClass->nodeValue;
5✔
251
            }
252

253
            $attributeStyle = $attributes->getNamedItem('style');
87✔
254
            if ($attributeStyle) {
87✔
255
                $styles = self::parseStyle($attributeStyle, $styles);
38✔
256
            }
257
        }
258

259
        return $styles;
87✔
260
    }
261

262
    /**
263
     * Parse a node and add a corresponding element to the parent element.
264
     *
265
     * @param DOMNode $node node to parse
266
     * @param AbstractContainer $element object to add an element corresponding with the node
267
     * @param array $styles Array with all styles
268
     * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems
269
     */
270
    protected static function parseNode($node, $element, $styles = [], $data = []): void
271
    {
272
        if ($node->nodeName == 'style') {
89✔
273
            self::$css = new Css($node->textContent);
5✔
274
            self::$css->process();
5✔
275

276
            return;
5✔
277
        }
278
        if ($node->nodeName === 'title') {
89✔
279
            if (self::$docInfo !== null) {
13✔
280
                $docTitle = $node->nodeValue;
13✔
281
                if ($docTitle !== 'PHPWord' && trim($docTitle) !== '') { // default
13✔
282
                    self::$docInfo->setTitle($node->nodeValue);
11✔
283
                }
284
            }
285

286
            return;
13✔
287
        }
288
        if ($node->nodeName === 'meta') {
89✔
289
            if (self::$docInfo !== null) {
9✔
290
                $attributes = $node->attributes;
9✔
291
                $name = $attributes->getNamedItem('name');
9✔
292
                $content = $attributes->getNamedItem('content');
9✔
293
                if ($name !== null && $content !== null) {
9✔
294
                    $mapArray = ['author' => 'creator'];
1✔
295
                    $others = [
1✔
296
                        'title',
1✔
297
                        'description',
1✔
298
                        'subject',
1✔
299
                        'keywords',
1✔
300
                        'category',
1✔
301
                        'company',
1✔
302
                        'manager',
1✔
303
                    ];
1✔
304
                    $nameValue = $name->nodeValue;
1✔
305
                    $propertyName = $mapArray[$nameValue] ?? (in_array($nameValue, $others, true) ? $nameValue : '');
1✔
306
                    $method = 'set' . ucfirst($propertyName);
1✔
307
                    if (method_exists(self::$docInfo, $method)) {
1✔
308
                        self::$docInfo->$method($content->nodeValue);
1✔
309
                    }
310
                }
311
            }
312

313
            return;
9✔
314
        }
315
        if ($node->nodeName === 'script') {
89✔
316
            return;
1✔
317
        }
318

319
        // Populate styles array
320
        $styleTypes = ['font', 'paragraph', 'list', 'table', 'row', 'cell'];
89✔
321
        foreach ($styleTypes as $styleType) {
89✔
322
            if (!isset($styles[$styleType])) {
89✔
323
                $styles[$styleType] = [];
89✔
324
            }
325
        }
326

327
        // Node mapping table
328
        $nodes = [
89✔
329
            // $method               $node   $element    $styles     $data   $argument1      $argument2
330
            'p' => ['Paragraph',     $node,  $element,   $styles,    null,   null,           null],
89✔
331
            'h1' => ['Heading',      $node,  $element,   $styles,    null,   'Heading1',     null],
89✔
332
            'h2' => ['Heading',      $node,  $element,   $styles,    null,   'Heading2',     null],
89✔
333
            'h3' => ['Heading',      $node,  $element,   $styles,    null,   'Heading3',     null],
89✔
334
            'h4' => ['Heading',      $node,  $element,   $styles,    null,   'Heading4',     null],
89✔
335
            'h5' => ['Heading',      $node,  $element,   $styles,    null,   'Heading5',     null],
89✔
336
            'h6' => ['Heading',      $node,  $element,   $styles,    null,   'Heading6',     null],
89✔
337
            '#text' => ['Text',      $node,  $element,   $styles,    null,   null,           null],
89✔
338
            'strong' => ['Property', null,   null,       $styles,    null,   'bold',         true],
89✔
339
            'b' => ['Property',    null,   null,       $styles,    null,   'bold',         true],
89✔
340
            'em' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
89✔
341
            'i' => ['Property',    null,   null,       $styles,    null,   'italic',       true],
89✔
342
            'u' => ['Property',    null,   null,       $styles,    null,   'underline',    'single'],
89✔
343
            'sup' => ['Property',    null,   null,       $styles,    null,   'superScript',  true],
89✔
344
            'sub' => ['Property',    null,   null,       $styles,    null,   'subScript',    true],
89✔
345
            'span' => ['Span',        $node,  null,       $styles,    null,   null,           null],
89✔
346
            'font' => ['Span',        $node,  null,       $styles,    null,   null,           null],
89✔
347
            'table' => ['Table',       $node,  $element,   $styles,    null,   null,           null],
89✔
348
            'tr' => ['Row',         $node,  $element,   $styles,    null,   null,           null],
89✔
349
            'td' => ['Cell',        $node,  $element,   $styles,    null,   null,           null],
89✔
350
            'th' => ['Cell',        $node,  $element,   $styles,    null,   null,           null],
89✔
351
            'ul' => ['List',        $node,  $element,   $styles,    $data,  null,           null],
89✔
352
            'ol' => ['List',        $node,  $element,   $styles,    $data,  null,           null],
89✔
353
            'li' => ['ListItem',    $node,  $element,   $styles,    $data,  null,           null],
89✔
354
            'img' => ['Image',       $node,  $element,   $styles,    null,   null,           null],
89✔
355
            'br' => ['LineBreak',   null,   $element,   $styles,    null,   null,           null],
89✔
356
            'a' => ['Link',        $node,  $element,   $styles,    null,   null,           null],
89✔
357
            'input' => ['Input',       $node,  $element,   $styles,    null,   null,           null],
89✔
358
            'hr' => ['HorizRule',   $node,  $element,   $styles,    null,   null,           null],
89✔
359
            'ruby' => ['Ruby',   $node,  $element,   $styles,    null,   null,           null],
89✔
360
        ];
89✔
361

362
        $newElement = null;
89✔
363
        $keys = ['node', 'element', 'styles', 'data', 'argument1', 'argument2'];
89✔
364

365
        if (isset($nodes[$node->nodeName])) {
89✔
366
            // Execute method based on node mapping table and return $newElement or null
367
            // Arguments are passed by reference
368
            $arguments = [];
89✔
369
            $args = [];
89✔
370
            [$method, $args[0], $args[1], $args[2], $args[3], $args[4], $args[5]] = $nodes[$node->nodeName];
89✔
371
            for ($i = 0; $i <= 5; ++$i) {
89✔
372
                if ($args[$i] !== null) {
89✔
373
                    $arguments[$keys[$i]] = &$args[$i];
89✔
374
                }
375
            }
376
            $method = "parse{$method}";
89✔
377
            $newElement = call_user_func_array(['PhpOffice\PhpWord\Shared\Html', $method], array_values($arguments));
89✔
378

379
            // Retrieve back variables from arguments
380
            foreach ($keys as $key) {
89✔
381
                if (array_key_exists($key, $arguments)) {
89✔
382
                    $$key = $arguments[$key];
89✔
383
                }
384
            }
385
        }
386

387
        if ($newElement === null) {
89✔
388
            $newElement = $element;
89✔
389
        }
390

391
        static::parseChildNodes($node, $newElement, $styles, $data);
89✔
392
    }
393

394
    /**
395
     * Parse child nodes.
396
     *
397
     * @param DOMNode $node
398
     * @param AbstractContainer|Row|Table $element
399
     * @param array $styles
400
     * @param array $data
401
     */
402
    protected static function parseChildNodes($node, $element, $styles, $data): void
403
    {
404
        if ('li' != $node->nodeName) {
89✔
405
            $cNodes = $node->childNodes;
89✔
406
            if (!empty($cNodes)) {
89✔
407
                foreach ($cNodes as $cNode) {
89✔
408
                    if ($element instanceof AbstractContainer || $element instanceof Table || $element instanceof Row) {
89✔
409
                        self::parseNode($cNode, $element, $styles, $data);
89✔
410
                    }
411
                }
412
            }
413
        }
414
    }
415

416
    /**
417
     * Parse paragraph node.
418
     *
419
     * @param DOMNode $node
420
     * @param AbstractContainer $element
421
     * @param array &$styles
422
     *
423
     * @return \PhpOffice\PhpWord\Element\PageBreak|TextRun
424
     */
425
    protected static function parseParagraph($node, $element, &$styles)
426
    {
427
        $styles['paragraph'] = self::recursiveParseStylesInHierarchy($node, $styles['paragraph']);
49✔
428
        if (isset($styles['paragraph']['isPageBreak']) && $styles['paragraph']['isPageBreak']) {
49✔
429
            return $element->addPageBreak();
1✔
430
        }
431

432
        $newElement = $element->addTextRun($styles['paragraph']);
48✔
433
        if (isset($styles['paragraph']['className']) && $newElement->getParagraphStyle() instanceof Paragraph) {
48✔
434
            $newElement->getParagraphStyle()->setStyleName($styles['paragraph']['className']);
2✔
435
        }
436

437
        return $newElement;
48✔
438
    }
439

440
    /**
441
     * Parse input node.
442
     *
443
     * @param DOMNode $node
444
     * @param AbstractContainer $element
445
     * @param array &$styles
446
     */
447
    protected static function parseInput($node, $element, &$styles): void
448
    {
449
        $attributes = $node->attributes;
1✔
450
        if (null === $attributes->getNamedItem('type')) {
1✔
451
            return;
×
452
        }
453

454
        $inputType = $attributes->getNamedItem('type')->nodeValue;
1✔
455
        switch ($inputType) {
456
            case 'checkbox':
1✔
457
                $checked = ($checked = $attributes->getNamedItem('checked')) && $checked->nodeValue === 'true' ? true : false;
1✔
458
                $textrun = $element->addTextRun($styles['paragraph']);
1✔
459
                $textrun->addFormField('checkbox')->setValue($checked);
1✔
460

461
                break;
1✔
462
        }
463
    }
464

465
    /**
466
     * Parse heading node.
467
     *
468
     * @todo Think of a clever way of defining header styles, now it is only based on the assumption, that
469
     * Heading1 - Heading6 are already defined somewhere
470
     */
471
    protected static function parseHeading(DOMNode $node, AbstractContainer $element, array &$styles, string $headingStyle): TextRun
472
    {
473
        $style = new Paragraph();
6✔
474
        $style->setStyleName($headingStyle);
6✔
475
        $style->setStyleByArray(self::parseInlineStyle($node, $styles['paragraph']));
6✔
476
        $textRun = new TextRun($style);
6✔
477

478
        // Create a title with level corresponding to number in heading style
479
        // (Eg, Heading1 = 1)
480
        $element->addTitle($textRun, (int) ltrim($headingStyle, 'Heading'));
6✔
481

482
        // Return TextRun so children are parsed
483
        return $textRun;
6✔
484
    }
485

486
    /**
487
     * Parse text node.
488
     *
489
     * @param DOMNode $node
490
     * @param AbstractContainer $element
491
     * @param array &$styles
492
     */
493
    protected static function parseText($node, $element, &$styles): void
494
    {
495
        $styles['font'] = self::recursiveParseStylesInHierarchy($node, $styles['font']);
73✔
496

497
        //alignment applies on paragraph, not on font. Let's copy it there
498
        if (isset($styles['font']['alignment']) && is_array($styles['paragraph'])) {
73✔
499
            $styles['paragraph']['alignment'] = $styles['font']['alignment'];
13✔
500
        }
501

502
        if (is_callable([$element, 'addText'])) {
73✔
503
            $font = $styles['font'];
73✔
504
            if (isset($font['className']) && count($font) === 1) {
73✔
505
                $font = $styles['font']['className'];
2✔
506
            }
507
            $element->addText($node->nodeValue, $font, $styles['paragraph']);
73✔
508
        }
509
    }
510

511
    /**
512
     * Parse property node.
513
     *
514
     * @param array &$styles
515
     * @param string $argument1 Style name
516
     * @param string $argument2 Style value
517
     */
518
    protected static function parseProperty(&$styles, $argument1, $argument2): void
519
    {
520
        $styles['font'][$argument1] = $argument2;
10✔
521
    }
522

523
    /**
524
     * Parse span node.
525
     *
526
     * @param DOMNode $node
527
     * @param array &$styles
528
     */
529
    protected static function parseSpan($node, &$styles): void
530
    {
531
        self::parseInlineStyle($node, $styles['font']);
19✔
532
    }
533

534
    /**
535
     * Parse table node.
536
     *
537
     * @param DOMNode $node
538
     * @param AbstractContainer $element
539
     * @param array &$styles
540
     *
541
     * @return Table $element
542
     *
543
     * @todo As soon as TableItem, RowItem and CellItem support relative width and height
544
     */
545
    protected static function parseTable($node, $element, &$styles)
546
    {
547
        $elementStyles = self::parseInlineStyle($node, $styles['table']);
23✔
548

549
        $newElement = $element->addTable($elementStyles);
23✔
550

551
        // Add style name from CSS Class
552
        if (isset($elementStyles['className'])) {
23✔
553
            $newElement->getStyle()->setStyleName($elementStyles['className']);
1✔
554
        }
555

556
        $attributes = $node->attributes;
23✔
557
        if ($attributes->getNamedItem('border') !== null && is_object($newElement->getStyle())) {
23✔
558
            $border = (int) $attributes->getNamedItem('border')->nodeValue;
2✔
559
            $newElement->getStyle()->setBorderSize((int) Converter::pixelToTwip($border));
2✔
560
            $newElement->getStyle()->setBorderStyle(($border === 0) ? 'none' : 'single');
2✔
561
        }
562

563
        return $newElement;
23✔
564
    }
565

566
    /**
567
     * Parse a table row.
568
     *
569
     * @param DOMNode $node
570
     * @param Table $element
571
     * @param array &$styles
572
     *
573
     * @return Row $element
574
     */
575
    protected static function parseRow($node, $element, &$styles)
576
    {
577
        $rowStyles = self::parseInlineStyle($node, $styles['row']);
23✔
578
        if ($node->parentNode->nodeName == 'thead') {
23✔
579
            $rowStyles['tblHeader'] = true;
6✔
580
        }
581

582
        // set cell height to control row heights
583
        $height = $rowStyles['height'] ?? null;
23✔
584
        unset($rowStyles['height']); // would not apply
23✔
585

586
        return $element->addRow($height, $rowStyles);
23✔
587
    }
588

589
    /**
590
     * Parse table cell.
591
     *
592
     * @param DOMNode $node
593
     * @param Table $element
594
     * @param array &$styles
595
     *
596
     * @return \PhpOffice\PhpWord\Element\Cell|TextRun $element
597
     */
598
    protected static function parseCell($node, $element, &$styles)
599
    {
600
        $cellStyles = self::recursiveParseStylesInHierarchy($node, $styles['cell']);
23✔
601

602
        $colspan = $node->getAttribute('colspan');
23✔
603
        if (!empty($colspan)) {
23✔
604
            $cellStyles['gridSpan'] = $colspan - 0;
6✔
605
        }
606

607
        // set cell width to control column widths
608
        $width = $cellStyles['width'] ?? null;
23✔
609
        unset($cellStyles['width']); // would not apply
23✔
610
        $cell = $element->addCell($width, $cellStyles);
23✔
611

612
        if (self::shouldAddTextRun($node)) {
23✔
613
            return $cell->addTextRun(self::filterOutNonInheritedStyles(self::parseInlineStyle($node, $styles['paragraph'])));
23✔
614
        }
615

616
        return $cell;
3✔
617
    }
618

619
    /**
620
     * Checks if $node contains an HTML element that cannot be added to TextRun.
621
     *
622
     * @return bool Returns true if the node contains an HTML element that cannot be added to TextRun
623
     */
624
    protected static function shouldAddTextRun(DOMNode $node)
625
    {
626
        $containsBlockElement = self::$xpath->query('.//table|./p|./ul|./ol|./h1|./h2|./h3|./h4|./h5|./h6', $node)->length > 0;
23✔
627
        if ($containsBlockElement) {
23✔
628
            return false;
3✔
629
        }
630

631
        return true;
23✔
632
    }
633

634
    /**
635
     * Recursively parses styles on parent nodes
636
     * TODO if too slow, add caching of parent nodes, !! everything is static here so watch out for concurrency !!
637
     */
638
    protected static function recursiveParseStylesInHierarchy(DOMNode $node, array $style)
639
    {
640
        $parentStyle = [];
87✔
641
        if ($node->parentNode != null && XML_ELEMENT_NODE == $node->parentNode->nodeType) {
87✔
642
            $parentStyle = self::recursiveParseStylesInHierarchy($node->parentNode, []);
87✔
643
        }
644
        if ($node->nodeName === '#text') {
87✔
645
            $parentStyle = array_merge($parentStyle, $style);
73✔
646
        } else {
647
            $parentStyle = self::filterOutNonInheritedStyles($parentStyle);
87✔
648
        }
649
        $style = self::parseInlineStyle($node, $parentStyle);
87✔
650

651
        return $style;
87✔
652
    }
653

654
    /**
655
     * Removes non-inherited styles from array.
656
     */
657
    protected static function filterOutNonInheritedStyles(array $styles)
658
    {
659
        $nonInheritedStyles = [
87✔
660
            'borderSize',
87✔
661
            'borderTopSize',
87✔
662
            'borderRightSize',
87✔
663
            'borderBottomSize',
87✔
664
            'borderLeftSize',
87✔
665
            'borderColor',
87✔
666
            'borderTopColor',
87✔
667
            'borderRightColor',
87✔
668
            'borderBottomColor',
87✔
669
            'borderLeftColor',
87✔
670
            'borderStyle',
87✔
671
            'spaceAfter',
87✔
672
            'spaceBefore',
87✔
673
            'underline',
87✔
674
            'strikethrough',
87✔
675
            'hidden',
87✔
676
        ];
87✔
677

678
        $styles = array_diff_key($styles, array_flip($nonInheritedStyles));
87✔
679

680
        return $styles;
87✔
681
    }
682

683
    /**
684
     * Parse list node.
685
     *
686
     * @param DOMNode $node
687
     * @param AbstractContainer $element
688
     * @param array &$styles
689
     * @param array &$data
690
     */
691
    protected static function parseList($node, $element, &$styles, &$data)
692
    {
693
        $isOrderedList = $node->nodeName === 'ol';
8✔
694
        if (isset($data['listdepth'])) {
8✔
695
            ++$data['listdepth'];
3✔
696
        } else {
697
            $data['listdepth'] = 0;
8✔
698
            $styles['list'] = 'listStyle_' . self::$listIndex++;
8✔
699
            $style = $element->getPhpWord()->addNumberingStyle($styles['list'], self::getListStyle($isOrderedList));
8✔
700

701
            // extract attributes start & type e.g. <ol type="A" start="3">
702
            $start = 0;
8✔
703
            $type = '';
8✔
704
            foreach ($node->attributes as $attribute) {
8✔
705
                switch ($attribute->name) {
2✔
706
                    case 'start':
2✔
707
                        $start = (int) $attribute->value;
1✔
708

709
                        break;
1✔
710
                    case 'type':
2✔
711
                        $type = $attribute->value;
2✔
712

713
                        break;
2✔
714
                }
715
            }
716

717
            $levels = $style->getLevels();
8✔
718
            /** @var \PhpOffice\PhpWord\Style\NumberingLevel */
719
            $level = $levels[0];
8✔
720
            if ($start > 0) {
8✔
721
                $level->setStart($start);
1✔
722
            }
723
            $type = $type ? self::mapListType($type) : null;
8✔
724
            if ($type) {
8✔
725
                $level->setFormat($type);
2✔
726
            }
727
        }
728
        if ($node->parentNode->nodeName === 'li') {
8✔
729
            return $element->getParent();
1✔
730
        }
731
    }
732

733
    /**
734
     * @param bool $isOrderedList
735
     *
736
     * @return array
737
     */
738
    protected static function getListStyle($isOrderedList)
739
    {
740
        if ($isOrderedList) {
8✔
741
            return [
6✔
742
                'type' => 'multilevel',
6✔
743
                'levels' => [
6✔
744
                    ['format' => NumberFormat::DECIMAL,      'text' => '%1.', 'alignment' => 'left',  'tabPos' => 720,  'left' => 720,  'hanging' => 360],
6✔
745
                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%2.', 'alignment' => 'left',  'tabPos' => 1440, 'left' => 1440, 'hanging' => 360],
6✔
746
                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%3.', 'alignment' => 'right', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 180],
6✔
747
                    ['format' => NumberFormat::DECIMAL,      'text' => '%4.', 'alignment' => 'left',  'tabPos' => 2880, 'left' => 2880, 'hanging' => 360],
6✔
748
                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%5.', 'alignment' => 'left',  'tabPos' => 3600, 'left' => 3600, 'hanging' => 360],
6✔
749
                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%6.', 'alignment' => 'right', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 180],
6✔
750
                    ['format' => NumberFormat::DECIMAL,      'text' => '%7.', 'alignment' => 'left',  'tabPos' => 5040, 'left' => 5040, 'hanging' => 360],
6✔
751
                    ['format' => NumberFormat::LOWER_LETTER, 'text' => '%8.', 'alignment' => 'left',  'tabPos' => 5760, 'left' => 5760, 'hanging' => 360],
6✔
752
                    ['format' => NumberFormat::LOWER_ROMAN,  'text' => '%9.', 'alignment' => 'right', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 180],
6✔
753
                ],
6✔
754
            ];
6✔
755
        }
756

757
        return [
4✔
758
            'type' => 'hybridMultilevel',
4✔
759
            'levels' => [
4✔
760
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 720,  'left' => 720,  'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
4✔
761
                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
4✔
762
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
4✔
763
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
4✔
764
                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
4✔
765
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
4✔
766
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360, 'font' => 'Symbol',      'hint' => 'default'],
4✔
767
                ['format' => NumberFormat::BULLET, 'text' => 'â—¦',  'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'],
4✔
768
                ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 360, 'font' => 'Wingdings',   'hint' => 'default'],
4✔
769
            ],
4✔
770
        ];
4✔
771
    }
772

773
    /**
774
     * Parse list item node.
775
     *
776
     * @param DOMNode $node
777
     * @param AbstractContainer $element
778
     * @param array &$styles
779
     * @param array $data
780
     *
781
     * @todo This function is almost the same like `parseChildNodes`. Merged?
782
     * @todo As soon as ListItem inherits from AbstractContainer or TextRun delete parsing part of childNodes
783
     */
784
    protected static function parseListItem($node, $element, &$styles, $data): void
785
    {
786
        $cNodes = $node->childNodes;
8✔
787
        if (!empty($cNodes)) {
8✔
788
            $listRun = $element->addListItemRun($data['listdepth'], $styles['list'], $styles['paragraph']);
8✔
789
            foreach ($cNodes as $cNode) {
8✔
790
                self::parseNode($cNode, $listRun, $styles, $data);
8✔
791
            }
792
        }
793
    }
794

795
    /**
796
     * Parse style.
797
     *
798
     * @param DOMAttr $attribute
799
     * @param array $styles
800
     *
801
     * @return array
802
     */
803
    protected static function parseStyle($attribute, $styles)
804
    {
805
        $properties = explode(';', trim($attribute->value, " \t\n\r\0\x0B;"));
39✔
806

807
        $selectors = [];
39✔
808
        foreach ($properties as $property) {
39✔
809
            [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null);
39✔
810
            $selectors[strtolower(trim($cKey))] = trim($cValue ?? '');
39✔
811
        }
812

813
        return self::parseStyleDeclarations($selectors, $styles);
39✔
814
    }
815

816
    protected static function parseStyleDeclarations(array $selectors, array $styles)
817
    {
818
        $bidi = ($selectors['direction'] ?? '') === 'rtl';
45✔
819
        foreach ($selectors as $property => $value) {
45✔
820
            switch ($property) {
821
                case 'text-decoration':
43✔
822
                    switch ($value) {
823
                        case 'underline':
4✔
824
                            $styles['underline'] = 'single';
3✔
825

826
                            break;
3✔
827
                        case 'line-through':
1✔
828
                            $styles['strikethrough'] = true;
1✔
829

830
                            break;
1✔
831
                    }
832

833
                    break;
4✔
834
                case 'text-align':
41✔
835
                    $styles['alignment'] = self::mapAlign($value, $bidi);
8✔
836

837
                    break;
8✔
838
                case 'ruby-align':
40✔
839
                    $styles['rubyAlignment'] = self::mapRubyAlign($value);
1✔
840

841
                    break;
1✔
842
                case 'display':
40✔
843
                    $styles['hidden'] = $value === 'none' || $value === 'hidden';
1✔
844

845
                    break;
1✔
846
                case 'direction':
39✔
847
                    $styles['rtl'] = $value === 'rtl';
4✔
848
                    $styles['bidi'] = $value === 'rtl';
4✔
849
                    $styles['textDirection'] = ($value === 'rtl') ? TextDirection::RLTB : TextDirection::LRTB;
4✔
850

851
                    break;
4✔
852
                case 'font-size':
35✔
853
                    $styles['size'] = Converter::cssToPoint($value);
7✔
854

855
                    break;
7✔
856
                case 'font-family':
32✔
857
                    $value = array_map('trim', explode(',', $value));
5✔
858
                    $styles['name'] = ucwords($value[0]);
5✔
859

860
                    break;
5✔
861
                case 'color':
28✔
862
                    HtmlColours::setArrayColour($styles, 'color', self::convertRgb($value));
5✔
863

864
                    break;
5✔
865
                case 'background-color':
26✔
866
                    HtmlColours::setArrayColour($styles, 'bgColor', self::convertRgb($value));
9✔
867

868
                    break;
9✔
869
                case 'line-height':
25✔
870
                    $matches = [];
2✔
871
                    if ($value === 'normal' || $value === 'inherit') {
2✔
872
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
873
                        $spacing = 0;
1✔
874
                    } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) {
2✔
875
                        //matches number with a unit, e.g. 12px, 15pt, 20mm, ...
876
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::EXACT;
2✔
877
                        $spacing = Converter::cssToTwip($matches[1]);
2✔
878
                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
1✔
879
                        //matches percentages
880
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
881
                        //we are subtracting 1 line height because the Spacing writer is adding one line
882
                        $spacing = ((((int) $matches[1]) / 100) * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT;
1✔
883
                    } else {
884
                        //any other, wich is a multiplier. E.g. 1.2
885
                        $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO;
1✔
886
                        //we are subtracting 1 line height because the Spacing writer is adding one line
887
                        $spacing = ($value * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT;
1✔
888
                    }
889
                    $styles['spacingLineRule'] = $spacingLineRule;
2✔
890
                    $styles['line-spacing'] = $spacing;
2✔
891

892
                    break;
2✔
893
                case 'letter-spacing':
23✔
894
                    $styles['letter-spacing'] = Converter::cssToTwip($value);
1✔
895

896
                    break;
1✔
897
                case 'text-indent':
22✔
898
                    $styles['indentation']['firstLine'] = Converter::cssToTwip($value);
1✔
899

900
                    break;
1✔
901
                case 'font-weight':
21✔
902
                    $tValue = false;
5✔
903
                    if (preg_match('#bold#', $value)) {
5✔
904
                        $tValue = true; // also match bolder
5✔
905
                    }
906
                    $styles['bold'] = $tValue;
5✔
907

908
                    break;
5✔
909
                case 'font-style':
19✔
910
                    $tValue = false;
2✔
911
                    if (preg_match('#(?:italic|oblique)#', $value)) {
2✔
912
                        $tValue = true;
2✔
913
                    }
914
                    $styles['italic'] = $tValue;
2✔
915

916
                    break;
2✔
917
                case 'font-variant':
18✔
918
                    $tValue = false;
1✔
919
                    if (preg_match('#small-caps#', $value)) {
1✔
920
                        $tValue = true;
1✔
921
                    }
922
                    $styles['smallCaps'] = $tValue;
1✔
923

924
                    break;
1✔
925
                case 'margin':
17✔
926
                    $value = Converter::cssToTwip($value);
1✔
927
                    $styles['spaceBefore'] = $value;
1✔
928
                    $styles['spaceAfter'] = $value;
1✔
929

930
                    break;
1✔
931
                case 'margin-top':
16✔
932
                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
933
                    $styles['spaceBefore'] = Converter::cssToTwip($value);
2✔
934

935
                    break;
2✔
936
                case 'margin-bottom':
16✔
937
                    // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value)
938
                    $styles['spaceAfter'] = Converter::cssToTwip($value);
2✔
939

940
                    break;
2✔
941

942
                case 'padding':
15✔
943
                    $valueTop = $valueRight = $valueBottom = $valueLeft = null;
2✔
944
                    $cValue = preg_replace('# +#', ' ', trim($value));
2✔
945
                    $paddingArr = explode(' ', $cValue);
2✔
946
                    $countParams = count($paddingArr);
2✔
947
                    if ($countParams == 1) {
2✔
948
                        $valueTop = $valueRight = $valueBottom = $valueLeft = $paddingArr[0];
1✔
949
                    } elseif ($countParams == 2) {
2✔
950
                        $valueTop = $valueBottom = $paddingArr[0];
1✔
951
                        $valueRight = $valueLeft = $paddingArr[1];
1✔
952
                    } elseif ($countParams == 3) {
2✔
953
                        $valueTop = $paddingArr[0];
1✔
954
                        $valueRight = $valueLeft = $paddingArr[1];
1✔
955
                        $valueBottom = $paddingArr[2];
1✔
956
                    } elseif ($countParams == 4) {
2✔
957
                        $valueTop = $paddingArr[0];
2✔
958
                        $valueRight = $paddingArr[1];
2✔
959
                        $valueBottom = $paddingArr[2];
2✔
960
                        $valueLeft = $paddingArr[3];
2✔
961
                    }
962
                    if ($valueTop !== null) {
2✔
963
                        $styles['paddingTop'] = Converter::cssToTwip($valueTop);
2✔
964
                    }
965
                    if ($valueRight !== null) {
2✔
966
                        $styles['paddingRight'] = Converter::cssToTwip($valueRight);
2✔
967
                    }
968
                    if ($valueBottom !== null) {
2✔
969
                        $styles['paddingBottom'] = Converter::cssToTwip($valueBottom);
2✔
970
                    }
971
                    if ($valueLeft !== null) {
2✔
972
                        $styles['paddingLeft'] = Converter::cssToTwip($valueLeft);
2✔
973
                    }
974

975
                    break;
2✔
976
                case 'padding-top':
14✔
977
                    $styles['paddingTop'] = Converter::cssToTwip($value);
1✔
978

979
                    break;
1✔
980
                case 'padding-right':
14✔
981
                    $styles['paddingRight'] = Converter::cssToTwip($value);
1✔
982

983
                    break;
1✔
984
                case 'padding-bottom':
14✔
985
                    $styles['paddingBottom'] = Converter::cssToTwip($value);
1✔
986

987
                    break;
1✔
988
                case 'padding-left':
14✔
989
                    $styles['paddingLeft'] = Converter::cssToTwip($value);
1✔
990

991
                    break;
1✔
992

993
                case 'border-color':
13✔
994
                    self::mapBorderColor($styles, $value);
4✔
995

996
                    break;
4✔
997
                case 'border-width':
13✔
998
                    $styles['borderSize'] = Converter::cssToPoint(self::SPECIAL_BORDER_WIDTHS[$value] ?? $value);
1✔
999

1000
                    break;
1✔
1001
                case 'border-style':
13✔
1002
                    $styles['borderStyle'] = self::mapBorderStyle($value);
4✔
1003

1004
                    break;
4✔
1005
                case 'width':
13✔
1006
                    if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) {
7✔
1007
                        $styles['width'] = Converter::cssToTwip($matches[1]);
2✔
1008
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP;
2✔
1009
                    } elseif (preg_match('/([0-9]+)%/', $value, $matches)) {
7✔
1010
                        $styles['width'] = $matches[1] * 50;
7✔
1011
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT;
7✔
1012
                    } elseif (preg_match('/([0-9]+)/', $value, $matches)) {
1✔
1013
                        $styles['width'] = $matches[1];
1✔
1014
                        $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO;
1✔
1015
                    }
1016

1017
                    break;
7✔
1018
                case 'height':
11✔
1019
                    $styles['height'] = Converter::cssToTwip($value);
1✔
1020
                    $styles['exactHeight'] = true;
1✔
1021

1022
                    break;
1✔
1023
                case 'border':
10✔
1024
                case 'border-top':
5✔
1025
                case 'border-bottom':
5✔
1026
                case 'border-right':
4✔
1027
                case 'border-left':
4✔
1028
                    $stylePattern = '/(^|\\s)(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)(\\s|$)/';
7✔
1029
                    if (!preg_match($stylePattern, $value, $matches)) {
7✔
NEW
1030
                        break;
×
1031
                    }
1032
                    $borderStyle = $matches[2];
7✔
1033
                    $value = preg_replace($stylePattern, ' ', $value) ?? '';
7✔
1034
                    $borderSize = $borderColor = null;
7✔
1035
                    $sizePattern = '/(^|\\s)([0-9]+([.][0-9]+)?+(%|[a-z]*)|thick|thin|medium)(\\s|$)/';
7✔
1036
                    if (preg_match($sizePattern, $value, $matches)) {
7✔
1037
                        $borderSize = $matches[2];
5✔
1038
                        $borderSize = self::SPECIAL_BORDER_WIDTHS[$borderSize] ?? $borderSize;
5✔
1039
                        $value = preg_replace($sizePattern, ' ', $value) ?? '';
5✔
1040
                    }
1041
                    $colorPattern = '/(^|\\s)([#][a-fA-F0-9]{6}|[#][a-fA-F0-9]{3}|[a-z][a-z0-9]+)(\\s|$)/';
7✔
1042
                    if (preg_match($colorPattern, $value, $matches)) {
7✔
1043
                        $borderColor = HtmlColours::convertColour($matches[2]);
5✔
1044
                    }
1045
                    if (false !== strpos($property, '-')) {
7✔
1046
                        $tmp = explode('-', $property);
1✔
1047
                        $which = $tmp[1];
1✔
1048
                        $which = ucfirst($which); // e.g. bottom -> Bottom
1✔
1049
                    } else {
1050
                        $which = '';
6✔
1051
                    }
1052
                    // Note - border width normalization:
1053
                    // Width of border in Word is calculated differently than HTML borders, usually showing up too bold.
1054
                    // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips.
1055
                    // Therefore we need to normalize converted twip value to cca 1/2 of value.
1056
                    // This may be adjusted, if better ratio or formula found.
1057
                    // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size)
1058
                    if ($borderSize !== null) {
7✔
1059
                        $size = Converter::cssToTwip($borderSize);
5✔
1060
                        $size = (int) ($size / 2);
5✔
1061
                        // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc ..
1062
                        $styles["border{$which}Size"] = $size; // twips
5✔
1063
                    }
1064
                    if (!empty($borderColor)) {
7✔
1065
                        $styles["border{$which}Color"] = $borderColor;
5✔
1066
                    }
1067
                    $styles["border{$which}Style"] = self::mapBorderStyle($borderStyle);
7✔
1068

1069
                    break;
7✔
1070
                case 'vertical-align':
4✔
1071
                    // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align
1072
                    if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) {
1✔
1073
                        $styles['valign'] = self::mapAlignVertical($matches[0]);
1✔
1074
                    }
1075

1076
                    break;
1✔
1077
                case 'page-break-after':
3✔
1078
                    if ($value == 'always') {
1✔
1079
                        $styles['isPageBreak'] = true;
1✔
1080
                    }
1081

1082
                    break;
1✔
1083
            }
1084
        }
1085

1086
        return $styles;
45✔
1087
    }
1088

1089
    /**
1090
     * Parse image node.
1091
     *
1092
     * @param DOMNode $node
1093
     * @param AbstractContainer $element
1094
     *
1095
     * @return \PhpOffice\PhpWord\Element\Image
1096
     */
1097
    protected static function parseImage($node, $element)
1098
    {
1099
        $style = [];
9✔
1100
        $src = null;
9✔
1101
        foreach ($node->attributes as $attribute) {
9✔
1102
            switch ($attribute->name) {
9✔
1103
                case 'src':
9✔
1104
                    $src = $attribute->value;
9✔
1105

1106
                    break;
9✔
1107
                case 'width':
9✔
1108
                    $style['width'] = self::convertHtmlSize($attribute->value);
9✔
1109
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
1110

1111
                    break;
9✔
1112
                case 'height':
9✔
1113
                    $style['height'] = self::convertHtmlSize($attribute->value);
9✔
1114
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
1115

1116
                    break;
9✔
1117
                case 'style':
6✔
1118
                    $styleattr = explode(';', $attribute->value);
5✔
1119
                    foreach ($styleattr as $attr) {
5✔
1120
                        if (strpos($attr, ':')) {
5✔
1121
                            [$k, $v] = explode(':', $attr);
5✔
1122
                            switch ($k) {
1123
                                case 'float':
5✔
1124
                                    if (trim($v) == 'right') {
5✔
1125
                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT;
5✔
1126
                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
5✔
1127
                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
5✔
1128
                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
5✔
1129
                                        $style['overlap'] = true;
5✔
1130
                                    }
1131
                                    if (trim($v) == 'left') {
5✔
1132
                                        $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT;
3✔
1133
                                        $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area
3✔
1134
                                        $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE;
3✔
1135
                                        $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT;
3✔
1136
                                        $style['overlap'] = true;
3✔
1137
                                    }
1138

1139
                                    break;
5✔
1140
                            }
1141
                        }
1142
                    }
1143

1144
                    break;
5✔
1145
            }
1146
        }
1147
        $originSrc = $src;
9✔
1148
        if (strpos($src, 'data:image') !== false) {
9✔
1149
            $tmpDir = Settings::getTempDir() . '/';
1✔
1150

1151
            $match = [];
1✔
1152
            preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match);
1✔
1153
            if (!empty($match)) {
1✔
1154
                $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1];
1✔
1155

1156
                $ifp = fopen($imgFile, 'wb');
1✔
1157

1158
                if ($ifp !== false) {
1✔
1159
                    fwrite($ifp, base64_decode($match[2]));
1✔
1160
                    fclose($ifp);
1✔
1161
                }
1162
            }
1163
        }
1164
        $src = urldecode($src);
9✔
1165

1166
        if (!is_file($src)
9✔
1167
            && null !== self::$options
9✔
1168
            && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'])
9✔
1169
        ) {
1170
            $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src);
1✔
1171
        }
1172

1173
        if (!is_file($src)) {
9✔
1174
            if ($imgBlob = @file_get_contents($src)) {
3✔
1175
                $tmpDir = Settings::getTempDir() . '/';
3✔
1176
                $match = [];
3✔
1177
                preg_match('/.+\.(\w+)$/', $src, $match);
3✔
1178
                $src = $tmpDir . uniqid();
3✔
1179
                if (isset($match[1])) {
3✔
1180
                    $src .= '.' . $match[1];
2✔
1181
                }
1182

1183
                $ifp = fopen($src, 'wb');
3✔
1184

1185
                if ($ifp !== false) {
3✔
1186
                    fwrite($ifp, $imgBlob);
3✔
1187
                    fclose($ifp);
3✔
1188
                }
1189
            }
1190
        }
1191

1192
        if (is_file($src)) {
9✔
1193
            $newElement = $element->addImage($src, $style);
9✔
1194
        } else {
1195
            throw new Exception("Could not load image $originSrc");
×
1196
        }
1197

1198
        return $newElement;
8✔
1199
    }
1200

1201
    /**
1202
     * Transforms a CSS border style into a word border style.
1203
     *
1204
     * @param string $cssBorderStyle
1205
     *
1206
     * @return null|string
1207
     */
1208
    protected static function mapBorderStyle($cssBorderStyle)
1209
    {
1210
        switch ($cssBorderStyle) {
1211
            case 'none':
8✔
1212
            case 'dashed':
8✔
1213
            case 'dotted':
8✔
1214
            case 'double':
5✔
1215
                return $cssBorderStyle;
6✔
1216
            case 'hidden':
4✔
1217
                return 'none';
1✔
1218
            default:
1219
                return 'single';
3✔
1220
        }
1221
    }
1222

1223
    protected static function mapBorderColor(&$styles, $cssBorderColor): void
1224
    {
1225
        $colors = explode(' ', $cssBorderColor);
4✔
1226
        $numColors = count($colors);
4✔
1227
        if ($numColors === 1) {
4✔
1228
            HtmlColours::setArrayColour($styles, 'borderColor', $cssBorderColor);
4✔
1229
        } else {
1230
            $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'];
1✔
1231
            for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) {
1✔
1232
                HtmlColours::setArrayColour($styles, $borders[$i], $colors[$i]);
1✔
1233
            }
1234
        }
1235
    }
1236

1237
    /**
1238
     * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1239
     *
1240
     * @param string $cssAlignment
1241
     * @param bool $bidi
1242
     *
1243
     * @return null|string
1244
     */
1245
    protected static function mapAlign($cssAlignment, $bidi)
1246
    {
1247
        switch ($cssAlignment) {
1248
            case 'right':
13✔
1249
                return $bidi ? Jc::START : Jc::END;
1✔
1250
            case 'center':
13✔
1251
                return Jc::CENTER;
11✔
1252
            case 'justify':
4✔
1253
                return Jc::BOTH;
1✔
1254
            default:
1255
                return $bidi ? Jc::END : Jc::START;
4✔
1256
        }
1257
    }
1258

1259
    /**
1260
     * Transforms a HTML/CSS ruby alignment into a \PhpOffice\PhpWord\SimpleType\Jc.
1261
     */
1262
    protected static function mapRubyAlign(string $cssRubyAlignment): string
1263
    {
1264
        switch ($cssRubyAlignment) {
1265
            case 'center':
1✔
1266
                return RubyProperties::ALIGNMENT_CENTER;
1✔
1267
            case 'start':
×
1268
                return RubyProperties::ALIGNMENT_LEFT;
×
1269
            case 'space-between':
×
1270
                return RubyProperties::ALIGNMENT_DISTRIBUTE_SPACE;
×
1271
            default:
1272
                return '';
×
1273
        }
1274
    }
1275

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

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

1328
    /**
1329
     * Parse line break.
1330
     *
1331
     * @param AbstractContainer $element
1332
     */
1333
    protected static function parseLineBreak($element): void
1334
    {
1335
        $element->addTextBreak();
2✔
1336
    }
1337

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

1353
                    break;
3✔
1354
            }
1355
        }
1356
        $styles['font'] = self::parseInlineStyle($node, $styles['font']);
3✔
1357

1358
        if (empty($target)) {
3✔
1359
            $target = '#';
1✔
1360
        }
1361

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

1366
        return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']);
2✔
1367
    }
1368

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

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

1384
        $fontStyle = $styles + ['size' => 3];
1✔
1385

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

1396
        $element->addText('', $fontStyle, $paragraphStyle);
1✔
1397

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

1404
    /**
1405
     * Parse ruby node.
1406
     *
1407
     * @param DOMNode $node
1408
     * @param AbstractContainer $element
1409
     * @param array $styles
1410
     */
1411
    protected static function parseRuby($node, $element, &$styles)
1412
    {
1413
        $rubyProperties = new RubyProperties();
1✔
1414
        $baseTextRun = new TextRun($styles['paragraph']);
1✔
1415
        $rubyTextRun = new TextRun(null);
1✔
1416
        if ($node->hasAttributes()) {
1✔
1417
            $langAttr = $node->attributes->getNamedItem('lang');
1✔
1418
            if ($langAttr !== null) {
1✔
1419
                $rubyProperties->setLanguageId($langAttr->textContent);
1✔
1420
            }
1421
            $styleAttr = $node->attributes->getNamedItem('style');
1✔
1422
            if ($styleAttr !== null) {
1✔
1423
                $styles = self::parseStyle($styleAttr, $styles['paragraph']);
1✔
1424
                if (isset($styles['rubyAlignment']) && $styles['rubyAlignment'] !== '') {
1✔
1425
                    $rubyProperties->setAlignment($styles['rubyAlignment']);
1✔
1426
                }
1427
                if (isset($styles['size']) && $styles['size'] !== '') {
1✔
1428
                    $rubyProperties->setFontSizeForBaseText($styles['size']);
1✔
1429
                }
1430
                $baseTextRun->setParagraphStyle($styles);
1✔
1431
            }
1432
        }
1433
        foreach ($node->childNodes as $child) {
1✔
1434
            if ($child->nodeName === '#text') {
1✔
1435
                $content = trim($child->textContent);
1✔
1436
                if ($content !== '') {
1✔
1437
                    $baseTextRun->addText($content);
1✔
1438
                }
1439
            } elseif ($child->nodeName === 'rt') {
1✔
1440
                $rubyTextRun->addText(trim($child->textContent));
1✔
1441
                if ($child->hasAttributes()) {
1✔
1442
                    $styleAttr = $child->attributes->getNamedItem('style');
1✔
1443
                    if ($styleAttr !== null) {
1✔
1444
                        $styles = self::parseStyle($styleAttr, []);
1✔
1445
                        if (isset($styles['size']) && $styles['size'] !== '') {
1✔
1446
                            $rubyProperties->setFontFaceSize($styles['size']);
1✔
1447
                        }
1448
                        $rubyTextRun->setParagraphStyle($styles);
1✔
1449
                    }
1450
                }
1451
            }
1452
        }
1453

1454
        return $element->addRuby($baseTextRun, $rubyTextRun, $rubyProperties);
1✔
1455
    }
1456

1457
    private static function convertRgb(string $rgb): string
1458
    {
1459
        if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) {
13✔
1460
            return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]);
1✔
1461
        }
1462

1463
        return trim($rgb, '# ');
13✔
1464
    }
1465

1466
    /**
1467
     * Transform HTML sizes (pt, px) in pixels.
1468
     */
1469
    protected static function convertHtmlSize(string $size): float
1470
    {
1471
        // pt
1472
        if (false !== strpos($size, 'pt')) {
16✔
1473
            return Converter::pointToPixel((float) str_replace('pt', '', $size));
2✔
1474
        }
1475

1476
        // px
1477
        if (false !== strpos($size, 'px')) {
14✔
1478
            return (float) str_replace('px', '', $size);
2✔
1479
        }
1480
        if (false !== strpos($size, 'cm')) {
12✔
1481
            return Converter::cmToPixel((float) str_replace('cm', '', $size));
1✔
1482
        }
1483
        if (false !== strpos($size, 'in')) {
11✔
1484
            return Converter::inchToPixel((float) str_replace('in', '', $size));
1✔
1485
        }
1486

1487
        return (float) $size;
10✔
1488
    }
1489
}
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