PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.1.7.1
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.1.7.1
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
learnpress / inc / libraries / class-emogrifier.php

class-emogrifier.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.1.7.1, at inc/libraries/class-emogrifier.php

795 lines 24.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
4 *
5 * For more information, please see the README.md file.
6 *
7 * @author Cameron Brooks
8 * @author Jaime Prado
9 * @author Roman O�ana <ozana@omdesign.cz>
10 */
11 if(!class_exists('Emogrifier')) {
12 class Emogrifier {
13 /**
14 * @var string
15 */
16 const ENCODING = 'UTF-8';
17
18 /**
19 * @var integer
20 */
21 const CACHE_KEY_CSS = 0;
22
23 /**
24 * @var integer
25 */
26 const CACHE_KEY_SELECTOR = 1;
27
28 /**
29 * @var integer
30 */
31 const CACHE_KEY_XPATH = 2;
32
33 /**
34 * @var integer
35 */
36 const CACHE_KEY_CSS_DECLARATION_BLOCK = 3;
37
38 /**
39 * for calculating nth-of-type and nth-child selectors
40 *
41 * @var integer
42 */
43 const INDEX = 0;
44
45 /**
46 * for calculating nth-of-type and nth-child selectors
47 *
48 * @var integer
49 */
50 const MULTIPLIER = 1;
51
52 /**
53 * @var string
54 */
55 const ID_ATTRIBUTE_MATCHER = '/(\\w+)?\\#([\\w\\-]+)/';
56
57 /**
58 * @var string
59 */
60 const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
61
62 /**
63 * @var string
64 */
65 private $html = '';
66
67 /**
68 * @var string
69 */
70 private $css = '';
71
72 /**
73 * @var array<string>
74 */
75 private $unprocessableHtmlTags = array( 'wbr' );
76
77 /**
78 * @var array<array>
79 */
80 private $caches = array(
81 self::CACHE_KEY_CSS => array(),
82 self::CACHE_KEY_SELECTOR => array(),
83 self::CACHE_KEY_XPATH => array(),
84 self::CACHE_KEY_CSS_DECLARATION_BLOCK => array(),
85 );
86
87 /**
88 * the visited nodes with the XPath paths as array keys
89 *
90 * @var array<\DOMNode>
91 */
92 private $visitedNodes = array();
93
94 /**
95 * the styles to apply to the nodes with the XPath paths as array keys for the outer array and the attribute names/values
96 * as key/value pairs for the inner array
97 *
98 * @var array<array><string>
99 */
100 private $styleAttributesForNodes = array();
101
102 /**
103 * This attribute applies to the case where you want to preserve your original text encoding.
104 *
105 * By default, emogrifier translates your text into HTML entities for two reasons:
106 *
107 * 1. Because of client incompatibilities, it is better practice to send out HTML entities rather than unicode over email.
108 *
109 * 2. It translates any illegal XML characters that DOMDocument cannot work with.
110 *
111 * If you would like to preserve your original encoding, set this attribute to TRUE.
112 *
113 * @var boolean
114 */
115 public $preserveEncoding = false;
116
117 public static $_media = '';
118
119 /**
120 * The constructor.
121 *
122 * @param string $html the HTML to emogrify, must be UTF-8-encoded
123 * @param string $css the CSS to merge, must be UTF-8-encoded
124 */
125 public function __construct( $html = '', $css = '' ) {
126 $this->setHtml( $html );
127 $this->setCss( $css );
128 }
129
130 /**
131 * The destructor.
132 */
133 public function __destruct() {
134 $this->purgeVisitedNodes();
135 }
136
137 /**
138 * Sets the HTML to emogrify.
139 *
140 * @param string $html the HTML to emogrify, must be UTF-8-encoded
141 */
142 public function setHtml( $html = '' ) {
143 $this->html = $html;
144 }
145
146 /**
147 * Sets the CSS to merge with the HTML.
148 *
149 * @param string $css the CSS to merge, must be UTF-8-encoded
150 */
151 public function setCss( $css = '' ) {
152 $this->css = $css;
153 }
154
155 /**
156 * Clears all caches.
157 */
158 private function clearAllCaches() {
159 $this->clearCache( self::CACHE_KEY_CSS );
160 $this->clearCache( self::CACHE_KEY_SELECTOR );
161 $this->clearCache( self::CACHE_KEY_XPATH );
162 $this->clearCache( self::CACHE_KEY_CSS_DECLARATION_BLOCK );
163 }
164
165 /**
166 * Clears a single cache by key.
167 *
168 * @param integer $key the cache key, must be CACHE_KEY_CSS, CACHE_KEY_SELECTOR, CACHE_KEY_XPATH or CACHE_KEY_CSS_DECLARATION_BLOCK
169 *
170 * @throws InvalidArgumentException
171 */
172 private function clearCache( $key ) {
173 $allowedCacheKeys = array( self::CACHE_KEY_CSS, self::CACHE_KEY_SELECTOR, self::CACHE_KEY_XPATH, self::CACHE_KEY_CSS_DECLARATION_BLOCK );
174 if ( !in_array( $key, $allowedCacheKeys, true ) ) {
175 throw new InvalidArgumentException( 'Invalid cache key: ' . $key, 1391822035 );
176 }
177
178 $this->caches[$key] = array();
179 }
180
181 /**
182 * Purges the visited nodes.
183 */
184 private function purgeVisitedNodes() {
185 $this->visitedNodes = array();
186 $this->styleAttributesForNodes = array();
187 }
188
189 /**
190 * Marks a tag for removal.
191 *
192 * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
193 * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
194 *
195 * Note: The tags will not be removed if they have any content.
196 *
197 * @param string $tagName the tag name, e.g., "p"
198 */
199 public function addUnprocessableHtmlTag( $tagName ) {
200 $this->unprocessableHtmlTags[] = $tagName;
201 }
202
203 /**
204 * Drops a tag from the removal list.
205 *
206 * @param string $tagName the tag name, e.g., "p"
207 */
208 public function removeUnprocessableHtmlTag( $tagName ) {
209 $key = array_search( $tagName, $this->unprocessableHtmlTags, true );
210 if ( $key !== false ) {
211 unset( $this->unprocessableHtmlTags[$key] );
212 }
213 }
214
215 /**
216 * Applies the CSS you submit to the HTML you submit.
217 *
218 * This method places the CSS inline.
219 *
220 * @return string
221 *
222 * @throws BadMethodCallException
223 */
224 public function emogrify() {
225 if ( $this->html === '' ) {
226 throw new BadMethodCallException( 'Please set some HTML first before calling emogrify.', 1390393096 );
227 }
228
229 $xmlDocument = $this->createXmlDocument();
230 $xpath = new DOMXPath( $xmlDocument );
231 $this->clearAllCaches();
232
233 // before be begin processing the CSS file, parse the document and normalize all existing CSS attributes (changes 'DISPLAY: none' to 'display: none');
234 // we wouldn't have to do this if DOMXPath supported XPath 2.0.
235 // also store a reference of nodes with existing inline styles so we don't overwrite them
236 $this->purgeVisitedNodes();
237
238 $nodesWithStyleAttributes = $xpath->query( '//*[@style]' );
239 if ( $nodesWithStyleAttributes !== false ) {
240 /** @var $nodeWithStyleAttribute DOMNode */
241 foreach ( $nodesWithStyleAttributes as $node ) {
242 $normalizedOriginalStyle = preg_replace_callback( '/[A-z\\-]+(?=\\:)/S', array( $this, 'strtolower' ), $node->getAttribute( 'style' ) );
243
244 // in order to not overwrite existing style attributes in the HTML, we have to save the original HTML styles
245 $nodePath = $node->getNodePath();
246 if ( !isset( $this->styleAttributesForNodes[$nodePath] ) ) {
247 $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationBlock( $normalizedOriginalStyle );
248 $this->visitedNodes[$nodePath] = $node;
249 }
250
251 $node->setAttribute( 'style', $normalizedOriginalStyle );
252 }
253 }
254
255 // grab any existing style blocks from the html and append them to the existing CSS
256 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
257 $allCss = $this->css;
258
259 $allCss .= $this->getCssFromAllStyleNodes( $xpath );
260
261 $cssParts = $this->splitCssAndMediaQuery( $allCss );
262 self::$_media = ''; // reset
263
264 $cssKey = md5( $cssParts['css'] );
265 if ( !isset( $this->caches[self::CACHE_KEY_CSS][$cssKey] ) ) {
266 // process the CSS file for selectors and definitions
267 preg_match_all( '/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mis', $cssParts['css'], $matches, PREG_SET_ORDER );
268
269 $allSelectors = array();
270 foreach ( $matches as $key => $selectorString ) {
271 // if there is a blank definition, skip
272 if ( !strlen( trim( $selectorString[2] ) ) ) {
273 continue;
274 }
275
276 // else split by commas and duplicate attributes so we can sort by selector precedence
277 $selectors = explode( ',', $selectorString[1] );
278 foreach ( $selectors as $selector ) {
279 // don't process pseudo-elements and behavioral (dynamic) pseudo-classes; ONLY allow structural pseudo-classes
280 if ( strpos( $selector, ':' ) !== false && !preg_match( '/:\\S+\\-(child|type)\\(/i', $selector ) ) {
281 continue;
282 }
283
284 $allSelectors[] = array( 'selector' => trim( $selector ),
285 'attributes' => trim( $selectorString[2] ),
286 // keep track of where it appears in the file, since order is important
287 'line' => $key,
288 );
289 }
290 }
291
292 // now sort the selectors by precedence
293 usort( $allSelectors, array( $this, 'sortBySelectorPrecedence' ) );
294
295 $this->caches[self::CACHE_KEY_CSS][$cssKey] = $allSelectors;
296 }
297
298 foreach ( $this->caches[self::CACHE_KEY_CSS][$cssKey] as $value ) {
299 // query the body for the xpath selector
300 $nodesMatchingCssSelectors = $xpath->query( $this->translateCssToXpath( $value['selector'] ) );
301
302 /** @var $node \DOMNode */
303 foreach ( $nodesMatchingCssSelectors as $node ) {
304 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
305 if ( $node->hasAttribute( 'style' ) ) {
306 // break it up into an associative array
307 $oldStyleDeclarations = $this->parseCssDeclarationBlock( $node->getAttribute( 'style' ) );
308 } else {
309 $oldStyleDeclarations = array();
310 }
311 $newStyleDeclarations = $this->parseCssDeclarationBlock( $value['attributes'] );
312 $node->setAttribute( 'style', $this->generateStyleStringFromDeclarationsArrays( $oldStyleDeclarations, $newStyleDeclarations ) );
313 }
314 }
315
316 // now iterate through the nodes that contained inline styles in the original HTML
317 foreach ( $this->styleAttributesForNodes as $nodePath => $styleAttributesForNode ) {
318 $node = $this->visitedNodes[$nodePath];
319 $currentStyleAttributes = $this->parseCssDeclarationBlock( $node->getAttribute( 'style' ) );
320 $node->setAttribute( 'style', $this->generateStyleStringFromDeclarationsArrays( $currentStyleAttributes, $styleAttributesForNode ) );
321 }
322
323 // This removes styles from your email that contain display:none.
324 // We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only supports XPath 1.0,
325 // lower-case() isn't available to us. We've thus far only set attributes to lowercase, not attribute values. Consequently, we need
326 // to translate() the letters that would be in 'NONE' ("NOE") to lowercase.
327 $nodesWithStyleDisplayNone = $xpath->query( '//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]' );
328 // The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
329 // we don't try to call removeChild on a nonexistent child node
330 if ( $nodesWithStyleDisplayNone->length > 0 ) {
331 /** @var $node \DOMNode */
332 foreach ( $nodesWithStyleDisplayNone as $node ) {
333 if ( $node->parentNode && is_callable( array( $node->parentNode, 'removeChild' ) ) ) {
334 $node->parentNode->removeChild( $node );
335 }
336 }
337 }
338
339 $this->copyCssWithMediaToStyleNode( $cssParts, $xmlDocument );
340
341 if ( $this->preserveEncoding ) {
342 if ( function_exists( 'mb_convert_encoding' ) ) {
343 return mb_convert_encoding( $xmlDocument->saveHTML(), self::ENCODING, 'HTML-ENTITIES' );
344 } else {
345 return htmlspecialchars_decode( utf8_encode( html_entity_decode( $xmlDocument->saveHTML(), ENT_COMPAT, self::ENCODING ) ) );
346 }
347 } else {
348 return $xmlDocument->saveHTML();
349 }
350 }
351
352 public function strtolower( array $m ) {
353 return strtolower( $m[0] );
354 }
355
356
357 /**
358 * This method merges old or existing name/value array with new name/value array
359 * and then generates a string of the combined style suitable for placing inline.
360 * This becomes the single point for CSS string generation allowing for consistent
361 * CSS output no matter where the CSS originally came from.
362 *
363 * @param array $oldStyles
364 * @param array $newStyles
365 *
366 * @return string
367 */
368 private function generateStyleStringFromDeclarationsArrays( array $oldStyles, array $newStyles ) {
369 $combinedStyles = array_merge( $oldStyles, $newStyles );
370 $style = '';
371 foreach ( $combinedStyles as $attributeName => $attributeValue ) {
372 $style .= ( strtolower( trim( $attributeName ) ) . ': ' . trim( $attributeValue ) . '; ' );
373 }
374 return trim( $style );
375 }
376
377
378 /**
379 * Copies the media part from CSS array parts to $xmlDocument.
380 *
381 * @param array $cssParts
382 * @param DOMDocument $xmlDocument
383 */
384 public function copyCssWithMediaToStyleNode( array $cssParts, DOMDocument $xmlDocument ) {
385 if ( isset( $cssParts['media'] ) && $cssParts['media'] !== '' ) {
386 $this->addStyleElementToDocument( $xmlDocument, $cssParts['media'] );
387 }
388 }
389
390 /**
391 * Returns CSS content.
392 *
393 * @param DOMXPath $xpath
394 *
395 * @return string
396 */
397 private function getCssFromAllStyleNodes( DOMXPath $xpath ) {
398 $styleNodes = $xpath->query( '//style' );
399
400 if ( $styleNodes === false ) {
401 return '';
402 }
403
404 $css = '';
405 /** @var $styleNode DOMNode */
406 foreach ( $styleNodes as $styleNode ) {
407 $css .= "\n\n" . $styleNode->nodeValue;
408 $styleNode->parentNode->removeChild( $styleNode );
409 }
410
411 return $css;
412 }
413
414 /**
415 * Adds a style element with $css to $document.
416 *
417 * @param DOMDocument $document
418 * @param string $css
419 */
420 private function addStyleElementToDocument( DOMDocument $document, $css ) {
421 $styleElement = $document->createElement( 'style', $css );
422 $styleAttribute = $document->createAttribute( 'type' );
423 $styleAttribute->value = 'text/css';
424 $styleElement->appendChild( $styleAttribute );
425
426 $head = $this->getOrCreateHeadElement( $document );
427 $head->appendChild( $styleElement );
428 }
429
430 /**
431 * Returns the existing or creates a new head element in $document.
432 *
433 * @param DOMDocument $document
434 *
435 * @return DOMNode the head element
436 */
437 private function getOrCreateHeadElement( DOMDocument $document ) {
438 $head = $document->getElementsByTagName( 'head' )->item( 0 );
439
440 if ( $head === null ) {
441 $head = $document->createElement( 'head' );
442 $html = $document->getElementsByTagName( 'html' )->item( 0 );
443 $html->insertBefore( $head, $document->getElementsByTagName( 'body' )->item( 0 ) );
444 }
445
446 return $head;
447 }
448
449 /**
450 * Splits input CSS code to an array where:
451 *
452 * - key "css" will be contains clean CSS code
453 * - key "media" will be contains all valuable media queries
454 *
455 * Example:
456 *
457 * The CSS code
458 *
459 * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
460 *
461 * will be parsed into the following array:
462 *
463 * "css" => "h1 { color:red; }"
464 * "media" => "@media { h1 {}}"
465 *
466 * @param string $css
467 *
468 * @return array
469 */
470 private function splitCssAndMediaQuery( $css ) {
471 $css = preg_replace_callback( '#@media\\s+(?:only\\s)?(?:[\\s{\(]|screen|all)\\s?[^{]+{.*}\\s*}\\s*#misU', array( $this, '_media_concat' ), $css );
472
473 // filter the CSS
474 $search = array(
475 // get rid of css comment code
476 '/\\/\\*.*\\*\\//sU',
477 // strip out any import directives
478 '/^\\s*@import\\s[^;]+;/misU',
479 // strip remains media enclosures
480 '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
481 );
482
483 $replace = array(
484 '',
485 '',
486 '',
487 );
488
489 // clean CSS before output
490 $css = preg_replace( $search, $replace, $css );
491
492 return array( 'css' => $css, 'media' => self::$_media );
493 }
494
495 private function _media_concat( $matches ) {
496 self::$_media .= $matches[0];
497 }
498
499 /**
500 * Creates a DOMDocument instance with the current HTML.
501 *
502 * @return DOMDocument
503 */
504 private function createXmlDocument() {
505 $xmlDocument = new DOMDocument;
506 $xmlDocument->encoding = self::ENCODING;
507 $xmlDocument->strictErrorChecking = false;
508 $xmlDocument->formatOutput = true;
509 $libXmlState = libxml_use_internal_errors( true );
510 $xmlDocument->loadHTML( $this->getUnifiedHtml() );
511 libxml_clear_errors();
512 libxml_use_internal_errors( $libXmlState );
513 $xmlDocument->normalizeDocument();
514
515 return $xmlDocument;
516 }
517
518 /**
519 * Returns the HTML with the non-ASCII characters converts into HTML entities and the unprocessable HTML tags removed.
520 *
521 * @return string the unified HTML
522 *
523 * @throws BadMethodCallException
524 */
525 private function getUnifiedHtml() {
526 if ( !empty( $this->unprocessableHtmlTags ) ) {
527 $unprocessableHtmlTags = implode( '|', $this->unprocessableHtmlTags );
528 $bodyWithoutUnprocessableTags = preg_replace( '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i', '', $this->html );
529 } else {
530 $bodyWithoutUnprocessableTags = $this->html;
531 }
532
533 if ( function_exists( 'mb_convert_encoding' ) ) {
534 return mb_convert_encoding( $bodyWithoutUnprocessableTags, 'HTML-ENTITIES', self::ENCODING );
535 } else {
536 return htmlspecialchars_decode( utf8_decode( htmlentities( $bodyWithoutUnprocessableTags, ENT_COMPAT, self::ENCODING, false ) ) );
537 }
538 }
539
540 /**
541 * @param array $a
542 * @param array $b
543 *
544 * @return integer
545 */
546 private function sortBySelectorPrecedence( array $a, array $b ) {
547 $precedenceA = $this->getCssSelectorPrecedence( $a['selector'] );
548 $precedenceB = $this->getCssSelectorPrecedence( $b['selector'] );
549
550 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
551 // selectors with greater precedence get sorted last.
552 // The parenthesis around the -1 are necessary to avoid a PHP_CodeSniffer warning about missing spaces around
553 // arithmetic operators.
554 // @see http://forge.typo3.org/issues/55605
555 $precedenceForEquals = ( $a['line'] < $b['line'] ? ( - 1 ) : 1 );
556 $precedenceForNotEquals = ( $precedenceA < $precedenceB ? ( - 1 ) : 1 );
557 return ( $precedenceA === $precedenceB ) ? $precedenceForEquals : $precedenceForNotEquals;
558 }
559
560 /**
561 * @param string $selector
562 *
563 * @return integer
564 */
565 private function getCssSelectorPrecedence( $selector ) {
566 $selectorKey = md5( $selector );
567 if ( !isset( $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] ) ) {
568 $precedence = 0;
569 $value = 100;
570 // ids: worth 100, classes: worth 10, elements: worth 1
571 $search = array( '\\#', '\\.', '' );
572
573 foreach ( $search as $s ) {
574 if ( trim( $selector == '' ) ) {
575 break;
576 }
577 $number = 0;
578 $selector = preg_replace( '/' . $s . '\\w+/', '', $selector, - 1, $number );
579 $precedence += ( $value * $number );
580 $value /= 10;
581 }
582 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
583 }
584
585 return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
586 }
587
588 /**
589 * Right now, we support all CSS 1 selectors and most CSS2/3 selectors.
590 *
591 * @see http://plasmasturm.org/log/444/
592 *
593 * @param string $paramCssSelector
594 *
595 * @return string
596 */
597 private function translateCssToXpath( $paramCssSelector ) {
598 $cssSelector = ' ' . $paramCssSelector . ' ';
599 $cssSelector = preg_replace_callback( '/\s+\w+\s+/', array( $this, 'strtolower' ), $cssSelector );
600 $cssSelector = trim( $cssSelector );
601 $xpathKey = md5( $cssSelector );
602 if ( !isset( $this->caches[self::CACHE_KEY_XPATH][$xpathKey] ) ) {
603 // returns an Xpath selector
604 $search = array(
605 // Matches any element that is a child of parent.
606 '/\\s+>\\s+/',
607 // Matches any element that is an adjacent sibling.
608 '/\\s+\\+\\s+/',
609 // Matches any element that is a descendant of an parent element element.
610 '/\\s+/',
611 // first-child pseudo-selector
612 '/([^\\/]+):first-child/i',
613 // last-child pseudo-selector
614 '/([^\\/]+):last-child/i',
615 // Matches attribute only selector
616 '/^\\[(\\w+)\\]/',
617 // Matches element with attribute
618 '/(\\w)\\[(\\w+)\\]/',
619 // Matches element with EXACT attribute
620 '/(\\w)\\[(\\w+)\\=[\'"]?(\\w+)[\'"]?\\]/',
621 );
622 $replace = array(
623 '/',
624 '/following-sibling::*[1]/self::',
625 '//',
626 '*[1]/self::\\1',
627 '*[last()]/self::\\1',
628 '*[@\\1]',
629 '\\1[@\\2]',
630 '\\1[@\\2="\\3"]',
631 );
632
633 $cssSelector = '//' . preg_replace( $search, $replace, $cssSelector );
634
635 $cssSelector = preg_replace_callback( self::ID_ATTRIBUTE_MATCHER, array( $this, 'matchIdAttributes' ), $cssSelector );
636 $cssSelector = preg_replace_callback( self::CLASS_ATTRIBUTE_MATCHER, array( $this, 'matchClassAttributes' ), $cssSelector );
637
638 // Advanced selectors are going to require a bit more advanced emogrification.
639 // When we required PHP 5.3, we could do this with closures.
640 $cssSelector = preg_replace_callback(
641 '/([^\\/]+):nth-child\\(\s*(odd|even|[+\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
642 array( $this, 'translateNthChild' ), $cssSelector
643 );
644 $cssSelector = preg_replace_callback(
645 '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
646 array( $this, 'translateNthOfType' ), $cssSelector
647 );
648
649 $this->caches[self::CACHE_KEY_SELECTOR][$xpathKey] = $cssSelector;
650 }
651 return $this->caches[self::CACHE_KEY_SELECTOR][$xpathKey];
652 }
653
654 /**
655 * @param array $match
656 *
657 * @return string
658 */
659 private function matchIdAttributes( array $match ) {
660 return ( strlen( $match[1] ) ? $match[1] : '*' ) . '[@id="' . $match[2] . '"]';
661 }
662
663 /**
664 * @param array $match
665 *
666 * @return string
667 */
668 private function matchClassAttributes( array $match ) {
669 return ( strlen( $match[1] ) ? $match[1] : '*' ) . '[contains(concat(" ",@class," "),concat(" ","' .
670 implode(
671 '"," "))][contains(concat(" ",@class," "),concat(" ","',
672 explode( '.', substr( $match[2], 1 ) )
673 ) . '"," "))]';
674 }
675
676 /**
677 * @param array $match
678 *
679 * @return string
680 */
681 private function translateNthChild( array $match ) {
682 $result = $this->parseNth( $match );
683
684 if ( isset( $result[self::MULTIPLIER] ) ) {
685 if ( $result[self::MULTIPLIER] < 0 ) {
686 $result[self::MULTIPLIER] = abs( $result[self::MULTIPLIER] );
687 return sprintf( '*[(last() - position()) mod %u = %u]/self::%s', $result[self::MULTIPLIER], $result[self::INDEX], $match[1] );
688 } else {
689 return sprintf( '*[position() mod %u = %u]/self::%s', $result[self::MULTIPLIER], $result[self::INDEX], $match[1] );
690 }
691 } else {
692 return sprintf( '*[%u]/self::%s', $result[self::INDEX], $match[1] );
693 }
694 }
695
696 /**
697 * @param array $match
698 *
699 * @return string
700 */
701 private function translateNthOfType( array $match ) {
702 $result = $this->parseNth( $match );
703
704 if ( isset( $result[self::MULTIPLIER] ) ) {
705 if ( $result[self::MULTIPLIER] < 0 ) {
706 $result[self::MULTIPLIER] = abs( $result[self::MULTIPLIER] );
707 return sprintf( '%s[(last() - position()) mod %u = %u]', $match[1], $result[self::MULTIPLIER], $result[self::INDEX] );
708 } else {
709 return sprintf( '%s[position() mod %u = %u]', $match[1], $result[self::MULTIPLIER], $result[self::INDEX] );
710 }
711 } else {
712 return sprintf( '%s[%u]', $match[1], $result[self::INDEX] );
713 }
714 }
715
716 /**
717 * @param array $match
718 *
719 * @return array
720 */
721 private function parseNth( array $match ) {
722 if ( in_array( strtolower( $match[2] ), array( 'even', 'odd' ) ) ) {
723 $index = strtolower( $match[2] ) == 'even' ? 0 : 1;
724 return array( self::MULTIPLIER => 2, self::INDEX => $index );
725 } elseif ( stripos( $match[2], 'n' ) === false ) {
726 // if there is a multiplier
727 $index = intval( str_replace( ' ', '', $match[2] ) );
728 return array( self::INDEX => $index );
729 } else {
730 if ( isset( $match[3] ) ) {
731 $multipleTerm = str_replace( $match[3], '', $match[2] );
732 $index = intval( str_replace( ' ', '', $match[3] ) );
733 } else {
734 $multipleTerm = $match[2];
735 $index = 0;
736 }
737
738 $multiplier = str_ireplace( 'n', '', $multipleTerm );
739
740 if ( !strlen( $multiplier ) ) {
741 $multiplier = 1;
742 } elseif ( $multiplier == 0 ) {
743 return array( self::INDEX => $index );
744 } else {
745 $multiplier = intval( $multiplier );
746 }
747
748 while ( $index < 0 ) {
749 $index += abs( $multiplier );
750 }
751
752 return array( self::MULTIPLIER => $multiplier, self::INDEX => $index );
753 }
754 }
755
756 /**
757 * Parses a CSS declaration block into property name/value pairs.
758 *
759 * Example:
760 *
761 * The declaration block
762 *
763 * "color: #000; font-weight: bold;"
764 *
765 * will be parsed into the following array:
766 *
767 * "color" => "#000"
768 * "font-weight" => "bold"
769 *
770 * @param string $cssDeclarationBlock the CSS declaration block without the curly braces, may be empty
771 *
772 * @return array the CSS declarations with the property names as array keys and the property values as array values
773 */
774 private function parseCssDeclarationBlock( $cssDeclarationBlock ) {
775 if ( isset( $this->caches[self::CACHE_KEY_CSS_DECLARATION_BLOCK][$cssDeclarationBlock] ) ) {
776 return $this->caches[self::CACHE_KEY_CSS_DECLARATION_BLOCK][$cssDeclarationBlock];
777 }
778
779 $properties = array();
780 $declarations = explode( ';', $cssDeclarationBlock );
781 foreach ( $declarations as $declaration ) {
782 $matches = array();
783 if ( !preg_match( '/ *([A-Za-z\\-]+) *: *([^;]+) */', $declaration, $matches ) ) {
784 continue;
785 }
786 $propertyName = strtolower( $matches[1] );
787 $propertyValue = $matches[2];
788 $properties[$propertyName] = $propertyValue;
789 }
790 $this->caches[self::CACHE_KEY_CSS_DECLARATION_BLOCK][$cssDeclarationBlock] = $properties;
791
792 return $properties;
793 }
794 }
795 }