PluginProbe
weForms – Easy Drag & Drop Contact Form Builder For WordPress / 1.0.1
weForms – Easy Drag & Drop Contact Form Builder For WordPress v1.0.1
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 +1564 -1532 1.6.21.0.1 View file →
@@ -1,1532 +1,1564 @@
1 -<?php
2 -
3 -use BadMethodCallException;
4 -use InvalidArgumentException;
5 -
6 -/**
7 - * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
8 - *
9 - * For more information, please see the README.md file.
10 - *
11 - * @version 1.2.0
12 - *
13 - * @author Cameron Brooks
14 - * @author Jaime Prado
15 - * @author Oliver Klee <typo3-coding@oliverklee.de>
16 - * @author Roman Ožana <ozana@omdesign.cz>
17 - * @author Sander Kruger <s.kruger@invessel.com>
18 - */
19 -class Emogrifier {
20 - /**
21 - * @var int
22 - */
23 - const CACHE_KEY_CSS = 0;
24 -
25 - /**
26 - * @var int
27 - */
28 - const CACHE_KEY_SELECTOR = 1;
29 -
30 - /**
31 - * @var int
32 - */
33 - const CACHE_KEY_XPATH = 2;
34 -
35 - /**
36 - * @var int
37 - */
38 - const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 3;
39 -
40 - /**
41 - * @var int
42 - */
43 - const CACHE_KEY_COMBINED_STYLES = 4;
44 -
45 - /**
46 - * for calculating nth-of-type and nth-child selectors
47 - *
48 - * @var int
49 - */
50 - const INDEX = 0;
51 -
52 - /**
53 - * for calculating nth-of-type and nth-child selectors
54 - *
55 - * @var int
56 - */
57 - const MULTIPLIER = 1;
58 -
59 - /**
60 - * @var string
61 - */
62 - const ID_ATTRIBUTE_MATCHER = '/(\\w+)?\\#([\\w\\-]+)/';
63 -
64 - /**
65 - * @var string
66 - */
67 - const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
68 -
69 - /**
70 - * @var string
71 - */
72 - const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
73 -
74 - /**
75 - * @var string
76 - */
77 - const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
78 -
79 - /**
80 - * @var string
81 - */
82 - private $html = '';
83 -
84 - /**
85 - * @var string
86 - */
87 - private $css = '';
88 -
89 - /**
90 - * @var bool[]
91 - */
92 - private $excludedSelectors = [];
93 -
94 - /**
95 - * @var string[]
96 - */
97 - private $unprocessableHtmlTags = ['wbr'];
98 -
99 - /**
100 - * @var bool[]
101 - */
102 - private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
103 -
104 - /**
105 - * @var mixed[]
106 - */
107 - private $caches = [
108 - self::CACHE_KEY_CSS => [],
109 - self::CACHE_KEY_SELECTOR => [],
110 - self::CACHE_KEY_XPATH => [],
111 - self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
112 - self::CACHE_KEY_COMBINED_STYLES => [],
113 - ];
114 -
115 - /**
116 - * the visited nodes with the XPath paths as array keys
117 - *
118 - * @var \DOMElement[]
119 - */
120 - private $visitedNodes = [];
121 -
122 - /**
123 - * the styles to apply to the nodes with the XPath paths as array keys for the outer array
124 - * and the attribute names/values as key/value pairs for the inner array
125 - *
126 - * @var string[][]
127 - */
128 - private $styleAttributesForNodes = [];
129 -
130 - /**
131 - * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
132 - * If set to false, the value of the style attributes will be discarded.
133 - *
134 - * @var bool
135 - */
136 - private $isInlineStyleAttributesParsingEnabled = true;
137 -
138 - /**
139 - * Determines whether the <style> blocks in the HTML passed to this class should be parsed.
140 - *
141 - * If set to true, the <style> blocks will be removed from the HTML and their contents will be applied to the HTML
142 - * via inline styles.
143 - *
144 - * If set to false, the <style> blocks will be left as they are in the HTML.
145 - *
146 - * @var bool
147 - */
148 - private $isStyleBlocksParsingEnabled = true;
149 -
150 - /**
151 - * Determines whether elements with the `display: none` property are
152 - * removed from the DOM.
153 - *
154 - * @var bool
155 - */
156 - private $shouldKeepInvisibleNodes = true;
157 -
158 - /**
159 - * @var string[]
160 - */
161 - private $xPathRules = [
162 - // child
163 - '/\\s*>\\s*/' => '/',
164 - // adjacent sibling
165 - '/\\s+\\+\\s+/' => '/following-sibling::*[1]/self::',
166 - // descendant
167 - '/\\s+(?=.*[^\\]]{1}$)/' => '//',
168 - // :first-child
169 - '/([^\\/]+):first-child/i' => '*[1]/self::\\1',
170 - // :last-child
171 - '/([^\\/]+):last-child/i' => '*[last()]/self::\\1',
172 - // attribute only
173 - '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/' => '*[@\\1]',
174 - // attribute
175 - '/(\\w)\\[(\\w+)\\]/' => '\\1[@\\2]',
176 - // exact attribute
177 - '/(\\w)\\[(\\w+)\\=[\'"]?([\\w\\s]+)[\'"]?\\]/' => '\\1[@\\2="\\3"]',
178 - // element attribute~=
179 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/' => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
180 - // element attribute^=
181 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
182 - // element attribute*=
183 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
184 - // element attribute$=
185 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/' => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
186 - // element attribute|=
187 - '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/' => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
188 - ];
189 -
190 - /**
191 - * Determines whether CSS styles that have an equivalent HTML attribute
192 - * should be mapped and attached to those elements.
193 - *
194 - * @var bool
195 - */
196 - private $shouldMapCssToHtml = false;
197 -
198 - /**
199 - * This multi-level array contains simple mappings of CSS properties to
200 - * HTML attributes. If a mapping only applies to certain HTML nodes or
201 - * only for certain values, the mapping is an object with a whitelist
202 - * of nodes and values.
203 - *
204 - * @var mixed[][]
205 - */
206 - private $cssToHtmlMap = [
207 - 'background-color' => [
208 - 'attribute' => 'bgcolor',
209 - ],
210 - 'text-align' => [
211 - 'attribute' => 'align',
212 - 'nodes' => ['p', 'div', 'td'],
213 - 'values' => ['left', 'right', 'center', 'justify'],
214 - ],
215 - 'float' => [
216 - 'attribute' => 'align',
217 - 'nodes' => ['table', 'img'],
218 - 'values' => ['left', 'right'],
219 - ],
220 - 'border-spacing' => [
221 - 'attribute' => 'cellspacing',
222 - 'nodes' => ['table'],
223 - ],
224 - ];
225 -
226 - /**
227 - * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
228 - *
229 - * @var bool
230 - */
231 - private $debug = false;
232 -
233 - /**
234 - * The constructor.
235 - *
236 - * @param string $html the HTML to emogrify, must be UTF-8-encoded
237 - * @param string $css the CSS to merge, must be UTF-8-encoded
238 - */
239 - public function __construct( $html = '', $css = '' ) {
240 - $this->setHtml( $html );
241 - $this->setCss( $css );
242 - }
243 -
244 - /**
245 - * The destructor.
246 - */
247 - public function __destruct() {
248 - $this->purgeVisitedNodes();
249 - }
250 -
251 - /**
252 - * Sets the HTML to emogrify.
253 - *
254 - * @param string $html the HTML to emogrify, must be UTF-8-encoded
255 - *
256 - * @return void
257 - */
258 - public function setHtml( $html ) {
259 - $this->html = $html;
260 - }
261 -
262 - /**
263 - * Sets the CSS to merge with the HTML.
264 - *
265 - * @param string $css the CSS to merge, must be UTF-8-encoded
266 - *
267 - * @return void
268 - */
269 - public function setCss( $css ) {
270 - $this->css = $css;
271 - }
272 -
273 - /**
274 - * Applies $this->css to $this->html and returns the HTML with the CSS
275 - * applied.
276 - *
277 - * This method places the CSS inline.
278 - *
279 - * @return string
280 - *
281 - * @throws BadMethodCallException
282 - */
283 - public function emogrify() {
284 - if ( $this->html === '' ) {
285 - throw new BadMethodCallException( 'Please set some HTML first before calling emogrify.', 1390393096 );
286 - }
287 -
288 - $xmlDocument = $this->createXmlDocument();
289 - $this->process( $xmlDocument );
290 -
291 - return $xmlDocument->saveHTML();
292 - }
293 -
294 - /**
295 - * Applies $this->css to $this->html and returns only the HTML content
296 - * within the <body> tag.
297 - *
298 - * This method places the CSS inline.
299 - *
300 - * @return string
301 - *
302 - * @throws BadMethodCallException
303 - */
304 - public function emogrifyBodyContent() {
305 - if ( $this->html === '' ) {
306 - throw new BadMethodCallException( 'Please set some HTML first before calling emogrify.', 1390393096 );
307 - }
308 -
309 - $xmlDocument = $this->createXmlDocument();
310 - $this->process( $xmlDocument );
311 -
312 - $innerDocument = new \DOMDocument();
313 -
314 - foreach ( $xmlDocument->documentElement->getElementsByTagName( 'body' )->item( 0 )->childNodes as $childNode ) {
315 - $innerDocument->appendChild( $innerDocument->importNode( $childNode, true ) );
316 - }
317 -
318 - return html_entity_decode( $innerDocument->saveHTML() );
319 - }
320 -
321 - /**
322 - * Applies $this->css to $xmlDocument.
323 - *
324 - * This method places the CSS inline.
325 - *
326 - * @param \DOMDocument $xmlDocument
327 - *
328 - * @return void
329 - *
330 - * @throws InvalidArgumentException
331 - */
332 - protected function process( DOMDocument $xmlDocument ) {
333 - $xPath = new \DOMXPath( $xmlDocument );
334 - $this->clearAllCaches();
335 -
336 - // Before be begin processing the CSS file, parse the document and normalize all existing CSS attributes.
337 - // This changes 'DISPLAY: none' to 'display: none'.
338 - // We wouldn't have to do this if DOMXPath supported XPath 2.0.
339 - // Also store a reference of nodes with existing inline styles so we don't overwrite them.
340 - $this->purgeVisitedNodes();
341 -
342 - set_error_handler( [$this, 'handleXpathError'], E_WARNING );
343 -
344 - $nodesWithStyleAttributes = $xPath->query( '//*[@style]' );
345 -
346 - if ( $nodesWithStyleAttributes !== false ) {
347 - /** @var \DOMElement $node */
348 - foreach ( $nodesWithStyleAttributes as $node ) {
349 - if ( $this->isInlineStyleAttributesParsingEnabled ) {
350 - $this->normalizeStyleAttributes( $node );
351 - } else {
352 - $node->removeAttribute( 'style' );
353 - }
354 - }
355 - }
356 -
357 - // grab any existing style blocks from the html and append them to the existing CSS
358 - // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
359 - $allCss = $this->css;
360 -
361 - if ( $this->isStyleBlocksParsingEnabled ) {
362 - $allCss .= $this->getCssFromAllStyleNodes( $xPath );
363 - }
364 -
365 - $cssParts = $this->splitCssAndMediaQuery( $allCss );
366 - $excludedNodes = $this->getNodesToExclude( $xPath );
367 - $cssRules = $this->parseCssRules( $cssParts['css'] );
368 -
369 - foreach ( $cssRules as $cssRule ) {
370 - // query the body for the xpath selector
371 - $nodesMatchingCssSelectors = $xPath->query( $this->translateCssToXpath( $cssRule['selector'] ) );
372 - // ignore invalid selectors
373 - if ( $nodesMatchingCssSelectors === false ) {
374 - continue;
375 - }
376 -
377 - /** @var \DOMElement $node */
378 - foreach ( $nodesMatchingCssSelectors as $node ) {
379 - if ( in_array( $node, $excludedNodes, true ) ) {
380 - continue;
381 - }
382 -
383 - // if it has a style attribute, get it, process it, and append (overwrite) new stuff
384 - if ( $node->hasAttribute( 'style' ) ) {
385 - // break it up into an associative array
386 - $oldStyleDeclarations = $this->parseCssDeclarationsBlock( $node->getAttribute( 'style' ) );
387 - } else {
388 - $oldStyleDeclarations = [];
389 - }
390 - $newStyleDeclarations = $this->parseCssDeclarationsBlock( $cssRule['declarationsBlock'] );
391 -
392 - if ( $this->shouldMapCssToHtml ) {
393 - $this->mapCssToHtmlAttributes( $newStyleDeclarations, $node );
394 - }
395 - $node->setAttribute(
396 - 'style',
397 - $this->generateStyleStringFromDeclarationsArrays( $oldStyleDeclarations, $newStyleDeclarations )
398 - );
399 - }
400 - }
401 -
402 - restore_error_handler();
403 -
404 - if ( $this->isInlineStyleAttributesParsingEnabled ) {
405 - $this->fillStyleAttributesWithMergedStyles();
406 - }
407 -
408 - if ( $this->shouldKeepInvisibleNodes ) {
409 - $this->removeInvisibleNodes( $xPath );
410 - }
411 -
412 - $this->copyCssWithMediaToStyleNode( $xmlDocument, $xPath, $cssParts['media'] );
413 - }
414 -
415 - /**
416 - * Applies $styles to $node.
417 - *
418 - * This method maps CSS styles to HTML attributes and adds those to the
419 - * node.
420 - *
421 - * @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
422 - * @param \DOMNode $node node to apply styles to
423 - *
424 - * @return void
425 - */
426 - private function mapCssToHtmlAttributes( array $styles, DOMNode $node ) {
427 - foreach ( $styles as $property => $value ) {
428 - // Strip !important indicator
429 - $value = trim( str_replace( '!important', '', $value ) );
430 - $this->mapCssToHtmlAttribute( $property, $value, $node );
431 - }
432 - }
433 -
434 - /**
435 - * Tries to apply the CSS style to $node as an attribute.
436 - *
437 - * This method maps a CSS rule to HTML attributes and adds those to the node.
438 - *
439 - * @param string $property the name of the CSS property to map
440 - * @param string $value the value of the style rule to map
441 - * @param \DOMNode $node node to apply styles to
442 - *
443 - * @return void
444 - */
445 - private function mapCssToHtmlAttribute( $property, $value, DOMNode $node ) {
446 - if ( !$this->mapSimpleCssProperty( $property, $value, $node ) ) {
447 - $this->mapComplexCssProperty( $property, $value, $node );
448 - }
449 - }
450 -
451 - /**
452 - * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
453 - *
454 - * @param string $property the name of the CSS property to map
455 - * @param string $value the value of the style rule to map
456 - * @param \DOMNode $node node to apply styles to
457 - *
458 - * @return bool true if the property cab be mapped using the simple mapping table
459 - */
460 - private function mapSimpleCssProperty( $property, $value, DOMNode $node ) {
461 - if ( !isset( $this->cssToHtmlMap[$property] ) ) {
462 - return false;
463 - }
464 -
465 - $mapping = $this->cssToHtmlMap[$property];
466 - $nodesMatch = !isset( $mapping['nodes'] ) || in_array( $node->nodeName, $mapping['nodes'], true );
467 - $valuesMatch = !isset( $mapping['values'] ) || in_array( $value, $mapping['values'], true );
468 -
469 - if ( !$nodesMatch || !$valuesMatch ) {
470 - return false;
471 - }
472 -
473 - $node->setAttribute( $mapping['attribute'], $value );
474 -
475 - return true;
476 - }
477 -
478 - /**
479 - * Maps CSS properties that need special transformation to an HTML attribute.
480 - *
481 - * @param string $property the name of the CSS property to map
482 - * @param string $value the value of the style rule to map
483 - * @param \DOMNode $node node to apply styles to
484 - *
485 - * @return void
486 - */
487 - private function mapComplexCssProperty( $property, $value, DOMNode $node ) {
488 - $nodeName = $node->nodeName;
489 - $isTable = $nodeName === 'table';
490 - $isImage = $nodeName === 'img';
491 - $isTableOrImage = $isTable || $isImage;
492 -
493 - switch ( $property ) {
494 - case 'background':
495 - // Parse out the color, if any
496 - $styles = explode( ' ', $value );
497 - $first = $styles[0];
498 -
499 - if ( !is_numeric( substr( $first, 0, 1 ) ) && substr( $first, 0, 3 ) !== 'url' ) {
500 - // This is not a position or image, assume it's a color
501 - $node->setAttribute( 'bgcolor', $first );
502 - }
503 - break;
504 -
505 - case 'width':
506 - // intentional fall-through
507 - case 'height':
508 - // Only parse values in px and %, but not values like "auto".
509 - if ( preg_match( '/^\d+(px|%)$/', $value ) ) {
510 - // Remove 'px'. This regex only conserves numbers and %
511 - $number = preg_replace( '/[^0-9.%]/', '', $value );
512 - $node->setAttribute( $property, $number );
513 - }
514 - break;
515 -
516 - case 'margin':
517 - if ( $isTableOrImage ) {
518 - $margins = $this->parseCssShorthandValue( $value );
519 -
520 - if ( $margins['left'] === 'auto' && $margins['right'] === 'auto' ) {
521 - $node->setAttribute( 'align', 'center' );
522 - }
523 - }
524 - break;
525 -
526 - case 'border':
527 - if ( $isTableOrImage ) {
528 - if ( $value === 'none' || $value === '0' ) {
529 - $node->setAttribute( 'border', '0' );
530 - }
531 - }
532 - break;
533 - default:
534 - }
535 - }
536 -
537 - /**
538 - * Parses a shorthand CSS value and splits it into individual values
539 - *
540 - * @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
541 - * For example: padding: 0 auto;
542 - * '0 auto' is split into top: 0, left: auto, bottom: 0,
543 - * right: auto
544 - *
545 - * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
546 - */
547 - private function parseCssShorthandValue( $value ) {
548 - $values = preg_split( '/\\s+/', $value );
549 -
550 - $css = [];
551 - $css['top'] = $values[0];
552 - $css['right'] = ( count( $values ) > 1 ) ? $values[1] : $css['top'];
553 - $css['bottom'] = ( count( $values ) > 2 ) ? $values[2] : $css['top'];
554 - $css['left'] = ( count( $values ) > 3 ) ? $values[3] : $css['right'];
555 -
556 - return $css;
557 - }
558 -
559 - /**
560 - * Extracts and parses the individual rules from a CSS string.
561 - *
562 - * @param string $css a string of raw CSS code
563 - *
564 - * @return string[][] an array of string sub-arrays with the keys
565 - * "selector" (the CSS selector(s), e.g., "*" or "h1"),
566 - * "declarationsBLock" (the semicolon-separated CSS declarations for that selector(s),
567 - * e.g., "color: red; height: 4px;"),
568 - * and "line" (the line number e.g. 42)
569 - */
570 - private function parseCssRules( $css ) {
571 - $cssKey = md5( $css );
572 -
573 - if ( !isset( $this->caches[self::CACHE_KEY_CSS][$cssKey] ) ) {
574 - // process the CSS file for selectors and definitions
575 - preg_match_all( '/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mis', $css, $matches, PREG_SET_ORDER );
576 -
577 - $cssRules = [];
578 - /** @var string[] $cssRule */
579 - foreach ( $matches as $key => $cssRule ) {
580 - $cssDeclaration = trim( $cssRule[2] );
581 -
582 - if ( $cssDeclaration === '' ) {
583 - continue;
584 - }
585 -
586 - $selectors = explode( ',', $cssRule[1] );
587 -
588 - foreach ( $selectors as $selector ) {
589 - // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
590 - // only allow structural pseudo-classes
591 - $hasPseudoElement = strpos( $selector, '::' ) !== false;
592 - $hasAnyPseudoClass = (bool) preg_match( '/:[a-zA-Z]/', $selector );
593 - $hasSupportedPseudoClass = (bool) preg_match( '/:\\S+\\-(child|type\\()/i', $selector );
594 -
595 - if ( $hasPseudoElement || ( $hasAnyPseudoClass && !$hasSupportedPseudoClass ) ) {
596 - continue;
597 - }
598 -
599 - $cssRules[] = [
600 - 'selector' => trim( $selector ),
601 - 'declarationsBlock' => $cssDeclaration,
602 - // keep track of where it appears in the file, since order is important
603 - 'line' => $key,
604 - ];
605 - }
606 - }
607 -
608 - usort( $cssRules, [$this, 'sortBySelectorPrecedence'] );
609 -
610 - $this->caches[self::CACHE_KEY_CSS][$cssKey] = $cssRules;
611 - }
612 -
613 - return $this->caches[self::CACHE_KEY_CSS][$cssKey];
614 - }
615 -
616 - /**
617 - * Disables the parsing of inline styles.
618 - *
619 - * @return void
620 - */
621 - public function disableInlineStyleAttributesParsing() {
622 - $this->isInlineStyleAttributesParsingEnabled = false;
623 - }
624 -
625 - /**
626 - * Disables the parsing of <style> blocks.
627 - *
628 - * @return void
629 - */
630 - public function disableStyleBlocksParsing() {
631 - $this->isStyleBlocksParsingEnabled = false;
632 - }
633 -
634 - /**
635 - * Disables the removal of elements with `display: none` properties.
636 - *
637 - * @return void
638 - */
639 - public function disableInvisibleNodeRemoval() {
640 - $this->shouldKeepInvisibleNodes = false;
641 - }
642 -
643 - /**
644 - * Enables the attachment/override of HTML attributes for which a
645 - * corresponding CSS property has been set.
646 - *
647 - * @return void
648 - */
649 - public function enableCssToHtmlMapping() {
650 - $this->shouldMapCssToHtml = true;
651 - }
652 -
653 - /**
654 - * Clears all caches.
655 - *
656 - * @return void
657 - */
658 - private function clearAllCaches() {
659 - $this->clearCache( self::CACHE_KEY_CSS );
660 - $this->clearCache( self::CACHE_KEY_SELECTOR );
661 - $this->clearCache( self::CACHE_KEY_XPATH );
662 - $this->clearCache( self::CACHE_KEY_CSS_DECLARATIONS_BLOCK );
663 - $this->clearCache( self::CACHE_KEY_COMBINED_STYLES );
664 - }
665 -
666 - /**
667 - * Clears a single cache by key.
668 - *
669 - * @param int $key the cache key, must be CACHE_KEY_CSS, CACHE_KEY_SELECTOR, CACHE_KEY_XPATH
670 - * or CACHE_KEY_CSS_DECLARATION_BLOCK
671 - *
672 - * @return void
673 - *
674 - * @throws InvalidArgumentException
675 - */
676 - private function clearCache( $key ) {
677 - $allowedCacheKeys = [
678 - self::CACHE_KEY_CSS,
679 - self::CACHE_KEY_SELECTOR,
680 - self::CACHE_KEY_XPATH,
681 - self::CACHE_KEY_CSS_DECLARATIONS_BLOCK,
682 - self::CACHE_KEY_COMBINED_STYLES,
683 - ];
684 -
685 - if ( !in_array( $key, $allowedCacheKeys, true ) ) {
686 - throw new InvalidArgumentException( 'Invalid cache key: ' . $key, 1391822035 );
687 - }
688 -
689 - $this->caches[$key] = [];
690 - }
691 -
692 - /**
693 - * Purges the visited nodes.
694 - *
695 - * @return void
696 - */
697 - private function purgeVisitedNodes() {
698 - $this->visitedNodes = [];
699 - $this->styleAttributesForNodes = [];
700 - }
701 -
702 - /**
703 - * Marks a tag for removal.
704 - *
705 - * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
706 - * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
707 - *
708 - * Note: The tags will not be removed if they have any content.
709 - *
710 - * @param string $tagName the tag name, e.g., "p"
711 - *
712 - * @return void
713 - */
714 - public function addUnprocessableHtmlTag( $tagName ) {
715 - $this->unprocessableHtmlTags[] = $tagName;
716 - }
717 -
718 - /**
719 - * Drops a tag from the removal list.
720 - *
721 - * @param string $tagName the tag name, e.g., "p"
722 - *
723 - * @return void
724 - */
725 - public function removeUnprocessableHtmlTag( $tagName ) {
726 - $key = array_search( $tagName, $this->unprocessableHtmlTags, true );
727 -
728 - if ( $key !== false ) {
729 - unset( $this->unprocessableHtmlTags[$key] );
730 - }
731 - }
732 -
733 - /**
734 - * Marks a media query type to keep.
735 - *
736 - * @param string $mediaName the media type name, e.g., "braille"
737 - *
738 - * @return void
739 - */
740 - public function addAllowedMediaType( $mediaName ) {
741 - $this->allowedMediaTypes[$mediaName] = true;
742 - }
743 -
744 - /**
745 - * Drops a media query type from the allowed list.
746 - *
747 - * @param string $mediaName the tag name, e.g., "braille"
748 - *
749 - * @return void
750 - */
751 - public function removeAllowedMediaType( $mediaName ) {
752 - if ( isset( $this->allowedMediaTypes[$mediaName] ) ) {
753 - unset( $this->allowedMediaTypes[$mediaName] );
754 - }
755 - }
756 -
757 - /**
758 - * Adds a selector to exclude nodes from emogrification.
759 - *
760 - * Any nodes that match the selector will not have their style altered.
761 - *
762 - * @param string $selector the selector to exclude, e.g., ".editor"
763 - *
764 - * @return void
765 - */
766 - public function addExcludedSelector( $selector ) {
767 - $this->excludedSelectors[$selector] = true;
768 - }
769 -
770 - /**
771 - * No longer excludes the nodes matching this selector from emogrification.
772 - *
773 - * @param string $selector the selector to no longer exclude, e.g., ".editor"
774 - *
775 - * @return void
776 - */
777 - public function removeExcludedSelector( $selector ) {
778 - if ( isset( $this->excludedSelectors[$selector] ) ) {
779 - unset( $this->excludedSelectors[$selector] );
780 - }
781 - }
782 -
783 - /**
784 - * This removes styles from your email that contain display:none.
785 - * We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
786 - * supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
787 - * not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
788 - * to lowercase.
789 - *
790 - * @param \DOMXPath $xPath
791 - *
792 - * @return void
793 - */
794 - private function removeInvisibleNodes( DOMXPath $xPath ) {
795 - $nodesWithStyleDisplayNone = $xPath->query(
796 - '//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]'
797 - );
798 -
799 - if ( $nodesWithStyleDisplayNone->length === 0 ) {
800 - return;
801 - }
802 -
803 - // The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
804 - // we don't try to call removeChild on a nonexistent child node
805 - /** @var \DOMNode $node */
806 - foreach ( $nodesWithStyleDisplayNone as $node ) {
807 - if ( $node->parentNode && is_callable( [$node->parentNode, 'removeChild'] ) ) {
808 - $node->parentNode->removeChild( $node );
809 - }
810 - }
811 - }
812 -
813 - /**
814 - * Normalizes the value of the "style" attribute and saves it.
815 - *
816 - * @param \DOMElement $node
817 - *
818 - * @return void
819 - */
820 - private function normalizeStyleAttributes( DOMElement $node ) {
821 - $normalizedOriginalStyle = preg_replace_callback(
822 - '/[A-z\\-]+(?=\\:)/S',
823 - function ( array $m ) {
824 - return strtolower( $m[0] );
825 - },
826 - $node->getAttribute( 'style' )
827 - );
828 -
829 - // in order to not overwrite existing style attributes in the HTML, we
830 - // have to save the original HTML styles
831 - $nodePath = $node->getNodePath();
832 -
833 - if ( !isset( $this->styleAttributesForNodes[$nodePath] ) ) {
834 - $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock( $normalizedOriginalStyle );
835 - $this->visitedNodes[$nodePath] = $node;
836 - }
837 -
838 - $node->setAttribute( 'style', $normalizedOriginalStyle );
839 - }
840 -
841 - /**
842 - * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
843 - *
844 - * @return void
845 - */
846 - private function fillStyleAttributesWithMergedStyles() {
847 - foreach ( $this->styleAttributesForNodes as $nodePath => $styleAttributesForNode ) {
848 - $node = $this->visitedNodes[$nodePath];
849 - $currentStyleAttributes = $this->parseCssDeclarationsBlock( $node->getAttribute( 'style' ) );
850 - $node->setAttribute(
851 - 'style',
852 - $this->generateStyleStringFromDeclarationsArrays(
853 - $currentStyleAttributes,
854 - $styleAttributesForNode
855 - )
856 - );
857 - }
858 - }
859 -
860 - /**
861 - * This method merges old or existing name/value array with new name/value array
862 - * and then generates a string of the combined style suitable for placing inline.
863 - * This becomes the single point for CSS string generation allowing for consistent
864 - * CSS output no matter where the CSS originally came from.
865 - *
866 - * @param string[] $oldStyles
867 - * @param string[] $newStyles
868 - *
869 - * @return string
870 - */
871 - private function generateStyleStringFromDeclarationsArrays( array $oldStyles, array $newStyles ) {
872 - $combinedStyles = array_merge( $oldStyles, $newStyles );
873 - $cacheKey = serialize( $combinedStyles );
874 -
875 - if ( isset( $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] ) ) {
876 - return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
877 - }
878 -
879 - foreach ( $oldStyles as $attributeName => $attributeValue ) {
880 - if ( !isset( $newStyles[$attributeName] ) ) {
881 - continue;
882 - }
883 -
884 - $newAttributeValue = $newStyles[$attributeName];
885 -
886 - if ( $this->attributeValueIsImportant( $attributeValue )
887 - && !$this->attributeValueIsImportant( $newAttributeValue )
888 - ) {
889 - $combinedStyles[$attributeName] = $attributeValue;
890 - }
891 - }
892 -
893 - $style = '';
894 -
895 - foreach ( $combinedStyles as $attributeName => $attributeValue ) {
896 - $style .= strtolower( trim( $attributeName ) ) . ': ' . trim( $attributeValue ) . '; ';
897 - }
898 - $trimmedStyle = rtrim( $style );
899 -
900 - $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
901 -
902 - return $trimmedStyle;
903 - }
904 -
905 - /**
906 - * Checks whether $attributeValue is marked as !important.
907 - *
908 - * @param string $attributeValue
909 - *
910 - * @return bool
911 - */
912 - private function attributeValueIsImportant( $attributeValue ) {
913 - return strtolower( substr( trim( $attributeValue ), -10 ) ) === '!important';
914 - }
915 -
916 - /**
917 - * Applies $css to $xmlDocument, limited to the media queries that actually apply to the document.
918 - *
919 - * @param \DOMDocument $xmlDocument the document to match against
920 - * @param \DOMXPath $xPath
921 - * @param string $css a string of CSS
922 - *
923 - * @return void
924 - */
925 - private function copyCssWithMediaToStyleNode( DOMDocument $xmlDocument, DOMXPath $xPath, $css ) {
926 - if ( $css === '' ) {
927 - return;
928 - }
929 -
930 - $mediaQueriesRelevantForDocument = [];
931 -
932 - foreach ( $this->extractMediaQueriesFromCss( $css ) as $mediaQuery ) {
933 - foreach ( $this->parseCssRules( $mediaQuery['css'] ) as $selector ) {
934 - if ( $this->existsMatchForCssSelector( $xPath, $selector['selector'] ) ) {
935 - $mediaQueriesRelevantForDocument[] = $mediaQuery['query'];
936 - break;
937 - }
938 - }
939 - }
940 -
941 - $this->addStyleElementToDocument( $xmlDocument, implode( $mediaQueriesRelevantForDocument ) );
942 - }
943 -
944 - /**
945 - * Extracts the media queries from $css while skipping empty media queries.
946 - *
947 - * @param string $css
948 - *
949 - * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
950 - */
951 - private function extractMediaQueriesFromCss( $css ) {
952 - preg_match_all( '/@media\\b[^{]*({((?:[^{}]+|(?1))*)})/', $css, $rawMediaQueries, PREG_SET_ORDER );
953 - $parsedQueries = [];
954 -
955 - foreach ( $rawMediaQueries as $mediaQuery ) {
956 - if ( $mediaQuery[2] !== '' ) {
957 - $parsedQueries[] = [
958 - 'css' => $mediaQuery[2],
959 - 'query' => $mediaQuery[0],
960 - ];
961 - }
962 - }
963 -
964 - return $parsedQueries;
965 - }
966 -
967 - /**
968 - * Checks whether there is at least one matching element for $cssSelector.
969 - *
970 - * @param \DOMXPath $xPath
971 - * @param string $cssSelector
972 - *
973 - * @return bool
974 - */
975 - private function existsMatchForCssSelector( DOMXPath $xPath, $cssSelector ) {
976 - $nodesMatchingSelector = $xPath->query( $this->translateCssToXpath( $cssSelector ) );
977 -
978 - return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
979 - }
980 -
981 - /**
982 - * Returns CSS content.
983 - *
984 - * @param \DOMXPath $xPath
985 - *
986 - * @return string
987 - */
988 - private function getCssFromAllStyleNodes( DOMXPath $xPath ) {
989 - $styleNodes = $xPath->query( '//style' );
990 -
991 - if ( $styleNodes === false ) {
992 - return '';
993 - }
994 -
995 - $css = '';
996 - /** @var \DOMNode $styleNode */
997 - foreach ( $styleNodes as $styleNode ) {
998 - $css .= "\n\n" . $styleNode->nodeValue;
999 - $styleNode->parentNode->removeChild( $styleNode );
1000 - }
1001 -
1002 - return $css;
1003 - }
1004 -
1005 - /**
1006 - * Adds a style element with $css to $document.
1007 - *
1008 - * This method is protected to allow overriding.
1009 - *
1010 - * @see https://github.com/jjriv/emogrifier/issues/103
1011 - *
1012 - * @param \DOMDocument $document
1013 - * @param string $css
1014 - *
1015 - * @return void
1016 - */
1017 - protected function addStyleElementToDocument( DOMDocument $document, $css ) {
1018 - $styleElement = $document->createElement( 'style', $css );
1019 - $styleAttribute = $document->createAttribute( 'type' );
1020 - $styleAttribute->value = 'text/css';
1021 - $styleElement->appendChild( $styleAttribute );
1022 -
1023 - $head = $this->getOrCreateHeadElement( $document );
1024 - $head->appendChild( $styleElement );
1025 - }
1026 -
1027 - /**
1028 - * Returns the existing or creates a new head element in $document.
1029 - *
1030 - * @param \DOMDocument $document
1031 - *
1032 - * @return \DOMNode the head element
1033 - */
1034 - private function getOrCreateHeadElement( DOMDocument $document ) {
1035 - $head = $document->getElementsByTagName( 'head' )->item( 0 );
1036 -
1037 - if ( $head === null ) {
1038 - $head = $document->createElement( 'head' );
1039 - $html = $document->getElementsByTagName( 'html' )->item( 0 );
1040 - $html->insertBefore( $head, $document->getElementsByTagName( 'body' )->item( 0 ) );
1041 - }
1042 -
1043 - return $head;
1044 - }
1045 -
1046 - /**
1047 - * Splits input CSS code to an array where:
1048 - *
1049 - * - key "css" will be contains clean CSS code
1050 - * - key "media" will be contains all valuable media queries
1051 - *
1052 - * Example:
1053 - *
1054 - * The CSS code
1055 - *
1056 - * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
1057 - *
1058 - * will be parsed into the following array:
1059 - *
1060 - * "css" => "h1 { color:red; }"
1061 - * "media" => "@media { h1 {}}"
1062 - *
1063 - * @param string $css
1064 - *
1065 - * @return string[]
1066 - */
1067 - private function splitCssAndMediaQuery( $css ) {
1068 - $cssWithoutComments = preg_replace( '/\\/\\*.*\\*\\//sU', '', $css );
1069 -
1070 - $mediaTypesExpression = '';
1071 -
1072 - if ( !empty( $this->allowedMediaTypes ) ) {
1073 - $mediaTypesExpression = '|' . implode( '|', array_keys( $this->allowedMediaTypes ) );
1074 - }
1075 -
1076 - $media = '';
1077 - $cssForAllowedMediaTypes = preg_replace_callback(
1078 - '#@media\\s+(?:only\\s)?(?:[\\s{\\(]' . $mediaTypesExpression . ')\\s?[^{]+{.*}\\s*}\\s*#misU',
1079 - function ( $matches ) use ( &$media ) {
1080 - $media .= $matches[0];
1081 - },
1082 - $cssWithoutComments
1083 - );
1084 -
1085 - // filter the CSS
1086 - $search = [
1087 - 'import directives' => '/^\\s*@import\\s[^;]+;/misU',
1088 - 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
1089 - ];
1090 -
1091 - $cleanedCss = preg_replace( $search, '', $cssForAllowedMediaTypes );
1092 -
1093 - return ['css' => $cleanedCss, 'media' => $media];
1094 - }
1095 -
1096 - /**
1097 - * Creates a DOMDocument instance with the current HTML.
1098 - *
1099 - * @return \DOMDocument
1100 - */
1101 - private function createXmlDocument() {
1102 - $xmlDocument = new \DOMDocument();
1103 - $xmlDocument->encoding = 'UTF-8';
1104 - $xmlDocument->strictErrorChecking = false;
1105 - $xmlDocument->formatOutput = true;
1106 - $libXmlState = libxml_use_internal_errors( true );
1107 - $xmlDocument->loadHTML( $this->getUnifiedHtml() );
1108 - libxml_clear_errors();
1109 - libxml_use_internal_errors( $libXmlState );
1110 - $xmlDocument->normalizeDocument();
1111 -
1112 - return $xmlDocument;
1113 - }
1114 -
1115 - /**
1116 - * Returns the HTML with the unprocessable HTML tags removed and
1117 - * with added document type and Content-Type meta tag if needed.
1118 - *
1119 - * @return string the unified HTML
1120 - *
1121 - * @throws BadMethodCallException
1122 - */
1123 - private function getUnifiedHtml() {
1124 - $htmlWithoutUnprocessableTags = $this->removeUnprocessableTags( $this->html );
1125 - $htmlWithDocumentType = $this->ensureDocumentType( $htmlWithoutUnprocessableTags );
1126 -
1127 - return $this->addContentTypeMetaTag( $htmlWithDocumentType );
1128 - }
1129 -
1130 - /**
1131 - * Removes the unprocessable tags from $html (if this feature is enabled).
1132 - *
1133 - * @param string $html
1134 - *
1135 - * @return string the reworked HTML with the unprocessable tags removed
1136 - */
1137 - private function removeUnprocessableTags( $html ) {
1138 - if ( empty( $this->unprocessableHtmlTags ) ) {
1139 - return $html;
1140 - }
1141 -
1142 - $unprocessableHtmlTags = implode( '|', $this->unprocessableHtmlTags );
1143 -
1144 - return preg_replace(
1145 - '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
1146 - '',
1147 - $html
1148 - );
1149 - }
1150 -
1151 - /**
1152 - * Makes sure that the passed HTML has a document type.
1153 - *
1154 - * @param string $html
1155 - *
1156 - * @return string HTML with document type
1157 - */
1158 - private function ensureDocumentType( $html ) {
1159 - $hasDocumentType = stripos( $html, '<!DOCTYPE' ) !== false;
1160 -
1161 - if ( $hasDocumentType ) {
1162 - return $html;
1163 - }
1164 -
1165 - return self::DEFAULT_DOCUMENT_TYPE . $html;
1166 - }
1167 -
1168 - /**
1169 - * Adds a Content-Type meta tag for the charset.
1170 - *
1171 - * @param string $html
1172 - *
1173 - * @return string the HTML with the meta tag added
1174 - */
1175 - private function addContentTypeMetaTag( $html ) {
1176 - $hasContentTypeMetaTag = stristr( $html, 'Content-Type' ) !== false;
1177 -
1178 - if ( $hasContentTypeMetaTag ) {
1179 - return $html;
1180 - }
1181 -
1182 - // We are trying to insert the meta tag to the right spot in the DOM.
1183 - // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
1184 - $hasHeadTag = stripos( $html, '<head' ) !== false;
1185 - $hasHtmlTag = stripos( $html, '<html' ) !== false;
1186 -
1187 - if ( $hasHeadTag ) {
1188 - $reworkedHtml = preg_replace( '/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html );
1189 - } elseif ( $hasHtmlTag ) {
1190 - $reworkedHtml = preg_replace(
1191 - '/<html(.*?)>/i',
1192 - '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
1193 - $html
1194 - );
1195 - } else {
1196 - $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
1197 - }
1198 -
1199 - return $reworkedHtml;
1200 - }
1201 -
1202 - /**
1203 - * @param string[] $a
1204 - * @param string[] $b
1205 - *
1206 - * @return int
1207 - */
1208 - private function sortBySelectorPrecedence( array $a, array $b ) {
1209 - $precedenceA = $this->getCssSelectorPrecedence( $a['selector'] );
1210 - $precedenceB = $this->getCssSelectorPrecedence( $b['selector'] );
1211 -
1212 - // We want these sorted in ascending order so selectors with lesser precedence get processed first and
1213 - // selectors with greater precedence get sorted last.
1214 - $precedenceForEquals = ( $a['line'] < $b['line'] ? -1 : 1 );
1215 - $precedenceForNotEquals = ( $precedenceA < $precedenceB ? -1 : 1 );
1216 -
1217 - return ( $precedenceA === $precedenceB ) ? $precedenceForEquals : $precedenceForNotEquals;
1218 - }
1219 -
1220 - /**
1221 - * @param string $selector
1222 - *
1223 - * @return int
1224 - */
1225 - private function getCssSelectorPrecedence( $selector ) {
1226 - $selectorKey = md5( $selector );
1227 -
1228 - if ( !isset( $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] ) ) {
1229 - $precedence = 0;
1230 - $value = 100;
1231 - // ids: worth 100, classes: worth 10, elements: worth 1
1232 - $search = ['\\#', '\\.', ''];
1233 -
1234 - foreach ( $search as $s ) {
1235 - if ( trim( $selector ) === '' ) {
1236 - break;
1237 - }
1238 - $number = 0;
1239 - $selector = preg_replace( '/' . $s . '\\w+/', '', $selector, -1, $number );
1240 - $precedence += ( $value * $number );
1241 - $value /= 10;
1242 - }
1243 - $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
1244 - }
1245 -
1246 - return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
1247 - }
1248 -
1249 - /**
1250 - * Maps a CSS selector to an XPath query string.
1251 - *
1252 - * @see http://plasmasturm.org/log/444/
1253 - *
1254 - * @param string $cssSelector a CSS selector
1255 - *
1256 - * @return string the corresponding XPath selector
1257 - */
1258 - private function translateCssToXpath( $cssSelector ) {
1259 - $paddedSelector = ' ' . $cssSelector . ' ';
1260 - $lowercasePaddedSelector = preg_replace_callback(
1261 - '/\\s+\\w+\\s+/',
1262 - function ( array $matches ) {
1263 - return strtolower( $matches[0] );
1264 - },
1265 - $paddedSelector
1266 - );
1267 - $trimmedLowercaseSelector = trim( $lowercasePaddedSelector );
1268 - $xPathKey = md5( $trimmedLowercaseSelector );
1269 -
1270 - if ( !isset( $this->caches[self::CACHE_KEY_XPATH][$xPathKey] ) ) {
1271 - $roughXpath = '//' . preg_replace(
1272 - array_keys( $this->xPathRules ),
1273 - $this->xPathRules,
1274 - $trimmedLowercaseSelector
1275 - );
1276 - $xPathWithIdAttributeMatchers = preg_replace_callback(
1277 - self::ID_ATTRIBUTE_MATCHER,
1278 - [$this, 'matchIdAttributes'],
1279 - $roughXpath
1280 - );
1281 - $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1282 - self::CLASS_ATTRIBUTE_MATCHER,
1283 - [$this, 'matchClassAttributes'],
1284 - $xPathWithIdAttributeMatchers
1285 - );
1286 -
1287 - // Advanced selectors are going to require a bit more advanced emogrification.
1288 - // When we required PHP 5.3, we could do this with closures.
1289 - $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1290 - '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1291 - [$this, 'translateNthChild'],
1292 - $xPathWithIdAttributeAndClassMatchers
1293 - );
1294 - $finalXpath = preg_replace_callback(
1295 - '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1296 - [$this, 'translateNthOfType'],
1297 - $xPathWithIdAttributeAndClassMatchers
1298 - );
1299 -
1300 - $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $finalXpath;
1301 - }
1302 -
1303 - return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1304 - }
1305 -
1306 - /**
1307 - * @param string[] $match
1308 - *
1309 - * @return string
1310 - */
1311 - private function matchIdAttributes( array $match ) {
1312 - return ( $match[1] !== '' ? $match[1] : '*' ) . '[@id="' . $match[2] . '"]';
1313 - }
1314 -
1315 - /**
1316 - * @param string[] $match
1317 - *
1318 - * @return string
1319 - */
1320 - private function matchClassAttributes( array $match ) {
1321 - return ( $match[1] !== '' ? $match[1] : '*' ) . '[contains(concat(" ",@class," "),concat(" ","' .
1322 - implode(
1323 - '"," "))][contains(concat(" ",@class," "),concat(" ","',
1324 - explode( '.', substr( $match[2], 1 ) )
1325 - ) . '"," "))]';
1326 - }
1327 -
1328 - /**
1329 - * @param string[] $match
1330 - *
1331 - * @return string
1332 - */
1333 - private function translateNthChild( array $match ) {
1334 - $parseResult = $this->parseNth( $match );
1335 -
1336 - if ( isset( $parseResult[self::MULTIPLIER] ) ) {
1337 - if ( $parseResult[self::MULTIPLIER] < 0 ) {
1338 - $parseResult[self::MULTIPLIER] = abs( $parseResult[self::MULTIPLIER] );
1339 - $xPathExpression = sprintf(
1340 - '*[(last() - position()) mod %u = %u]/self::%s',
1341 - $parseResult[self::MULTIPLIER],
1342 - $parseResult[self::INDEX],
1343 - $match[1]
1344 - );
1345 - } else {
1346 - $xPathExpression = sprintf(
1347 - '*[position() mod %u = %u]/self::%s',
1348 - $parseResult[self::MULTIPLIER],
1349 - $parseResult[self::INDEX],
1350 - $match[1]
1351 - );
1352 - }
1353 - } else {
1354 - $xPathExpression = sprintf( '*[%u]/self::%s', $parseResult[self::INDEX], $match[1] );
1355 - }
1356 -
1357 - return $xPathExpression;
1358 - }
1359 -
1360 - /**
1361 - * @param string[] $match
1362 - *
1363 - * @return string
1364 - */
1365 - private function translateNthOfType( array $match ) {
1366 - $parseResult = $this->parseNth( $match );
1367 -
1368 - if ( isset( $parseResult[self::MULTIPLIER] ) ) {
1369 - if ( $parseResult[self::MULTIPLIER] < 0 ) {
1370 - $parseResult[self::MULTIPLIER] = abs( $parseResult[self::MULTIPLIER] );
1371 - $xPathExpression = sprintf(
1372 - '%s[(last() - position()) mod %u = %u]',
1373 - $match[1],
1374 - $parseResult[self::MULTIPLIER],
1375 - $parseResult[self::INDEX]
1376 - );
1377 - } else {
1378 - $xPathExpression = sprintf(
1379 - '%s[position() mod %u = %u]',
1380 - $match[1],
1381 - $parseResult[self::MULTIPLIER],
1382 - $parseResult[self::INDEX]
1383 - );
1384 - }
1385 - } else {
1386 - $xPathExpression = sprintf( '%s[%u]', $match[1], $parseResult[self::INDEX] );
1387 - }
1388 -
1389 - return $xPathExpression;
1390 - }
1391 -
1392 - /**
1393 - * @param string[] $match
1394 - *
1395 - * @return int[]
1396 - */
1397 - private function parseNth( array $match ) {
1398 - if ( in_array( strtolower( $match[2] ), ['even', 'odd'], true ) ) {
1399 - // we have "even" or "odd"
1400 - $index = strtolower( $match[2] ) === 'even' ? 0 : 1;
1401 -
1402 - return [self::MULTIPLIER => 2, self::INDEX => $index];
1403 - }
1404 -
1405 - if ( stripos( $match[2], 'n' ) === false ) {
1406 - // if there is a multiplier
1407 - $index = (int) str_replace( ' ', '', $match[2] );
1408 -
1409 - return [self::INDEX => $index];
1410 - }
1411 -
1412 - if ( isset( $match[3] ) ) {
1413 - $multipleTerm = str_replace( $match[3], '', $match[2] );
1414 - $index = (int) str_replace( ' ', '', $match[3] );
1415 - } else {
1416 - $multipleTerm = $match[2];
1417 - $index = 0;
1418 - }
1419 -
1420 - $multiplier = str_ireplace( 'n', '', $multipleTerm );
1421 -
1422 - if ( $multiplier === '' ) {
1423 - $multiplier = 1;
1424 - } elseif ( $multiplier === '0' ) {
1425 - return [self::INDEX => $index];
1426 - } else {
1427 - $multiplier = (int) $multiplier;
1428 - }
1429 -
1430 - while ( $index < 0 ) {
1431 - $index += abs( $multiplier );
1432 - }
1433 -
1434 - return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
1435 - }
1436 -
1437 - /**
1438 - * Parses a CSS declaration block into property name/value pairs.
1439 - *
1440 - * Example:
1441 - *
1442 - * The declaration block
1443 - *
1444 - * "color: #000; font-weight: bold;"
1445 - *
1446 - * will be parsed into the following array:
1447 - *
1448 - * "color" => "#000"
1449 - * "font-weight" => "bold"
1450 - *
1451 - * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
1452 - *
1453 - * @return string[]
1454 - * the CSS declarations with the property names as array keys and the property values as array values
1455 - */
1456 - private function parseCssDeclarationsBlock( $cssDeclarationsBlock ) {
1457 - if ( isset( $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] ) ) {
1458 - return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
1459 - }
1460 -
1461 - $properties = [];
1462 - $declarations = preg_split( '/;(?!base64|charset)/', $cssDeclarationsBlock );
1463 -
1464 - foreach ( $declarations as $declaration ) {
1465 - $matches = [];
1466 -
1467 - if ( !preg_match( '/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim( $declaration ), $matches ) ) {
1468 - continue;
1469 - }
1470 -
1471 - $propertyName = strtolower( $matches[1] );
1472 - $propertyValue = $matches[2];
1473 - $properties[$propertyName] = $propertyValue;
1474 - }
1475 - $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
1476 -
1477 - return $properties;
1478 - }
1479 -
1480 - /**
1481 - * Find the nodes that are not to be emogrified.
1482 - *
1483 - * @param \DOMXPath $xPath
1484 - *
1485 - * @return \DOMElement[]
1486 - */
1487 - private function getNodesToExclude( DOMXPath $xPath ) {
1488 - $excludedNodes = [];
1489 -
1490 - foreach ( array_keys( $this->excludedSelectors ) as $selectorToExclude ) {
1491 - foreach ( $xPath->query( $this->translateCssToXpath( $selectorToExclude ) ) as $node ) {
1492 - $excludedNodes[] = $node;
1493 - }
1494 - }
1495 -
1496 - return $excludedNodes;
1497 - }
1498 -
1499 - /**
1500 - * Handles invalid xPath expression warnings, generated by process() method,
1501 - * during querying \DOMDocument and trigger \InvalidArgumentException
1502 - * with invalid selector.
1503 - *
1504 - * @param int $type
1505 - * @param string $message
1506 - * @param string $file
1507 - * @param int $line
1508 - *
1509 - * @return bool always false
1510 - *
1511 - * @throws InvalidArgumentException
1512 - */
1513 - public function handleXpathError( $type, $message, $file, $line, array $context ) {
1514 - if ( $this->debug && $type === E_WARNING && isset( $context['cssRule']['selector'] ) ) {
1515 - throw new InvalidArgumentException( sprintf( '%s in selector >> %s << in %s on line %s', $message, $context['cssRule']['selector'], $file, $line ) );
1516 - }
1517 -
1518 - // the normal error handling continues when handler return false
1519 - return false;
1520 - }
1521 -
1522 - /**
1523 - * Sets the debug mode.
1524 - *
1525 - * @param bool $debug set to true to enable debug mode
1526 - *
1527 - * @return void
1528 - */
1529 - public function setDebug( $debug ) {
1530 - $this->debug = $debug;
1531 - }
1532 -}
1 +<?php
2 +
3 +/**
4 + * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
5 + *
6 + * For more information, please see the README.md file.
7 + *
8 + * @version 1.2.0
9 + *
10 + * @author Cameron Brooks
11 + * @author Jaime Prado
12 + * @author Oliver Klee <typo3-coding@oliverklee.de>
13 + * @author Roman Ožana <ozana@omdesign.cz>
14 + * @author Sander Kruger <s.kruger@invessel.com>
15 + */
16 +class Emogrifier
17 +{
18 + /**
19 + * @var int
20 + */
21 + const CACHE_KEY_CSS = 0;
22 +
23 + /**
24 + * @var int
25 + */
26 + const CACHE_KEY_SELECTOR = 1;
27 +
28 + /**
29 + * @var int
30 + */
31 + const CACHE_KEY_XPATH = 2;
32 +
33 + /**
34 + * @var int
35 + */
36 + const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 3;
37 +
38 + /**
39 + * @var int
40 + */
41 + const CACHE_KEY_COMBINED_STYLES = 4;
42 +
43 + /**
44 + * for calculating nth-of-type and nth-child selectors
45 + *
46 + * @var int
47 + */
48 + const INDEX = 0;
49 +
50 + /**
51 + * for calculating nth-of-type and nth-child selectors
52 + *
53 + * @var int
54 + */
55 + const MULTIPLIER = 1;
56 +
57 + /**
58 + * @var string
59 + */
60 + const ID_ATTRIBUTE_MATCHER = '/(\\w+)?\\#([\\w\\-]+)/';
61 +
62 + /**
63 + * @var string
64 + */
65 + const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
66 +
67 + /**
68 + * @var string
69 + */
70 + const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
71 +
72 + /**
73 + * @var string
74 + */
75 + const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
76 +
77 + /**
78 + * @var string
79 + */
80 + private $html = '';
81 +
82 + /**
83 + * @var string
84 + */
85 + private $css = '';
86 +
87 + /**
88 + * @var bool[]
89 + */
90 + private $excludedSelectors = [];
91 +
92 + /**
93 + * @var string[]
94 + */
95 + private $unprocessableHtmlTags = ['wbr'];
96 +
97 + /**
98 + * @var bool[]
99 + */
100 + private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
101 +
102 + /**
103 + * @var mixed[]
104 + */
105 + private $caches = [
106 + self::CACHE_KEY_CSS => [],
107 + self::CACHE_KEY_SELECTOR => [],
108 + self::CACHE_KEY_XPATH => [],
109 + self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
110 + self::CACHE_KEY_COMBINED_STYLES => [],
111 + ];
112 +
113 + /**
114 + * the visited nodes with the XPath paths as array keys
115 + *
116 + * @var \DOMElement[]
117 + */
118 + private $visitedNodes = [];
119 +
120 + /**
121 + * the styles to apply to the nodes with the XPath paths as array keys for the outer array
122 + * and the attribute names/values as key/value pairs for the inner array
123 + *
124 + * @var string[][]
125 + */
126 + private $styleAttributesForNodes = [];
127 +
128 + /**
129 + * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
130 + * If set to false, the value of the style attributes will be discarded.
131 + *
132 + * @var bool
133 + */
134 + private $isInlineStyleAttributesParsingEnabled = true;
135 +
136 + /**
137 + * Determines whether the <style> blocks in the HTML passed to this class should be parsed.
138 + *
139 + * If set to true, the <style> blocks will be removed from the HTML and their contents will be applied to the HTML
140 + * via inline styles.
141 + *
142 + * If set to false, the <style> blocks will be left as they are in the HTML.
143 + *
144 + * @var bool
145 + */
146 + private $isStyleBlocksParsingEnabled = true;
147 +
148 + /**
149 + * Determines whether elements with the `display: none` property are
150 + * removed from the DOM.
151 + *
152 + * @var bool
153 + */
154 + private $shouldKeepInvisibleNodes = true;
155 +
156 + /**
157 + * @var string[]
158 + */
159 + private $xPathRules = [
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
175 + '/(\\w)\\[(\\w+)\\=[\'"]?([\\w\\s]+)[\'"]?\\]/' => '\\1[@\\2="\\3"]',
176 + // element attribute~=
177 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/'
178 + => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
179 + // element attribute^=
180 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
181 + // element attribute*=
182 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
183 + // element attribute$=
184 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/'
185 + => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
186 + // element attribute|=
187 + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/'
188 + => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
189 + ];
190 +
191 + /**
192 + * Determines whether CSS styles that have an equivalent HTML attribute
193 + * should be mapped and attached to those elements.
194 + *
195 + * @var bool
196 + */
197 + private $shouldMapCssToHtml = false;
198 +
199 + /**
200 + * This multi-level array contains simple mappings of CSS properties to
201 + * HTML attributes. If a mapping only applies to certain HTML nodes or
202 + * only for certain values, the mapping is an object with a whitelist
203 + * of nodes and values.
204 + *
205 + * @var mixed[][]
206 + */
207 + private $cssToHtmlMap = [
208 + 'background-color' => [
209 + 'attribute' => 'bgcolor',
210 + ],
211 + 'text-align' => [
212 + 'attribute' => 'align',
213 + 'nodes' => ['p', 'div', 'td'],
214 + 'values' => ['left', 'right', 'center', 'justify'],
215 + ],
216 + 'float' => [
217 + 'attribute' => 'align',
218 + 'nodes' => ['table', 'img'],
219 + 'values' => ['left', 'right'],
220 + ],
221 + 'border-spacing' => [
222 + 'attribute' => 'cellspacing',
223 + 'nodes' => ['table'],
224 + ],
225 + ];
226 +
227 + /**
228 + * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
229 + *
230 + * @var bool
231 + */
232 + private $debug = false;
233 +
234 + /**
235 + * The constructor.
236 + *
237 + * @param string $html the HTML to emogrify, must be UTF-8-encoded
238 + * @param string $css the CSS to merge, must be UTF-8-encoded
239 + */
240 + public function __construct($html = '', $css = '')
241 + {
242 + $this->setHtml($html);
243 + $this->setCss($css);
244 + }
245 +
246 + /**
247 + * The destructor.
248 + */
249 + public function __destruct()
250 + {
251 + $this->purgeVisitedNodes();
252 + }
253 +
254 + /**
255 + * Sets the HTML to emogrify.
256 + *
257 + * @param string $html the HTML to emogrify, must be UTF-8-encoded
258 + *
259 + * @return void
260 + */
261 + public function setHtml($html)
262 + {
263 + $this->html = $html;
264 + }
265 +
266 + /**
267 + * Sets the CSS to merge with the HTML.
268 + *
269 + * @param string $css the CSS to merge, must be UTF-8-encoded
270 + *
271 + * @return void
272 + */
273 + public function setCss($css)
274 + {
275 + $this->css = $css;
276 + }
277 +
278 + /**
279 + * Applies $this->css to $this->html and returns the HTML with the CSS
280 + * applied.
281 + *
282 + * This method places the CSS inline.
283 + *
284 + * @return string
285 + *
286 + * @throws \BadMethodCallException
287 + */
288 + public function emogrify()
289 + {
290 + if ($this->html === '') {
291 + throw new \BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
292 + }
293 +
294 + $xmlDocument = $this->createXmlDocument();
295 + $this->process($xmlDocument);
296 +
297 + return $xmlDocument->saveHTML();
298 + }
299 +
300 + /**
301 + * Applies $this->css to $this->html and returns only the HTML content
302 + * within the <body> tag.
303 + *
304 + * This method places the CSS inline.
305 + *
306 + * @return string
307 + *
308 + * @throws \BadMethodCallException
309 + */
310 + public function emogrifyBodyContent()
311 + {
312 + if ($this->html === '') {
313 + throw new \BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
314 + }
315 +
316 + $xmlDocument = $this->createXmlDocument();
317 + $this->process($xmlDocument);
318 +
319 + $innerDocument = new \DOMDocument();
320 + foreach ($xmlDocument->documentElement->getElementsByTagName('body')->item(0)->childNodes as $childNode) {
321 + $innerDocument->appendChild($innerDocument->importNode($childNode, true));
322 + }
323 +
324 + return html_entity_decode($innerDocument->saveHTML());
325 + }
326 +
327 + /**
328 + * Applies $this->css to $xmlDocument.
329 + *
330 + * This method places the CSS inline.
331 + *
332 + * @param \DOMDocument $xmlDocument
333 + *
334 + * @return void
335 + *
336 + * @throws \InvalidArgumentException
337 + */
338 + protected function process(\DOMDocument $xmlDocument)
339 + {
340 + $xPath = new \DOMXPath($xmlDocument);
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.
347 + $this->purgeVisitedNodes();
348 +
349 + set_error_handler([$this, 'handleXpathError'], E_WARNING);
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 +
363 + // grab any existing style blocks from the html and append them to the existing CSS
364 + // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
365 + $allCss = $this->css;
366 +
367 + if ($this->isStyleBlocksParsingEnabled) {
368 + $allCss .= $this->getCssFromAllStyleNodes($xPath);
369 + }
370 +
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) {
379 + continue;
380 + }
381 +
382 + /** @var \DOMElement $node */
383 + foreach ($nodesMatchingCssSelectors as $node) {
384 + if (in_array($node, $excludedNodes, true)) {
385 + continue;
386 + }
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 + );
403 + }
404 + }
405 +
406 + restore_error_handler();
407 +
408 + if ($this->isInlineStyleAttributesParsingEnabled) {
409 + $this->fillStyleAttributesWithMergedStyles();
410 + }
411 +
412 + if ($this->shouldKeepInvisibleNodes) {
413 + $this->removeInvisibleNodes($xPath);
414 + }
415 +
416 + $this->copyCssWithMediaToStyleNode($xmlDocument, $xPath, $cssParts['media']);
417 + }
418 +
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 + }
437 + }
438 +
439 + /**
440 + * Tries to apply the CSS style to $node as an attribute.
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 + *
448 + * @return void
449 + */
450 + private function mapCssToHtmlAttribute($property, $value, \DOMNode $node)
451 + {
452 + if (!$this->mapSimpleCssProperty($property, $value, $node)) {
453 + $this->mapComplexCssProperty($property, $value, $node);
454 + }
455 + }
456 +
457 + /**
458 + * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
459 + *
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
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 + *
491 + * @return void
492 + */
493 + private function mapComplexCssProperty($property, $value, \DOMNode $node)
494 + {
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:
536 + }
537 + }
538 +
539 + /**
540 + * Parses a shorthand CSS value and splits it into individual values
541 + *
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)
548 + */
549 + private function parseCssShorthandValue($value)
550 + {
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;
560 + }
561 +
562 + /**
563 + * Extracts and parses the individual rules from a CSS string.
564 + *
565 + * @param string $css a string of raw CSS code
566 + *
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),
570 + * e.g., "color: red; height: 4px;"),
571 + * and "line" (the line number e.g. 42)
572 + */
573 + private function parseCssRules($css)
574 + {
575 + $cssKey = md5($css);
576 + if (!isset($this->caches[self::CACHE_KEY_CSS][$cssKey])) {
577 + // process the CSS file for selectors and definitions
578 + preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mis', $css, $matches, PREG_SET_ORDER);
579 +
580 + $cssRules = [];
581 + /** @var string[] $cssRule */
582 + foreach ($matches as $key => $cssRule) {
583 + $cssDeclaration = trim($cssRule[2]);
584 + if ($cssDeclaration === '') {
585 + continue;
586 + }
587 +
588 + $selectors = explode(',', $cssRule[1]);
589 + foreach ($selectors as $selector) {
590 + // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
591 + // only allow structural pseudo-classes
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 + }
598 +
599 + $cssRules[] = [
600 + 'selector' => trim($selector),
601 + 'declarationsBlock' => $cssDeclaration,
602 + // keep track of where it appears in the file, since order is important
603 + 'line' => $key,
604 + ];
605 + }
606 + }
607 +
608 + usort($cssRules, [$this, 'sortBySelectorPrecedence']);
609 +
610 + $this->caches[self::CACHE_KEY_CSS][$cssKey] = $cssRules;
611 + }
612 +
613 + return $this->caches[self::CACHE_KEY_CSS][$cssKey];
614 + }
615 +
616 + /**
617 + * Disables the parsing of inline styles.
618 + *
619 + * @return void
620 + */
621 + public function disableInlineStyleAttributesParsing()
622 + {
623 + $this->isInlineStyleAttributesParsingEnabled = false;
624 + }
625 +
626 + /**
627 + * Disables the parsing of <style> blocks.
628 + *
629 + * @return void
630 + */
631 + public function disableStyleBlocksParsing()
632 + {
633 + $this->isStyleBlocksParsingEnabled = false;
634 + }
635 +
636 + /**
637 + * Disables the removal of elements with `display: none` properties.
638 + *
639 + * @return void
640 + */
641 + public function disableInvisibleNodeRemoval()
642 + {
643 + $this->shouldKeepInvisibleNodes = false;
644 + }
645 +
646 + /**
647 + * Enables the attachment/override of HTML attributes for which a
648 + * corresponding CSS property has been set.
649 + *
650 + * @return void
651 + */
652 + public function enableCssToHtmlMapping()
653 + {
654 + $this->shouldMapCssToHtml = true;
655 + }
656 +
657 + /**
658 + * Clears all caches.
659 + *
660 + * @return void
661 + */
662 + private function clearAllCaches()
663 + {
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,
689 + ];
690 + if (!in_array($key, $allowedCacheKeys, true)) {
691 + throw new \InvalidArgumentException('Invalid cache key: ' . $key, 1391822035);
692 + }
693 +
694 + $this->caches[$key] = [];
695 + }
696 +
697 + /**
698 + * Purges the visited nodes.
699 + *
700 + * @return void
701 + */
702 + private function purgeVisitedNodes()
703 + {
704 + $this->visitedNodes = [];
705 + $this->styleAttributesForNodes = [];
706 + }
707 +
708 + /**
709 + * Marks a tag for removal.
710 + *
711 + * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
712 + * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
713 + *
714 + * Note: The tags will not be removed if they have any content.
715 + *
716 + * @param string $tagName the tag name, e.g., "p"
717 + *
718 + * @return void
719 + */
720 + public function addUnprocessableHtmlTag($tagName)
721 + {
722 + $this->unprocessableHtmlTags[] = $tagName;
723 + }
724 +
725 + /**
726 + * Drops a tag from the removal list.
727 + *
728 + * @param string $tagName the tag name, e.g., "p"
729 + *
730 + * @return void
731 + */
732 + public function removeUnprocessableHtmlTag($tagName)
733 + {
734 + $key = array_search($tagName, $this->unprocessableHtmlTags, true);
735 + if ($key !== false) {
736 + unset($this->unprocessableHtmlTags[$key]);
737 + }
738 + }
739 +
740 + /**
741 + * Marks a media query type to keep.
742 + *
743 + * @param string $mediaName the media type name, e.g., "braille"
744 + *
745 + * @return void
746 + */
747 + public function addAllowedMediaType($mediaName)
748 + {
749 + $this->allowedMediaTypes[$mediaName] = true;
750 + }
751 +
752 + /**
753 + * Drops a media query type from the allowed list.
754 + *
755 + * @param string $mediaName the tag name, e.g., "braille"
756 + *
757 + * @return void
758 + */
759 + public function removeAllowedMediaType($mediaName)
760 + {
761 + if (isset($this->allowedMediaTypes[$mediaName])) {
762 + unset($this->allowedMediaTypes[$mediaName]);
763 + }
764 + }
765 +
766 + /**
767 + * Adds a selector to exclude nodes from emogrification.
768 + *
769 + * Any nodes that match the selector will not have their style altered.
770 + *
771 + * @param string $selector the selector to exclude, e.g., ".editor"
772 + *
773 + * @return void
774 + */
775 + public function addExcludedSelector($selector)
776 + {
777 + $this->excludedSelectors[$selector] = true;
778 + }
779 +
780 + /**
781 + * No longer excludes the nodes matching this selector from emogrification.
782 + *
783 + * @param string $selector the selector to no longer exclude, e.g., ".editor"
784 + *
785 + * @return void
786 + */
787 + public function removeExcludedSelector($selector)
788 + {
789 + if (isset($this->excludedSelectors[$selector])) {
790 + unset($this->excludedSelectors[$selector]);
791 + }
792 + }
793 +
794 + /**
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.
800 + *
801 + * @param \DOMXPath $xPath
802 + *
803 + * @return void
804 + */
805 + private function removeInvisibleNodes(\DOMXPath $xPath)
806 + {
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);
820 + }
821 + }
822 + }
823 +
824 + /**
825 + * Normalizes the value of the "style" attribute and saves it.
826 + *
827 + * @param \DOMElement $node
828 + *
829 + * @return void
830 + */
831 + private function normalizeStyleAttributes(\DOMElement $node)
832 + {
833 + $normalizedOriginalStyle = preg_replace_callback(
834 + '/[A-z\\-]+(?=\\:)/S',
835 + function (array $m) {
836 + return strtolower($m[0]);
837 + },
838 + $node->getAttribute('style')
839 + );
840 +
841 + // in order to not overwrite existing style attributes in the HTML, we
842 + // have to save the original HTML styles
843 + $nodePath = $node->getNodePath();
844 + if (!isset($this->styleAttributesForNodes[$nodePath])) {
845 + $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
846 + $this->visitedNodes[$nodePath] = $node;
847 + }
848 +
849 + $node->setAttribute('style', $normalizedOriginalStyle);
850 + }
851 +
852 + /**
853 + * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
854 + *
855 + * @return void
856 + */
857 + private function fillStyleAttributesWithMergedStyles()
858 + {
859 + foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
860 + $node = $this->visitedNodes[$nodePath];
861 + $currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
862 + $node->setAttribute(
863 + 'style',
864 + $this->generateStyleStringFromDeclarationsArrays(
865 + $currentStyleAttributes,
866 + $styleAttributesForNode
867 + )
868 + );
869 + }
870 + }
871 +
872 + /**
873 + * This method merges old or existing name/value array with new name/value array
874 + * and then generates a string of the combined style suitable for placing inline.
875 + * This becomes the single point for CSS string generation allowing for consistent
876 + * CSS output no matter where the CSS originally came from.
877 + *
878 + * @param string[] $oldStyles
879 + * @param string[] $newStyles
880 + *
881 + * @return string
882 + */
883 + private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles)
884 + {
885 + $combinedStyles = array_merge($oldStyles, $newStyles);
886 + $cacheKey = serialize($combinedStyles);
887 + if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
888 + return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
889 + }
890 +
891 + foreach ($oldStyles as $attributeName => $attributeValue) {
892 + if (!isset($newStyles[$attributeName])) {
893 + continue;
894 + }
895 +
896 + $newAttributeValue = $newStyles[$attributeName];
897 + if ($this->attributeValueIsImportant($attributeValue)
898 + && !$this->attributeValueIsImportant($newAttributeValue)
899 + ) {
900 + $combinedStyles[$attributeName] = $attributeValue;
901 + }
902 + }
903 +
904 + $style = '';
905 + foreach ($combinedStyles as $attributeName => $attributeValue) {
906 + $style .= strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; ';
907 + }
908 + $trimmedStyle = rtrim($style);
909 +
910 + $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
911 +
912 + return $trimmedStyle;
913 + }
914 +
915 + /**
916 + * Checks whether $attributeValue is marked as !important.
917 + *
918 + * @param string $attributeValue
919 + *
920 + * @return bool
921 + */
922 + private function attributeValueIsImportant($attributeValue)
923 + {
924 + return strtolower(substr(trim($attributeValue), -10)) === '!important';
925 + }
926 +
927 + /**
928 + * Applies $css to $xmlDocument, limited to the media queries that actually apply to the document.
929 + *
930 + * @param \DOMDocument $xmlDocument the document to match against
931 + * @param \DOMXPath $xPath
932 + * @param string $css a string of CSS
933 + *
934 + * @return void
935 + */
936 + private function copyCssWithMediaToStyleNode(\DOMDocument $xmlDocument, \DOMXPath $xPath, $css)
937 + {
938 + if ($css === '') {
939 + return;
940 + }
941 +
942 + $mediaQueriesRelevantForDocument = [];
943 +
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 + }
950 + }
951 + }
952 +
953 + $this->addStyleElementToDocument($xmlDocument, implode($mediaQueriesRelevantForDocument));
954 + }
955 +
956 + /**
957 + * Extracts the media queries from $css while skipping empty media queries.
958 + *
959 + * @param string $css
960 + *
961 + * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
962 + */
963 + private function extractMediaQueriesFromCss($css)
964 + {
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 + }
975 + }
976 +
977 + return $parsedQueries;
978 + }
979 +
980 + /**
981 + * Checks whether there is at least one matching element for $cssSelector.
982 + *
983 + * @param \DOMXPath $xPath
984 + * @param string $cssSelector
985 + *
986 + * @return bool
987 + */
988 + private function existsMatchForCssSelector(\DOMXPath $xPath, $cssSelector)
989 + {
990 + $nodesMatchingSelector = $xPath->query($this->translateCssToXpath($cssSelector));
991 +
992 + return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
993 + }
994 +
995 + /**
996 + * Returns CSS content.
997 + *
998 + * @param \DOMXPath $xPath
999 + *
1000 + * @return string
1001 + */
1002 + private function getCssFromAllStyleNodes(\DOMXPath $xPath)
1003 + {
1004 + $styleNodes = $xPath->query('//style');
1005 +
1006 + if ($styleNodes === false) {
1007 + return '';
1008 + }
1009 +
1010 + $css = '';
1011 + /** @var \DOMNode $styleNode */
1012 + foreach ($styleNodes as $styleNode) {
1013 + $css .= "\n\n" . $styleNode->nodeValue;
1014 + $styleNode->parentNode->removeChild($styleNode);
1015 + }
1016 +
1017 + return $css;
1018 + }
1019 +
1020 + /**
1021 + * Adds a style element with $css to $document.
1022 + *
1023 + * This method is protected to allow overriding.
1024 + *
1025 + * @see https://github.com/jjriv/emogrifier/issues/103
1026 + *
1027 + * @param \DOMDocument $document
1028 + * @param string $css
1029 + *
1030 + * @return void
1031 + */
1032 + protected function addStyleElementToDocument(\DOMDocument $document, $css)
1033 + {
1034 + $styleElement = $document->createElement('style', $css);
1035 + $styleAttribute = $document->createAttribute('type');
1036 + $styleAttribute->value = 'text/css';
1037 + $styleElement->appendChild($styleAttribute);
1038 +
1039 + $head = $this->getOrCreateHeadElement($document);
1040 + $head->appendChild($styleElement);
1041 + }
1042 +
1043 + /**
1044 + * Returns the existing or creates a new head element in $document.
1045 + *
1046 + * @param \DOMDocument $document
1047 + *
1048 + * @return \DOMNode the head element
1049 + */
1050 + private function getOrCreateHeadElement(\DOMDocument $document)
1051 + {
1052 + $head = $document->getElementsByTagName('head')->item(0);
1053 +
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));
1058 + }
1059 +
1060 + return $head;
1061 + }
1062 +
1063 + /**
1064 + * Splits input CSS code to an array where:
1065 + *
1066 + * - key "css" will be contains clean CSS code
1067 + * - key "media" will be contains all valuable media queries
1068 + *
1069 + * Example:
1070 + *
1071 + * The CSS code
1072 + *
1073 + * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
1074 + *
1075 + * will be parsed into the following array:
1076 + *
1077 + * "css" => "h1 { color:red; }"
1078 + * "media" => "@media { h1 {}}"
1079 + *
1080 + * @param string $css
1081 + *
1082 + * @return string[]
1083 + */
1084 + private function splitCssAndMediaQuery($css)
1085 + {
1086 + $cssWithoutComments = preg_replace('/\\/\\*.*\\*\\//sU', '', $css);
1087 +
1088 + $mediaTypesExpression = '';
1089 + if (!empty($this->allowedMediaTypes)) {
1090 + $mediaTypesExpression = '|' . implode('|', array_keys($this->allowedMediaTypes));
1091 + }
1092 +
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
1100 + );
1101 +
1102 + // filter the CSS
1103 + $search = [
1104 + 'import directives' => '/^\\s*@import\\s[^;]+;/misU',
1105 + 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
1106 + ];
1107 +
1108 + $cleanedCss = preg_replace($search, '', $cssForAllowedMediaTypes);
1109 +
1110 + return ['css' => $cleanedCss, 'media' => $media];
1111 + }
1112 +
1113 + /**
1114 + * Creates a DOMDocument instance with the current HTML.
1115 + *
1116 + * @return \DOMDocument
1117 + */
1118 + private function createXmlDocument()
1119 + {
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;
1160 + }
1161 +
1162 + $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
1163 +
1164 + return preg_replace(
1165 + '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
1166 + '',
1167 + $html
1168 + );
1169 + }
1170 +
1171 + /**
1172 + * Makes sure that the passed HTML has a document type.
1173 + *
1174 + * @param string $html
1175 + *
1176 + * @return string HTML with document type
1177 + */
1178 + private function ensureDocumentType($html)
1179 + {
1180 + $hasDocumentType = stripos($html, '<!DOCTYPE') !== false;
1181 + if ($hasDocumentType) {
1182 + return $html;
1183 + }
1184 +
1185 + return self::DEFAULT_DOCUMENT_TYPE . $html;
1186 + }
1187 +
1188 + /**
1189 + * Adds a Content-Type meta tag for the charset.
1190 + *
1191 + * @param string $html
1192 + *
1193 + * @return string the HTML with the meta tag added
1194 + */
1195 + private function addContentTypeMetaTag($html)
1196 + {
1197 + $hasContentTypeMetaTag = stristr($html, 'Content-Type') !== false;
1198 + if ($hasContentTypeMetaTag) {
1199 + return $html;
1200 + }
1201 +
1202 + // We are trying to insert the meta tag to the right spot in the DOM.
1203 + // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
1204 + $hasHeadTag = stripos($html, '<head') !== false;
1205 + $hasHtmlTag = stripos($html, '<html') !== false;
1206 +
1207 + if ($hasHeadTag) {
1208 + $reworkedHtml = preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
1209 + } elseif ($hasHtmlTag) {
1210 + $reworkedHtml = preg_replace(
1211 + '/<html(.*?)>/i',
1212 + '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
1213 + $html
1214 + );
1215 + } else {
1216 + $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
1217 + }
1218 +
1219 + return $reworkedHtml;
1220 + }
1221 +
1222 + /**
1223 + * @param string[] $a
1224 + * @param string[] $b
1225 + *
1226 + * @return int
1227 + */
1228 + private function sortBySelectorPrecedence(array $a, array $b)
1229 + {
1230 + $precedenceA = $this->getCssSelectorPrecedence($a['selector']);
1231 + $precedenceB = $this->getCssSelectorPrecedence($b['selector']);
1232 +
1233 + // We want these sorted in ascending order so selectors with lesser precedence get processed first and
1234 + // selectors with greater precedence get sorted last.
1235 + $precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
1236 + $precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
1237 + return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
1238 + }
1239 +
1240 + /**
1241 + * @param string $selector
1242 + *
1243 + * @return int
1244 + */
1245 + private function getCssSelectorPrecedence($selector)
1246 + {
1247 + $selectorKey = md5($selector);
1248 + if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
1249 + $precedence = 0;
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) === '') {
1256 + break;
1257 + }
1258 + $number = 0;
1259 + $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
1260 + $precedence += ($value * $number);
1261 + $value /= 10;
1262 + }
1263 + $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
1264 + }
1265 +
1266 + return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
1267 + }
1268 +
1269 + /**
1270 + * Maps a CSS selector to an XPath query string.
1271 + *
1272 + * @see http://plasmasturm.org/log/444/
1273 + *
1274 + * @param string $cssSelector a CSS selector
1275 + *
1276 + * @return string the corresponding XPath selector
1277 + */
1278 + private function translateCssToXpath($cssSelector)
1279 + {
1280 + $paddedSelector = ' ' . $cssSelector . ' ';
1281 + $lowercasePaddedSelector = preg_replace_callback(
1282 + '/\\s+\\w+\\s+/',
1283 + function (array $matches) {
1284 + return strtolower($matches[0]);
1285 + },
1286 + $paddedSelector
1287 + );
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 + );
1306 +
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;
1321 + }
1322 + return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1323 + }
1324 +
1325 + /**
1326 + * @param string[] $match
1327 + *
1328 + * @return string
1329 + */
1330 + private function matchIdAttributes(array $match)
1331 + {
1332 + return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
1333 + }
1334 +
1335 + /**
1336 + * @param string[] $match
1337 + *
1338 + * @return string
1339 + */
1340 + private function matchClassAttributes(array $match)
1341 + {
1342 + return ($match[1] !== '' ? $match[1] : '*') . '[contains(concat(" ",@class," "),concat(" ","' .
1343 + implode(
1344 + '"," "))][contains(concat(" ",@class," "),concat(" ","',
1345 + explode('.', substr($match[2], 1))
1346 + ) . '"," "))]';
1347 + }
1348 +
1349 + /**
1350 + * @param string[] $match
1351 + *
1352 + * @return string
1353 + */
1354 + private function translateNthChild(array $match)
1355 + {
1356 + $parseResult = $this->parseNth($match);
1357 +
1358 + if (isset($parseResult[self::MULTIPLIER])) {
1359 + if ($parseResult[self::MULTIPLIER] < 0) {
1360 + $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1361 + $xPathExpression = sprintf(
1362 + '*[(last() - position()) mod %u = %u]/self::%s',
1363 + $parseResult[self::MULTIPLIER],
1364 + $parseResult[self::INDEX],
1365 + $match[1]
1366 + );
1367 + } else {
1368 + $xPathExpression = sprintf(
1369 + '*[position() mod %u = %u]/self::%s',
1370 + $parseResult[self::MULTIPLIER],
1371 + $parseResult[self::INDEX],
1372 + $match[1]
1373 + );
1374 + }
1375 + } else {
1376 + $xPathExpression = sprintf('*[%u]/self::%s', $parseResult[self::INDEX], $match[1]);
1377 + }
1378 +
1379 + return $xPathExpression;
1380 + }
1381 +
1382 + /**
1383 + * @param string[] $match
1384 + *
1385 + * @return string
1386 + */
1387 + private function translateNthOfType(array $match)
1388 + {
1389 + $parseResult = $this->parseNth($match);
1390 +
1391 + if (isset($parseResult[self::MULTIPLIER])) {
1392 + if ($parseResult[self::MULTIPLIER] < 0) {
1393 + $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1394 + $xPathExpression = sprintf(
1395 + '%s[(last() - position()) mod %u = %u]',
1396 + $match[1],
1397 + $parseResult[self::MULTIPLIER],
1398 + $parseResult[self::INDEX]
1399 + );
1400 + } else {
1401 + $xPathExpression = sprintf(
1402 + '%s[position() mod %u = %u]',
1403 + $match[1],
1404 + $parseResult[self::MULTIPLIER],
1405 + $parseResult[self::INDEX]
1406 + );
1407 + }
1408 + } else {
1409 + $xPathExpression = sprintf('%s[%u]', $match[1], $parseResult[self::INDEX]);
1410 + }
1411 +
1412 + return $xPathExpression;
1413 + }
1414 +
1415 + /**
1416 + * @param string[] $match
1417 + *
1418 + * @return int[]
1419 + */
1420 + private function parseNth(array $match)
1421 + {
1422 + if (in_array(strtolower($match[2]), ['even', 'odd'], true)) {
1423 + // we have "even" or "odd"
1424 + $index = strtolower($match[2]) === 'even' ? 0 : 1;
1425 + return [self::MULTIPLIER => 2, self::INDEX => $index];
1426 + }
1427 + if (stripos($match[2], 'n') === false) {
1428 + // if there is a multiplier
1429 + $index = (int) str_replace(' ', '', $match[2]);
1430 + return [self::INDEX => $index];
1431 + }
1432 +
1433 + if (isset($match[3])) {
1434 + $multipleTerm = str_replace($match[3], '', $match[2]);
1435 + $index = (int) str_replace(' ', '', $match[3]);
1436 + } else {
1437 + $multipleTerm = $match[2];
1438 + $index = 0;
1439 + }
1440 +
1441 + $multiplier = str_ireplace('n', '', $multipleTerm);
1442 +
1443 + if ($multiplier === '') {
1444 + $multiplier = 1;
1445 + } elseif ($multiplier === '0') {
1446 + return [self::INDEX => $index];
1447 + } else {
1448 + $multiplier = (int) $multiplier;
1449 + }
1450 +
1451 + while ($index < 0) {
1452 + $index += abs($multiplier);
1453 + }
1454 +
1455 + return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
1456 + }
1457 +
1458 + /**
1459 + * Parses a CSS declaration block into property name/value pairs.
1460 + *
1461 + * Example:
1462 + *
1463 + * The declaration block
1464 + *
1465 + * "color: #000; font-weight: bold;"
1466 + *
1467 + * will be parsed into the following array:
1468 + *
1469 + * "color" => "#000"
1470 + * "font-weight" => "bold"
1471 + *
1472 + * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
1473 + *
1474 + * @return string[]
1475 + * the CSS declarations with the property names as array keys and the property values as array values
1476 + */
1477 + private function parseCssDeclarationsBlock($cssDeclarationsBlock)
1478 + {
1479 + if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
1480 + return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
1481 + }
1482 +
1483 + $properties = [];
1484 + $declarations = preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
1485 +
1486 + foreach ($declarations as $declaration) {
1487 + $matches = [];
1488 + if (!preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches)) {
1489 + continue;
1490 + }
1491 +
1492 + $propertyName = strtolower($matches[1]);
1493 + $propertyValue = $matches[2];
1494 + $properties[$propertyName] = $propertyValue;
1495 + }
1496 + $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
1497 +
1498 + return $properties;
1499 + }
1500 +
1501 + /**
1502 + * Find the nodes that are not to be emogrified.
1503 + *
1504 + * @param \DOMXPath $xPath
1505 + *
1506 + * @return \DOMElement[]
1507 + */
1508 + private function getNodesToExclude(\DOMXPath $xPath)
1509 + {
1510 + $excludedNodes = [];
1511 + foreach (array_keys($this->excludedSelectors) as $selectorToExclude) {
1512 + foreach ($xPath->query($this->translateCssToXpath($selectorToExclude)) as $node) {
1513 + $excludedNodes[] = $node;
1514 + }
1515 + }
1516 +
1517 + return $excludedNodes;
1518 + }
1519 +
1520 + /**
1521 + * Handles invalid xPath expression warnings, generated by process() method,
1522 + * during querying \DOMDocument and trigger \InvalidArgumentException
1523 + * with invalid selector.
1524 + *
1525 + * @param int $type
1526 + * @param string $message
1527 + * @param string $file
1528 + * @param int $line
1529 + * @param array $context
1530 + *
1531 + * @return bool always false
1532 + *
1533 + * @throws \InvalidArgumentException
1534 + */
1535 + public function handleXpathError($type, $message, $file, $line, array $context)
1536 + {
1537 + if ($this->debug && $type === E_WARNING && isset($context['cssRule']['selector'])) {
1538 + throw new \InvalidArgumentException(
1539 + sprintf(
1540 + '%s in selector >> %s << in %s on line %s',
1541 + $message,
1542 + $context['cssRule']['selector'],
1543 + $file,
1544 + $line
1545 + )
1546 + );
1547 + }
1548 +
1549 + // the normal error handling continues when handler return false
1550 + return false;
1551 + }
1552 +
1553 + /**
1554 + * Sets the debug mode.
1555 + *
1556 + * @param bool $debug set to true to enable debug mode
1557 + *
1558 + * @return void
1559 + */
1560 + public function setDebug($debug)
1561 + {
1562 + $this->debug = $debug;
1563 + }
1564 +}