Completed
Push — master ( d77e91...0f6bbd )
by Lars
07:17
created

CssToInlineStyles::splitStyleIntoChunks()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 30
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 5.0114

Importance

Changes 3
Bugs 1 Features 0
Metric Value
c 3
b 1
f 0
dl 0
loc 30
ccs 12
cts 13
cp 0.9231
rs 8.439
cc 5
eloc 13
nc 4
nop 1
crap 5.0114
1
<?php
2
namespace voku\CssToInlineStyles;
3
4
use Symfony\Component\CssSelector\CssSelector;
5
use Symfony\Component\CssSelector\Exception\ExceptionInterface;
6
7
/**
8
 * CSS to Inline Styles class
9
 *
10
 * @author     Tijs Verkoyen <[email protected]>
11
 */
12
class CssToInlineStyles
13
{
14
15
  /**
16
   * regular expression: css media queries
17
   *
18
   * @var string
19
   */
20
  private static $cssMediaQueriesRegEx = '#@media\\s+(?:only\\s)?(?:[\\s{\\(]|screen|all)\\s?[^{]+{.*}\\s*}\\s*#misU';
21
22
  /**
23
   * regular expression: conditional inline style tags
24
   *
25
   * @var string
26
   */
27
  private static $excludeConditionalInlineStylesBlockRegEx = '/<!--.*<style.*?-->/is';
28
29
  /**
30
   * regular expression: inline style tags
31
   *
32
   * @var string
33
   */
34
  private static $styleTagRegEx = '|<style(.*)>(.*)</style>|isU';
35
36
  /**
37
   * regular expression: css-comments
38
   *
39
   * @var string
40
   */
41
  private static $styleCommentRegEx = '/\\/\\*.*\\*\\//sU';
42
43
  /**
44
   * The CSS to use
45
   *
46
   * @var  string
47
   */
48
  private $css;
49
50
  /**
51
   * Should the generated HTML be cleaned
52
   *
53
   * @var  bool
54
   */
55
  private $cleanup = false;
56
57
  /**
58
   * The encoding to use.
59
   *
60
   * @var  string
61
   */
62
  private $encoding = 'UTF-8';
63
64
  /**
65
   * The HTML to process
66
   *
67
   * @var  string
68
   */
69
  private $html;
70
71
  /**
72
   * Use inline-styles block as CSS
73
   *
74
   * @var  bool
75
   */
76
  private $useInlineStylesBlock = false;
77
78
  /**
79
   * Strip original style tags
80
   *
81
   * @var bool
82
   */
83
  private $stripOriginalStyleTags = false;
84
85
  /**
86
   * Exclude conditional inline-style blocks
87
   *
88
   * @var bool
89
   */
90
  private $excludeConditionalInlineStylesBlock = true;
91
92
  /**
93
   * Exclude media queries from "$this->css" and keep media queries for inline-styles blocks
94
   *
95
   * @var bool
96
   */
97
  private $excludeMediaQueries = true;
98
99
  /**
100
   * Creates an instance, you could set the HTML and CSS here, or load it
101
   * later.
102
   *
103
   * @param  null|string $html The HTML to process.
104
   * @param  null|string $css  The CSS to use.
105
   */
106 41
  public function __construct($html = null, $css = null)
107
  {
108 41
    if (null !== $html) {
109 1
      $this->setHTML($html);
110 1
    }
111
112 41
    if (null !== $css) {
113 1
      $this->setCSS($css);
114 1
    }
115 41
  }
116
117
  /**
118
   * Set HTML to process
119
   *
120
   * @param  string $html The HTML to process.
121
   */
122 41
  public function setHTML($html)
123
  {
124
    // strip style definitions, if we use css-class "cleanup" on a style-element
125 41
    $this->html = (string)preg_replace('/<style[^>]+class="cleanup"[^>]*>.*<\/style>/Usi', ' ', $html);
126 41
  }
127
128
  /**
129
   * Set CSS to use
130
   *
131
   * @param  string $css The CSS to use.
132
   */
133 39
  public function setCSS($css)
134
  {
135 39
    $this->css = (string)$css;
136 39
  }
137
138
  /**
139
   * Sort an array on the specificity element
140
   *
141
   * @return int
142
   *
143
   * @param Specificity[] $e1 The first element.
144
   * @param Specificity[] $e2 The second element.
145
   */
146 14
  private static function sortOnSpecificity($e1, $e2)
147
  {
148
    // Compare the specificity
149 14
    $value = $e1['specificity']->compareTo($e2['specificity']);
150
151
    // if the specificity is the same, use the order in which the element appeared
152 14
    if (0 === $value) {
153 9
      $value = $e1['order'] - $e2['order'];
154 9
    }
155
156 14
    return $value;
157
  }
158
159
  /**
160
   * Converts the loaded HTML into an HTML-string with inline styles based on the loaded CSS
161
   *
162
   * @return string
163
   *
164
   * @param  bool $outputXHTML Should we output valid XHTML?
165
   *
166
   * @throws Exception
167
   */
168 41
  public function convert($outputXHTML = false)
169
  {
170
    // redefine
171 41
    $outputXHTML = (bool)$outputXHTML;
172
173
    // validate
174 41
    if (!$this->html) {
175 1
      throw new Exception('No HTML provided.');
176
    }
177
178
    // use local variables
179 40
    $css = $this->css;
180
181
    // should we use inline style-block
182 40
    if ($this->useInlineStylesBlock) {
183
184 24
      if (true === $this->excludeConditionalInlineStylesBlock) {
185 21
        $this->html = preg_replace(self::$excludeConditionalInlineStylesBlockRegEx, '', $this->html);
186 21
      }
187
188 24
      $css .= $this->getCssFromInlineHtmlStyleBlock($this->html);
189 24
    }
190
191
    // process css
192 40
    $cssRules = $this->processCSS($css);
193
194
    // create new DOMDocument
195 40
    $document = $this->createDOMDocument($this->html);
196
197
    // create new XPath
198 40
    $xPath = $this->createXPath($document, $cssRules);
199
200
    // strip original style tags if we need to
201 40
    if ($this->stripOriginalStyleTags === true) {
202 12
      $this->stripOriginalStyleTags($xPath);
203 12
    }
204
205
    // cleanup the HTML if we need to
206 40
    if (true === $this->cleanup) {
207 3
      $this->cleanupHTML($xPath);
208 3
    }
209
210
    // should we output XHTML?
211 40
    if (true === $outputXHTML) {
212
      // set formatting
213 4
      $document->formatOutput = true;
214
215
      // get the HTML as XML
216 4
      $html = $document->saveXML(null, LIBXML_NOEMPTYTAG);
217
218
      // remove the XML-header
219 4
      return ltrim(preg_replace('/<\?xml.*\?>/', '', $html));
220
    }
221
222
    // just regular HTML 4.01 as it should be used in newsletters
223 36
    return $document->saveHTML();
224
  }
225
226
  /**
227
   * get css from inline-html style-block
228
   *
229
   * @param string $html
230
   *
231
   * @return string
232
   */
233 24
  public function getCssFromInlineHtmlStyleBlock($html)
234
  {
235
    // init var
236 24
    $css = '';
237 24
    $matches = array();
238
239
    // match the style blocks
240 24
    preg_match_all(self::$styleTagRegEx, $html, $matches);
241
242
    // any style-blocks found?
243 24
    if (!empty($matches[2])) {
244
      // add
245 23
      foreach ($matches[2] as $match) {
246 23
        $css .= trim($match) . "\n";
247 23
      }
248 23
    }
249
250 24
    return $css;
251
  }
252
253
  /**
254
   * Process the loaded CSS
255
   *
256
   * @param $css
257
   *
258
   * @return array
259
   */
260 40
  private function processCSS($css)
261
  {
262
    //reset current set of rules
263 40
    $cssRules = array();
264
265
    // init vars
266 40
    $css = (string)$css;
267
268 40
    $css = $this->doCleanup($css);
269
270
    // rules are splitted by }
271 40
    $rules = (array)explode('}', $css);
272
273
    // init var
274 40
    $i = 1;
275
276
    // loop rules
277 40
    foreach ($rules as $rule) {
278
      // split into chunks
279 40
      $chunks = explode('{', $rule);
280
281
      // invalid rule?
282 40
      if (!isset($chunks[1])) {
283 40
        continue;
284
      }
285
286
      // set the selectors
287 30
      $selectors = trim($chunks[0]);
288
289
      // get cssProperties
290 30
      $cssProperties = trim($chunks[1]);
291
292
      // split multiple selectors
293 30
      $selectors = (array)explode(',', $selectors);
294
295
      // loop selectors
296 30
      foreach ($selectors as $selector) {
297
        // cleanup
298 30
        $selector = trim($selector);
299
300
        // build an array for each selector
301 30
        $ruleSet = array();
302
303
        // store selector
304 30
        $ruleSet['selector'] = $selector;
305
306
        // process the properties
307 30
        $ruleSet['properties'] = $this->processCSSProperties($cssProperties);
308
309
310
        // calculate specificity
311 30
        $ruleSet['specificity'] = Specificity::fromSelector($selector);
312
313
        // remember the order in which the rules appear
314 30
        $ruleSet['order'] = $i;
315
316
        // add into rules
317 30
        $cssRules[] = $ruleSet;
318
319
        // increment
320 30
        $i++;
321 30
      }
322 40
    }
323
324
    // sort based on specificity
325 40
    if (0 !== count($cssRules)) {
326 30
      usort($cssRules, array(__CLASS__, 'sortOnSpecificity'));
327 30
    }
328
329 40
    return $cssRules;
330
  }
331
332
  /**
333
   * @param string $css
334
   *
335
   * @return string
336
   */
337 40
  private function doCleanup($css)
338
  {
339
    // remove newlines & replace double quotes by single quotes
340 40
    $css = str_replace(
341 40
        array("\r", "\n", '"'),
342 40
        array('', '', '\''),
343
        $css
344 40
    );
345
346
    // remove comments
347 40
    $css = preg_replace(self::$styleCommentRegEx, '', $css);
348
349
    // remove spaces
350 40
    $css = preg_replace('/\s\s+/', ' ', $css);
351
352
    // remove css media queries
353 40
    if (true === $this->excludeMediaQueries) {
354 39
      $css = $this->stripeMediaQueries($css);
355 39
    }
356
357 40
    return (string)$css;
358
  }
359
360
  /**
361
   * remove css media queries from the string
362
   *
363
   * @param string $css
364
   *
365
   * @return string
366
   */
367 39
  private function stripeMediaQueries($css)
368
  {
369
    // remove comments previously to matching media queries
370 39
    $css = preg_replace(self::$styleCommentRegEx, '', $css);
371
372 39
    return (string)preg_replace(self::$cssMediaQueriesRegEx, '', $css);
373
  }
374
375
  /**
376
   * Process the CSS-properties
377
   *
378
   * @return array
379
   *
380
   * @param  string $propertyString The CSS-properties.
381
   */
382 30
  private function processCSSProperties($propertyString)
383
  {
384
    // split into chunks
385 30
    $properties = $this->splitIntoProperties($propertyString);
386
387
    // init var
388 30
    $pairs = array();
389
390
    // loop properties
391 30
    foreach ($properties as $property) {
392
      // split into chunks
393 30
      $chunks = (array)explode(':', $property, 2);
394
395
      // validate
396 30
      if (!isset($chunks[1])) {
397 24
        continue;
398
      }
399
400
      // cleanup
401 29
      $chunks[0] = trim($chunks[0]);
402 29
      $chunks[1] = trim($chunks[1]);
403
404
      // add to pairs array
405
      if (
406 29
          !isset($pairs[$chunks[0]])
407 29
          ||
408 3
          !in_array($chunks[1], $pairs[$chunks[0]], true)
409 29
      ) {
410 29
        $pairs[$chunks[0]][] = $chunks[1];
411 29
      }
412 30
    }
413
414
    // sort the pairs
415 30
    ksort($pairs);
416
417
    // return
418 30
    return $pairs;
419
  }
420
421
  /**
422
   * Split a style string into an array of properties.
423
   * The returned array can contain empty strings.
424
   *
425
   * @param string $styles ex: 'color:blue;font-size:12px;'
426
   *
427
   * @return array an array of strings containing css property ex: array('color:blue','font-size:12px')
428
   */
429 30
  private function splitIntoProperties($styles)
430
  {
431 30
    $properties = (array)explode(';', $styles);
432 30
    $propertiesCount = count($properties);
433
434 30
    for ($i = 0; $i < $propertiesCount; $i++) {
435
      // If next property begins with base64,
436
      // Then the ';' was part of this property (and we should not have split on it).
437
      if (
438 30
          isset($properties[$i + 1])
439 30
          &&
440 23
          strpos($properties[$i + 1], 'base64,') !== false
441 30
      ) {
442 1
        $properties[$i] .= ';' . $properties[$i + 1];
443 1
        $properties[$i + 1] = '';
444 1
        ++$i;
445 1
      }
446 30
    }
447
448 30
    return $properties;
449
  }
450
451
  /**
452
   * create DOMDocument from HTML
453
   *
454
   * @param $html
455
   *
456
   * @return \DOMDocument
457
   */
458 40
  private function createDOMDocument($html)
459
  {
460
    // create new DOMDocument
461 40
    $document = new \DOMDocument('1.0', $this->getEncoding());
462
463
    // DOMDocument settings
464 40
    $document->preserveWhiteSpace = false;
465 40
    $document->formatOutput = true;
466
467
    // set error level
468 40
    $internalErrors = libxml_use_internal_errors(true);
469
470
    // load HTML
471
    //
472
    // with UTF-8 hack: http://php.net/manual/en/domdocument.loadhtml.php#95251
0 ignored issues
show
Unused Code Comprehensibility introduced by
40% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
473
    //
474 40
    $document->loadHTML('<?xml encoding="' . $this->getEncoding() . '">' . $html);
475
476
    // remove the "xml-encoding" hack
477 40
    foreach ($document->childNodes as $child) {
478 40
      if ($child->nodeType == XML_PI_NODE) {
479 40
        $document->removeChild($child);
480 40
      }
481 40
    }
482
483
    // set encoding
484 40
    $document->encoding = $this->getEncoding();
485
486
    // restore error level
487 40
    libxml_use_internal_errors($internalErrors);
488
489 40
    return $document;
490
  }
491
492
  /**
493
   * Get the encoding to use
494
   *
495
   * @return string
496
   */
497 40
  private function getEncoding()
498
  {
499 40
    return $this->encoding;
500
  }
501
502
  /**
503
   * create XPath
504
   *
505
   * @param \DOMDocument $document
506
   * @param array        $cssRules
507
   *
508
   * @return \DOMXPath
509
   */
510 40
  private function createXPath(\DOMDocument $document, array $cssRules)
511
  {
512 40
    $xPath = new \DOMXPath($document);
513
514
    // any rules?
515 40
    if (0 !== count($cssRules)) {
516
      // loop rules
517 30
      foreach ($cssRules as $rule) {
518
519
        try {
520 30
          $query = CssSelector::toXPath($rule['selector']);
521 30
        } catch (ExceptionInterface $e) {
522 4
          $query = null;
523
        }
524
525
        // validate query
526 30
        if (null === $query) {
527 4
          continue;
528
        }
529
530
        // search elements
531 28
        $elements = $xPath->query($query);
532
533
        // validate elements
534 28
        if (false === $elements) {
535
          continue;
536
        }
537
538
        // loop found elements
539 28
        foreach ($elements as $element) {
540
541
          /**
542
           * @var $element \DOMElement
543
           */
544
545
          // no styles stored?
546 28
          if (null === $element->attributes->getNamedItem('data-css-to-inline-styles-original-styles')) {
547
548
            // init var
549 28
            $originalStyle = '';
550
551 28
            if (null !== $element->attributes->getNamedItem('style')) {
552 5
              $originalStyle = $element->attributes->getNamedItem('style')->value;
553 5
            }
554
555
            // store original styles
556 28
            $element->setAttribute('data-css-to-inline-styles-original-styles', $originalStyle);
557
558
            // clear the styles
559 28
            $element->setAttribute('style', '');
560 28
          }
561
562 28
          $propertiesString = $this->createPropertyChunks($element, $rule['properties']);
563
564
          // set attribute
565 28
          if ('' != $propertiesString) {
566 28
            $element->setAttribute('style', $propertiesString);
567 28
          }
568 28
        }
569 30
      }
570
571
      // reapply original styles
572
      // search elements
573 30
      $elements = $xPath->query('//*[@data-css-to-inline-styles-original-styles]');
574
575
      // loop found elements
576 30
      foreach ($elements as $element) {
577
        // get the original styles
578 28
        $originalStyle = $element->attributes->getNamedItem('data-css-to-inline-styles-original-styles')->value;
579
580 28
        if ('' != $originalStyle) {
581 5
          $originalStyles = $this->splitIntoProperties($originalStyle);
582
583 5
          $originalProperties = $this->splitStyleIntoChunks($originalStyles);
584
585 5
          $propertiesString = $this->createPropertyChunks($element, $originalProperties);
586
587
          // set attribute
588 5
          if ('' != $propertiesString) {
589 5
            $element->setAttribute('style', $propertiesString);
590 5
          }
591 5
        }
592
593
        // remove placeholder
594 28
        $element->removeAttribute('data-css-to-inline-styles-original-styles');
595 30
      }
596 30
    }
597
598 40
    return $xPath;
599
  }
600
601
  /**
602
   * @param \DOMElement $element
603
   * @param array       $ruleProperties
604
   *
605
   * @return array
606
   */
607 28
  private function createPropertyChunks(\DOMElement $element, array $ruleProperties)
608
  {
609
    // init var
610 28
    $properties = array();
611
612
    // get current styles
613 28
    $stylesAttribute = $element->attributes->getNamedItem('style');
614
615
    // any styles defined before?
616 28
    if (null !== $stylesAttribute) {
617
      // get value for the styles attribute
618 28
      $definedStyles = (string)$stylesAttribute->value;
619
620
      // split into properties
621 28
      $definedProperties = $this->splitIntoProperties($definedStyles);
622
623 28
      $properties = $this->splitStyleIntoChunks($definedProperties);
624 28
    }
625
626
    // add new properties into the list
627 28
    foreach ($ruleProperties as $key => $value) {
628
      // If one of the rules is already set and is !important, don't apply it,
629
      // except if the new rule is also important.
630
      if (
631 28
          !isset($properties[$key])
632 28
          ||
633 6
          false === stripos($properties[$key], '!important')
634 6
          ||
635 2
          false !== stripos(implode('', (array)$value), '!important')
636 28
      ) {
637 28
        $properties[$key] = $value;
638 28
      }
639 28
    }
640
641
    // build string
642 28
    $propertyChunks = array();
643
644
    // build chunks
645 28
    foreach ($properties as $key => $values) {
646 28
      foreach ((array)$values as $value) {
647 28
        $propertyChunks[] = $key . ': ' . $value . ';';
648 28
      }
649 28
    }
650
651 28
    return implode(' ', $propertyChunks);
652
  }
653
654
  /**
655
   * @param array $definedProperties
656
   *
657
   * @return array
658
   */
659 28
  private function splitStyleIntoChunks(array $definedProperties)
660
  {
661
    // init var
662 28
    $properties = array();
663
664
    // loop properties
665 28
    foreach ($definedProperties as $property) {
666
      // validate property
667
      if (
668
          !$property
669 28
          ||
670 13
          strpos($property, ':') === false
671 28
      ) {
672 28
        continue;
673
      }
674
675
      // split into chunks
676 13
      $chunks = (array)explode(':', trim($property), 2);
677
678
      // validate
679 13
      if (!isset($chunks[1])) {
680
        continue;
681
      }
682
683
      // loop chunks
684 13
      $properties[$chunks[0]] = trim($chunks[1]);
685 28
    }
686
687 28
    return $properties;
688
  }
689
690
  /**
691
   * Strip style tags into the generated HTML
692
   *
693
   * @param  \DOMXPath $xPath The DOMXPath for the entire document.
694
   *
695
   * @return string
696
   */
697 12
  private function stripOriginalStyleTags(\DOMXPath $xPath)
698
  {
699
    // get all style tags
700 12
    $nodes = $xPath->query('descendant-or-self::style');
701 12
    foreach ($nodes as $node) {
702 11
      if ($this->excludeMediaQueries === true) {
703
704
        // remove comments previously to matching media queries
705 10
        $node->nodeValue = preg_replace(self::$styleCommentRegEx, '', $node->nodeValue);
706
707
        // search for Media Queries
708 10
        preg_match_all(self::$cssMediaQueriesRegEx, $node->nodeValue, $mqs);
709
710
        // replace the nodeValue with just the Media Queries
711 10
        $node->nodeValue = implode("\n", $mqs[0]);
712
713 10
      } else {
714
        // remove the entire style tag
715 1
        $node->parentNode->removeChild($node);
716
      }
717 12
    }
718 12
  }
719
720
  /**
721
   * Remove id and class attributes.
722
   *
723
   * @param  \DOMXPath $xPath The DOMXPath for the entire document.
724
   *
725
   * @return string
726
   */
727 3
  private function cleanupHTML(\DOMXPath $xPath)
728
  {
729 3
    $nodes = $xPath->query('//@class | //@id');
730 3
    foreach ($nodes as $node) {
731 3
      $node->ownerElement->removeAttributeNode($node);
732 3
    }
733 3
  }
734
735
  /**
736
   * Should the IDs and classes be removed?
737
   *
738
   * @param  bool $on Should we enable cleanup?
739
   */
740 3
  public function setCleanup($on = true)
741
  {
742 3
    $this->cleanup = (bool)$on;
743 3
  }
744
745
  /**
746
   * Set the encoding to use with the DOMDocument
747
   *
748
   * @param  string $encoding The encoding to use.
749
   *
750
   * @deprecated Doesn't have any effect
751
   */
752
  public function setEncoding($encoding)
753
  {
754
    $this->encoding = (string)$encoding;
755
  }
756
757
  /**
758
   * Set use of inline styles block
759
   * If this is enabled the class will use the style-block in the HTML.
760
   *
761
   * @param  bool $on Should we process inline styles?
762
   */
763 24
  public function setUseInlineStylesBlock($on = true)
764
  {
765 24
    $this->useInlineStylesBlock = (bool)$on;
766 24
  }
767
768
  /**
769
   * Set strip original style tags
770
   * If this is enabled the class will remove all style tags in the HTML.
771
   *
772
   * @param  bool $on Should we process inline styles?
773
   */
774 15
  public function setStripOriginalStyleTags($on = true)
775
  {
776 15
    $this->stripOriginalStyleTags = (bool)$on;
777 15
  }
778
779
  /**
780
   * Set exclude media queries
781
   *
782
   * If this is enabled the media queries will be removed before inlining the rules.
783
   *
784
   * WARNING: If you use inline styles block "<style>" the this option will keep the media queries.
785
   *
786
   * @param bool $on
787
   */
788 12
  public function setExcludeMediaQueries($on = true)
789
  {
790 12
    $this->excludeMediaQueries = (bool)$on;
791 12
  }
792
793
  /**
794
   * Set exclude conditional inline-style blocks e.g.: <!--[if gte mso 9]><style>.foo { bar } </style><![endif]-->
795
   *
796
   * @param bool $on
797
   */
798 4
  public function setExcludeConditionalInlineStylesBlock($on = true)
799
  {
800 4
    $this->excludeConditionalInlineStylesBlock = (bool)$on;
801 4
  }
802
803
}
1 ignored issue
show
Coding Style introduced by
According to PSR2, the closing brace of classes should be placed on the next line directly after the body.

Below you find some examples:

// Incorrect placement according to PSR2
class MyClass
{
    public function foo()
    {

    }
    // This blank line is not allowed.

}

// Correct
class MyClass
{
    public function foo()
    {

    } // No blank lines after this line.
}
Loading history...
804