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

PHPOffice / PHPWord / 17396475851

02 Sep 2025 07:28AM UTC coverage: 96.925% (+0.2%) from 96.757%
17396475851

Pull #2567

github

web-flow
Merge d685340a1 into 0ab0b4940
Pull Request #2567: WIP Do Not Install

308 of 313 new or added lines in 28 files covered. (98.4%)

4 existing lines in 1 file now uncovered.

12704 of 13107 relevant lines covered (96.93%)

37.38 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 DOMDocument;
22
use DOMNode;
23
use DOMXPath;
24
use Exception;
25
use PhpOffice\PhpWord\ComplexType\RubyProperties;
26
use PhpOffice\PhpWord\Element\AbstractContainer;
27
use PhpOffice\PhpWord\Element\Row;
28
use PhpOffice\PhpWord\Element\Table;
29
use PhpOffice\PhpWord\Element\TextRun;
30
use PhpOffice\PhpWord\Metadata\DocInfo;
31
use PhpOffice\PhpWord\PhpWord;
32
use PhpOffice\PhpWord\Settings;
33
use PhpOffice\PhpWord\SimpleType\Border;
34
use PhpOffice\PhpWord\SimpleType\Jc;
35
use PhpOffice\PhpWord\SimpleType\NumberFormat;
36
use PhpOffice\PhpWord\SimpleType\TextDirection;
37
use PhpOffice\PhpWord\Style\Paragraph;
38
use Throwable;
39

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

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

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

53
    protected static $listIndex = 0;
54

55
    protected static $xpath;
56

57
    protected static $options;
58

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

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

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

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

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

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

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

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

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

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

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

168
        return $convert;
90✔
169
    }
170

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

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

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

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

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

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

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

235
                        break;
1✔
236
                }
237
            }
238

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

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

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

258
        return $styles;
87✔
259
    }
260

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

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

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

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

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

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

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

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

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

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

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

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

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

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

436
        return $newElement;
48✔
437
    }
438

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

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

460
                break;
1✔
461
        }
462
    }
463

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

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

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

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

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

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

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

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

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

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

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

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

562
        return $newElement;
23✔
563
    }
564

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

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

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

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

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

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

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

615
        return $cell;
3✔
616
    }
617

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

630
        return true;
23✔
631
    }
632

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

650
        return $style;
87✔
651
    }
652

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

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

679
        return $styles;
87✔
680
    }
681

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

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

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

712
                        break;
2✔
713
                }
714
            }
715

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

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

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

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

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

803
        $selectors = [];
39✔
804
        foreach ($properties as $property) {
39✔
805
            [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null);
39✔
806
            $selectors[strtolower(trim($cKey))] = trim($cValue ?? '');
39✔
807
        }
808

809
        return self::parseStyleDeclarations($selectors, $styles);
39✔
810
    }
811

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

822
                            break;
3✔
823
                        case 'line-through':
1✔
824
                            $styles['strikethrough'] = true;
1✔
825

826
                            break;
1✔
827
                    }
828

829
                    break;
4✔
830
                case 'text-align':
41✔
831
                    $styles['alignment'] = self::mapAlign($value, $bidi);
8✔
832

833
                    break;
8✔
834
                case 'ruby-align':
40✔
835
                    $styles['rubyAlignment'] = self::mapRubyAlign($value);
1✔
836

837
                    break;
1✔
838
                case 'display':
40✔
839
                    $styles['hidden'] = $value === 'none' || $value === 'hidden';
1✔
840

841
                    break;
1✔
842
                case 'direction':
39✔
843
                    $styles['rtl'] = $value === 'rtl';
4✔
844
                    $styles['bidi'] = $value === 'rtl';
4✔
845
                    $styles['textDirection'] = ($value === 'rtl') ? TextDirection::RLTB : TextDirection::LRTB;
4✔
846

847
                    break;
4✔
848
                case 'font-size':
35✔
849
                    $styles['size'] = Converter::cssToPoint($value);
7✔
850

851
                    break;
7✔
852
                case 'font-family':
32✔
853
                    $value = array_map('trim', explode(',', $value));
5✔
854
                    $styles['name'] = ucwords($value[0]);
5✔
855

856
                    break;
5✔
857
                case 'color':
28✔
858
                    HtmlColours::setArrayColour($styles, 'color', self::convertRgb($value));
5✔
859

860
                    break;
5✔
861
                case 'background-color':
26✔
862
                    HtmlColours::setArrayColour($styles, 'bgColor', self::convertRgb($value));
