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

MyIntervals / emogrifier / 40088165517

24 Oct 2026 11:00AM UTC coverage: 98.211% (-0.6%) from 98.825%
40088165517

Pull #1678

github

web-flow
Merge 07777634d into a3808e41c
Pull Request #1678: [BUGFIX] Clear declaration cache after inlineCss

40 of 46 new or added lines in 5 files covered. (86.96%)

933 of 950 relevant lines covered (98.21%)

245.78 hits per line

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

92.75
/src/HtmlProcessor/AbstractHtmlProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Pelago\Emogrifier\HtmlProcessor;
6

7
use Pelago\Emogrifier\Utilities\DeclarationBlockParser;
8
use Pelago\Emogrifier\Utilities\Preg;
9

10
/**
11
 * Base class for HTML processor that e.g., can remove, add or modify nodes or attributes.
12
 *
13
 * The "vanilla" subclass is the HtmlNormalizer.
14
 */
15
abstract class AbstractHtmlProcessor
16
{
17
    protected const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
18
    protected const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
19

20
    /**
21
     * Regular expression part to match tag names that PHP's DOMDocument implementation is not
22
     * aware are self-closing. These are mostly HTML5 elements, but for completeness `<command>` (obsolete) and
23
     * `<keygen>` (deprecated) are also included.
24
     *
25
     * @see https://bugs.php.net/bug.php?id=73175
26
     */
27
    protected const PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER = '(?:command|embed|keygen|source|track|wbr)';
28

29
    /**
30
     * Regular expression part to match tag names that may appear before the start of the `<body>` element.  A start tag
31
     * for any other element would implicitly start the `<body>` element due to tag omission rules.
32
     */
33
    protected const TAGNAME_ALLOWED_BEFORE_BODY_MATCHER
34
        = '(?:html|head|base|command|link|meta|noscript|script|style|template|title)';
35

36
    /**
37
     * regular expression pattern to match an HTML comment, including delimiters and modifiers
38
     */
39
    protected const HTML_COMMENT_PATTERN = '/<!--[^-]*+(?:-(?!->)[^-]*+)*+(?:-->|$)/';
40

41
    /**
42
     * regular expression pattern to match an HTML `<template>` element, including delimiters and modifiers
43
     */
44
    protected const HTML_TEMPLATE_ELEMENT_PATTERN
45
        = '%<template[\\s>][^<]*+(?:<(?!/template>)[^<]*+)*+(?:</template>|$)%i';
46

47
    /**
48
     * @var \DOMDocument
49
     */
50
    private $domDocument;
51

52
    /**
53
     * @var \DOMXPath
54
     */
55
    protected $xPath;
56

57
    /**
58
     * Parses CSS declaration blocks for this processor instance.
59
     *
60
     * @var ?DeclarationBlockParser
61
     */
62
    private $declarationBlockParser;
63

64
    /**
65
     * The constructor.
66
     *
67
     * Please use `::fromHtml` or `::fromDomDocument` instead.
68
     */
69
    final private function __construct() {}
70

71
    /**
72
     * Builds a new instance from the given HTML.
73
     *
74
     * @param non-empty-string $unprocessedHtml raw HTML, must be UTF-encoded
75
     *
76
     * @return static
77
     *
78
     * @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
79
     */
80
    public static function fromHtml(string $unprocessedHtml): self
612 ✔
81
    {
82
        // @phpstan-ignore identical.alwaysFalse (We're checking for a contract violation here.)
83
        if ($unprocessedHtml === '') {
612 ✔
84
            throw new \InvalidArgumentException('The provided HTML must not be empty.', 1515763647);
1 ✔
85
        }
86

87
        $instance = new static();
611 ✔
88
        $instance->setHtml($unprocessedHtml);
611 ✔
89
        \assert($instance->domDocument instanceof \DOMDocument);
611 ✔
90
        \assert($instance->xPath instanceof \DOMXPath);
611 ✔
91

92
        return $instance;
611 ✔
93
    }
94

95
    /**
96
     * Builds a new instance from the given DOM document.
97
     *
98
     * @param \DOMDocument $document a DOM document returned by getDomDocument() of another instance
99
     *
100
     * @return static
101
     */
102
    public static function fromDomDocument(\DOMDocument $document): self
4 ✔
103
    {
104
        $instance = new static();
4 ✔
105
        $instance->setDomDocument($document);
4 ✔
106
        \assert($instance->domDocument instanceof \DOMDocument);
4 ✔
107
        \assert($instance->xPath instanceof \DOMXPath);
4 ✔
108

109
        return $instance;
4 ✔
110
    }
111

112
    /**
113
     * Returns the parser for CSS declaration blocks, creating it on first use.
114
     */
NEW
115
    protected function getDeclarationBlockParser(): DeclarationBlockParser
×
116
    {
NEW
117
        if (!$this->declarationBlockParser instanceof DeclarationBlockParser) {
×
NEW
118
            $this->declarationBlockParser = new DeclarationBlockParser();
×
119
        }
120

NEW
121
        return $this->declarationBlockParser;
×
122
    }
123

124
    /**
125
     * Sets the HTML to process.
126
     *
127
     * @param string $html the HTML to process, must be UTF-8-encoded
128
     */
129
    private function setHtml(string $html): void
611 ✔
130
    {
131
        $this->createUnifiedDomDocument($html);
611 ✔
132
    }
133

134
    /**
135
     * Provides access to the internal DOMDocument representation of the HTML in its current state.
136
     */
137
    public function getDomDocument(): \DOMDocument
613 ✔
138
    {
139
        \assert($this->domDocument instanceof \DOMDocument);
613 ✔
140

141
        return $this->domDocument;
613 ✔
142
    }
143

144
    private function setDomDocument(\DOMDocument $domDocument): void
615 ✔
145
    {
146
        $this->domDocument = $domDocument;
615 ✔
147
        $this->xPath = new \DOMXPath($domDocument);
615 ✔
148
    }
149

150
    /**
151
     * Renders the normalized and processed HTML.
152
     *
153
     * @throws \RuntimeException if there is an internal error with `DOMDocument`
154
     */
155
    public function render(): string
212 ✔
156
    {
157
        return $this->getHtml();
212 ✔
158
    }
159

160
    /**
161
     * Renders the content of the BODY element of the normalized and processed HTML.
162
     *
163
     * @throws \RuntimeException if there is an internal error with `DOMDocument`
164
     */
165
    public function renderBodyContent(): string
12 ✔
166
    {
167
        $bodyNodeHtml = $this->getHtml($this->getBodyElement());
12 ✔
168

169
        return (new Preg())->replace('%</?+body(?:\\s[^>]*+)?+>%', '', $bodyNodeHtml);
12 ✔
170
    }
171

172
    /**
173
     * @param ?\DOMNode $node optional parameter to output a subset of the document
174
     *
175
     * @throws \RuntimeException if there is an internal error with `DOMDocument`
176
     */
177
    private function getHtml(?\DOMNode $node = null): string
224 ✔
178
    {
179
        $html = $this->getDomDocument()->saveHTML($node);
224 ✔
180

181
        if (!\is_string($html)) {
224 ✔
182
            throw new \RuntimeException('`DOMDocument::saveHTML()` failed.', 1773018082);
×
183
        }
184
        return $this->removeSelfClosingTagsClosingTags($html);
224 ✔
185
    }
186

187
    /**
188
     * Eliminates any invalid closing tags for void elements from the given HTML.
189
     */
190
    private function removeSelfClosingTagsClosingTags(string $html): string
224 ✔
191
    {
192
        return (new Preg())->replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
224 ✔
193
    }
194

195
    /**
196
     * Returns the HTML element.
197
     *
198
     * This method assumes that there always is an HTML element, throwing an exception otherwise.
199
     *
200
     * @throws \UnexpectedValueException
201
     */
202
    protected function getHtmlElement(): \DOMElement
414 ✔
203
    {
204
        $htmlElement = $this->getDomDocument()->getElementsByTagName('html')->item(0);
414 ✔
205
        if (!$htmlElement instanceof \DOMElement) {
414 ✔
206
            throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930853);
×
207
        }
208

209
        return $htmlElement;
414 ✔
210
    }
