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

MyIntervals / emogrifier / 39525833918

21 Oct 2026 01:11AM UTC coverage: 98.829% (+0.004%) from 98.825%
39525833918

Pull #1678

github

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

33 of 33 new or added lines in 4 files covered. (100.0%)

6 existing lines in 1 file now uncovered.

928 of 939 relevant lines covered (98.83%)

246.77 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace Pelago\Emogrifier\HtmlProcessor;
6

7
use Pelago\Emogrifier\Utilities\Preg;
8

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

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

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

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

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

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

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

56
    /**
57
     * The constructor.
58
     *
59
     * Please use `::fromHtml` or `::fromDomDocument` instead.
60
     */
61
    final private function __construct() {}
62

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

79
        $instance = new static();
611 ✔
80
        $instance->setHtml($unprocessedHtml);
611 ✔
81
        \assert($instance->domDocument instanceof \DOMDocument);
611 ✔
82
        \assert($instance->xPath instanceof \DOMXPath);
611 ✔
83

84
        return $instance;
611 ✔
85
    }
86

87
    /**
88
     * Builds a new instance from the given DOM document.
89
     *
90
     * @param \DOMDocument $document a DOM document returned by getDomDocument() of another instance
91
     *
92
     * @return static
93
     */
94
    public static function fromDomDocument(\DOMDocument $document): self
4 ✔
95
    {
96
        $instance = new static();
4 ✔
97
        $instance->setDomDocument($document);
4 ✔
98
        \assert($instance->domDocument instanceof \DOMDocument);
4 ✔
99
        \assert($instance->xPath instanceof \DOMXPath);
4 ✔
100

101
        return $instance;
4 ✔
102
    }
103

104
    /**
105
     * Sets the HTML to process.
106
     *
107
     * @param string $html the HTML to process, must be UTF-8-encoded
108
     */
109
    private function setHtml(string $html): void
611 ✔
110
    {
111
        $this->createUnifiedDomDocument($html);
611 ✔
112
    }
113

114
    /**
115
     * Provides access to the internal DOMDocument representation of the HTML in its current state.
116
     */
117
    public function getDomDocument(): \DOMDocument
613 ✔
118
    {
119
        \assert($this->domDocument instanceof \DOMDocument);
613 ✔
120

121
        return $this->domDocument;
613 ✔
122
    }
123

124
    private function setDomDocument(\DOMDocument $domDocument): void
615 ✔
125
    {
126
        $this->domDocument = $domDocument;
615 ✔
127
        $this->xPath = new \DOMXPath($domDocument);
615 ✔
128
    }
129

130
    /**
131
     * Renders the normalized and processed HTML.
132
     *
133
     * @throws \RuntimeException if there is an internal error with `DOMDocument`
134
     */
135
    public function render(): string
212 ✔
136
    {
137
        return $this->getHtml();
212 ✔
138
    }
139

140
    /**
141
     * Renders the content of the BODY element of the normalized and processed HTML.
142
     *
143
     * @throws \RuntimeException if there is an internal error with `DOMDocument`
144
     */
145
    public function renderBodyContent(): string
12 ✔
146
    {
147
        $bodyNodeHtml = $this->getHtml($this->getBodyElement());
12 ✔
148

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

152
    /**
153
     * @param ?\DOMNode $node optional parameter to output a subset of the document
154
     *
155
     * @throws \RuntimeException if there is an internal error with `DOMDocument`
156
     */
157
    private function getHtml(?\DOMNode $node = null): string
224 ✔
158
    {
159
        $html = $this->getDomDocument()->saveHTML($node);
224 ✔
160

161
        if (!\is_string($html)) {
224 ✔
UNCOV
162
            throw new \RuntimeException('`DOMDocument::saveHTML()` failed.', 1773018082);
×
163
        }
164
        return $this->removeSelfClosingTagsClosingTags($html);
224 ✔
165
    }
166

167
    /**
168
     * Eliminates any invalid closing tags for void elements from the given HTML.
169
     */
170
    private function removeSelfClosingTagsClosingTags(string $html): string
224 ✔
171
    {
172
        return (new Preg())->replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
224 ✔
173
    }
174

175
    /**
176
     * Returns the HTML element.
177
     *
178
     * This method assumes that there always is an HTML element, throwing an exception otherwise.
179
     *
180
     * @throws \UnexpectedValueException
181
     */
182
    protected function getHtmlElement(): \DOMElement
414 ✔
183
    {
184
        $htmlElement = $this->getDomDocument()->getElementsByTagName('html')->item(0);
414 ✔
185
        if (!$htmlElement instanceof \DOMElement) {
414 ✔
UNCOV
186
            throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930853);
×
187
        }
188

189
        return $htmlElement;
414 ✔
190
    }
