PluginProbe
weForms – Easy Drag & Drop Contact Form Builder For WordPress / 1.5.4
weForms – Easy Drag & Drop Contact Form Builder For WordPress v1.5.4
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
weforms / includes / library / Emogrifier.php

Emogrifier.php in weForms – Easy Drag & Drop Contact Form Builder For WordPress 1.5.4, at includes/library/Emogrifier.php

1,533 lines 50.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
1533