PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.1
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.1
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 4.2.1 All 138 releases
learnpress / inc / libraries / class-emogrifier.php

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

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