211

212
    /**
213
     * Returns the BODY element.
214
     *
215
     * This method assumes that there always is a BODY element.
216
     *
217
     * @throws \RuntimeException
218
     */
219
    private function getBodyElement(): \DOMElement
12 ✔
220
    {
221
        $node = $this->getDomDocument()->getElementsByTagName('body')->item(0);
12 ✔
222
        if (!$node instanceof \DOMElement) {
12 ✔
223
            throw new \RuntimeException('There is no body element.', 1617922607);
×
224
        }
225

226
        return $node;
12 ✔
227
    }
228

229
    /**
230
     * Creates a DOM document from the given HTML and stores it in `$this->domDocument`.
231
     *
232
     * The DOM document will always have a BODY element and a document type.
233
     */
234
    private function createUnifiedDomDocument(string $html): void
611 ✔
235
    {
236
        $this->createRawDomDocument($html);
611 ✔
237
        $this->ensureExistenceOfBodyElement();
611 ✔
238
    }
239

240
    /**
241
     * Creates a DOMDocument instance from the given HTML and stores it in `$this->domDocument`.
242
     */
243
    private function createRawDomDocument(string $html): void
611 ✔
244
    {
245
        $domDocument = new \DOMDocument();
611 ✔
246
        $domDocument->strictErrorChecking = false;
611 ✔
247
        $domDocument->formatOutput = false;
611 ✔
248
        $libXmlState = \libxml_use_internal_errors(true);
611 ✔
249
        $domDocument->loadHTML($this->prepareHtmlForDomConversion($html), LIBXML_PARSEHUGE);
611 ✔
250
        \libxml_clear_errors();
611 ✔
251
        \libxml_use_internal_errors($libXmlState);
611 ✔
252

253
        $this->setDomDocument($domDocument);
611 ✔
254
    }
