PluginProbe
weForms – Easy Drag & Drop Contact Form Builder For WordPress / 1.0.0
weForms – Easy Drag & Drop Contact Form Builder For WordPress v1.0.0
1.6.7 1.6.8 1.6.9 1.6.12 1.6.13 1.6.14 1.6.15 1.6.16 1.6.17 1.6.18 1.6.19 1.6.2 1.6.20 1.6.21 1.6.22 1.6.23 1.6.24 1.6.25 1.6.26 1.6.27 1.6.28 1.6.3 1.6.4 1.6.5 1.6.6 All 74 releases
← All changes | includes/library/Emogrifier.php +574 -884 1.6.81.0.0 View file →
@@ -4,16 +4,15 @@
4 4 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
5 5 *
6 6 * For more information, please see the README.md file.
7 7 *
8 - * @deprecated Will be removed for version 4.0.0. Please use the CssInliner class instead.
8 + * @version 1.2.0
9 9 *
10 10 * @author Cameron Brooks
11 11 * @author Jaime Prado
12 - * @author Oliver Klee <github@oliverklee.de>
12 + * @author Oliver Klee <typo3-coding@oliverklee.de>
13 13 * @author Roman Ožana <ozana@omdesign.cz>
14 14 * @author Sander Kruger <s.kruger@invessel.com>
15 - * @author Zoli Szabó <zoli.szabo+github@gmail.com>
16 15 */
17 16 class Emogrifier
18 17 {
19 18 /**
@@ -65,20 +64,10 @@
65 64 */
66 65 const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
67 66
68 67 /**
69 - * Regular expression component matching a static pseudo class in a selector, without the preceding ":",
70 - * for which the applicable elements can be determined (by converting the selector to an XPath expression).
71 - * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
72 - * group, as appropriate for the usage context.)
73 - *
74 68 * @var string
75 69 */
76 - const PSEUDO_CLASS_MATCHER = '(?:first|last|nth)-child|nth-of-type|not\\([[:ascii:]]*\\)';
77 -
78 - /**
79 - * @var string
80 - */
81 70 const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
82 71
83 72 /**
84 73 * @var string
@@ -85,27 +74,13 @@
85 74 */
86 75 const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
87 76
88 77 /**
89 - * @var string Regular expression part to match tag names that PHP's DOMDocument implementation is not aware are
90 - * self-closing. These are mostly HTML5 elements, but for completeness <command> (obsolete) and <keygen>
91 - * (deprecated) are also included.
92 - *
93 - * @see https://bugs.php.net/bug.php?id=73175
78 + * @var string
94 79 */
95 - const PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER = '(?:command|embed|keygen|source|track|wbr)';
80 + private $html = '';
96 81
97 82 /**
98 - * @var \DOMDocument
99 - */
100 - protected $domDocument = null;
101 -
102 - /**
103 - * @var \DOMXPath
104 - */
105 - protected $xPath = null;
106 -
107 - /**
108 83 * @var string
109 84 */
110 85 private $css = '';
111 86
@@ -170,41 +145,48 @@
170 145 */
171 146 private $isStyleBlocksParsingEnabled = true;
172 147
173 148 /**
174 - * For calculating selector precedence order.
175 - * Keys are a regular expression part to match before a CSS name.
176 - * Values are a multiplier factor per match to weight specificity.
149 + * Determines whether elements with the `display: none` property are
150 + * removed from the DOM.
177 151 *
178 - * @var int[]
152 + * @var bool
179 153 */
180 - private $selectorPrecedenceMatchers = [
181 - // IDs: worth 10000
182 - '\\#' => 10000,
183 - // classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
184 - '(?:\\.|\\[|(?<!:):(?!not\\())' => 100,
185 - // elements (not attribute values or `:not`), pseudo-elements: worth 1
186 - '(?:(?<![="\':\\w\\-])|::)' => 1,
187 - ];
154 + private $shouldKeepInvisibleNodes = true;
188 155
189 156 /**
190 157 * @var string[]
191 158 */
192 159 private $xPathRules = [
193 - // attribute presence
194 - '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/' => '*[@\\1]',
195 - // type and attribute exact value
160 + // child
161 + '/\\s*>\\s*/' => '/',
162 + // adjacent sibling
163 + '/\\s+\\+\\s+/' => '/following-sibling::*[1]/self::',
164 + // descendant
165 + '/\\s+(?=.*[^\\]]{1}$)/' => '//',
166 + // :first-child
167 + '/([^\\/]+):first-child/i' => '*[1]/self::\\1',
168 + // :last-child
169 + '/([^\\/]+):last-child/i' => '*[last()]/self::\\1',
170 + // attribute only
171 + '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/' => '*[@\\1]',
172 + // attribute
173 + '/(\\w)\\[(\\w+)\\]/' => '\\1[@\\2]',
174 + // exact attribute
196 175 '/(\\w)\\[(\\w+)\\=[\'"]?([\\w\\s]+)[\'"]?\\]/' => '\\1[@\\2="\\3"]',
197 176 // element attribute~=
198 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w\-_\\/]+)[\'"]?\\]/' => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
177 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/'
178 + => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
199 179 // element attribute^=
200 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w\-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
180 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
201 181 // element attribute*=
202 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w\-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
182 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
203 183 // element attribute$=
204 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w\-_\\s\\/]+)[\'"]?\\]/' => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
184 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/'
185 + => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
205 186 // element attribute|=
206 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w\-_\\s\\/]+)[\'"]?\\]/' => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
187 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/'
188 + => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
207 189 ];
208 190
209 191 /**
210 192 * Determines whether CSS styles that have an equivalent HTML attribute
@@ -227,46 +209,20 @@
227 209 'attribute' => 'bgcolor',
228 210 ],
229 211 'text-align' => [
230 212 'attribute' => 'align',
231 - 'nodes' => ['p', 'div', 'td'],
232 - 'values' => ['left', 'right', 'center', 'justify'],
213 + 'nodes' => ['p', 'div', 'td'],
214 + 'values' => ['left', 'right', 'center', 'justify'],
233 215 ],
234 216 'float' => [
235 217 'attribute' => 'align',
236 - 'nodes' => ['table', 'img'],
237 - 'values' => ['left', 'right'],
218 + 'nodes' => ['table', 'img'],
219 + 'values' => ['left', 'right'],
238 220 ],
239 221 'border-spacing' => [
240 222 'attribute' => 'cellspacing',
241 - 'nodes' => ['table'],
223 + 'nodes' => ['table'],
242 224 ],
243 - // type and attribute value with ~ (one word within a whitespace-separated list of words)
244 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w\\-\_\\/]+)[\'"]?\\]/'
245 - => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
246 - // type and attribute value with | (either exact value match or prefix followed by a hyphen)
247 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w\\-\_\\s\\/]+)[\'"]?\\]/'
248 - => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
249 - // type and attribute value with ^ (prefix match)
250 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w\\-\_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
251 - // type and attribute value with * (substring match)
252 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w\\-\_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
253 - // adjacent sibling
254 - '/\\s*\\+\\s*/' => '/following-sibling::*[1]/self::',
255 - // child
256 - '/\\s*>\\s*/' => '/',
257 - // descendant (don't match spaces within already translated XPath predicates)
258 - '/\\s+(?![^\\[\\]]*+\\])/' => '//',
259 - // type and :first-child
260 - '/([^\\/]+):first-child/i' => '*[1]/self::\\1',
261 - // type and :last-child
262 - '/([^\\/]+):last-child/i' => '*[last()]/self::\\1',
263 -
264 - // The following matcher will break things if it is placed before the adjacent matcher.
265 - // So one of the matchers matches either too much or not enough.
266 - // type and attribute value with $ (suffix match)
267 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w\\-\_\\s\\/]+)[\'"]?\\]/'
268 - => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
269 225 ];
270 226
271 227 /**
272 228 * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
@@ -275,48 +231,37 @@
275 231 */
276 232 private $debug = false;
277 233
278 234 /**
279 - * @param string $unprocessedHtml the HTML to process, must be UTF-8-encoded
235 + * The constructor.
236 + *
237 + * @param string $html the HTML to emogrify, must be UTF-8-encoded
280 238 * @param string $css the CSS to merge, must be UTF-8-encoded
281 239 */
282 - public function __construct($unprocessedHtml = '', $css = '')
240 + public function __construct($html = '', $css = '')
283 241 {
284 - if ($unprocessedHtml !== '') {
285 - $this->setHtml($unprocessedHtml);
286 - }
242 + $this->setHtml($html);
287 243 $this->setCss($css);
288 244 }
289 245
290 246 /**
291 - * Sets the HTML to process.
292 - *
293 - * @param string $html the HTML to process, must be UTF-encoded, must not be empty
294 - *
295 - * @return void
296 - *
297 - * @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
247 + * The destructor.
298 248 */
299 - public function setHtml($html)
249 + public function __destruct()
300 250 {
301 - if (!\is_string($html)) {
302 - throw new \InvalidArgumentException('The provided HTML must be a string.', 1540403913);
303 - }
304 - if ($html === '') {
305 - throw new \InvalidArgumentException('The provided HTML must not be empty.', 1540403910);
306 - }
307 -
308 - $this->createUnifiedDomDocument($html);
251 + $this->purgeVisitedNodes();
309 252 }
310 253
311 254 /**
312 - * Provides access to the internal DOMDocument representation of the HTML in its current state.
255 + * Sets the HTML to emogrify.
313 256 *
314 - * @return \DOMDocument
257 + * @param string $html the HTML to emogrify, must be UTF-8-encoded
258 + *
259 + * @return void
315 260 */
316 - public function getDomDocument()
261 + public function setHtml($html)
317 262 {
318 - return $this->domDocument;
263 + $this->html = $html;
319 264 }
320 265
321 266 /**
322 267 * Sets the CSS to merge with the HTML.
@@ -330,70 +275,9 @@
330 275 $this->css = $css;
331 276 }
332 277
333 278 /**
334 - * Renders the normalized and processed HTML.
335 - *
336 - * @return string
337 - */
338 - protected function render()
339 - {
340 - $htmlWithPossibleErroneousClosingTags = $this->domDocument->saveHTML();
341 -
342 - return $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
343 - }
344 -
345 - /**
346 - * Renders the content of the BODY element of the normalized and processed HTML.
347 - *
348 - * @return string
349 - */
350 - protected function renderBodyContent()
351 - {
352 - $htmlWithPossibleErroneousClosingTags = $this->domDocument->saveHTML($this->getBodyElement());
353 - $bodyNodeHtml = $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
354 -
355 - return \preg_replace('%</?+body(?:\\s[^>]*+)?+>%', '', $bodyNodeHtml);
356 - }
357 -
358 - /**
359 - * Eliminates any invalid closing tags for void elements from the given HTML.
360 - *
361 - * @param string $html
362 - *
363 - * @return string
364 - */
365 - private function removeSelfClosingTagsClosingTags($html)
366 - {
367 - return \preg_replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
368 - }
369 -
370 - /**
371 - * Returns the BODY element.
372 - *
373 - * This method assumes that there always is a BODY element.
374 - *
375 - * @return \DOMElement
376 - */
377 - private function getBodyElement()
378 - {
379 - return $this->domDocument->getElementsByTagName('body')->item(0);
380 - }
381 -
382 - /**
383 - * Returns the HEAD element.
384 - *
385 - * This method assumes that there always is a HEAD element.
386 - *
387 - * @return \DOMElement
388 - */
389 - private function getHeadElement()
390 - {
391 - return $this->domDocument->getElementsByTagName('head')->item(0);
392 - }
393 -
394 - /**
395 - * Applies $this->css to the given HTML and returns the HTML with the CSS
279 + * Applies $this->css to $this->html and returns the HTML with the CSS
396 280 * applied.
397 281 *
398 282 * This method places the CSS inline.
399 283 *
@@ -402,17 +286,20 @@
402 286 * @throws \BadMethodCallException
403 287 */
404 288 public function emogrify()
405 289 {
406 - $this->assertExistenceOfHtml();
290 + if ($this->html === '') {
291 + throw new \BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
292 + }
407 293
408 - $this->process();
294 + $xmlDocument = $this->createXmlDocument();
295 + $this->process($xmlDocument);
409 296
410 - return $this->render();
297 + return $xmlDocument->saveHTML();
411 298 }
412 299
413 300 /**
414 - * Applies $this->css to the given HTML and returns only the HTML content
301 + * Applies $this->css to $this->html and returns only the HTML content
415 302 * within the <body> tag.
416 303 *
417 304 * This method places the CSS inline.
418 305 *
@@ -421,263 +308,305 @@
421 308 * @throws \BadMethodCallException
422 309 */
423 310 public function emogrifyBodyContent()
424 311 {
425 - $this->assertExistenceOfHtml();
312 + if ($this->html === '') {
313 + throw new \BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
314 + }
426 315
427 - $this->process();
316 + $xmlDocument = $this->createXmlDocument();
317 + $this->process($xmlDocument);
428 318
429 - return $this->renderBodyContent();
430 - }
431 -
432 - /**
433 - * Checks that some HTML has been set, and throws an exception otherwise.
434 - *
435 - * @return void
436 - *
437 - * @throws \BadMethodCallException
438 - */
439 - private function assertExistenceOfHtml()
440 - {
441 - if ($this->domDocument === null) {
442 - throw new \BadMethodCallException('Please set some HTML first.', 1390393096);
319 + $innerDocument = new \DOMDocument();
320 + foreach ($xmlDocument->documentElement->getElementsByTagName('body')->item(0)->childNodes as $childNode) {
321 + $innerDocument->appendChild($innerDocument->importNode($childNode, true));
443 322 }
444 - }
445 323
446 - /**
447 - * Creates a DOM document from the given HTML and stores it in $this->domDocument.
448 - *
449 - * The DOM document will always have a BODY element.
450 - *
451 - * @param string $html
452 - *
453 - * @return void
454 - */
455 - private function createUnifiedDomDocument($html)
456 - {
457 - $this->createRawDomDocument($html);
458 - $this->ensureExistenceOfBodyElement();
324 + return html_entity_decode($innerDocument->saveHTML());
459 325 }
460 326
461 327 /**
462 - * Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
328 + * Applies $this->css to $xmlDocument.
463 329 *
464 - * @param string $html
330 + * This method places the CSS inline.
465 331 *
466 - * @return void
467 - */
468 - private function createRawDomDocument($html)
469 - {
470 - $domDocument = new \DOMDocument();
471 - $domDocument->strictErrorChecking = false;
472 - $domDocument->formatOutput = true;
473 - $libXmlState = \libxml_use_internal_errors(true);
474 - $domDocument->loadHTML($this->prepareHtmlForDomConversion($html));
475 - \libxml_clear_errors();
476 - \libxml_use_internal_errors($libXmlState);
477 -
478 - $this->domDocument = $domDocument;
479 - $this->xPath = new \DOMXPath($this->domDocument);
480 - }
481 -
482 - /**
483 - * Returns the HTML with added document type, Content-Type meta tag, and self-closing slashes, if needed,
484 - * ensuring that the HTML will be good for creating a DOM document from it.
332 + * @param \DOMDocument $xmlDocument
485 333 *
486 - * @param string $html
487 - *
488 - * @return string the unified HTML
489 - */
490 - private function prepareHtmlForDomConversion($html)
491 - {
492 - $htmlWithSelfClosingSlashes = $this->ensurePhpUnrecognizedSelfClosingTagsAreXml($html);
493 - $htmlWithDocumentType = $this->ensureDocumentType($htmlWithSelfClosingSlashes);
494 -
495 - return $this->addContentTypeMetaTag($htmlWithDocumentType);
496 - }
497 -
498 - /**
499 - * Applies $this->css to $this->domDocument.
500 - *
501 - * This method places the CSS inline.
502 - *
503 334 * @return void
504 335 *
505 336 * @throws \InvalidArgumentException
506 337 */
507 - protected function process()
338 + protected function process(\DOMDocument $xmlDocument)
508 339 {
340 + $xPath = new \DOMXPath($xmlDocument);
509 341 $this->clearAllCaches();
342 +
343 + // Before be begin processing the CSS file, parse the document and normalize all existing CSS attributes.
344 + // This changes 'DISPLAY: none' to 'display: none'.
345 + // We wouldn't have to do this if DOMXPath supported XPath 2.0.
346 + // Also store a reference of nodes with existing inline styles so we don't overwrite them.
510 347 $this->purgeVisitedNodes();
511 348
512 - \set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING);
513 - $this->removeUnprocessableTags();
514 - $this->normalizeStyleAttributesOfAllNodes();
349 + set_error_handler([$this, 'handleXpathError'], E_WARNING);
515 350
351 + $nodesWithStyleAttributes = $xPath->query('//*[@style]');
352 + if ($nodesWithStyleAttributes !== false) {
353 + /** @var \DOMElement $node */
354 + foreach ($nodesWithStyleAttributes as $node) {
355 + if ($this->isInlineStyleAttributesParsingEnabled) {
356 + $this->normalizeStyleAttributes($node);
357 + } else {
358 + $node->removeAttribute('style');
359 + }
360 + }
361 + }
362 +
516 363 // grab any existing style blocks from the html and append them to the existing CSS
517 364 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
518 365 $allCss = $this->css;
366 +
519 367 if ($this->isStyleBlocksParsingEnabled) {
520 - $allCss .= $this->getCssFromAllStyleNodes();
368 + $allCss .= $this->getCssFromAllStyleNodes($xPath);
521 369 }
522 370
523 - $cssWithoutComments = $this->removeCssComments($allCss);
524 - list($cssWithoutCommentsCharsetOrImport, $cssImportRules)
525 - = $this->extractImportAndCharsetRules($cssWithoutComments);
526 -
527 - $excludedNodes = $this->getNodesToExclude();
528 - $cssRules = $this->parseCssRules($cssWithoutCommentsCharsetOrImport);
529 - foreach ($cssRules['inlinable'] as $cssRule) {
530 - // There's no real way to test "PHP Warning" output generated by the following XPath query unless PHPUnit
531 - // converts it to an exception. Unfortunately, this would only apply to tests and not work for production
532 - // executions, which can still flood logs/output unnecessarily. Instead, Emogrifier's error handler should
533 - // always throw an exception and it must be caught here and only rethrown if in debug mode.
534 - try {
535 - // \DOMXPath::query will always return a DOMNodeList or throw an exception when errors are caught.
536 - $nodesMatchingCssSelectors = $this->xPath->query($this->translateCssToXpath($cssRule['selector']));
537 - } catch (\InvalidArgumentException $e) {
538 - if ($this->debug) {
539 - throw $e;
540 - }
371 + $cssParts = $this->splitCssAndMediaQuery($allCss);
372 + $excludedNodes = $this->getNodesToExclude($xPath);
373 + $cssRules = $this->parseCssRules($cssParts['css']);
374 + foreach ($cssRules as $cssRule) {
375 + // query the body for the xpath selector
376 + $nodesMatchingCssSelectors = $xPath->query($this->translateCssToXpath($cssRule['selector']));
377 + // ignore invalid selectors
378 + if ($nodesMatchingCssSelectors === false) {
541 379 continue;
542 380 }
543 381
544 382 /** @var \DOMElement $node */
545 383 foreach ($nodesMatchingCssSelectors as $node) {
546 - if (\in_array($node, $excludedNodes, true)) {
384 + if (in_array($node, $excludedNodes, true)) {
547 385 continue;
548 386 }
549 - $this->copyInlinableCssToStyleAttribute($node, $cssRule);
387 +
388 + // if it has a style attribute, get it, process it, and append (overwrite) new stuff
389 + if ($node->hasAttribute('style')) {
390 + // break it up into an associative array
391 + $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
392 + } else {
393 + $oldStyleDeclarations = [];
394 + }
395 + $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
396 + if ($this->shouldMapCssToHtml) {
397 + $this->mapCssToHtmlAttributes($newStyleDeclarations, $node);
398 + }
399 + $node->setAttribute(
400 + 'style',
401 + $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
402 + );
550 403 }
551 404 }
552 405
406 + restore_error_handler();
407 +
553 408 if ($this->isInlineStyleAttributesParsingEnabled) {
554 409 $this->fillStyleAttributesWithMergedStyles();
555 410 }
556 411
557 - $this->removeImportantAnnotationFromAllInlineStyles();
412 + if ($this->shouldKeepInvisibleNodes) {
413 + $this->removeInvisibleNodes($xPath);
414 + }
558 415
559 - $this->copyUninlinableCssToStyleNode($cssRules['uninlinable'], $cssImportRules);
416 + $this->copyCssWithMediaToStyleNode($xmlDocument, $xPath, $cssParts['media']);
417 + }
560 418
561 - \restore_error_handler();
419 + /**
420 + * Applies $styles to $node.
421 + *
422 + * This method maps CSS styles to HTML attributes and adds those to the
423 + * node.
424 + *
425 + * @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
426 + * @param \DOMNode $node node to apply styles to
427 + *
428 + * @return void
429 + */
430 + private function mapCssToHtmlAttributes(array $styles, \DOMNode $node)
431 + {
432 + foreach ($styles as $property => $value) {
433 + // Strip !important indicator
434 + $value = trim(str_replace('!important', '', $value));
435 + $this->mapCssToHtmlAttribute($property, $value, $node);
436 + }
562 437 }
563 438
564 439 /**
565 - * Searches for all nodes with a style attribute and removes the "!important" annotations out of
566 - * the inline style declarations, eventually by rearranging declarations.
440 + * Tries to apply the CSS style to $node as an attribute.
567 441 *
442 + * This method maps a CSS rule to HTML attributes and adds those to the node.
443 + *
444 + * @param string $property the name of the CSS property to map
445 + * @param string $value the value of the style rule to map
446 + * @param \DOMNode $node node to apply styles to
447 + *
568 448 * @return void
569 449 */
570 - private function removeImportantAnnotationFromAllInlineStyles()
450 + private function mapCssToHtmlAttribute($property, $value, \DOMNode $node)
571 451 {
572 - foreach ($this->getAllNodesWithStyleAttribute() as $node) {
573 - $this->removeImportantAnnotationFromNodeInlineStyle($node);
452 + if (!$this->mapSimpleCssProperty($property, $value, $node)) {
453 + $this->mapComplexCssProperty($property, $value, $node);
574 454 }
575 455 }
576 456
577 457 /**
578 - * Removes the "!important" annotations out of the inline style declarations,
579 - * eventually by rearranging declarations.
580 - * Rearranging needed when !important shorthand properties are followed by some of their
581 - * not !important expanded-version properties.
582 - * For example "font: 12px serif !important; font-size: 13px;" must be reordered
583 - * to "font-size: 13px; font: 12px serif;" in order to remain correct.
458 + * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
584 459 *
585 - * @param \DOMElement $node
460 + * @param string $property the name of the CSS property to map
461 + * @param string $value the value of the style rule to map
462 + * @param \DOMNode $node node to apply styles to
586 463 *
464 + * @return bool true if the property cab be mapped using the simple mapping table
465 + */
466 + private function mapSimpleCssProperty($property, $value, \DOMNode $node)
467 + {
468 + if (!isset($this->cssToHtmlMap[$property])) {
469 + return false;
470 + }
471 +
472 + $mapping = $this->cssToHtmlMap[$property];
473 + $nodesMatch = !isset($mapping['nodes']) || in_array($node->nodeName, $mapping['nodes'], true);
474 + $valuesMatch = !isset($mapping['values']) || in_array($value, $mapping['values'], true);
475 + if (!$nodesMatch || !$valuesMatch) {
476 + return false;
477 + }
478 +
479 + $node->setAttribute($mapping['attribute'], $value);
480 +
481 + return true;
482 + }
483 +
484 + /**
485 + * Maps CSS properties that need special transformation to an HTML attribute.
486 + *
487 + * @param string $property the name of the CSS property to map
488 + * @param string $value the value of the style rule to map
489 + * @param \DOMNode $node node to apply styles to
490 + *
587 491 * @return void
588 492 */
589 - private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node)
493 + private function mapComplexCssProperty($property, $value, \DOMNode $node)
590 494 {
591 - $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
592 - $regularStyleDeclarations = [];
593 - $importantStyleDeclarations = [];
594 - foreach ($inlineStyleDeclarations as $property => $value) {
595 - if ($this->attributeValueIsImportant($value)) {
596 - $importantStyleDeclarations[$property] = \trim(\str_replace('!important', '', $value));
597 - } else {
598 - $regularStyleDeclarations[$property] = $value;
599 - }
495 + $nodeName = $node->nodeName;
496 + $isTable = $nodeName === 'table';
497 + $isImage = $nodeName === 'img';
498 + $isTableOrImage = $isTable || $isImage;
499 +
500 + switch ($property) {
501 + case 'background':
502 + // Parse out the color, if any
503 + $styles = explode(' ', $value);
504 + $first = $styles[0];
505 + if (!is_numeric(substr($first, 0, 1)) && substr($first, 0, 3) !== 'url') {
506 + // This is not a position or image, assume it's a color
507 + $node->setAttribute('bgcolor', $first);
508 + }
509 + break;
510 + case 'width':
511 + // intentional fall-through
512 + case 'height':
513 + // Only parse values in px and %, but not values like "auto".
514 + if (preg_match('/^\d+(px|%)$/', $value)) {
515 + // Remove 'px'. This regex only conserves numbers and %
516 + $number = preg_replace('/[^0-9.%]/', '', $value);
517 + $node->setAttribute($property, $number);
518 + }
519 + break;
520 + case 'margin':
521 + if ($isTableOrImage) {
522 + $margins = $this->parseCssShorthandValue($value);
523 + if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
524 + $node->setAttribute('align', 'center');
525 + }
526 + }
527 + break;
528 + case 'border':
529 + if ($isTableOrImage) {
530 + if ($value === 'none' || $value === '0') {
531 + $node->setAttribute('border', '0');
532 + }
533 + }
534 + break;
535 + default:
600 536 }
601 - $inlineStyleDeclarationsInNewOrder = \array_merge(
602 - $regularStyleDeclarations,
603 - $importantStyleDeclarations
604 - );
605 - $node->setAttribute(
606 - 'style',
607 - $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
608 - );
609 537 }
610 538
611 539 /**
612 - * Returns a list with all DOM nodes that have a style attribute.
540 + * Parses a shorthand CSS value and splits it into individual values
613 541 *
614 - * @return \DOMNodeList
542 + * @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
543 + * For example: padding: 0 auto;
544 + * '0 auto' is split into top: 0, left: auto, bottom: 0,
545 + * right: auto.
546 + *
547 + * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
615 548 */
616 - private function getAllNodesWithStyleAttribute()
549 + private function parseCssShorthandValue($value)
617 550 {
618 - return $this->xPath->query('//*[@style]');
551 + $values = preg_split('/\\s+/', $value);
552 +
553 + $css = [];
554 + $css['top'] = $values[0];
555 + $css['right'] = (count($values) > 1) ? $values[1] : $css['top'];
556 + $css['bottom'] = (count($values) > 2) ? $values[2] : $css['top'];
557 + $css['left'] = (count($values) > 3) ? $values[3] : $css['right'];
558 +
559 + return $css;
619 560 }
620 561
621 562 /**
622 563 * Extracts and parses the individual rules from a CSS string.
623 564 *
624 - * @param string $css a string of raw CSS code with comments removed
565 + * @param string $css a string of raw CSS code
625 566 *
626 - * @return string[][][] A 2-entry array with the key "inlinable" containing rules which can be inlined as `style`
627 - * attributes and the key "uninlinable" containing rules which cannot. Each value is an array of string
628 - * sub-arrays with the keys
629 - * "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
630 - * or an empty string if not from a `@media` rule),
631 - * "selector" (the CSS selector, e.g., "*" or "header h1"),
632 - * "hasUnmatchablePseudo" (true if that selector contains pseudo-elements or dynamic pseudo-classes
633 - * such that the declarations cannot be applied inline),
634 - * "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
567 + * @return string[][] an array of string sub-arrays with the keys
568 + * "selector" (the CSS selector(s), e.g., "*" or "h1"),
569 + * "declarationsBLock" (the semicolon-separated CSS declarations for that selector(s),
635 570 * e.g., "color: red; height: 4px;"),
636 571 * and "line" (the line number e.g. 42)
637 572 */
638 573 private function parseCssRules($css)
639 574 {
640 - $cssKey = \md5($css);
575 + $cssKey = md5($css);
641 576 if (!isset($this->caches[self::CACHE_KEY_CSS][$cssKey])) {
642 - $matches = $this->getCssRuleMatches($css);
577 + // process the CSS file for selectors and definitions
578 + preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mis', $css, $matches, PREG_SET_ORDER);
643 579
644 - $cssRules = [
645 - 'inlinable' => [],
646 - 'uninlinable' => [],
647 - ];
648 - /** @var string[][] $matches */
580 + $cssRules = [];
649 581 /** @var string[] $cssRule */
650 582 foreach ($matches as $key => $cssRule) {
651 - $cssDeclaration = \trim($cssRule['declarations']);
583 + $cssDeclaration = trim($cssRule[2]);
652 584 if ($cssDeclaration === '') {
653 585 continue;
654 586 }
655 587
656 - foreach (\explode(',', $cssRule['selectors']) as $selector) {
588 + $selectors = explode(',', $cssRule[1]);
589 + foreach ($selectors as $selector) {
657 590 // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
658 591 // only allow structural pseudo-classes
659 - $hasPseudoElement = \strpos($selector, '::') !== false;
660 - $hasUnsupportedPseudoClass = (bool)\preg_match(
661 - '/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i',
662 - $selector
663 - );
664 - $hasUnmatchablePseudo = $hasPseudoElement || $hasUnsupportedPseudoClass;
592 + $hasPseudoElement = strpos($selector, '::') !== false;
593 + $hasAnyPseudoClass = (bool) preg_match('/:[a-zA-Z]/', $selector);
594 + $hasSupportedPseudoClass = (bool) preg_match('/:\\S+\\-(child|type\\()/i', $selector);
595 + if ($hasPseudoElement || ($hasAnyPseudoClass && !$hasSupportedPseudoClass)) {
596 + continue;
597 + }
665 598
666 - $parsedCssRule = [
667 - 'media' => $cssRule['media'],
668 - 'selector' => \trim($selector),
669 - 'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
599 + $cssRules[] = [
600 + 'selector' => trim($selector),
670 601 'declarationsBlock' => $cssDeclaration,
671 602 // keep track of where it appears in the file, since order is important
672 603 'line' => $key,
673 604 ];
674 - $ruleType = ($cssRule['media'] === '' && !$hasUnmatchablePseudo) ? 'inlinable' : 'uninlinable';
675 - $cssRules[$ruleType][] = $parsedCssRule;
676 605 }
677 606 }
678 607
679 - \usort($cssRules['inlinable'], [$this, 'sortBySelectorPrecedence']);
608 + usort($cssRules, [$this, 'sortBySelectorPrecedence']);
680 609
681 610 $this->caches[self::CACHE_KEY_CSS][$cssKey] = $cssRules;
682 611 }
683 612
@@ -684,59 +613,46 @@
684 613 return $this->caches[self::CACHE_KEY_CSS][$cssKey];
685 614 }
686 615
687 616 /**
688 - * Parses a string of CSS into the media query, selectors and declarations for each ruleset in order.
617 + * Disables the parsing of inline styles.
689 618 *
690 - * @param string $css CSS with comments removed
619 + * @return void
620 + */
621 + public function disableInlineStyleAttributesParsing()
622 + {
623 + $this->isInlineStyleAttributesParsingEnabled = false;
624 + }
625 +
626 + /**
627 + * Disables the parsing of <style> blocks.
691 628 *
692 - * @return string[][] Array of string sub-arrays with the keys
693 - * "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
694 - * or an empty string if not from an `@media` rule),
695 - * "selectors" (the CSS selector(s), e.g., "*" or "h1, h2"),
696 - * "declarations" (the semicolon-separated CSS declarations for that/those selector(s),
697 - * e.g., "color: red; height: 4px;"),
629 + * @return void
698 630 */
699 - private function getCssRuleMatches($css)
631 + public function disableStyleBlocksParsing()
700 632 {
701 - $splitCss = $this->splitCssAndMediaQuery($css);
702 -
703 - $ruleMatches = [];
704 - foreach ($splitCss as $cssPart) {
705 - // process each part for selectors and definitions
706 - \preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mi', $cssPart['css'], $matches, PREG_SET_ORDER);
707 -
708 - /** @var string[][] $matches */
709 - foreach ($matches as $cssRule) {
710 - $ruleMatches[] = [
711 - 'media' => $cssPart['media'],
712 - 'selectors' => $cssRule[1],
713 - 'declarations' => $cssRule[2],
714 - ];
715 - }
716 - }
717 -
718 - return $ruleMatches;
633 + $this->isStyleBlocksParsingEnabled = false;
719 634 }
720 635
721 636 /**
722 - * Disables the parsing of inline styles.
637 + * Disables the removal of elements with `display: none` properties.
723 638 *
724 639 * @return void
725 640 */
726 - public function disableInlineStyleAttributesParsing()
641 + public function disableInvisibleNodeRemoval()
727 642 {
728 - $this->isInlineStyleAttributesParsingEnabled = false;
643 + $this->shouldKeepInvisibleNodes = false;
729 644 }
730 645
731 646 /**
732 - * Disables the parsing of <style> blocks.
647 + * Enables the attachment/override of HTML attributes for which a
648 + * corresponding CSS property has been set.
733 649 *
734 650 * @return void
735 651 */
736 - public function disableStyleBlocksParsing()
652 + public function enableCssToHtmlMapping()
737 653 {
738 - $this->isStyleBlocksParsingEnabled = false;
654 + $this->shouldMapCssToHtml = true;
739 655 }
740 656
741 657 /**
742 658 * Clears all caches.
@@ -744,15 +660,39 @@
744 660 * @return void
745 661 */
746 662 private function clearAllCaches()
747 663 {
748 - $this->caches = [
749 - self::CACHE_KEY_CSS => [],
750 - self::CACHE_KEY_SELECTOR => [],
751 - self::CACHE_KEY_XPATH => [],
752 - self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
753 - self::CACHE_KEY_COMBINED_STYLES => [],
664 + $this->clearCache(self::CACHE_KEY_CSS);
665 + $this->clearCache(self::CACHE_KEY_SELECTOR);
666 + $this->clearCache(self::CACHE_KEY_XPATH);
667 + $this->clearCache(self::CACHE_KEY_CSS_DECLARATIONS_BLOCK);
668 + $this->clearCache(self::CACHE_KEY_COMBINED_STYLES);
669 + }
670 +
671 + /**
672 + * Clears a single cache by key.
673 + *
674 + * @param int $key the cache key, must be CACHE_KEY_CSS, CACHE_KEY_SELECTOR, CACHE_KEY_XPATH
675 + * or CACHE_KEY_CSS_DECLARATION_BLOCK
676 + *
677 + * @return void
678 + *
679 + * @throws \InvalidArgumentException
680 + */
681 + private function clearCache($key)
682 + {
683 + $allowedCacheKeys = [
684 + self::CACHE_KEY_CSS,
685 + self::CACHE_KEY_SELECTOR,
686 + self::CACHE_KEY_XPATH,
687 + self::CACHE_KEY_CSS_DECLARATIONS_BLOCK,
688 + self::CACHE_KEY_COMBINED_STYLES,
754 689 ];
690 + if (!in_array($key, $allowedCacheKeys, true)) {
691 + throw new \InvalidArgumentException('Invalid cache key: ' . $key, 1391822035);
692 + }
693 +
694 + $this->caches[$key] = [];
755 695 }
756 696
757 697 /**
758 698 * Purges the visited nodes.
@@ -790,11 +730,10 @@
790 730 * @return void
791 731 */
792 732 public function removeUnprocessableHtmlTag($tagName)
793 733 {
794 - $key = \array_search($tagName, $this->unprocessableHtmlTags, true);
734 + $key = array_search($tagName, $this->unprocessableHtmlTags, true);
795 735 if ($key !== false) {
796 - /** @var int|string $key */
797 736 unset($this->unprocessableHtmlTags[$key]);
798 737 }
799 738 }
800 739
@@ -852,27 +791,34 @@
852 791 }
853 792 }
854 793
855 794 /**
856 - * Parses the document and normalizes all existing CSS attributes.
857 - * This changes 'DISPLAY: none' to 'display: none'.
858 - * We wouldn't have to do this if DOMXPath supported XPath 2.0.
859 - * Also stores a reference of nodes with existing inline styles so we don't overwrite them.
795 + * This removes styles from your email that contain display:none.
796 + * We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
797 + * supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
798 + * not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
799 + * to lowercase.
860 800 *
801 + * @param \DOMXPath $xPath
802 + *
861 803 * @return void
862 804 */
863 - private function normalizeStyleAttributesOfAllNodes()
805 + private function removeInvisibleNodes(\DOMXPath $xPath)
864 806 {
865 - /** @var \DOMElement $node */
866 - foreach ($this->getAllNodesWithStyleAttribute() as $node) {
867 - if ($this->isInlineStyleAttributesParsingEnabled) {
868 - $this->normalizeStyleAttributes($node);
807 + $nodesWithStyleDisplayNone = $xPath->query(
808 + '//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]'
809 + );
810 + if ($nodesWithStyleDisplayNone->length === 0) {
811 + return;
812 + }
813 +
814 + // The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
815 + // we don't try to call removeChild on a nonexistent child node
816 + /** @var \DOMNode $node */
817 + foreach ($nodesWithStyleDisplayNone as $node) {
818 + if ($node->parentNode && is_callable([$node->parentNode, 'removeChild'])) {
819 + $node->parentNode->removeChild($node);
869 820 }
870 - // Remove style attribute in every case, so we can add them back (if inline style attributes
871 - // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
872 - // else original inline style rules may remain at the beginning of the final inline style definition
873 - // of a node, which may give not the desired results
874 - $node->removeAttribute('style');
875 821 }
876 822 }
877 823
878 824 /**
@@ -883,12 +829,12 @@
883 829 * @return void
884 830 */
885 831 private function normalizeStyleAttributes(\DOMElement $node)
886 832 {
887 - $normalizedOriginalStyle = \preg_replace_callback(
888 - '/-?+[_a-zA-Z][\\w\\-]*+(?=:)/S',
889 - static function (array $m) {
890 - return \strtolower($m[0]);
833 + $normalizedOriginalStyle = preg_replace_callback(
834 + '/[A-z\\-]+(?=\\:)/S',
835 + function (array $m) {
836 + return strtolower($m[0]);
891 837 },
892 838 $node->getAttribute('style')
893 839 );
894 840
@@ -935,14 +881,14 @@
935 881 * @return string
936 882 */
937 883 private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles)
938 884 {
939 - $cacheKey = \serialize([$oldStyles, $newStyles]);
885 + $combinedStyles = array_merge($oldStyles, $newStyles);
886 + $cacheKey = serialize($combinedStyles);
940 887 if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
941 888 return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
942 889 }
943 890
944 - // Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
945 891 foreach ($oldStyles as $attributeName => $attributeValue) {
946 892 if (!isset($newStyles[$attributeName])) {
947 893 continue;
948 894 }
@@ -947,25 +893,20 @@
947 893 continue;
948 894 }
949 895
950 896 $newAttributeValue = $newStyles[$attributeName];
951 - if (
952 - $this->attributeValueIsImportant($attributeValue)
897 + if ($this->attributeValueIsImportant($attributeValue)
953 898 && !$this->attributeValueIsImportant($newAttributeValue)
954 899 ) {
955 - unset($newStyles[$attributeName]);
956 - } else {
957 - unset($oldStyles[$attributeName]);
900 + $combinedStyles[$attributeName] = $attributeValue;
958 901 }
959 902 }
960 903
961 - $combinedStyles = \array_merge($oldStyles, $newStyles);
962 -
963 904 $style = '';
964 905 foreach ($combinedStyles as $attributeName => $attributeValue) {
965 - $style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; ';
906 + $style .= strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; ';
966 907 }
967 - $trimmedStyle = \rtrim($style);
908 + $trimmedStyle = rtrim($style);
968 909
969 910 $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
970 911
971 912 return $trimmedStyle;
@@ -971,20 +912,8 @@
971 912 return $trimmedStyle;
972 913 }
973 914
974 915 /**
975 - * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
976 - *
977 - * @param string[] $styleDeclarations
978 - *
979 - * @return string
980 - */
981 - private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations)
982 - {
983 - return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
984 - }
985 -
986 - /**
987 916 * Checks whether $attributeValue is marked as !important.
988 917 *
989 918 * @param string $attributeValue
990 919 *
@@ -991,174 +920,75 @@
991 920 * @return bool
992 921 */
993 922 private function attributeValueIsImportant($attributeValue)
994 923 {
995 - return \strtolower(\substr(\trim($attributeValue), -10)) === '!important';
924 + return strtolower(substr(trim($attributeValue), -10)) === '!important';
996 925 }
997 926
998 927 /**
999 - * Copies $cssRule into the style attribute of $node.
928 + * Applies $css to $xmlDocument, limited to the media queries that actually apply to the document.
1000 929 *
1001 - * Note: This method does not check whether $cssRule matches $node.
930 + * @param \DOMDocument $xmlDocument the document to match against
931 + * @param \DOMXPath $xPath
932 + * @param string $css a string of CSS
1002 933 *
1003 - * @param \DOMElement $node
1004 - * @param string[][] $cssRule
1005 - *
1006 934 * @return void
1007 935 */
1008 - private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule)
936 + private function copyCssWithMediaToStyleNode(\DOMDocument $xmlDocument, \DOMXPath $xPath, $css)
1009 937 {
1010 - $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
1011 - if ($newStyleDeclarations === []) {
938 + if ($css === '') {
1012 939 return;
1013 940 }
1014 941
1015 - // if it has a style attribute, get it, process it, and append (overwrite) new stuff
1016 - if ($node->hasAttribute('style')) {
1017 - // break it up into an associative array
1018 - $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
1019 - } else {
1020 - $oldStyleDeclarations = [];
1021 - }
1022 - $node->setAttribute(
1023 - 'style',
1024 - $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
1025 - );
1026 - }
942 + $mediaQueriesRelevantForDocument = [];
1027 943
1028 - /**
1029 - * Applies $cssRules to $this->domDocument, limited to the rules that actually apply to the document, by placing
1030 - * them as CSS in a `<style>` element.
1031 - *
1032 - * @param string[][] $cssRules the "uninlinable" array of CSS rules returned by `parseCssRules`
1033 - * @param string $cssImportRules This may contain any `@import` rules that should precede the CSS placed in the
1034 - * `<style>` element. If there are no unlinlinable CSS rules to copy there, a `<style>` element will be
1035 - * created containing just `$cssImportRules`. `$cssImportRules` may be an empty string; if it is, and there
1036 - * are no unlinlinable CSS rules, an empty `<style>` element will not be created.
1037 - *
1038 - * @return void
1039 - */
1040 - private function copyUninlinableCssToStyleNode(array $cssRules, $cssImportRules)
1041 - {
1042 - $css = $cssImportRules;
1043 -
1044 - $cssRulesRelevantForDocument = \array_filter($cssRules, [$this, 'existsMatchForSelectorInCssRule']);
1045 -
1046 - // avoid including unneeded class dependency if there are no rules
1047 - if ($cssRulesRelevantForDocument !== []) {
1048 - // support use without autoload
1049 - if (!\class_exists(CssConcatenator::class)) {
1050 - require_once __DIR__ . '/Emogrifier/Utilities/CssConcatenator.php';
944 + foreach ($this->extractMediaQueriesFromCss($css) as $mediaQuery) {
945 + foreach ($this->parseCssRules($mediaQuery['css']) as $selector) {
946 + if ($this->existsMatchForCssSelector($xPath, $selector['selector'])) {
947 + $mediaQueriesRelevantForDocument[] = $mediaQuery['query'];
948 + break;
949 + }
1051 950 }
1052 -
1053 - $cssConcatenator = new CssConcatenator();
1054 - foreach ($cssRulesRelevantForDocument as $cssRule) {
1055 - $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
1056 - }
1057 -
1058 - $css .= $cssConcatenator->getCss();
1059 951 }
1060 952
1061 - // avoid adding empty style element
1062 - if ($css !== '') {
1063 - $this->addStyleElementToDocument($css);
1064 - }
953 + $this->addStyleElementToDocument($xmlDocument, implode($mediaQueriesRelevantForDocument));
1065 954 }
1066 955
1067 956 /**
1068 - * Checks whether there is at least one matching element for the CSS selector contained in the `selector` element
1069 - * of the provided CSS rule.
957 + * Extracts the media queries from $css while skipping empty media queries.
1070 958 *
1071 - * Any dynamic pseudo-classes will be assumed to apply. If the selector matches a pseudo-element,
1072 - * it will test for a match with its originating element.
959 + * @param string $css
1073 960 *
1074 - * @param string[] $cssRule
1075 - *
1076 - * @return bool
1077 - *
1078 - * @throws \InvalidArgumentException
961 + * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
1079 962 */
1080 - private function existsMatchForSelectorInCssRule(array $cssRule)
963 + private function extractMediaQueriesFromCss($css)
1081 964 {
1082 - $selector = $cssRule['selector'];
1083 - if ($cssRule['hasUnmatchablePseudo']) {
1084 - $selector = $this->removeUnmatchablePseudoComponents($selector);
965 + preg_match_all('/@media\\b[^{]*({((?:[^{}]+|(?1))*)})/', $css, $rawMediaQueries, PREG_SET_ORDER);
966 + $parsedQueries = [];
967 +
968 + foreach ($rawMediaQueries as $mediaQuery) {
969 + if ($mediaQuery[2] !== '') {
970 + $parsedQueries[] = [
971 + 'css' => $mediaQuery[2],
972 + 'query' => $mediaQuery[0],
973 + ];
974 + }
1085 975 }
1086 - return $this->existsMatchForCssSelector($selector);
1087 - }
1088 976
1089 - /**
1090 - * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
1091 - * If such a pseudo-component is within the argument of `:not`, the entire `:not` component is removed or replaced.
1092 - *
1093 - * @param string $selector
1094 - *
1095 - * @return string Selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply,
1096 - * or in the case of pseudo-elements will match their originating element.
1097 - */
1098 - private function removeUnmatchablePseudoComponents($selector)
1099 - {
1100 - // The regex allows nested brackets via `(?2)`.
1101 - // A space is temporarily prepended because the callback can't determine if the match was at the very start.
1102 - $selectorWithoutNots = \ltrim(\preg_replace_callback(
1103 - '/(\\s?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i',
1104 - [$this, 'replaceUnmatchableNotComponent'],
1105 - ' ' . $selector
1106 - ));
1107 -
1108 - $pseudoComponentMatcher = ':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+';
1109 - return \preg_replace(
1110 - ['/(\\s|^)' . $pseudoComponentMatcher . '/i', '/' . $pseudoComponentMatcher . '/i'],
1111 - ['$1*', ''],
1112 - $selectorWithoutNots
1113 - );
977 + return $parsedQueries;
1114 978 }
1115 979
1116 980 /**
1117 - * Helps `removeUnmatchablePseudoComponents()` replace or remove a selector `:not(...)` component if its argument
1118 - * contains pseudo-elements or dynamic pseudo-classes.
1119 - *
1120 - * @param string[] $matches array of elements matched by the regular expression
1121 - *
1122 - * @return string the full match if there were no unmatchable pseudo components within; otherwise, any preceding
1123 - * whitespace followed by "*", or an empty string if there was no preceding whitespace
1124 - */
1125 - private function replaceUnmatchableNotComponent(array $matches)
1126 - {
1127 - list($notComponentWithAnyPrecedingWhitespace, $anyPrecedingWhitespace, $notArgumentInBrackets) = $matches;
1128 -
1129 - $hasUnmatchablePseudo = \preg_match(
1130 - '/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-:]/i',
1131 - $notArgumentInBrackets
1132 - );
1133 -
1134 - if ($hasUnmatchablePseudo) {
1135 - return $anyPrecedingWhitespace !== '' ? $anyPrecedingWhitespace . '*' : '';
1136 - }
1137 - return $notComponentWithAnyPrecedingWhitespace;
1138 - }
1139 -
1140 - /**
1141 981 * Checks whether there is at least one matching element for $cssSelector.
1142 - * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
1143 - * just not implemented/recognized yet by Emogrifier).
1144 982 *
983 + * @param \DOMXPath $xPath
1145 984 * @param string $cssSelector
1146 985 *
1147 986 * @return bool
1148 - *
1149 - * @throws \InvalidArgumentException
1150 987 */
1151 - private function existsMatchForCssSelector($cssSelector)
988 + private function existsMatchForCssSelector(\DOMXPath $xPath, $cssSelector)
1152 989 {
1153 - try {
1154 - $nodesMatchingSelector = $this->xPath->query($this->translateCssToXpath($cssSelector));
1155 - } catch (\InvalidArgumentException $e) {
1156 - if ($this->debug) {
1157 - throw $e;
1158 - }
1159 - return true;
1160 - }
990 + $nodesMatchingSelector = $xPath->query($this->translateCssToXpath($cssSelector));
1161 991
1162 992 return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
1163 993 }
1164 994
@@ -1164,13 +994,15 @@
1164 994
1165 995 /**
1166 996 * Returns CSS content.
1167 997 *
998 + * @param \DOMXPath $xPath
999 + *
1168 1000 * @return string
1169 1001 */
1170 - private function getCssFromAllStyleNodes()
1002 + private function getCssFromAllStyleNodes(\DOMXPath $xPath)
1171 1003 {
1172 - $styleNodes = $this->xPath->query('//style');
1004 + $styleNodes = $xPath->query('//style');
1173 1005
1174 1006 if ($styleNodes === false) {
1175 1007 return '';
1176 1008 }
@@ -1185,106 +1017,56 @@
1185 1017 return $css;
1186 1018 }
1187 1019
1188 1020 /**
1189 - * Adds a style element with $css to $this->domDocument.
1021 + * Adds a style element with $css to $document.
1190 1022 *
1191 1023 * This method is protected to allow overriding.
1192 1024 *
1193 - * @see https://github.com/MyIntervals/emogrifier/issues/103
1025 + * @see https://github.com/jjriv/emogrifier/issues/103
1194 1026 *
1027 + * @param \DOMDocument $document
1195 1028 * @param string $css
1196 1029 *
1197 1030 * @return void
1198 1031 */
1199 - protected function addStyleElementToDocument($css)
1032 + protected function addStyleElementToDocument(\DOMDocument $document, $css)
1200 1033 {
1201 - $styleElement = $this->domDocument->createElement('style', $css);
1202 - $styleAttribute = $this->domDocument->createAttribute('type');
1034 + $styleElement = $document->createElement('style', $css);
1035 + $styleAttribute = $document->createAttribute('type');
1203 1036 $styleAttribute->value = 'text/css';
1204 1037 $styleElement->appendChild($styleAttribute);
1205 1038
1206 - $headElement = $this->getHeadElement();
1207 - $headElement->appendChild($styleElement);
1039 + $head = $this->getOrCreateHeadElement($document);
1040 + $head->appendChild($styleElement);
1208 1041 }
1209 1042
1210 1043 /**
1211 - * Checks that $this->domDocument has a BODY element and adds it if it is missing.
1044 + * Returns the existing or creates a new head element in $document.
1212 1045 *
1213 - * @return void
1046 + * @param \DOMDocument $document
1214 1047 *
1215 - * @throws \UnexpectedValueException
1048 + * @return \DOMNode the head element
1216 1049 */
1217 - private function ensureExistenceOfBodyElement()
1050 + private function getOrCreateHeadElement(\DOMDocument $document)
1218 1051 {
1219 - if ($this->domDocument->getElementsByTagName('body')->item(0) !== null) {
1220 - return;
1221 - }
1052 + $head = $document->getElementsByTagName('head')->item(0);
1222 1053
1223 - $htmlElement = $this->domDocument->getElementsByTagName('html')->item(0);
1224 - if ($htmlElement === null) {
1225 - throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930874);
1054 + if ($head === null) {
1055 + $head = $document->createElement('head');
1056 + $html = $document->getElementsByTagName('html')->item(0);
1057 + $html->insertBefore($head, $document->getElementsByTagName('body')->item(0));
1226 1058 }
1227 - $htmlElement->appendChild($this->domDocument->createElement('body'));
1228 - }
1229 1059
1230 - /**
1231 - * Removes comments from the supplied CSS.
1232 - *
1233 - * @param string $css
1234 - *
1235 - * @return string CSS with the comments removed
1236 - */
1237 - private function removeCssComments($css)
1238 - {
1239 - return \preg_replace('%/\\*[^*]*+(?:\\*(?!/)[^*]*+)*+\\*/%', '', $css);
1060 + return $head;
1240 1061 }
1241 1062
1242 1063 /**
1243 - * Extracts `@import` and `@charset` rules from the supplied CSS. These rules must not be preceded by any other
1244 - * rules, or they will be ignored. (From the CSS 2.1 specification: "CSS 2.1 user agents must ignore any '@import'
1245 - * rule that occurs inside a block or after any non-ignored statement other than an @charset or an @import rule."
1246 - * Note also that `@charset` is case sensitive whereas `@import` is not.)
1064 + * Splits input CSS code to an array where:
1247 1065 *
1248 - * @param string $css CSS with comments removed
1066 + * - key "css" will be contains clean CSS code
1067 + * - key "media" will be contains all valuable media queries
1249 1068 *
1250 - * @return string[] The first element is the CSS with the valid `@import` and `@charset` rules removed. The second
1251 - * element contains a concatenation of the valid `@import` rules, each followed by whatever whitespace followed it
1252 - * in the original CSS (so that either unminified or minified formatting is preserved); if there were no `@import`
1253 - * rules, it will be an empty string. The (valid) `@charset` rules are discarded.
1254 - */
1255 - private function extractImportAndCharsetRules($css)
1256 - {
1257 - $possiblyModifiedCss = $css;
1258 - $importRules = '';
1259 -
1260 - while (
1261 - \preg_match(
1262 - '/^\\s*+(@((?i)import(?-i)|charset)\\s[^;]++;\\s*+)/',
1263 - $possiblyModifiedCss,
1264 - $matches
1265 - )
1266 - ) {
1267 - list($fullMatch, $atRuleAndFollowingWhitespace, $atRuleName) = $matches;
1268 -
1269 - if (\strtolower($atRuleName) === 'import') {
1270 - $importRules .= $atRuleAndFollowingWhitespace;
1271 - }
1272 -
1273 - $possiblyModifiedCss = \substr($possiblyModifiedCss, \strlen($fullMatch));
1274 - }
1275 -
1276 - return [$possiblyModifiedCss, $importRules];
1277 - }
1278 -
1279 - /**
1280 - * Splits input CSS code into an array of parts for different media queries, in order.
1281 - * Each part is an array where:
1282 - *
1283 - * - key "css" will contain clean CSS code (for @media rules this will be the group rule body within "{...}")
1284 - * - key "media" will contain "@media " followed by the media query list, for all allowed media queries,
1285 - * or an empty string for CSS not within a media query
1286 - *
1287 1069 * Example:
1288 1070 *
1289 1071 * The CSS code
1290 1072 *
@@ -1291,86 +1073,100 @@
1291 1073 * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
1292 1074 *
1293 1075 * will be parsed into the following array:
1294 1076 *
1295 - * 0 => [
1296 - * "css" => "h1 { color:red; }",
1297 - * "media" => ""
1298 - * ],
1299 - * 1 => [
1300 - * "css" => " h1 {}",
1301 - * "media" => "@media "
1302 - * ]
1077 + * "css" => "h1 { color:red; }"
1078 + * "media" => "@media { h1 {}}"
1303 1079 *
1304 1080 * @param string $css
1305 1081 *
1306 - * @return string[][]
1082 + * @return string[]
1307 1083 */
1308 1084 private function splitCssAndMediaQuery($css)
1309 1085 {
1086 + $cssWithoutComments = preg_replace('/\\/\\*.*\\*\\//sU', '', $css);
1087 +
1310 1088 $mediaTypesExpression = '';
1311 1089 if (!empty($this->allowedMediaTypes)) {
1312 - $mediaTypesExpression = '|' . \implode('|', \array_keys($this->allowedMediaTypes));
1090 + $mediaTypesExpression = '|' . implode('|', array_keys($this->allowedMediaTypes));
1313 1091 }
1314 1092
1315 - $mediaRuleBodyMatcher = '[^{]*+{(?:[^{}]*+{.*})?\\s*+}\\s*+';
1316 -
1317 - $cssSplitForAllowedMediaTypes = \preg_split(
1318 - '#(@media\\s++(?:only\\s++)?+(?:(?=[{(])' . $mediaTypesExpression . ')' . $mediaRuleBodyMatcher
1319 - . ')#misU',
1320 - $css,
1321 - -1,
1322 - PREG_SPLIT_DELIM_CAPTURE
1093 + $media = '';
1094 + $cssForAllowedMediaTypes = preg_replace_callback(
1095 + '#@media\\s+(?:only\\s)?(?:[\\s{\\(]' . $mediaTypesExpression . ')\\s?[^{]+{.*}\\s*}\\s*#misU',
1096 + function ($matches) use (&$media) {
1097 + $media .= $matches[0];
1098 + },
1099 + $cssWithoutComments
1323 1100 );
1324 1101
1325 - // filter the CSS outside/between allowed @media rules
1326 - $cssCleaningMatchers = [
1327 - 'import/charset directives' => '/\\s*+@(?:import|charset)\\s[^;]++;/i',
1328 - 'remaining media enclosures' => '/\\s*+@media\\s' . $mediaRuleBodyMatcher . '/isU',
1102 + // filter the CSS
1103 + $search = [
1104 + 'import directives' => '/^\\s*@import\\s[^;]+;/misU',
1105 + 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
1329 1106 ];
1330 1107
1331 - $splitCss = [];
1332 - foreach ($cssSplitForAllowedMediaTypes as $index => $cssPart) {
1333 - $isMediaRule = $index % 2 !== 0;
1334 - if ($isMediaRule) {
1335 - \preg_match('/^([^{]*+){(.*)}[^}]*+$/s', $cssPart, $matches);
1336 - $splitCss[] = [
1337 - 'css' => $matches[2],
1338 - 'media' => $matches[1],
1339 - ];
1340 - } else {
1341 - $cleanedCss = \trim(\preg_replace($cssCleaningMatchers, '', $cssPart));
1342 - if ($cleanedCss !== '') {
1343 - $splitCss[] = [
1344 - 'css' => $cleanedCss,
1345 - 'media' => '',
1346 - ];
1347 - }
1348 - }
1349 - }
1350 - return $splitCss;
1108 + $cleanedCss = preg_replace($search, '', $cssForAllowedMediaTypes);
1109 +
1110 + return ['css' => $cleanedCss, 'media' => $media];
1351 1111 }
1352 1112
1353 1113 /**
1354 - * Removes empty unprocessable tags from the DOM document.
1114 + * Creates a DOMDocument instance with the current HTML.
1355 1115 *
1356 - * @return void
1116 + * @return \DOMDocument
1357 1117 */
1358 - private function removeUnprocessableTags()
1118 + private function createXmlDocument()
1359 1119 {
1360 - foreach ($this->unprocessableHtmlTags as $tagName) {
1361 - // Deleting nodes from a 'live' NodeList invalidates iteration on it, so a copy must be made to iterate.
1362 - $nodes = [];
1363 - foreach ($this->domDocument->getElementsByTagName($tagName) as $node) {
1364 - $nodes[] = $node;
1365 - }
1366 - /** @var \DOMNode $node */
1367 - foreach ($nodes as $node) {
1368 - if (!$node->hasChildNodes()) {
1369 - $node->parentNode->removeChild($node);
1370 - }
1371 - }
1120 + $xmlDocument = new \DOMDocument;
1121 + $xmlDocument->encoding = 'UTF-8';
1122 + $xmlDocument->strictErrorChecking = false;
1123 + $xmlDocument->formatOutput = true;
1124 + $libXmlState = libxml_use_internal_errors(true);
1125 + $xmlDocument->loadHTML($this->getUnifiedHtml());
1126 + libxml_clear_errors();
1127 + libxml_use_internal_errors($libXmlState);
1128 + $xmlDocument->normalizeDocument();
1129 +
1130 + return $xmlDocument;
1131 + }
1132 +
1133 + /**
1134 + * Returns the HTML with the unprocessable HTML tags removed and
1135 + * with added document type and Content-Type meta tag if needed.
1136 + *
1137 + * @return string the unified HTML
1138 + *
1139 + * @throws \BadMethodCallException
1140 + */
1141 + private function getUnifiedHtml()
1142 + {
1143 + $htmlWithoutUnprocessableTags = $this->removeUnprocessableTags($this->html);
1144 + $htmlWithDocumentType = $this->ensureDocumentType($htmlWithoutUnprocessableTags);
1145 +
1146 + return $this->addContentTypeMetaTag($htmlWithDocumentType);
1147 + }
1148 +
1149 + /**
1150 + * Removes the unprocessable tags from $html (if this feature is enabled).
1151 + *
1152 + * @param string $html
1153 + *
1154 + * @return string the reworked HTML with the unprocessable tags removed
1155 + */
1156 + private function removeUnprocessableTags($html)
1157 + {
1158 + if (empty($this->unprocessableHtmlTags)) {
1159 + return $html;
1372 1160 }
1161 +
1162 + $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
1163 +
1164 + return preg_replace(
1165 + '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
1166 + '',
1167 + $html
1168 + );
1373 1169 }
1374 1170
1375 1171 /**
1376 1172 * Makes sure that the passed HTML has a document type.
@@ -1380,9 +1176,9 @@
1380 1176 * @return string HTML with document type
1381 1177 */
1382 1178 private function ensureDocumentType($html)
1383 1179 {
1384 - $hasDocumentType = \stripos($html, '<!DOCTYPE') !== false;
1180 + $hasDocumentType = stripos($html, '<!DOCTYPE') !== false;
1385 1181 if ($hasDocumentType) {
1386 1182 return $html;
1387 1183 }
1388 1184
@@ -1391,10 +1187,8 @@
1391 1187
1392 1188 /**
1393 1189 * Adds a Content-Type meta tag for the charset.
1394 1190 *
1395 - * This method also ensures that there is a HEAD element.
1396 - *
1397 1191 * @param string $html
1398 1192 *
1399 1193 * @return string the HTML with the meta tag added
1400 1194 */
@@ -1399,9 +1193,9 @@
1399 1193 * @return string the HTML with the meta tag added
1400 1194 */
1401 1195 private function addContentTypeMetaTag($html)
1402 1196 {
1403 - $hasContentTypeMetaTag = \stripos($html, 'Content-Type') !== false;
1197 + $hasContentTypeMetaTag = stristr($html, 'Content-Type') !== false;
1404 1198 if ($hasContentTypeMetaTag) {
1405 1199 return $html;
1406 1200 }
1407 1201
@@ -1406,15 +1200,15 @@
1406 1200 }
1407 1201
1408 1202 // We are trying to insert the meta tag to the right spot in the DOM.
1409 1203 // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
1410 - $hasHeadTag = \stripos($html, '<head') !== false;
1411 - $hasHtmlTag = \stripos($html, '<html') !== false;
1204 + $hasHeadTag = stripos($html, '<head') !== false;
1205 + $hasHtmlTag = stripos($html, '<html') !== false;
1412 1206
1413 1207 if ($hasHeadTag) {
1414 - $reworkedHtml = \preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
1208 + $reworkedHtml = preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
1415 1209 } elseif ($hasHtmlTag) {
1416 - $reworkedHtml = \preg_replace(
1210 + $reworkedHtml = preg_replace(
1417 1211 '/<html(.*?)>/i',
1418 1212 '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
1419 1213 $html
1420 1214 );
@@ -1425,25 +1219,8 @@
1425 1219 return $reworkedHtml;
1426 1220 }
1427 1221
1428 1222 /**
1429 - * Makes sure that any self-closing tags not recognized as such by PHP's DOMDocument implementation have a
1430 - * self-closing slash.
1431 - *
1432 - * @param string $html
1433 - *
1434 - * @return string HTML with problematic tags converted.
1435 - */
1436 - private function ensurePhpUnrecognizedSelfClosingTagsAreXml($html)
1437 - {
1438 - return \preg_replace(
1439 - '%<' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '\\b[^>]*+(?<!/)(?=>)%',
1440 - '$0/',
1441 - $html
1442 - );
1443 - }
1444 -
1445 - /**
1446 1223 * @param string[] $a
1447 1224 * @param string[] $b
1448 1225 *
1449 1226 * @return int
@@ -1466,18 +1243,23 @@
1466 1243 * @return int
1467 1244 */
1468 1245 private function getCssSelectorPrecedence($selector)
1469 1246 {
1470 - $selectorKey = \md5($selector);
1247 + $selectorKey = md5($selector);
1471 1248 if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
1472 1249 $precedence = 0;
1473 - foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
1474 - if (\trim($selector) === '') {
1250 + $value = 100;
1251 + // ids: worth 100, classes: worth 10, elements: worth 1
1252 + $search = ['\\#','\\.',''];
1253 +
1254 + foreach ($search as $s) {
1255 + if (trim($selector) === '') {
1475 1256 break;
1476 1257 }
1477 1258 $number = 0;
1478 - $selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number);
1259 + $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
1479 1260 $precedence += ($value * $number);
1261 + $value /= 10;
1480 1262 }
1481 1263 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
1482 1264 }
1483 1265
@@ -1495,110 +1277,53 @@
1495 1277 */
1496 1278 private function translateCssToXpath($cssSelector)
1497 1279 {
1498 1280 $paddedSelector = ' ' . $cssSelector . ' ';
1499 - $lowercasePaddedSelector = \preg_replace_callback(
1281 + $lowercasePaddedSelector = preg_replace_callback(
1500 1282 '/\\s+\\w+\\s+/',
1501 - static function (array $matches) {
1502 - return \strtolower($matches[0]);
1283 + function (array $matches) {
1284 + return strtolower($matches[0]);
1503 1285 },
1504 1286 $paddedSelector
1505 1287 );
1506 - $trimmedLowercaseSelector = \trim($lowercasePaddedSelector);
1507 - $xPathKey = \md5($trimmedLowercaseSelector);
1508 - if (isset($this->caches[self::CACHE_KEY_XPATH][$xPathKey])) {
1509 - return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1510 - }
1288 + $trimmedLowercaseSelector = trim($lowercasePaddedSelector);
1289 + $xPathKey = md5($trimmedLowercaseSelector);
1290 + if (!isset($this->caches[self::CACHE_KEY_XPATH][$xPathKey])) {
1291 + $roughXpath = '//' . preg_replace(
1292 + array_keys($this->xPathRules),
1293 + $this->xPathRules,
1294 + $trimmedLowercaseSelector
1295 + );
1296 + $xPathWithIdAttributeMatchers = preg_replace_callback(
1297 + self::ID_ATTRIBUTE_MATCHER,
1298 + [$this, 'matchIdAttributes'],
1299 + $roughXpath
1300 + );
1301 + $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1302 + self::CLASS_ATTRIBUTE_MATCHER,
1303 + [$this, 'matchClassAttributes'],
1304 + $xPathWithIdAttributeMatchers
1305 + );
1511 1306
1512 - $hasNotSelector = (bool)\preg_match(
1513 - '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/',
1514 - $trimmedLowercaseSelector,
1515 - $matches
1516 - );
1517 - if ($hasNotSelector) {
1518 - /** @var string[] $matches */
1519 - list(, $partBeforeNot, $notContents) = $matches;
1520 - $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) .
1521 - '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]';
1522 - } else {
1523 - $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector);
1307 + // Advanced selectors are going to require a bit more advanced emogrification.
1308 + // When we required PHP 5.3, we could do this with closures.
1309 + $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1310 + '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1311 + [$this, 'translateNthChild'],
1312 + $xPathWithIdAttributeAndClassMatchers
1313 + );
1314 + $finalXpath = preg_replace_callback(
1315 + '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1316 + [$this, 'translateNthOfType'],
1317 + $xPathWithIdAttributeAndClassMatchers
1318 + );
1319 +
1320 + $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $finalXpath;
1524 1321 }
1525 - $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $xPath;
1526 -
1527 1322 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1528 1323 }
1529 1324
1530 1325 /**
1531 - * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector.
1532 - *
1533 - * @param string $trimmedLowercaseSelector
1534 - *
1535 - * @return string
1536 - */
1537 - private function translateCssToXpathPass($trimmedLowercaseSelector)
1538 - {
1539 - return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1540 - $trimmedLowercaseSelector,
1541 - [$this, 'matchClassAttributes']
1542 - );
1543 - }
1544 -
1545 - /**
1546 - * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector for inline usage.
1547 - *
1548 - * @param string $trimmedLowercaseSelector
1549 - *
1550 - * @return string
1551 - */
1552 - private function translateCssToXpathPassInline($trimmedLowercaseSelector)
1553 - {
1554 - return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1555 - $trimmedLowercaseSelector,
1556 - [$this, 'matchClassAttributesInline']
1557 - );
1558 - }
1559 -
1560 - /**
1561 - * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector while using
1562 - * $matchClassAttributesCallback as to match the class attributes.
1563 - *
1564 - * @param string $trimmedLowercaseSelector
1565 - * @param callable $matchClassAttributesCallback
1566 - *
1567 - * @return string
1568 - */
1569 - private function translateCssToXpathPassWithMatchClassAttributesCallback(
1570 - $trimmedLowercaseSelector,
1571 - callable $matchClassAttributesCallback
1572 - ) {
1573 - $roughXpath = \preg_replace(\array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector);
1574 - $xPathWithIdAttributeMatchers = \preg_replace_callback(
1575 - self::ID_ATTRIBUTE_MATCHER,
1576 - [$this, 'matchIdAttributes'],
1577 - $roughXpath
1578 - );
1579 - $xPathWithIdAttributeAndClassMatchers = \preg_replace_callback(
1580 - self::CLASS_ATTRIBUTE_MATCHER,
1581 - $matchClassAttributesCallback,
1582 - $xPathWithIdAttributeMatchers
1583 - );
1584 -
1585 - // Advanced selectors are going to require a bit more advanced emogrification.
1586 - $xPathWithIdAttributeAndClassMatchers = \preg_replace_callback(
1587 - '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1588 - [$this, 'translateNthChild'],
1589 - $xPathWithIdAttributeAndClassMatchers
1590 - );
1591 - $finalXpath = \preg_replace_callback(
1592 - '/([^\\/]+):nth-of-type\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1593 - [$this, 'translateNthOfType'],
1594 - $xPathWithIdAttributeAndClassMatchers
1595 - );
1596 -
1597 - return $finalXpath;
1598 - }
1599 -
1600 - /**
1601 1326 * @param string[] $match
1602 1327 *
1603 1328 * @return string
1604 1329 */
@@ -1609,30 +1334,22 @@
1609 1334
1610 1335 /**
1611 1336 * @param string[] $match
1612 1337 *
1613 - * @return string xPath class attribute query wrapped in element selector
1338 + * @return string
1614 1339 */
1615 1340 private function matchClassAttributes(array $match)
1616 1341 {
1617 - return ($match[1] !== '' ? $match[1] : '*') . '[' . $this->matchClassAttributesInline($match) . ']';
1342 + return ($match[1] !== '' ? $match[1] : '*') . '[contains(concat(" ",@class," "),concat(" ","' .
1343 + implode(
1344 + '"," "))][contains(concat(" ",@class," "),concat(" ","',
1345 + explode('.', substr($match[2], 1))
1346 + ) . '"," "))]';
1618 1347 }
1619 1348
1620 1349 /**
1621 1350 * @param string[] $match
1622 1351 *
1623 - * @return string xPath class attribute query
1624 - */
1625 - private function matchClassAttributesInline(array $match)
1626 - {
1627 - return 'contains(concat(" ",@class," "),concat(" ","' .
1628 - \str_replace('.', '"," "))][contains(concat(" ",@class," "),concat(" ","', \substr($match[2], 1)) .
1629 - '"," "))';
1630 - }
1631 -
1632 - /**
1633 - * @param string[] $match
1634 - *
1635 1352 * @return string
1636 1353 */
1637 1354 private function translateNthChild(array $match)
1638 1355 {
@@ -1639,18 +1356,18 @@
1639 1356 $parseResult = $this->parseNth($match);
1640 1357
1641 1358 if (isset($parseResult[self::MULTIPLIER])) {
1642 1359 if ($parseResult[self::MULTIPLIER] < 0) {
1643 - $parseResult[self::MULTIPLIER] = \abs($parseResult[self::MULTIPLIER]);
1644 - $xPathExpression = \sprintf(
1645 - '*[(last() - position()) mod %1%u = %2$u]/self::%3$s',
1360 + $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1361 + $xPathExpression = sprintf(
1362 + '*[(last() - position()) mod %u = %u]/self::%s',
1646 1363 $parseResult[self::MULTIPLIER],
1647 1364 $parseResult[self::INDEX],
1648 1365 $match[1]
1649 1366 );
1650 1367 } else {
1651 - $xPathExpression = \sprintf(
1652 - '*[position() mod %1$u = %2$u]/self::%3$s',
1368 + $xPathExpression = sprintf(
1369 + '*[position() mod %u = %u]/self::%s',
1653 1370 $parseResult[self::MULTIPLIER],
1654 1371 $parseResult[self::INDEX],
1655 1372 $match[1]
1656 1373 );
@@ -1655,9 +1372,9 @@
1655 1372 $match[1]
1656 1373 );
1657 1374 }
1658 1375 } else {
1659 - $xPathExpression = \sprintf('*[%1$u]/self::%2$s', $parseResult[self::INDEX], $match[1]);
1376 + $xPathExpression = sprintf('*[%u]/self::%s', $parseResult[self::INDEX], $match[1]);
1660 1377 }
1661 1378
1662 1379 return $xPathExpression;
1663 1380 }
@@ -1672,18 +1389,18 @@
1672 1389 $parseResult = $this->parseNth($match);
1673 1390
1674 1391 if (isset($parseResult[self::MULTIPLIER])) {
1675 1392 if ($parseResult[self::MULTIPLIER] < 0) {
1676 - $parseResult[self::MULTIPLIER] = \abs($parseResult[self::MULTIPLIER]);
1677 - $xPathExpression = \sprintf(
1678 - '%1$s[(last() - position()) mod %2$u = %3$u]',
1393 + $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1394 + $xPathExpression = sprintf(
1395 + '%s[(last() - position()) mod %u = %u]',
1679 1396 $match[1],
1680 1397 $parseResult[self::MULTIPLIER],
1681 1398 $parseResult[self::INDEX]
1682 1399 );
1683 1400 } else {
1684 - $xPathExpression = \sprintf(
1685 - '%1$s[position() mod %2$u = %3$u]',
1401 + $xPathExpression = sprintf(
1402 + '%s[position() mod %u = %u]',
1686 1403 $match[1],
1687 1404 $parseResult[self::MULTIPLIER],
1688 1405 $parseResult[self::INDEX]
1689 1406 );
@@ -1688,9 +1405,9 @@
1688 1405 $parseResult[self::INDEX]
1689 1406 );
1690 1407 }
1691 1408 } else {
1692 - $xPathExpression = \sprintf('%1$s[%2$u]', $match[1], $parseResult[self::INDEX]);
1409 + $xPathExpression = sprintf('%s[%u]', $match[1], $parseResult[self::INDEX]);
1693 1410 }
1694 1411
1695 1412 return $xPathExpression;
1696 1413 }
@@ -1701,28 +1418,28 @@
1701 1418 * @return int[]
1702 1419 */
1703 1420 private function parseNth(array $match)
1704 1421 {
1705 - if (\in_array(\strtolower($match[2]), ['even', 'odd'], true)) {
1422 + if (in_array(strtolower($match[2]), ['even', 'odd'], true)) {
1706 1423 // we have "even" or "odd"
1707 - $index = \strtolower($match[2]) === 'even' ? 0 : 1;
1424 + $index = strtolower($match[2]) === 'even' ? 0 : 1;
1708 1425 return [self::MULTIPLIER => 2, self::INDEX => $index];
1709 1426 }
1710 - if (\stripos($match[2], 'n') === false) {
1427 + if (stripos($match[2], 'n') === false) {
1711 1428 // if there is a multiplier
1712 - $index = (int)\str_replace(' ', '', $match[2]);
1429 + $index = (int) str_replace(' ', '', $match[2]);
1713 1430 return [self::INDEX => $index];
1714 1431 }
1715 1432
1716 1433 if (isset($match[3])) {
1717 - $multipleTerm = \str_replace($match[3], '', $match[2]);
1718 - $index = (int)\str_replace(' ', '', $match[3]);
1434 + $multipleTerm = str_replace($match[3], '', $match[2]);
1435 + $index = (int) str_replace(' ', '', $match[3]);
1719 1436 } else {
1720 1437 $multipleTerm = $match[2];
1721 1438 $index = 0;
1722 1439 }
1723 1440
1724 - $multiplier = \str_ireplace('n', '', $multipleTerm);
1441 + $multiplier = str_ireplace('n', '', $multipleTerm);
1725 1442
1726 1443 if ($multiplier === '') {
1727 1444 $multiplier = 1;
1728 1445 } elseif ($multiplier === '0') {
@@ -1727,13 +1444,13 @@
1727 1444 $multiplier = 1;
1728 1445 } elseif ($multiplier === '0') {
1729 1446 return [self::INDEX => $index];
1730 1447 } else {
1731 - $multiplier = (int)$multiplier;
1448 + $multiplier = (int) $multiplier;
1732 1449 }
1733 1450
1734 1451 while ($index < 0) {
1735 - $index += \abs($multiplier);
1452 + $index += abs($multiplier);
1736 1453 }
1737 1454
1738 1455 return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
1739 1456 }
@@ -1763,15 +1480,17 @@
1763 1480 return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
1764 1481 }
1765 1482
1766 1483 $properties = [];
1767 - foreach (\preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock) as $declaration) {
1484 + $declarations = preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
1485 +
1486 + foreach ($declarations as $declaration) {
1768 1487 $matches = [];
1769 - if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
1488 + if (!preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches)) {
1770 1489 continue;
1771 1490 }
1772 1491
1773 - $propertyName = \strtolower($matches[1]);
1492 + $propertyName = strtolower($matches[1]);
1774 1493 $propertyValue = $matches[2];
1775 1494 $properties[$propertyName] = $propertyValue;
1776 1495 }
1777 1496 $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
@@ -1781,25 +1500,17 @@
1781 1500
1782 1501 /**
1783 1502 * Find the nodes that are not to be emogrified.
1784 1503 *
1504 + * @param \DOMXPath $xPath
1505 + *
1785 1506 * @return \DOMElement[]
1786 - *
1787 - * @throws \InvalidArgumentException
1788 1507 */
1789 - private function getNodesToExclude()
1508 + private function getNodesToExclude(\DOMXPath $xPath)
1790 1509 {
1791 1510 $excludedNodes = [];
1792 - foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
1793 - try {
1794 - $matchingNodes = $this->xPath->query($this->translateCssToXpath($selectorToExclude));
1795 - } catch (\InvalidArgumentException $e) {
1796 - if ($this->debug) {
1797 - throw $e;
1798 - }
1799 - continue;
1800 - }
1801 - foreach ($matchingNodes as $node) {
1511 + foreach (array_keys($this->excludedSelectors) as $selectorToExclude) {
1512 + foreach ($xPath->query($this->translateCssToXpath($selectorToExclude)) as $node) {
1802 1513 $excludedNodes[] = $node;
1803 1514 }
1804 1515 }
1805 1516
@@ -1806,11 +1517,11 @@
1806 1517 return $excludedNodes;
1807 1518 }
1808 1519
1809 1520 /**
1810 - * Handles invalid xPath expression warnings, generated during the process() method,
1811 - * during querying \DOMDocument and trigger an \InvalidArgumentException with an invalid selector
1812 - * or \RuntimeException, depending on the source of the warning.
1521 + * Handles invalid xPath expression warnings, generated by process() method,
1522 + * during querying \DOMDocument and trigger \InvalidArgumentException
1523 + * with invalid selector.
1813 1524 *
1814 1525 * @param int $type
1815 1526 * @param string $message
1816 1527 * @param string $file
@@ -1819,44 +1530,23 @@
1819 1530 *
1820 1531 * @return bool always false
1821 1532 *
1822 1533 * @throws \InvalidArgumentException
1823 - * @throws \RuntimeException
1824 1534 */
1825 - public function handleXpathQueryWarnings(// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
1826 - $type,
1827 - $message,
1828 - $file,
1829 - $line,
1830 - array $context
1831 - ) {
1832 - $selector = '';
1833 - if (isset($context['cssRule']['selector'])) {
1834 - // warnings generated by invalid/unrecognized selectors in method process()
1835 - $selector = $context['cssRule']['selector'];
1836 - } elseif (isset($context['selectorToExclude'])) {
1837 - // warnings generated by invalid/unrecognized selectors in method getNodesToExclude()
1838 - $selector = $context['selectorToExclude'];
1839 - } elseif (isset($context['cssSelector'])) {
1840 - // warnings generated by invalid/unrecognized selectors in method existsMatchForCssSelector()
1841 - $selector = $context['cssSelector'];
1842 - }
1843 -
1844 - if ($selector !== '') {
1535 + public function handleXpathError($type, $message, $file, $line, array $context)
1536 + {
1537 + if ($this->debug && $type === E_WARNING && isset($context['cssRule']['selector'])) {
1845 1538 throw new \InvalidArgumentException(
1846 - \sprintf('%1$s in selector >> %2$s << in %3$s on line %4$u', $message, $selector, $file, $line),
1847 - 1509279985
1539 + sprintf(
1540 + '%s in selector >> %s << in %s on line %s',
1541 + $message,
1542 + $context['cssRule']['selector'],
1543 + $file,
1544 + $line
1545 + )
1848 1546 );
1849 1547 }
1850 1548
1851 - // Catches eventual warnings generated by method getAllNodesWithStyleAttribute()
1852 - if (isset($context['xPath'])) {
1853 - throw new \RuntimeException(
1854 - \sprintf('%1$s in %2$s on line %3$u', $message, $file, $line),
1855 - 1509280067
1856 - );
1857 - }
1858 -
1859 1549 // the normal error handling continues when handler return false
1860 1550 return false;
1861 1551 }
1862 1552
@@ -1870,5 +1560,5 @@
1870 1560 public function setDebug($debug)
1871 1561 {
1872 1562 $this->debug = $debug;
1873 1563 }
1874 -}
1564 +}