* @version 1.1.0 * @copyright Copyright (c), Tijs Verkoyen. All rights reserved. * @license BSD License */ class CssToInlineStyles { /** * The CSS to use * * @var string */ private $css; /** * The processed CSS rules * * @var array */ private $cssRules; /** * Should the generated HTML be cleaned * * @var bool */ private $cleanup = false; /** * The encoding to use. * * @var string */ private $encoding = 'UTF-8'; /** * The HTML to process * * @var string */ private $html; /** * Use inline-styles block as CSS * * @var bool */ private $useInlineStylesBlock = false; /* * Strip original style tags * * @var bool */ private $stripOriginalStyleTags = false; /** * Creates an instance, you could set the HTML and CSS here, or load it * later. * * @return void * @param string[optional] $html The HTML to process. * @param string[optional] $css The CSS to use. */ public function __construct($html = null, $css = null) { if($html !== null) $this->setHTML($html); if($css !== null) $this->setCSS($css); } /** * Convert a CSS-selector into an xPath-query * * @return string * @param string $selector The CSS-selector. */ private function buildXPathQuery($selector) { // redefine $selector = (string) $selector; // the CSS selector $cssSelector = array( // E F, Matches any F element that is a descendant of an E element '/(\w)\s+(\w)/', // E > F, Matches any F element that is a child of an element E '/(\w)\s*>\s*(\w)/', // E:first-child, Matches element E when E is the first child of its parent '/(\w):first-child/', // E + F, Matches any F element immediately preceded by an element '/(\w)\s*\+\s*(\w)/', // E[foo], Matches any E element with the "foo" attribute set (whatever the value) '/(\w)\[([\w\-]+)]/', // E[foo="warning"], Matches any E element whose "foo" attribute value is exactly equal to "warning" '/(\w)\[([\w\-]+)\=\"(.*)\"]/', // div.warning, HTML only. The same as DIV[class~="warning"] '/(\w+|\*)+\.([\w\-]+)+/', // .warning, HTML only. The same as *[class~="warning"] '/\.([\w\-]+)/', // E#myid, Matches any E element with id-attribute equal to "myid" '/(\w+)+\#([\w\-]+)/', // #myid, Matches any element with id-attribute equal to "myid" '/\#([\w\-]+)/' ); // the xPath-equivalent $xPathQuery = array( // E F, Matches any F element that is a descendant of an E element '\1//\2', // E > F, Matches any F element that is a child of an element E '\1/\2', // E:first-child, Matches element E when E is the first child of its parent '*[1]/self::\1', // E + F, Matches any F element immediately preceded by an element '\1/following-sibling::*[1]/self::\2', // E[foo], Matches any E element with the "foo" attribute set (whatever the value) '\1 [ @\2 ]', // E[foo="warning"], Matches any E element whose "foo" attribute value is exactly equal to "warning" '\1[ contains( concat( " ", @\2, " " ), concat( " ", "\3", " " ) ) ]', // div.warning, HTML only. The same as DIV[class~="warning"] '\1[ contains( concat( " ", @class, " " ), concat( " ", "\2", " " ) ) ]', // .warning, HTML only. The same as *[class~="warning"] '*[ contains( concat( " ", @class, " " ), concat( " ", "\1", " " ) ) ]', // E#myid, Matches any E element with id-attribute equal to "myid" '\1[ @id = "\2" ]', // #myid, Matches any element with id-attribute equal to "myid" '*[ @id = "\1" ]' ); // return $xPath = (string) '//' . preg_replace($cssSelector, $xPathQuery, $selector); return str_replace('] *', ']//*', $xPath); } /** * Calculate the specifity for the CSS-selector * * @return int * @param string $selector The selector to calculate the specifity for. */ private function calculateCSSSpecifity($selector) { // cleanup selector $selector = str_replace(array('>', '+'), array(' > ', ' + '), $selector); // init var $specifity = 0; // split the selector into chunks based on spaces $chunks = explode(' ', $selector); // loop chunks foreach ($chunks as $chunk) { // an ID is important, so give it a high specifity if(strstr($chunk, '#') !== false) $specifity += 100; // classes are more important than a tag, but less important then an ID elseif(strstr($chunk, '.')) $specifity += 10; // anything else isn't that important else $specifity += 1; } // return return $specifity; } /** * Cleanup the generated HTML * * @return string * @param string $html The HTML to cleanup. */ private function cleanupHTML($html) { // remove classes $html = preg_replace('/(\s)+class="(.*)"(\s)+/U', ' ', $html); // remove IDs $html = preg_replace('/(\s)+id="(.*)"(\s)+/U', ' ', $html); // return return $html; } /** * Converts the loaded HTML into an HTML-string with inline styles based on the loaded CSS * * @return string * @param bool[optional] $outputXHTML Should we output valid XHTML? */ public function convert($outputXHTML = false) { // redefine $outputXHTML = (bool) $outputXHTML; // validate if($this->html == null) throw new Exception('No HTML provided.'); // should we use inline style-block if ($this->useInlineStylesBlock) { // init var $matches = array(); // match the style blocks preg_match_all('|(.*)|isU', $this->html, $matches); // any style-blocks found? if (!empty($matches[2])) { // add foreach($matches[2] as $match) $this->css .= trim($match) ."\n"; } } // process css $this->processCSS(); // create new DOMDocument $document = new \DOMDocument('1.0', $this->getEncoding()); // set error level libxml_use_internal_errors(true); // load HTML // $document->loadHTML($this->html); $document->loadHTML('<'.'?xml version="1.0" encoding="'.$this->getEncoding().'"?'.'>'.$this->html); // create new XPath $xPath = new \DOMXPath($document); // any rules? if (!empty($this->cssRules)) { // loop rules foreach ($this->cssRules as $rule) { // init var $query = $this->buildXPathQuery($rule['selector']); // validate query if($query === false) continue; // search elements $elements = $xPath->query($query); // validate elements if($elements === false) continue; // loop found elements foreach ($elements as $element) { // no styles stored? if ($element->attributes->getNamedItem( 'data-css-to-inline-styles-original-styles' ) == null) { // init var $originalStyle = ''; if ($element->attributes->getNamedItem('style') !== null) { $originalStyle = $element->attributes->getNamedItem('style')->value; } // store original styles $element->setAttribute( 'data-css-to-inline-styles-original-styles', $originalStyle ); // clear the styles $element->setAttribute('style', ''); } // init var $properties = array(); // get current styles $stylesAttribute = $element->attributes->getNamedItem('style'); // any styles defined before? if ($stylesAttribute !== null) { // get value for the styles attribute $definedStyles = (string) $stylesAttribute->value; // split into properties $definedProperties = (array) explode(';', $definedStyles); // loop properties foreach ($definedProperties as $property) { // validate property if($property == '') continue; // split into chunks $chunks = (array) explode(':', trim($property), 2); // validate if(!isset($chunks[1])) continue; // loop chunks $properties[$chunks[0]] = trim($chunks[1]); } } // add new properties into the list foreach ($rule['properties'] as $key => $value) { $properties[$key] = $value; } // build string $propertyChunks = array(); // build chunks foreach ($properties as $key => $values) { foreach ((array) $values as $value) { $propertyChunks[] = $key . ': ' . $value . ';'; } } // build properties string $propertiesString = implode(' ', $propertyChunks); // set attribute if ($propertiesString != '') { $element->setAttribute('style', $propertiesString); } } } // reapply original styles $query = $this->buildXPathQuery( '*[@data-css-to-inline-styles-original-styles]' ); // validate query if($query === false) return; // search elements $elements = $xPath->query($query); // loop found elements foreach ($elements as $element) { // get the original styles $originalStyle = $element->attributes->getNamedItem( 'data-css-to-inline-styles-original-styles' )->value; if ($originalStyle != '') { $originalProperties = array(); $originalStyles = (array) explode(';', $originalStyle); foreach ($originalStyles as $property) { // validate property if($property == '') continue; // split into chunks $chunks = (array) explode(':', trim($property), 2); // validate if(!isset($chunks[1])) continue; // loop chunks $originalProperties[$chunks[0]] = trim($chunks[1]); } // get current styles $stylesAttribute = $element->attributes->getNamedItem('style'); $properties = array(); // any styles defined before? if ($stylesAttribute !== null) { // get value for the styles attribute $definedStyles = (string) $stylesAttribute->value; // split into properties $definedProperties = (array) explode(';', $definedStyles); // loop properties foreach ($definedProperties as $property) { // validate property if($property == '') continue; // split into chunks $chunks = (array) explode(':', trim($property), 2); // validate if(!isset($chunks[1])) continue; // loop chunks $properties[$chunks[0]] = trim($chunks[1]); } } // add new properties into the list foreach ($originalProperties as $key => $value) { $properties[$key] = $value; } // build string $propertyChunks = array(); // build chunks foreach ($properties as $key => $values) { foreach ((array) $values as $value) { $propertyChunks[] = $key . ': ' . $value . ';'; } } // build properties string $propertiesString = implode(' ', $propertyChunks); // set attribute if($propertiesString != '') $element->setAttribute( 'style', $propertiesString ); } // remove placeholder $element->removeAttribute( 'data-css-to-inline-styles-original-styles' ); } } // should we output XHTML? if ($outputXHTML) { // set formating $document->formatOutput = true; // get the HTML as XML $html = $document->saveXML(null, LIBXML_NOEMPTYTAG); // get start of the XML-declaration $startPosition = strpos($html, '', $startPosition); // remove the XML-header $html = ltrim(substr($html, $endPosition + 1)); } } // just regular HTML 4.01 as it should be used in newsletters else { // get the HTML $html = $document->saveHTML(); } // cleanup the HTML if we need to if($this->cleanup) $html = $this->cleanupHTML($html); // strip original style tags if we need to if ($this->stripOriginalStyleTags) { $html = $this->stripOriginalStyleTags($html); } // return return $html; } /** * Get the encoding to use * * @return string */ private function getEncoding() { return $this->encoding; } /** * Process the loaded CSS * * @return void */ private function processCSS() { // init vars $css = (string) $this->css; // remove newlines $css = str_replace(array("\r", "\n"), '', $css); // replace double quotes by single quotes $css = str_replace('"', '\'', $css); // remove comments $css = preg_replace('|/\*.*?\*/|', '', $css); // remove spaces $css = preg_replace('/\s\s+/', ' ', $css); // rules are splitted by } $rules = (array) explode('}', $css); // init var $i = 1; // loop rules foreach ($rules as $rule) { // split into chunks $chunks = explode('{', $rule); // invalid rule? if(!isset($chunks[1])) continue; // set the selectors $selectors = trim($chunks[0]); // get cssProperties $cssProperties = trim($chunks[1]); // split multiple selectors $selectors = (array) explode(',', $selectors); // loop selectors foreach ($selectors as $selector) { // cleanup $selector = trim($selector); // build an array for each selector $ruleSet = array(); // store selector $ruleSet['selector'] = $selector; // process the properties $ruleSet['properties'] = $this->processCSSProperties( $cssProperties ); // calculate specifity $ruleSet['specifity'] = $this->calculateCSSSpecifity( $selector ) + $i; // add into global rules $this->cssRules[] = $ruleSet; } // increment $i++; } // sort based on specifity if (!empty($this->cssRules)) { usort($this->cssRules, array(__CLASS__, 'sortOnSpecifity')); } } /** * Process the CSS-properties * * @return array * @param string $propertyString The CSS-properties. */ private function processCSSProperties($propertyString) { // split into chunks $properties = (array) explode(';', $propertyString); // init var $pairs = array(); // loop properties foreach ($properties as $property) { // split into chunks $chunks = (array) explode(':', $property, 2); // validate if(!isset($chunks[1])) continue; // cleanup $chunks[0] = trim($chunks[0]); $chunks[1] = trim($chunks[1]); // add to pairs array if(!isset($pairs[$chunks[0]]) || !in_array($chunks[1], $pairs[$chunks[0]])) { $pairs[$chunks[0]][] = $chunks[1]; } } // sort the pairs ksort($pairs); // return return $pairs; } /** * Should the IDs and classes be removed? * * @return void * @param bool[optional] $on Should we enable cleanup? */ public function setCleanup($on = true) { $this->cleanup = (bool) $on; } /** * Set CSS to use * * @return void * @param string $css The CSS to use. */ public function setCSS($css) { $this->css = (string) $css; } /** * Set the encoding to use with the DOMDocument * * @return void * @param string $encoding The encoding to use. */ public function setEncoding($encoding) { $this->encoding = (string) $encoding; } /** * Set HTML to process * * @return void * @param string $html The HTML to process. */ public function setHTML($html) { $this->html = (string) $html; } /** * Set use of inline styles block * If this is enabled the class will use the style-block in the HTML. * * @return void * @param bool[optional] $on Should we process inline styles? */ public function setUseInlineStylesBlock($on = true) { $this->useInlineStylesBlock = (bool) $on; } /** * Set strip original style tags * If this is enabled the class will remove all style tags in the HTML. * * @return void * @param bool[optional] $onShould we process inline styles? */ public function setStripOriginalStyleTags($on = true) { $this->stripOriginalStyleTags = (bool) $on; } /** * Strip style tags into the generated HTML * * @return string * @param string $html The HTML to strip style tags. */ private function stripOriginalStyleTags($html) { return preg_replace('|(.*)|isU', '', $html); } /** * Sort an array on the specifity element * * @return int * @param array $e1 The first element. * @param array $e2 The second element. */ private static function sortOnSpecifity($e1, $e2) { // validate if(!isset($e1['specifity']) || !isset($e2['specifity'])) return 0; // lower if($e1['specifity'] < $e2['specifity']) return -1; // higher if($e1['specifity'] > $e2['specifity']) return 1; // fallback return 0; } }