255

256
    /**
257
     * Returns the HTML with added document type, Content-Type meta tag, and self-closing slashes, if needed,
258
     * ensuring that the HTML will be good for creating a DOM document from it.
259
     */
260
    private function prepareHtmlForDomConversion(string $html): string
611 ✔
261
    {
262
        $htmlWithSelfClosingSlashes = $this->ensurePhpUnrecognizedSelfClosingTagsAreXml($html);
611 ✔
263
        $htmlWithDocumentType = $this->ensureDocumentType($htmlWithSelfClosingSlashes);
611 ✔
264

265
        return $this->addContentTypeMetaTag($htmlWithDocumentType);
611 ✔
266
    }
267

268
    /**
269
     * Makes sure that the passed HTML has a document type, with lowercase "html".
270
     *
271
     * @return non-empty-string HTML with document type
272
     */
273
    private function ensureDocumentType(string $html): string
611 ✔
274
    {
275
        $hasDocumentType = \stripos($html, '<!DOCTYPE') !== false;
611 ✔
276
        if ($hasDocumentType) {
611 ✔
277
            return $this->normalizeDocumentType($html);
39 ✔
278
        }
279

280
        return self::DEFAULT_DOCUMENT_TYPE . $html;
572 ✔
281
    }
282

283
    /**
284
     * Makes sure the document type in the passed HTML has lowercase `html`.
285
     *
286
     * @param non-empty-string $html
287
     *
288
     * @return non-empty-string HTML with normalized document type
289
     */
290
    private function normalizeDocumentType(string $html): string