9✔
863

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

888
                    break;
2✔
889
                case 'letter-spacing':
23✔
890
                    $styles['letter-spacing'] = Converter::cssToTwip($value);
1✔
891

892
                    break;
1✔
893
                case 'text-indent':
22✔
894
                    $styles['indentation']['firstLine'] = Converter::cssToTwip($value);
1✔
895

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

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

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

920
                    break;
1✔
921
                case 'margin':
17✔
922
                    $value = Converter::cssToTwip($value);
1✔
923
                    $styles['spaceBefore'] = $value;
1✔
924
                    $styles['spaceAfter'] = $value;
1✔
925

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

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

936
                    break;
2✔
937

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

971
                    break;
2✔
972
                case 'padding-top':
14✔
973
                    $styles['paddingTop'] = Converter::cssToTwip($value);
1✔
974

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

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

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

987
                    break;
1✔
988

989
                case 'border-color':
13✔
990
                    self::mapBorderColor($styles, $value);
4✔
991

992
                    break;
4✔
993
                case 'border-width':
13✔
994
                    $styles['borderSize'] = Converter::cssToPoint(self::SPECIAL_BORDER_WIDTHS[$value] ?? $value);
1✔
995

996
                    break;
1✔
997
                case 'border-style':
13✔
998
                    $styles['borderStyle'] = self::mapBorderStyle($value);
4✔
999

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

1013
                    break;
7✔
1014
                case 'height':
11✔
1015
                    $styles['height'] = Converter::cssToTwip($value);
1✔
1016
                    $styles['exactHeight'] = true;
1✔
1017

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

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

1072
                    break;
1✔
1073
                case 'page-break-after':
3✔
1074
                    if ($value == 'always') {
1✔
1075
                        $styles['isPageBreak'] = true;
1✔
1076
                    }
1077

1078
                    break;
1✔
1079
            }
1080
        }
1081

1082
        return $styles;
45✔
1083
    }
1084

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

1102
                    break;
9✔
1103
                case 'width':
9✔
1104
                    $style['width'] = self::convertHtmlSize($attribute->value);
9✔
1105
                    $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX;
9✔
1106

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

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

1135
                                    break;
5✔
1136
                            }
1137
                        }
1138
                    }
1139

1140
                    break;
5✔
1141
            }
1142
        }
1143
        $originSrc = $src;
9✔
1144
        if (strpos($src, 'data:image') !== false) {
9✔
1145
            $tmpDir = Settings::getTempDir() . '/';
1✔
1146

1147
            $match = [];
1✔
1148
            preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match);
1✔
1149
            if (!empty($match)) {
1✔
1150
                $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1];
1✔
1151

1152
                $ifp = fopen($imgFile, 'wb');
1✔
1153

1154
                if ($ifp !== false) {
1✔
1155
                    fwrite($ifp, base64_decode($match[2]));
1✔
1156
                    fclose($ifp);
1✔
1157
                }
1158
            }
1159
        }
1160
        $src = urldecode($src);
9✔
1161

1162
        if (!is_file($src)
9✔
1163
            && null !== self::$options
9✔
1164
            && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'])
9✔
1165
        ) {
1166
            $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src);
1✔
1167
        }
1168

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

1179
                $ifp = fopen($src, 'wb');
3✔
1180

1181
                if ($ifp !== false) {
3✔
1182
                    fwrite($ifp, $imgBlob);
3✔
1183
                    fclose($ifp);
3✔
1184
                }
1185
            }
1186
        }
1187

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

1194
        return $newElement;
8✔
1195
    }
1196

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

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

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

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

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

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

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

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

1349
                    break;
3✔
1350
            }
1351
        }
1352
        $styles['font'] = self::parseInlineStyle($node, $styles['font']);
3✔
1353

1354
        if (empty($target)) {
3✔
1355
            $target = '#';
1✔
1356
        }
1357

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

1362
        return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']);
2✔
1363
    }
1364

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

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

1380
        $fontStyle = $styles + ['size' => 3];
1✔
1381

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

1392
        $element->addText('', $fontStyle, $paragraphStyle);
1✔
1393

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

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

1450
        return $element->addRuby($baseTextRun, $rubyTextRun, $rubyProperties);
1✔
1451
    }
1452

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

1459
        return trim($rgb, '# ');
13✔
1460
    }
1461

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

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

1483
        return (float) $size;
10✔
1484
    }
1485
}
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