191

192
    /**
193
     * Returns the BODY element.
194
     *
195
     * This method assumes that there always is a BODY element.
196
     *
197
     * @throws \RuntimeException
198
     */
199
    private function getBodyElement(): \DOMElement
12 ✔
200
    {
201
        $node = $this->getDomDocument()->getElementsByTagName('body')->item(0);
12 ✔
202
        if (!$node instanceof \DOMElement) {
12 ✔
UNCOV
203
            throw new \RuntimeException('There is no body element.', 1617922607);
×
204
        }
205

206
        return $node;
12 ✔
207
    }
208

209
    /**
210
     * Creates a DOM document from the given HTML and stores it in `$this->domDocument`.
211
     *
212
     * The DOM document will always have a BODY element and a document type.
213
     */
214
    private function createUnifiedDomDocument(string $html): void
611 ✔
215
    {
216
        $this->createRawDomDocument($html);
611 ✔
217
        $this->ensureExistenceOfBodyElement();
611 ✔
218
    }
219

220
    /**
221
     * Creates a DOMDocument instance from the given HTML and stores it in `$this->domDocument`.
222
     */
223
    private function createRawDomDocument(string $html): void
611 ✔
224
    {
225
        $domDocument = new \DOMDocument();
611 ✔
226
        $domDocument->strictErrorChecking = false;
611 ✔
227
        $domDocument->formatOutput = false;
611 ✔
228
        $libXmlState = \libxml_use_internal_errors(true);
611 ✔
229
        $domDocument->loadHTML($this->prepareHtmlForDomConversion($html), LIBXML_PARSEHUGE);
611 ✔
230
        \libxml_clear_errors();
611 ✔
231
        \libxml_use_internal_errors($libXmlState);
611 ✔
232

233
        $this->setDomDocument($domDocument);
611 ✔
234
    }
235

236
    /**
237
     * Returns the HTML with added document type, Content-Type meta tag, and self-closing slashes, if needed,
238
     * ensuring that the HTML will be good for creating a DOM document from it.
239
     */
240
    private function prepareHtmlForDomConversion(string $html): string
611 ✔
241
    {
242
        $htmlWithSelfClosingSlashes = $this->ensurePhpUnrecognizedSelfClosingTagsAreXml($html);
611 ✔
243
        $htmlWithDocumentType = $this->ensureDocumentType($htmlWithSelfClosingSlashes);
611 ✔
244

245
        return $this->addContentTypeMetaTag($htmlWithDocumentType);
611 ✔
246
    }
247

248
    /**
249
     * Makes sure that the passed HTML has a document type, with lowercase "html".
250
     *
251
     * @return non-empty-string HTML with document type
252
     */
253
    private function ensureDocumentType(string $html): string
611 ✔
254
    {
255
        $hasDocumentType = \stripos($html, '<!DOCTYPE') !== false;
611 ✔
256
        if ($hasDocumentType) {
611 ✔
257
            return $this->normalizeDocumentType($html);
39 ✔
258
        }
259

260
        return self::DEFAULT_DOCUMENT_TYPE . $html;
572 ✔
261
    }
262

263
    /**
264
     * Makes sure the document type in the passed HTML has lowercase `html`.
265
     *
266
     * @param non-empty-string $html
267
     *
268
     * @return non-empty-string HTML with normalized document type
269
     */
270
    private function normalizeDocumentType(string $html): string
39 ✔
271
    {
272
        // Limit to replacing the first occurrence: as an optimization; and in case an example exists as unescaped text.
273
        $result = (new Preg())->replace(
39 ✔
274
            '/<!DOCTYPE\\s++html(?=[\\s>])/i',
39 ✔
275
            '<!DOCTYPE html',
39 ✔
276
            $html,
39 ✔
277
            1,
39 ✔
278
        );
39 ✔
279

280
        \assert($result !== '');
39 ✔
281

282
        return $result;
39 ✔
283
    }
284

285
    /**
286
     * Adds a Content-Type meta tag for the charset.
287
     *
288
     * This method also ensures that there is a HEAD element.
289
     *
290
     * @param non-empty-string $html
291
     *
292
     * @return non-empty-string
293
     */