39 ✔
291
    {
292
        // Limit to replacing the first occurrence: as an optimization; and in case an example exists as unescaped text.
293
        $result = (new Preg())->replace(
39 ✔
294
            '/<!DOCTYPE\\s++html(?=[\\s>])/i',
39 ✔
295
            '<!DOCTYPE html',
39 ✔
296
            $html,
39 ✔
297
            1,
39 ✔
298
        );
39 ✔
299

300
        \assert($result !== '');
39 ✔
301

302
        return $result;
39 ✔
303
    }
304

305
    /**
306
     * Adds a Content-Type meta tag for the charset.
307
     *
308
     * This method also ensures that there is a HEAD element.
309
     *
310
     * @param non-empty-string $html
311
     *
312
     * @return non-empty-string
313
     */
314
    private function addContentTypeMetaTag(string $html): string
611 ✔
315
    {
316
        if ($this->hasContentTypeMetaTagInHead($html)) {
611 ✔
317
            return $html;
374 ✔
318
        }
319

320
        $preg = new Preg();
237 ✔
321

322
        // We are trying to insert the meta tag to the right spot in the DOM.
323
        // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
324
        $hasHeadTag = $preg->match('/<head[\\s>]/i', $html) !== 0;
237 ✔
325
        $hasHtmlTag = \stripos($html, '<html') !== false;
237 ✔
326

327
        if ($hasHeadTag) {
237 ✔
328
            $reworkedHtml = $preg->replace(
42 ✔
329
                '/<head(?=[\\s>])([^>]*+)>/i',
42 ✔
330
                '<head$1>' . self::CONTENT_TYPE_META_TAG,
42 ✔
331
                $html,
42 ✔
332
            );
42 ✔
333
        } elseif ($hasHtmlTag) {
195 ✔
334
            $reworkedHtml = $preg->replace(
83 ✔
335
                '/<html(.*?)>/is',
83 ✔
336
                '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
83 ✔
337
                $html,
83 ✔
338
            );
83 ✔
339
        } else {
340
            $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
112 ✔
341
        }
342

343
        \assert($reworkedHtml !== '');
237 ✔
344

345
        return $reworkedHtml;
237 ✔
346
    }
347

348
    /**
349
     * Tests whether the given HTML has a valid `Content-Type` metadata element within the `<head>` element.  Due to tag
350
     * omission rules, HTML parsers are expected to end the `<head>` element and start the `<body>` element upon
351
     * encountering a start tag for any element which is permitted only within the `<body>`.
352
     */
353
    private function hasContentTypeMetaTagInHead(string $html): bool
