PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / 11.0.2
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress v11.0.2
11.1.1 11.1.0 11.0.5 11.0.4 11.0.3 11.0.2 11.0.1 11.0.0 10.11.1 10.11.0 10.10.2 10.10.1 10.10.0 10.9.1 trunk 10.0.0 10.0.1 10.1.0 10.1.1 10.1.2 10.1.3 10.1.4 10.2.0 10.2.1 10.2.2 All 62 releases
acymailing / back / Libraries / Pelago / Emogrifier / src / HtmlProcessor / AbstractHtmlProcessor.php

AbstractHtmlProcessor.php in AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress 11.0.2, at back/Libraries/Pelago/Emogrifier/src/HtmlProcessor/AbstractHtmlProcessor.php

436 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace AcyMailing\Libraries\Pelago\Emogrifier\HtmlProcessor;
6
7 use AcyMailing\Libraries\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 /**
17 * @var non-empty-string
18 */
19 protected const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
20
21 /**
22 * @var non-empty-string
23 */
24 protected const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
25
26 /**
27 * @var non-empty-string Regular expression part to match tag names that PHP's DOMDocument implementation is not
28 * aware are self-closing. These are mostly HTML5 elements, but for completeness `<command>` (obsolete) and
29 * `<keygen>` (deprecated) are also included.
30 *
31 * @see https://bugs.php.net/bug.php?id=73175
32 */
33 protected const PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER = '(?:command|embed|keygen|source|track|wbr)';
34
35 /**
36 * Regular expression part to match tag names that may appear before the start of the `<body>` element. A start tag
37 * for any other element would implicitly start the `<body>` element due to tag omission rules.
38 *
39 * @var non-empty-string
40 */
41 protected const TAGNAME_ALLOWED_BEFORE_BODY_MATCHER
42 = '(?:html|head|base|command|link|meta|noscript|script|style|template|title)';
43
44 /**
45 * regular expression pattern to match an HTML comment, including delimiters and modifiers
46 *
47 * @var non-empty-string
48 */
49 protected const HTML_COMMENT_PATTERN = '/<!--[^-]*+(?:-(?!->)[^-]*+)*+(?:-->|$)/';
50
51 /**
52 * regular expression pattern to match an HTML `<template>` element, including delimiters and modifiers
53 *
54 * @var non-empty-string
55 */
56 protected const HTML_TEMPLATE_ELEMENT_PATTERN
57 = '%<template[\\s>][^<]*+(?:<(?!/template>)[^<]*+)*+(?:</template>|$)%i';
58
59 /**
60 * @var \DOMDocument|null
61 */
62 protected $domDocument = null;
63
64 /**
65 * @var \DOMXPath|null
66 */
67 private $xPath = null;
68
69 /**
70 * The constructor.
71 *
72 * Please use `::fromHtml` or `::fromDomDocument` instead.
73 */
74 private function __construct() {}
75
76 /**
77 * Builds a new instance from the given HTML.
78 *
79 * @param non-empty-string $unprocessedHtml raw HTML, must be UTF-encoded
80 *
81 * @return static
82 *
83 * @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
84 */
85 public static function fromHtml(string $unprocessedHtml): self
86 {
87 // @phpstan-ignore-next-line argument.type We're checking for a contract violation here.
88 if ($unprocessedHtml === '') {
89 throw new \InvalidArgumentException('The provided HTML must not be empty.', 1515763647);
90 }
91
92 $instance = new static();
93 $instance->setHtml($unprocessedHtml);
94
95 return $instance;
96 }
97
98 /**
99 * Builds a new instance from the given DOM document.
100 *
101 * @param \DOMDocument $document a DOM document returned by getDomDocument() of another instance
102 *
103 * @return static
104 */
105 public static function fromDomDocument(\DOMDocument $document): self
106 {
107 $instance = new static();
108 $instance->setDomDocument($document);
109
110 return $instance;
111 }
112
113 /**
114 * Sets the HTML to process.
115 *
116 * @param string $html the HTML to process, must be UTF-8-encoded
117 */
118 private function setHtml(string $html): void
119 {
120 $this->createUnifiedDomDocument($html);
121 }
122
123 /**
124 * Provides access to the internal DOMDocument representation of the HTML in its current state.
125 *
126 * @throws \UnexpectedValueException
127 */
128 public function getDomDocument(): \DOMDocument
129 {
130 if (!$this->domDocument instanceof \DOMDocument) {
131 $message = self::class . '::setDomDocument() has not yet been called on ' . static::class;
132 throw new \UnexpectedValueException($message, 1570472239);
133 }
134
135 return $this->domDocument;
136 }
137
138 private function setDomDocument(\DOMDocument $domDocument): void
139 {
140 $this->domDocument = $domDocument;
141 $this->xPath = new \DOMXPath($this->domDocument);
142 }
143
144 /**
145 * @throws \UnexpectedValueException
146 */
147 protected function getXPath(): \DOMXPath
148 {
149 if (!$this->xPath instanceof \DOMXPath) {
150 $message = self::class . '::setDomDocument() has not yet been called on ' . static::class;
151 throw new \UnexpectedValueException($message, 1617819086);
152 }
153
154 return $this->xPath;
155 }
156
157 /**
158 * Renders the normalized and processed HTML.
159 */
160 public function render(): string
161 {
162 $htmlWithPossibleErroneousClosingTags = $this->getDomDocument()->saveHTML();
163
164 return $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
165 }
166
167 /**
168 * Renders the content of the BODY element of the normalized and processed HTML.
169 */
170 public function renderBodyContent(): string
171 {
172 $htmlWithPossibleErroneousClosingTags = $this->getDomDocument()->saveHTML($this->getBodyElement());
173
174 return $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
175 }
176
177 /**
178 * Eliminates any invalid closing tags for void elements from the given HTML.
179 */
180 private function removeSelfClosingTagsClosingTags(string $html): string
181 {
182 return (new Preg())->replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
183 }
184
185 /**
186 * Returns the HTML element.
187 *
188 * This method assumes that there always is an HTML element, throwing an exception otherwise.
189 *
190 * @throws \UnexpectedValueException
191 */
192 protected function getHtmlElement(): \DOMElement
193 {
194 $htmlElement = $this->getDomDocument()->getElementsByTagName('html')->item(0);
195 if (!$htmlElement instanceof \DOMElement) {
196 throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930853);
197 }
198
199 return $htmlElement;
200 }
201
202 /**
203 * Returns the BODY element.
204 *
205 * This method assumes that there always is a BODY element.
206 *
207 * @throws \RuntimeException
208 */
209 private function getBodyElement(): \DOMElement
210 {
211 $node = $this->getDomDocument()->getElementsByTagName('body')->item(0);
212 if (!$node instanceof \DOMElement) {
213 throw new \RuntimeException('There is no body element.', 1617922607);
214 }
215
216 return $node;
217 }
218
219 /**
220 * Creates a DOM document from the given HTML and stores it in $this->domDocument.
221 *
222 * The DOM document will always have a BODY element and a document type.
223 */
224 private function createUnifiedDomDocument(string $html): void
225 {
226 $this->createRawDomDocument($html);
227 $this->ensureExistenceOfBodyElement();
228 }
229
230 /**
231 * Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
232 */
233 private function createRawDomDocument(string $html): void
234 {
235 $domDocument = new \DOMDocument();
236 $domDocument->strictErrorChecking = false;
237 $domDocument->formatOutput = false;
238 $libXmlState = \libxml_use_internal_errors(true);
239 $domDocument->loadHTML($this->prepareHtmlForDomConversion($html));
240 \libxml_clear_errors();
241 \libxml_use_internal_errors($libXmlState);
242
243 $this->setDomDocument($domDocument);
244 }
245
246 /**
247 * Returns the HTML with added document type, Content-Type meta tag, and self-closing slashes, if needed,
248 * ensuring that the HTML will be good for creating a DOM document from it.
249 */
250 private function prepareHtmlForDomConversion(string $html): string
251 {
252 $htmlWithSelfClosingSlashes = $this->ensurePhpUnrecognizedSelfClosingTagsAreXml($html);
253 $htmlWithDocumentType = $this->ensureDocumentType($htmlWithSelfClosingSlashes);
254
255 return $this->addContentTypeMetaTag($htmlWithDocumentType);
256 }
257
258 /**
259 * Makes sure that the passed HTML has a document type, with lowercase "html".
260 *
261 * @return non-empty-string HTML with document type
262 */
263 private function ensureDocumentType(string $html): string
264 {
265 $hasDocumentType = \stripos($html, '<!DOCTYPE') !== false;
266 if ($hasDocumentType) {
267 return $this->normalizeDocumentType($html);
268 }
269
270 return self::DEFAULT_DOCUMENT_TYPE . $html;
271 }
272
273 /**
274 * Makes sure the document type in the passed HTML has lowercase `html`.
275 *
276 * @param non-empty-string $html
277 *
278 * @return non-empty-string HTML with normalized document type
279 */
280 private function normalizeDocumentType(string $html): string
281 {
282 // Limit to replacing the first occurrence: as an optimization; and in case an example exists as unescaped text.
283 $result = (new Preg())->replace(
284 '/<!DOCTYPE\\s++html(?=[\\s>])/i',
285 '<!DOCTYPE html',
286 $html,
287 1
288 );
289 \assert($result !== '');
290
291 return $result;
292 }
293
294 /**
295 * Adds a Content-Type meta tag for the charset.
296 *
297 * This method also ensures that there is a HEAD element.
298 *
299 * @param non-empty-string $html
300 *
301 * @return non-empty-string
302 */
303 private function addContentTypeMetaTag(string $html): string
304 {
305 if ($this->hasContentTypeMetaTagInHead($html)) {
306 return $html;
307 }
308
309 // We are trying to insert the meta tag to the right spot in the DOM.
310 // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
311 $hasHeadTag = (new Preg())->match('/<head[\\s>]/i', $html) !== 0;
312 $hasHtmlTag = \stripos($html, '<html') !== false;
313
314 if ($hasHeadTag) {
315 $reworkedHtml = (new Preg())->replace(
316 '/<head(?=[\\s>])([^>]*+)>/i',
317 '<head$1>' . self::CONTENT_TYPE_META_TAG,
318 $html
319 );
320 } elseif ($hasHtmlTag) {
321 $reworkedHtml = (new Preg())->replace(
322 '/<html(.*?)>/is',
323 '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
324 $html
325 );
326 } else {
327 $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
328 }
329 \assert($reworkedHtml !== '');
330
331 return $reworkedHtml;
332 }
333
334 /**
335 * Tests whether the given HTML has a valid `Content-Type` metadata element within the `<head>` element. Due to tag
336 * omission rules, HTML parsers are expected to end the `<head>` element and start the `<body>` element upon
337 * encountering a start tag for any element which is permitted only within the `<body>`.
338 */
339 private function hasContentTypeMetaTagInHead(string $html): bool
340 {
341 (new Preg())->match(
342 '%^.*?(?=<meta(?=\\s)[^>]*\\shttp-equiv=(["\']?+)Content-Type\\g{-1}[\\s/>])%is',
343 $html,
344 $matches
345 );
346 if (isset($matches[0])) {
347 $htmlBefore = $matches[0];
348 try {
349 $hasContentTypeMetaTagInHead = !$this->hasEndOfHeadElement($htmlBefore);
350 } catch (\RuntimeException $exception) {
351 // If something unexpected occurs, assume the `Content-Type` that was found is valid.
352 \trigger_error($exception->getMessage());
353 $hasContentTypeMetaTagInHead = true;
354 }
355 } else {
356 $hasContentTypeMetaTagInHead = false;
357 }
358
359 return $hasContentTypeMetaTagInHead;
360 }
361
362 /**
363 * Tests whether the `<head>` element ends within the given HTML. Due to tag omission rules, HTML parsers are
364 * expected to end the `<head>` element and start the `<body>` element upon encountering a start tag for any element
365 * which is permitted only within the `<body>`.
366 *
367 * @throws \RuntimeException
368 */
369 private function hasEndOfHeadElement(string $html): bool
370 {
371 if (
372 (new Preg())->match('%<(?!' . self::TAGNAME_ALLOWED_BEFORE_BODY_MATCHER . '[\\s/>])\\w|</head>%i', $html)
373 !== 0
374 ) {
375 // An exception to the implicit end of the `<head>` is any content within a `<template>` element, as well in
376 // comments. As an optimization, this is only checked for if a potential `<head>` end tag is found.
377 $htmlWithoutCommentsOrTemplates = $this->removeHtmlTemplateElements($this->removeHtmlComments($html));
378 $hasEndOfHeadElement = $htmlWithoutCommentsOrTemplates === $html
379 || $this->hasEndOfHeadElement($htmlWithoutCommentsOrTemplates);
380 } else {
381 $hasEndOfHeadElement = false;
382 }
383
384 return $hasEndOfHeadElement;
385 }
386
387 /**
388 * Removes comments from the given HTML, including any which are unterminated, for which the remainder of the string
389 * is removed.
390 *
391 * @throws \RuntimeException
392 */
393 private function removeHtmlComments(string $html): string
394 {
395 return (new Preg())->throwExceptions(true)->replace(self::HTML_COMMENT_PATTERN, '', $html);
396 }
397
398 /**
399 * Removes `<template>` elements from the given HTML, including any without an end tag, for which the remainder of
400 * the string is removed.
401 *
402 * @throws \RuntimeException
403 */
404 private function removeHtmlTemplateElements(string $html): string
405 {
406 return (new Preg())->throwExceptions(true)->replace(self::HTML_TEMPLATE_ELEMENT_PATTERN, '', $html);
407 }
408
409 /**
410 * Makes sure that any self-closing tags not recognized as such by PHP's DOMDocument implementation have a
411 * self-closing slash.
412 */
413 private function ensurePhpUnrecognizedSelfClosingTagsAreXml(string $html): string
414 {
415 return (new Preg())->replace(
416 '%<' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '\\b[^>]*+(?<!/)(?=>)%',
417 '$0/',
418 $html
419 );
420 }
421
422 /**
423 * Checks that $this->domDocument has a BODY element and adds it if it is missing.
424 *
425 * @throws \UnexpectedValueException
426 */
427 private function ensureExistenceOfBodyElement(): void
428 {
429 if ($this->getDomDocument()->getElementsByTagName('body')->item(0) instanceof \DOMElement) {
430 return;
431 }
432
433 $this->getHtmlElement()->appendChild($this->getDomDocument()->createElement('body'));
434 }
435 }
436