294
    private function addContentTypeMetaTag(string $html): string
611 ✔
295
    {
296
        if ($this->hasContentTypeMetaTagInHead($html)) {
611 ✔
297
            return $html;
374 ✔
298
        }
299

300
        $preg = new Preg();
237 ✔
301

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

307
        if ($hasHeadTag) {
237 ✔
308
            $reworkedHtml = $preg->replace(
42 ✔
309
                '/<head(?=[\\s>])([^>]*+)>/i',
42 ✔
310
                '<head$1>' . self::CONTENT_TYPE_META_TAG,
42 ✔
311
                $html,
42 ✔
312
            );
42 ✔
313
        } elseif ($hasHtmlTag) {
195 ✔
314
            $reworkedHtml = $preg->replace(
83 ✔
315
                '/<html(.*?)>/is',
83 ✔
316
                '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
83 ✔
317
                $html,
83 ✔
318
            );
83 ✔
319
        } else {
320
            $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
112 ✔
321
        }
322

323
        \assert($reworkedHtml !== '');
237 ✔
324

325
        return $reworkedHtml;
237 ✔
326
    }
327

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

391
        return $hasContentTypeMetaTagInHead;
611 ✔
392
    }
393

394
    /**
395
     * Tests whether the `<head>` element ends within the given HTML.  Due to tag omission rules, HTML parsers are
396
     * expected to end the `<head>` element and start the `<body>` element upon encountering a start tag for any element
397
     * which is permitted only within the `<body>`.
398
     *
399
     * @throws \RuntimeException
400
     */
401
    private function hasEndOfHeadElement(string $html): bool
396 ✔
402
    {
403
        if (
404
            (new Preg())->match(
396 ✔
405
                '%<(?!' . self::TAGNAME_ALLOWED_BEFORE_BODY_MATCHER . '[\\s/>])\\w|</head>%i',
396 ✔
406
                $html
396 ✔
407
            ) !== 0
396 ✔
408
        ) {
409
            // An exception to the implicit end of the `<head>` is any content within a `<template>` element, as well in
410
            // comments.  As an optimization, this is only checked for if a potential `<head>` end tag is found.
411
            $htmlWithoutCommentsOrTemplates = $this->removeHtmlTemplateElements($this->removeHtmlComments($html));
70 ✔
412
            $hasEndOfHeadElement = $htmlWithoutCommentsOrTemplates === $html
70 ✔
413
                || $this->hasEndOfHeadElement($htmlWithoutCommentsOrTemplates);
70 ✔
414
        } else {
415
            $hasEndOfHeadElement = false;
374 ✔
416
        }
417

418
        return $hasEndOfHeadElement;
396 ✔
419
    }
420

421
    /**
422
     * Removes comments from the given HTML, including any which are unterminated, for which the remainder of the string
423
     * is removed.
424
     */
425
    private function removeHtmlComments(string $html): string
70 ✔
426
    {
427
        return (new Preg())->replace(self::HTML_COMMENT_PATTERN, '', $html);
70 ✔
428
    }
429

430
    /**
431
     * Removes `<template>` elements from the given HTML, including any without an end tag, for which the remainder of
432
     * the string is removed.
433
     */
434
    private function removeHtmlTemplateElements(string $html): string
70 ✔
435
    {
436
        return (new Preg())->replace(self::HTML_TEMPLATE_ELEMENT_PATTERN, '', $html);
70 ✔
437
    }
438

439
    /**
440
     * Makes sure that any self-closing tags not recognized as such by PHP's DOMDocument implementation have a
441
     * self-closing slash.
442
     */
443
    private function ensurePhpUnrecognizedSelfClosingTagsAreXml(string $html): string
611 ✔
444
    {
445
        return (new Preg())->replace(
611 ✔
446
            '%<' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '\\b[^>]*+(?<!/)(?=>)%',
611 ✔
447
            '$0/',
611 ✔
448
            $html,
611 ✔
449
        );
611 ✔
450
    }
451

452
    /**
453
     * Checks that `$this->domDocument` has a BODY element and adds it if it is missing.
454
     */
455
    private function ensureExistenceOfBodyElement(): void
611 ✔
456
    {
457
        if ($this->getDomDocument()->getElementsByTagName('body')->item(0) instanceof \DOMElement) {
611 ✔
458
            return;
198 ✔
459
        }
460

461
        $this->getHtmlElement()->appendChild($this->getDomDocument()->createElement('body'));
413 ✔
462
    }
463
}
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