611 ✔
354
    {
355
        (new Preg())->match(
611 ✔
356
            '%
611 ✔
357
                (?(DEFINE)
358
                    # the target `http-equiv` attribute match
359
                    (?<target_attribute>
360
                        http-equiv=(["\']?+)Content-Type\\g{-1}
361
                        # must be followed by one of these characters
362
                        [\\s/>]
363
                    )
364
                    # the target `meta` element match without the opening `<`
365
                    (?<target>
366
                        meta(?=\\s)
367
                        # one or other of these
368
                        (?:
369
                            # one or more characters other than `>` or space
370
                            [^>\\s]++
371
                            |
372
                            # space not followed by the target `http-equiv` attribute
373
                            \\s(?!(?&target_attribute))
374
                        )
375
                        # any number of times (including zero)
376
                        *+
377
                        \\s(?&target_attribute)
378
                    )
379
                )
380
                # start of `subject`
381
                ^
382
                # one or other of these
383
                (?:
384
                    # one or more characters other than `<`
385
                    [^<]++
386
                    |
387
                    # `<` not followed by `target`
388
                    <(?!(?&target))
389
                )
390
                # any number of times (including zero)
391
                *+
392
                # followed by the target, not captured
393
                (?=<(?&target))
394
            %isx',
611 ✔
395
            $html,
611 ✔
396
            $matches,
611 ✔
397
        );
611 ✔
398
        if (isset($matches[0])) {
611 ✔
399
            $htmlBefore = $matches[0];
396 ✔
400
            try {
401
                $hasContentTypeMetaTagInHead = !$this->hasEndOfHeadElement($htmlBefore);
396 ✔
402
            } catch (\RuntimeException $exception) {
×
403
                // If something unexpected occurs, assume the `Content-Type` that was found is valid.
404
                \trigger_error($exception->getMessage());
×
405
                $hasContentTypeMetaTagInHead = true;
×
406
            }
407
        } else {
408
            $hasContentTypeMetaTagInHead = false;
215 ✔
409
        }
410

411
        return $hasContentTypeMetaTagInHead;
611 ✔
412
    }
413

414
    /**
415
     * Tests whether the `<head>` element ends within the given HTML.  Due to tag omission rules, HTML parsers are
416
     * expected to end the `<head>` element and start the `<body>` element upon encountering a start tag for any element
417
     * which is permitted only within the `<body>`.
418
     *
419
     * @throws \RuntimeException
420
     */
421
    private function hasEndOfHeadElement(string $html): bool
396 ✔
422
    {
423
        if (
424
            (new Preg())->match(
396 ✔
425
                '%<(?!' . self::TAGNAME_ALLOWED_BEFORE_BODY_MATCHER . '[\\s/>])\\w|</head>%i',
396 ✔
426
                $html
396 ✔
427
            ) !== 0
396 ✔
428
        ) {
429
            // An exception to the implicit end of the `<head>` is any content within a `<template>` element, as well in
430
            // comments.  As an optimization, this is only checked for if a potential `<head>` end tag is found.
431
            $htmlWithoutCommentsOrTemplates = $this->removeHtmlTemplateElements($this->removeHtmlComments($html));
70 ✔
432
            $hasEndOfHeadElement = $htmlWithoutCommentsOrTemplates === $html
70 ✔
433
                || $this->hasEndOfHeadElement($htmlWithoutCommentsOrTemplates);
70 ✔
434
        } else {
435
            $hasEndOfHeadElement = false;
374 ✔
436
        }
437

438
        return $hasEndOfHeadElement;
396 ✔
439
    }
440

441
    /**
442
     * Removes comments from the given HTML, including any which are unterminated, for which the remainder of the string
443
     * is removed.
444
     */
445
    private function removeHtmlComments(string $html): string
70 ✔
446
    {
447
        return (new Preg())->replace(self::HTML_COMMENT_PATTERN, '', $html);
70 ✔
448
    }
449

450
    /**
451
     * Removes `<template>` elements from the given HTML, including any without an end tag, for which the remainder of
452
     * the string is removed.
453
     */
454
    private function removeHtmlTemplateElements(string $html): string
70 ✔
455
    {
456
        return (new Preg())->replace(self::HTML_TEMPLATE_ELEMENT_PATTERN, '', $html);
70 ✔
457
    }
458

459
    /**
460
     * Makes sure that any self-closing tags not recognized as such by PHP's DOMDocument implementation have a
461
     * self-closing slash.
462
     */
463
    private function ensurePhpUnrecognizedSelfClosingTagsAreXml(string $html): string
611 ✔
464
    {
465
        return (new Preg())->replace(
611 ✔
466
            '%<' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '\\b[^>]*+(?<!/)(?=>)%',
611 ✔
467
            '$0/',
611 ✔
468
            $html,
611 ✔
469
        );
611 ✔
470
    }
471

472
    /**
473
     * Checks that `$this->domDocument` has a BODY element and adds it if it is missing.
474
     */
475
    private function ensureExistenceOfBodyElement(): void
611 ✔
476
    {
477
        if ($this->getDomDocument()->getElementsByTagName('body')->item(0) instanceof \DOMElement) {
611 ✔
478
            return;
198 ✔
479
        }
480

481
        $this->getHtmlElement()->appendChild($this->getDomDocument()->createElement('body'));
413 ✔
482
    }
483
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc