diff --git a/.gitignore b/.gitignore index 0ba6972..8b7ef35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /vendor composer.lock -Gemfile.lock diff --git a/.travis.yml b/.travis.yml index 1fd67f5..7010c1f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,23 @@ language: php -php: - - 5.3 - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - hhvm +sudo: false matrix: - allow_failures: + include: + - php: 5.5 + - php: 5.6 - php: 7.0 + - php: hhvm + allow_failures: + - php: hhvm before_script: - - travis_retry composer self-update - - travis_retry composer install --no-interaction --prefer-source --dev + - composer self-update + - composer install --prefer-source --no-interaction --no-scripts script: - - vendor/bin/phpunit --verbose --coverage-text + - vendor/bin/phpunit --verbose --coverage-clover=coverage.clover + +after_success: + - if [[ "$TRAVIS_PHP_VERSION" != "hhvm" ]] && [[ "$TRAVIS_PHP_VERSION" != "nightly" ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi + - if [[ "$TRAVIS_PHP_VERSION" != "hhvm" ]] && [[ "$TRAVIS_PHP_VERSION" != "nightly" ]]; then php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a22f9..59cb213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# Changelog since 1.5.4 + +The 2.0 version is a major overhaul, which is *not* backwards compatible. + +* From now on you can re-use the class for multiple mails. +* A lot less complicated options, as in: no more options at all. +* More separate classes which handle their own (tested) methods. +* A lot more tests + +The reason why I did this was to made the class more usable. + # Changelog since 1.5.3 * Fix properties split on base64 encoded url content, thx to [tguyard](https://github.com/Giga-gg), diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 54a7b0b..0000000 --- a/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source 'http://rubygems.org' - -gem 'travis-lint' diff --git a/LICENSE.md b/LICENSE.md index 81adb07..5f40959 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -7,16 +7,17 @@ modification, are permitted provided that the following conditions are met: 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -3. The name of the author may not be used to endorse or promote products - derived from this software without specific prior written permission. +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. -This software is provided by the author "as is" and any express or implied -warranties, including, but not limited to, the implied warranties of -merchantability and fitness for a particular purpose are disclaimed. In no event -shall the author be liable for any direct, indirect, incidental, special, -exemplary, or consequential damages (including, but not limited to, procurement -of substitute goods or services; loss of use, data, or profits; or business -interruption) however caused and on any theory of liability, whether in -contract, strict liability, or tort (including negligence or otherwise) arising -in any way out of the use of this software, even if advised of the possibility -of such damage. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md old mode 100755 new mode 100644 index d56f79a..0a4d5d7 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # CssToInlineStyles class -[![Build Status](https://travis-ci.org/tijsverkoyen/CssToInlineStyles.svg?branch=master)](https://travis-ci.org/tijsverkoyen/CssToInlineStyles) +[![Build Status](https://travis-ci.org/tijsverkoyen/CssToInlineStyles.svg?branch=master)](https://travis-ci.org/tijsverkoyen/CssToInlineStyles) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/tijsverkoyen/CssToInlineStyles/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/tijsverkoyen/CssToInlineStyles/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/tijsverkoyen/CssToInlineStyles/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/tijsverkoyen/CssToInlineStyles/?branch=master) [![SensioLabsInsight](https://insight.sensiolabs.com/projects/5c0ce94f-de6d-403e-9e0a-431268deb75c/mini.png)](https://insight.sensiolabs.com/projects/5c0ce94f-de6d-403e-9e0a-431268deb75c) + +## Installation > CssToInlineStyles is a class that enables you to convert HTML-pages/files into > HTML-pages/files with inline styles. This is very usefull when you're sending @@ -22,32 +24,22 @@ $ composer require tijsverkoyen/css-to-inline-styles use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; - // Convert HTML + CSS to HTML with inlined CSS - $cssToInlineStyles= new CssToInlineStyles(); - $cssToInlineStyles->setHTML($html); - $cssToInlineStyles->setCSS($css); - $html = $cssToInlineStyles->convert(); - - // Or use inline-styles blocks from the HTML as CSS - $cssToInlineStyles = new CssToInlineStyles($html); - $cssToInlineStyles->setUseInlineStylesBlock(true); - $html = $cssToInlineStyles->convert(); - - -## Documentation + // create instance + $cssToInlineStyles = new CssToInlineStyles(); -The following properties exists and have get/set methods available: + $html = file_get_contents(__DIR__ . '/examples/sumo/index.htm'); + $css = file_get_contents(__DIR__ . '/examples/sumo/style.css'); -Property | Default | Description --------|---------|------------ -cleanup|false|Should the generated HTML be cleaned? -useInlineStylesBlock |false|Use inline-styles block as CSS. -stripOriginalStyleTags |false|Strip original style tags. -excludeMediaQueries |true|Exclude the media queries from the inlined styles. + // output + echo $cssToInlineStyles->convert( + $html, + $css + ); ## Known issues * no support for pseudo selectors +* no support for [css-escapes](https://mathiasbynens.be/notes/css-escapes) * UTF-8 charset is not always detected correctly. Make sure you set the charset to UTF-8 using the following meta-tag in the head: ``. _(Note: using `` does NOT work!)_ ## Sites using this class diff --git a/composer.json b/composer.json index 4e6b92b..6d738b2 100644 --- a/composer.json +++ b/composer.json @@ -1,31 +1,30 @@ { - "name": "tijsverkoyen/css-to-inline-styles", - "type": "library", + "name": "tijsverkoyen/css-to-inline-styles", + "type": "library", "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", - "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", - "license": "BSD", - "authors": [ + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "license": "BSD-3-Clause", + "authors": [ { - "name": "Tijs Verkoyen", + "name": "Tijs Verkoyen", "email": "css_to_inline_styles@verkoyen.eu", - "role": "Developer" + "role": "Developer" } ], - "require": { - "php": ">=5.3.0", - "symfony/css-selector": "~2.1" + "require": { + "symfony/css-selector": "^2.7|~3.0" }, "require-dev": { - "phpunit/phpunit": "~4.0" + "phpunit/phpunit": "~4.8|5.1.*" }, - "autoload": { + "autoload": { "psr-4": { "TijsVerkoyen\\CssToInlineStyles\\": "src" } }, - "extra": { + "extra": { "branch-alias": { - "dev-master": "1.5.x-dev" + "dev-master": "2.0.x-dev" } } } diff --git a/example/config.php b/example/config.php deleted file mode 100644 index 8ca14dc..0000000 --- a/example/config.php +++ /dev/null @@ -1,8 +0,0 @@ -setHTML($html); -$cssToInlineStyles->setCSS($css); - // output -echo $cssToInlineStyles->convert(); +echo $cssToInlineStyles->convert( + $html, + $css +); diff --git a/phpunit.xml b/phpunit.xml.dist similarity index 66% rename from phpunit.xml rename to phpunit.xml.dist index b28724e..da5f589 100644 --- a/phpunit.xml +++ b/phpunit.xml.dist @@ -1,9 +1,7 @@ - ./tests/ - - - - src - - vendor - - - diff --git a/src/Css/Processor.php b/src/Css/Processor.php new file mode 100644 index 0000000..c3af3d2 --- /dev/null +++ b/src/Css/Processor.php @@ -0,0 +1,64 @@ +doCleanup($css); + $rulesProcessor = new RuleProcessor(); + $rules = $rulesProcessor->splitIntoSeparateRules($css); + + return $rulesProcessor->convertArrayToObjects($rules, $existingRules); + } + + /** + * Get the CSS from the style-tags in the given HTML-string + * + * @param string $html + * @return string + */ + public function getCssFromStyleTags($html) + { + $css = ''; + $matches = array(); + preg_match_all('|(.*)|isU', $html, $matches); + + if (!empty($matches[2])) { + foreach ($matches[2] as $match) { + $css .= trim($match) . "\n"; + } + } + + return $css; + } + + /** + * @param string $css + * @return string + */ + private function doCleanup($css) + { + // remove media queries + $css = preg_replace('/@media [^{]*{([^{}]|{[^{}]*})*}/', '', $css); + + $css = str_replace(array("\r", "\n"), '', $css); + $css = str_replace(array("\t"), ' ', $css); + $css = str_replace('"', '\'', $css); + $css = preg_replace('|/\*.*?\*/|', '', $css); + $css = preg_replace('/\s\s+/', ' ', $css); + $css = trim($css); + + return $css; + } +} diff --git a/src/Css/Property/Processor.php b/src/Css/Property/Processor.php new file mode 100644 index 0000000..2751fe6 --- /dev/null +++ b/src/Css/Property/Processor.php @@ -0,0 +1,122 @@ +cleanup($propertiesString); + + $properties = (array) explode(';', $propertiesString); + $keysToRemove = array(); + $numberOfProperties = count($properties); + + for ($i = 0; $i < $numberOfProperties; $i++) { + $properties[$i] = trim($properties[$i]); + + // if the new property begins with base64 it is part of the current property + if (isset($properties[$i + 1]) && strpos(trim($properties[$i + 1]), 'base64,') === 0) { + $properties[$i] .= ';' . trim($properties[$i + 1]); + $keysToRemove[] = $i + 1; + } + } + + if (!empty($keysToRemove)) { + foreach ($keysToRemove as $key) { + unset($properties[$key]); + } + } + + return array_values($properties); + } + + /** + * @param $string + * @return mixed|string + */ + private function cleanup($string) + { + $string = str_replace(array("\r", "\n"), '', $string); + $string = str_replace(array("\t"), ' ', $string); + $string = str_replace('"', '\'', $string); + $string = preg_replace('|/\*.*?\*/|', '', $string); + $string = preg_replace('/\s\s+/', ' ', $string); + + $string = trim($string); + $string = rtrim($string, ';'); + + return $string; + } + + /** + * Convert a property-string into an object + * + * @param string $property + * @return Property|null + */ + public function convertToObject($property, Specificity $specificity = null) + { + if (strpos($property, ':') === false) { + return null; + } + + list($name, $value) = explode(':', $property, 2); + + $name = trim($name); + $value = trim($value); + + if ($value === '') { + return null; + } + + return new Property($name, $value, $specificity); + } + + /** + * Convert an array of property-strings into objects + * + * @param array $properties + * @return Property[] + */ + public function convertArrayToObjects(array $properties, Specificity $specificity = null) + { + $objects = array(); + + foreach ($properties as $property) { + $object = $this->convertToObject($property, $specificity); + if ($object === null) { + continue; + } + + $objects[] = $object; + } + + return $objects; + } + + /** + * Build the property-string for multiple properties + * + * @param array $properties + * @return string + */ + public function buildPropertiesString(array $properties) + { + $chunks = array(); + + foreach ($properties as $property) { + $chunks[] = $property->toString(); + } + + return implode(' ', $chunks); + } +} diff --git a/src/Css/Property/Property.php b/src/Css/Property/Property.php new file mode 100644 index 0000000..0452461 --- /dev/null +++ b/src/Css/Property/Property.php @@ -0,0 +1,90 @@ +name = $name; + $this->value = $value; + $this->originalSpecificity = $specificity; + } + + /** + * Get name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Get value + * + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * Get originalSpecificity + * + * @return Specificity + */ + public function getOriginalSpecificity() + { + return $this->originalSpecificity; + } + + /** + * Is this property important? + * + * @return bool + */ + public function isImportant() + { + return (stripos($this->value, '!important') !== false); + } + + /** + * Get the textual representation of the property + * + * @return string + */ + public function toString() + { + return sprintf( + '%1$s: %2$s;', + $this->name, + $this->value + ); + } +} diff --git a/src/Css/Rule/Processor.php b/src/Css/Rule/Processor.php new file mode 100644 index 0000000..4beeac4 --- /dev/null +++ b/src/Css/Rule/Processor.php @@ -0,0 +1,154 @@ +cleanup($rulesString); + + return (array) explode('}', $rulesString); + } + + /** + * @param string $string + * @return string + */ + private function cleanup($string) + { + $string = str_replace(array("\r", "\n"), '', $string); + $string = str_replace(array("\t"), ' ', $string); + $string = str_replace('"', '\'', $string); + $string = preg_replace('|/\*.*?\*/|', '', $string); + $string = preg_replace('/\s\s+/', ' ', $string); + + $string = trim($string); + $string = rtrim($string, '}'); + + return $string; + } + + /** + * Convert a rule-string into an object + * + * @param string $rule + * @param int $originalOrder + * @return array + */ + public function convertToObjects($rule, $originalOrder) + { + $rule = $this->cleanup($rule); + + $chunks = explode('{', $rule); + if (!isset($chunks[1])) { + return array(); + } + $propertiesProcessor = new PropertyProcessor(); + $rules = array(); + $selectors = (array) explode(',', trim($chunks[0])); + $properties = $propertiesProcessor->splitIntoSeparateProperties($chunks[1]); + + foreach ($selectors as $selector) { + $selector = trim($selector); + $specificity = $this->calculateSpecificityBasedOnASelector($selector); + + $rules[] = new Rule( + $selector, + $propertiesProcessor->convertArrayToObjects($properties, $specificity), + $specificity, + $originalOrder + ); + } + + return $rules; + } + + /** + * Calculate the specificity based on a CSS Selector string, + * Based on the patterns from premailer/css_parser by Alex Dunae + * + * @see https://github.com/premailer/css_parser/blob/master/lib/css_parser/regexps.rb + * @param string $selector + * @return Specificity + */ + public function calculateSpecificityBasedOnASelector($selector) + { + $idSelectorsPattern = " \#"; + $classAttributesPseudoClassesSelectorsPattern = " (\.[\w]+) # classes + | + \[(\w+) # attributes + | + (\:( # pseudo classes + link|visited|active + |hover|focus + |lang + |target + |enabled|disabled|checked|indeterminate + |root + |nth-child|nth-last-child|nth-of-type|nth-last-of-type + |first-child|last-child|first-of-type|last-of-type + |only-child|only-of-type + |empty|contains + ))"; + + $typePseudoElementsSelectorPattern = " ((^|[\s\+\>\~]+)[\w]+ # elements + | + \:{1,2}( # pseudo-elements + after|before + |first-letter|first-line + |selection + ) + )"; + + return new Specificity( + preg_match_all("/{$idSelectorsPattern}/ix", $selector, $matches), + preg_match_all("/{$classAttributesPseudoClassesSelectorsPattern}/ix", $selector, $matches), + preg_match_all("/{$typePseudoElementsSelectorPattern}/ix", $selector, $matches) + ); + } + + /** + * @param array $rules + * @return Rule[] + */ + public function convertArrayToObjects(array $rules, array $objects = array()) + { + $order = 1; + foreach ($rules as $rule) { + $objects = array_merge($objects, $this->convertToObjects($rule, $order)); + $order++; + } + + return $objects; + } + + /** + * Sort an array on the specificity element + * + * @return int + * @param Rule $e1 The first element. + * @param Rule $e2 The second element. + */ + private static function sortOnSpecificity(Rule $e1, Rule $e2) + { + $e1Specificity = $e1->getSpecificity(); + $value = $e1Specificity->compareTo($e2->getSpecificity()); + + // if the specificity is the same, use the order in which the element appeared + if ($value === 0) { + $value = $e1->getOrder() - $e2->getOrder(); + } + + return $value; + } +} diff --git a/src/Css/Rule/Rule.php b/src/Css/Rule/Rule.php new file mode 100644 index 0000000..1f2b59f --- /dev/null +++ b/src/Css/Rule/Rule.php @@ -0,0 +1,84 @@ +selector = $selector; + $this->properties = $properties; + $this->specificity = $specificity; + $this->order = $order; + } + + /** + * Get selector + * + * @return string + */ + public function getSelector() + { + return $this->selector; + } + + /** + * Get properties + * + * @return array + */ + public function getProperties() + { + return $this->properties; + } + + /** + * Get specificity + * + * @return Specificity + */ + public function getSpecificity() + { + return $this->specificity; + } + + /** + * Get order + * + * @return int + */ + public function getOrder() + { + return $this->order; + } +} diff --git a/src/CssToInlineStyles.php b/src/CssToInlineStyles.php index acf610a..e119cbb 100644 --- a/src/CssToInlineStyles.php +++ b/src/CssToInlineStyles.php @@ -1,682 +1,245 @@ - * @version 1.5.4 - * @copyright Copyright (c), Tijs Verkoyen. All rights reserved. - * @license Revised 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 + * Will inline the $css into the given $html * - * @var string - */ - private $html; - - /** - * Use inline-styles block as CSS - * - * @var bool - */ - private $useInlineStylesBlock = false; - - /** - * Strip original style tags + * Remark: if the html contains |isU', $this->html, $matches); + $cssProperties = array(); + $inlineProperties = $this->getInlineStyles($element); - // any style-blocks found? - if (!empty($matches[2])) { - // add - foreach ($matches[2] as $match) { - $this->css .= trim($match) . "\n"; - } + if (!empty($inlineProperties)) { + foreach ($inlineProperties as $property) { + $cssProperties[$property->getName()] = $property; } } - // process css - $this->processCSS(); - - // create new DOMDocument - $document = new \DOMDocument('1.0', $this->getEncoding()); - - // set error level - $internalErrors = libxml_use_internal_errors(true); - - // load HTML - $document->loadHTML($this->html); - - // Restore error level - libxml_use_internal_errors($internalErrors); - - // create new XPath - $xPath = new \DOMXPath($document); - - // any rules? - if (!empty($this->cssRules)) { - // loop rules - foreach ($this->cssRules as $rule) { - try { - $query = CssSelector::toXPath($rule['selector']); - } catch (ExceptionInterface $e) { - 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 = $this->splitIntoProperties($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) { - // If one of the rules is already set and is !important, don't apply it, - // except if the new rule is also important. - if ( - !isset($properties[$key]) - || stristr($properties[$key], '!important') === false - || (stristr(implode('', $value), '!important') !== false) - ) { - $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 - // search elements - $elements = $xPath->query('//*[@data-css-to-inline-styles-original-styles]'); - - // 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 = $this->splitIntoProperties($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 = $this->splitIntoProperties($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' - ); + foreach ($properties as $property) { + if (!isset($cssProperties[$property->getName()])) { + $cssProperties[$property->getName()] = $property; } } - // strip original style tags if we need to - if ($this->stripOriginalStyleTags) { - $this->stripOriginalStyleTags($xPath); - } - - // cleanup the HTML if we need to - if ($this->cleanup) { - $this->cleanupHTML($xPath); + $rules = array(); + foreach ($cssProperties as $property) { + $rules[] = $property->toString(); } + $element->setAttribute('style', implode(' ', $rules)); - // should we output XHTML? - if ($outputXHTML) { - // set formating - $document->formatOutput = true; - - // get the HTML as XML - $html = $document->saveXML(null, LIBXML_NOEMPTYTAG); - - // remove the XML-header - $html = ltrim(preg_replace('/<\?xml (.*)\?>/', '', $html)); - } // just regular HTML 4.01 as it should be used in newsletters - else { - // get the HTML - $html = $document->saveHTML(); - } - - // return - return $html; + return $element; } /** - * Split a style string into an array of properties. - * The returned array can contain empty strings. + * Get the current inline styles for a given DOMElement * - * @param string $styles ex: 'color:blue;font-size:12px;' - * @return array an array of strings containing css property ex: array('color:blue','font-size:12px') + * @param \DOMElement $element + * @return Css\Property\Property[] */ - private function splitIntoProperties($styles) { - $properties = (array) explode(';', $styles); - - for ($i = 0; $i < count($properties); $i++) { - // If next property begins with base64, - // Then the ';' was part of this property (and we should not have split on it). - if (isset($properties[$i + 1]) && strpos($properties[$i + 1], 'base64,') === 0) { - $properties[$i] .= ';' . $properties[$i + 1]; - $properties[$i + 1] = ''; - $i += 1; - } - } - return $properties; + public function getInlineStyles(\DOMElement $element) + { + $processor = new PropertyProcessor(); + + return $processor->convertArrayToObjects( + $processor->splitIntoSeparateProperties( + $element->getAttribute('style') + ) + ); } /** - * Get the encoding to use - * - * @return string + * @param string $html + * @return \DOMDocument */ - private function getEncoding() + protected function createDomDocumentFromHtml($html) { - return $this->encoding; + $document = new \DOMDocument('1.0', 'UTF-8'); + $internalErrors = libxml_use_internal_errors(true); + $document->loadHTML($html); + libxml_use_internal_errors($internalErrors); + $document->formatOutput = true; + + return $document; } /** - * Process the loaded CSS - * - * @return void + * @param \DOMDocument $document + * @return string */ - private function processCSS() + protected function getHtmlFromDocument(\DOMDocument $document) { - // 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); + $xml = $document->saveXML(null, LIBXML_NOEMPTYTAG); - if ($this->excludeMediaQueries) { - $css = preg_replace('/@media [^{]*{([^{}]|{[^{}]*})*}/', '', $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); + $html = preg_replace( + '|<\?xml (.*)\?>|', + '', + $xml + ); - // 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 specificity - $ruleSet['specificity'] = Specificity::fromSelector($selector); - - // remember the order in which the rules appear - $ruleSet['order'] = $i; - - // add into global rules - $this->cssRules[] = $ruleSet; - } - - // increment - $i++; - } - - // sort based on specificity - if (!empty($this->cssRules)) { - usort($this->cssRules, array(__CLASS__, 'sortOnSpecificity')); - } + return ltrim($html); } /** - * Process the CSS-properties - * - * @return array - * @param string $propertyString The CSS-properties. + * @param \DOMDocument $document + * @param Css\Rule\Rule[] $rules + * @return \DOMDocument */ - private function processCSSProperties($propertyString) + protected function inline(\DOMDocument $document, array $rules) { - // split into chunks - $properties = $this->splitIntoProperties($propertyString); + if (empty($rules)) { + return $document; + } - // init var - $pairs = array(); + $xPath = new \DOMXPath($document); + foreach ($rules as $rule) { + try { + if (class_exists('Symfony\Component\CssSelector\CssSelectorConverter')) { + $converter = new CssSelectorConverter(); + $expression = $converter->toXPath($rule->getSelector()); + } else { + $expression = CssSelector::toXPath($rule->getSelector()); + } + } catch (ExceptionInterface $e) { + continue; + } - // loop properties - foreach ($properties as $property) { - // split into chunks - $chunks = (array) explode(':', $property, 2); + $elements = $xPath->query($expression); - // validate - if (!isset($chunks[1])) { + if ($elements === false) { 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]; + foreach ($elements as $element) { + $this->calculatePropertiesToBeApplied($element, $rule->getProperties()); } } - // 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; - } + $elements = $xPath->query('//*[@data-css-to-inline-styles]'); - /** - * Set the encoding to use with the DOMDocument - * - * @return void - * @param string $encoding The encoding to use. - * - * @deprecated Doesn't have any effect - */ - public function setEncoding($encoding) - { - $this->encoding = (string) $encoding; - } + foreach ($elements as $element) { + $propertiesToBeApplied = $element->attributes->getNamedItem('data-css-to-inline-styles'); + $element->removeAttribute('data-css-to-inline-styles'); - /** - * Set HTML to process - * - * @return void - * @param string $html The HTML to process. - */ - public function setHTML($html) - { - $this->html = (string) $html; - } + if ($propertiesToBeApplied !== null) { + $properties = unserialize(base64_decode($propertiesToBeApplied->value)); + $this->inlineCssOnElement($element, $properties); + } + } - /** - * 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; + return $document; } /** - * Set strip original style tags - * If this is enabled the class will remove all style tags in the HTML. + * Store the calculated values in a temporary data-attribute * - * @return void - * @param bool [optional] $on Should we process inline styles? + * @param \DOMElement $element + * @param Css\Property\Property[] $properties + * @return \DOMElement */ - public function setStripOriginalStyleTags($on = true) - { - $this->stripOriginalStyleTags = (bool) $on; - } + private function calculatePropertiesToBeApplied( + \DOMElement $element, + array $properties + ) { + if (empty($properties)) { + return $element; + } - /** - * Set exclude media queries - * - * If this is enabled the media queries will be removed before inlining the rules - * - * @return void - * @param bool [optional] $on - */ - public function setExcludeMediaQueries($on = true) - { - $this->excludeMediaQueries = (bool) $on; - } + $cssProperties = array(); + $currentStyles = $element->attributes->getNamedItem('data-css-to-inline-styles'); - /** - * Strip style tags into the generated HTML - * - * @return string - * @param \DOMXPath $xPath The DOMXPath for the entire document. - */ - private function stripOriginalStyleTags(\DOMXPath $xPath) - { - // Get all style tags - $nodes = $xPath->query('descendant-or-self::style'); + if ($currentStyles !== null) { + $currentProperties = unserialize( + base64_decode( + $currentStyles->value + ) + ); - foreach ($nodes as $node) { - if ($this->excludeMediaQueries) { - //Search for Media Queries - preg_match_all('/@media [^{]*{([^{}]|{[^{}]*})*}/', $node->nodeValue, $mqs); + foreach ($currentProperties as $property) { + $cssProperties[$property->getName()] = $property; + } + } - // Replace the nodeValue with just the Media Queries - $node->nodeValue = implode("\n", $mqs[0]); + foreach ($properties as $property) { + if (isset($cssProperties[$property->getName()])) { + $existingProperty = $cssProperties[$property->getName()]; + + if ( + ($existingProperty->isImportant() && $property->isImportant()) && + ($existingProperty->getOriginalSpecificity()->compareTo($property->getOriginalSpecificity()) <= 0) + ) { + // if both the properties are important we should use the specificity + $cssProperties[$property->getName()] = $property; + } elseif (!$existingProperty->isImportant() && $property->isImportant()) { + // if the existing property is not important but the new one is, it should be overruled + $cssProperties[$property->getName()] = $property; + } elseif ( + !$existingProperty->isImportant() && + ($existingProperty->getOriginalSpecificity()->compareTo($property->getOriginalSpecificity()) <= 0) + ) { + // if the existing property is not important we should check the specificity + $cssProperties[$property->getName()] = $property; + } } else { - // Remove the entire style tag - $node->parentNode->removeChild($node); + $cssProperties[$property->getName()] = $property; } } - } - /** - * Sort an array on the specificity element - * - * @return int - * @param array $e1 The first element. - * @param array $e2 The second element. - */ - private static function sortOnSpecificity($e1, $e2) - { - // Compare the specificity - $value = $e1['specificity']->compareTo($e2['specificity']); - - // if the specificity is the same, use the order in which the element appeared - if ($value === 0) { - $value = $e1['order'] - $e2['order']; - } + $element->setAttribute( + 'data-css-to-inline-styles', + base64_encode( + serialize( + array_values($cssProperties) + ) + ) + ); - return $value; + return $element; } } diff --git a/src/Exception.php b/src/Exception.php deleted file mode 100644 index 709b055..0000000 --- a/src/Exception.php +++ /dev/null @@ -1,11 +0,0 @@ - - */ -class Exception extends \Exception -{ -} diff --git a/src/Specificity.php b/src/Specificity.php deleted file mode 100644 index 8e5a2fd..0000000 --- a/src/Specificity.php +++ /dev/null @@ -1,133 +0,0 @@ -a = $a; - $this->b = $b; - $this->c = $c; - } - - /** - * Increase the current specificity by adding the three values - * - * @param int $a The number of ID selectors in the selector - * @param int $b The number of class selectors, attributes selectors, and pseudo-classes in the selector - * @param int $c The number of type selectors and pseudo-elements in the selector - */ - public function increase($a, $b, $c) - { - $this->a += $a; - $this->b += $b; - $this->c += $c; - } - - /** - * Get the specificity values as an array - * - * @return array - */ - public function getValues() - { - return array($this->a, $this->b, $this->c); - } - - /** - * Calculate the specificity based on a CSS Selector string, - * Based on the patterns from premailer/css_parser by Alex Dunae - * - * @see https://github.com/premailer/css_parser/blob/master/lib/css_parser/regexps.rb - * @param string $selector - * @return static - */ - public static function fromSelector($selector) - { - $pattern_a = " \#"; - $pattern_b = " (\.[\w]+) # classes - | - \[(\w+) # attributes - | - (\:( # pseudo classes - link|visited|active - |hover|focus - |lang - |target - |enabled|disabled|checked|indeterminate - |root - |nth-child|nth-last-child|nth-of-type|nth-last-of-type - |first-child|last-child|first-of-type|last-of-type - |only-child|only-of-type - |empty|contains - ))"; - - $pattern_c = " ((^|[\s\+\>\~]+)[\w]+ # elements - | - \:{1,2}( # pseudo-elements - after|before - |first-letter|first-line - |selection - ) - )"; - - return new static( - preg_match_all("/{$pattern_a}/ix", $selector, $matches), - preg_match_all("/{$pattern_b}/ix", $selector, $matches), - preg_match_all("/{$pattern_c}/ix", $selector, $matches) - ); - } - - /** - * Returns <0 when $specificity is greater, 0 when equal, >0 when smaller - * - * @param Specificity $specificity - * @return int - */ - public function compareTo(Specificity $specificity) - { - if ($this->a !== $specificity->a) { - return $this->a - $specificity->a; - } elseif ($this->b !== $specificity->b) { - return $this->b - $specificity->b; - } else { - return $this->c - $specificity->c; - } - } -} diff --git a/tests/Css/ProcessorTest.php b/tests/Css/ProcessorTest.php new file mode 100644 index 0000000..f286da5 --- /dev/null +++ b/tests/Css/ProcessorTest.php @@ -0,0 +1,136 @@ +processor = new Processor(); + } + + public function tearDown() + { + $this->processor = null; + } + + public function testCssWithOneRule() + { + $css = <<processor->getRules($css); + + $this->assertCount(1, $rules); + $this->assertInstanceOf('TijsVerkoyen\CssToInlineStyles\Css\Rule\Rule', $rules[0]); + $this->assertEquals('a', $rules[0]->getSelector()); + $this->assertCount(2, $rules[0]->getProperties()); + $this->assertEquals('padding', $rules[0]->getProperties()[0]->getName()); + $this->assertEquals('5px', $rules[0]->getProperties()[0]->getValue()); + $this->assertEquals('display', $rules[0]->getProperties()[1]->getName()); + $this->assertEquals('block', $rules[0]->getProperties()[1]->getValue()); + $this->assertEquals(1, $rules[0]->getOrder()); + } + + public function testCssWithMediaQueries() + { + $css = <<processor->getRules($css); + + $this->assertCount(1, $rules); + $this->assertInstanceOf('TijsVerkoyen\CssToInlineStyles\Css\Rule\Rule', $rules[0]); + $this->assertEquals('a', $rules[0]->getSelector()); + $this->assertCount(1, $rules[0]->getProperties()); + $this->assertEquals('color', $rules[0]->getProperties()[0]->getName()); + $this->assertEquals('red', $rules[0]->getProperties()[0]->getValue()); + $this->assertEquals(1, $rules[0]->getOrder()); + } + + public function testMakeSureMediaQueriesAreRemoved() + { + $css = '@media tv and (min-width: 700px) and (orientation: landscape) {.foo {display: none;}}'; + $this->assertEmpty($this->processor->getRules($css)); + + $css = '@media (min-width: 700px), handheld and (orientation: landscape) {.foo {display: none;}}'; + $this->assertEmpty($this->processor->getRules($css)); + + $css = '@media not screen and (color), print and (color)'; + $this->assertEmpty($this->processor->getRules($css)); + + $css = '@media screen and (min-aspect-ratio: 1/1) {.foo {display: none;}}'; + $this->assertEmpty($this->processor->getRules($css)); + + $css = '@media screen and (device-aspect-ratio: 16/9), screen and (device-aspect-ratio: 16/10) {.foo {display: none;}}'; + $this->assertEmpty($this->processor->getRules($css)); + } + + public function testSimpleStyleTagsInHtml() + { + $expected = 'p { color: #F00; }' . "\n"; + $this->assertEquals( + $expected, + $this->processor->getCssFromStyleTags( + << + + + + +

foo

+ + +EOF + ) + ); + } + + public function testMultipleStyleTagsInHtml() + { + $expected = 'p { color: #F00; }' . "\n" . 'p { color: #0F0; }' . "\n"; + $this->assertEquals( + $expected, + $this->processor->getCssFromStyleTags( + << + + + + + +

foo

+ + +EOF + ) + ); + } +} diff --git a/tests/Css/Property/ProcessorTest.php b/tests/Css/Property/ProcessorTest.php new file mode 100644 index 0000000..980cf20 --- /dev/null +++ b/tests/Css/Property/ProcessorTest.php @@ -0,0 +1,80 @@ +processor = new Processor(); + } + + public function tearDown() + { + $this->processor = null; + } + + public function testMostBasicProperty() + { + $propertiesString = 'padding: 0;'; + $this->assertEquals( + array( + 'padding: 0', + ), + $this->processor->splitIntoSeparateProperties($propertiesString) + ); + } + + public function testInvalidProperty() + { + $this->assertNull( + $this->processor->convertToObject('foo:') + ); + } + + public function testBase64ContainsSemiColon() + { + $propertiesString = <<assertEquals( + array( + 'background: url(data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7) no-repeat left center', + 'padding: 5px 0 5px 25px', + ), + $this->processor->splitIntoSeparateProperties($propertiesString) + ); + } + + public function testBuildingPropertiesString() + { + $properties = array( + new Property('padding', '5px'), + new Property('display', 'block'), + ); + + $this->assertEquals( + 'padding: 5px; display: block;', + $this->processor->buildPropertiesString($properties) + ); + } + + public function testFaultyProperties() + { + $this->assertNull($this->processor->convertToObject('foo')); + $this->assertNull($this->processor->convertToObject('foo:')); + } +} diff --git a/tests/Css/Property/PropertyTest.php b/tests/Css/Property/PropertyTest.php new file mode 100644 index 0000000..4bd0773 --- /dev/null +++ b/tests/Css/Property/PropertyTest.php @@ -0,0 +1,35 @@ +assertEquals('padding', $property->getName()); + $this->assertEquals('5px', $property->getValue()); + } + + public function testSimplePropertyToString() + { + $property = new Property('padding', '5px'); + + $this->assertEquals( + 'padding: 5px;', + $property->toString() + ); + } + + public function testIfImportantIsDetected() + { + $property = new Property('padding', '5px !important'); + $this->assertTrue($property->isImportant()); + + $property = new Property('padding', '5px'); + $this->assertFalse($property->isImportant()); + } +} diff --git a/tests/Css/Rule/ProcessorTest.php b/tests/Css/Rule/ProcessorTest.php new file mode 100644 index 0000000..8e57fca --- /dev/null +++ b/tests/Css/Rule/ProcessorTest.php @@ -0,0 +1,91 @@ +processor = new Processor(); + } + + public function tearDown() + { + $this->processor = null; + } + + public function testMostBasicRule() + { + $css = <<processor->convertToObjects($css, 1); + + $this->assertCount(1, $rules); + $this->assertInstanceOf('TijsVerkoyen\CssToInlineStyles\Css\Rule\Rule', $rules[0]); + $this->assertEquals('a', $rules[0]->getSelector()); + $this->assertCount(2, $rules[0]->getProperties()); + $this->assertEquals('padding', $rules[0]->getProperties()[0]->getName()); + $this->assertEquals('5px', $rules[0]->getProperties()[0]->getValue()); + $this->assertEquals('display', $rules[0]->getProperties()[1]->getName()); + $this->assertEquals('block', $rules[0]->getProperties()[1]->getValue()); + $this->assertEquals(1, $rules[0]->getOrder()); + } + + public function testMaintainOrderOfProperties() + { + $css = <<processor->convertToObjects($css, 1); + + $this->assertCount(1, $rules); + $this->assertInstanceOf('TijsVerkoyen\CssToInlineStyles\Css\Rule\Rule', $rules[0]); + $this->assertEquals('div', $rules[0]->getSelector()); + $this->assertCount(2, $rules[0]->getProperties()); + $this->assertEquals('width', $rules[0]->getProperties()[0]->getName()); + $this->assertEquals('200px', $rules[0]->getProperties()[0]->getValue()); + $this->assertEquals('_width', $rules[0]->getProperties()[1]->getName()); + $this->assertEquals('211px', $rules[0]->getProperties()[1]->getValue()); + $this->assertEquals(1, $rules[0]->getOrder()); + } + + public function testSingleIdSelector() + { + $this->assertEquals( + new Specificity(1, 0, 0), + $this->processor->calculateSpecificityBasedOnASelector('#foo') + ); + } + + public function testSingleClassSelector() + { + $this->assertEquals( + new Specificity(0, 1, 0), + $this->processor->calculateSpecificityBasedOnASelector('.foo') + ); + } + + public function testSingleElementSelector() + { + $this->assertEquals( + new Specificity(0, 0, 1), + $this->processor->calculateSpecificityBasedOnASelector('a') + ); + } +} diff --git a/tests/Css/Rule/RuleTest.php b/tests/Css/Rule/RuleTest.php new file mode 100644 index 0000000..a946659 --- /dev/null +++ b/tests/Css/Rule/RuleTest.php @@ -0,0 +1,28 @@ +assertEquals('a', $rule->getSelector()); + $this->assertEquals(array($property), $rule->getProperties()); + $this->assertEquals($specificity, $rule->getSpecificity()); + $this->assertEquals(1, $rule->getOrder()); + } +} diff --git a/tests/CssToInlineStylesTest.php b/tests/CssToInlineStylesTest.php index 61a7e1a..7e30253 100644 --- a/tests/CssToInlineStylesTest.php +++ b/tests/CssToInlineStylesTest.php @@ -2,7 +2,8 @@ namespace TijsVerkoyen\CssToInlineStyles\tests; -use \TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; +use TijsVerkoyen\CssToInlineStyles\Css\Property\Property; +use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; class CssToInlineStylesTest extends \PHPUnit_Framework_TestCase { @@ -16,17 +17,87 @@ public function setUp() $this->cssToInlineStyles = new CssToInlineStyles(); } - public function teardown() + public function tearDown() { $this->cssToInlineStyles = null; } + public function testNoXMLHeaderPresent() + { + $this->assertNotContains( + 'cssToInlineStyles->convert( + '

foo

', + '' + ) + ); + } + + public function testApplyNoStylesOnElement() + { + $document = new \DOMDocument(); + $element = $document->createElement('a', 'foo'); + $inlineElement = $this->cssToInlineStyles->inlineCssOnElement( + $element, + array() + ); + + $document->appendChild($inlineElement); + $this->assertEquals('foo', trim($document->saveHTML())); + } + + public function testApplyBasicStylesOnElement() + { + $document = new \DOMDocument(); + $element = $document->createElement('a', 'foo'); + $inlineElement = $this->cssToInlineStyles->inlineCssOnElement( + $element, + array( + new Property('padding', '5px'), + ) + ); + + $document->appendChild($inlineElement); + + $this->assertEquals('foo', trim($document->saveHTML())); + } + + public function testApplyBasicStylesOnElementWithInlineStyles() + { + $document = new \DOMDocument(); + $element = $document->createElement('a', 'foo'); + $element->setAttribute('style', 'color: green;'); + $inlineElement = $this->cssToInlineStyles->inlineCssOnElement( + $element, + array( + new Property('padding', '5px'), + ) + ); + + $document->appendChild($inlineElement); + + $this->assertEquals( + 'foo', + trim($document->saveHTML()) + ); + } + + public function testBasicRealHTMLExample() + { + $html = '

foo

'; + $css = 'p { color: red; }'; + $expected = '

foo

'; + + $this->assertCorrectConversion($expected, $html, $css); + } + public function testSimpleElementSelector() { $html = '
'; $css = 'div { display: none; }'; $expected = '
'; - $this->runHTMLToCSS($html, $css, $expected); + + $this->assertCorrectConversion($expected, $html, $css); } public function testSimpleCssSelector() @@ -34,58 +105,48 @@ public function testSimpleCssSelector() $html = 'nodeContent'; $css = '.test-class { background-color: #aaa; text-decoration: none; }'; $expected = 'nodeContent'; - $this->runHTMLToCSS($html, $css, $expected); + + $this->assertCorrectConversion($expected, $html, $css); } public function testSimpleIdSelector() { - $html = ''; - $css = '#IMG1 { border: 1px solid red; }'; - $expected = ''; - $this->runHTMLToCSS($html, $css, $expected); - } + $html = '
'; + $css = '#div1 { border: 1px solid red; }'; + $expected = '
'; - public function testInlineStylesBlock() - { - $html = << - a { - padding: 10px; - margin: 0; - } - - -EOF; - $expected = ''; - $this->cssToInlineStyles->setUseInlineStylesBlock(); - $this->cssToInlineStyles->setHTML($html); - $actual = $this->findAndSaveNode($this->cssToInlineStyles->convert(), '//a'); - $this->assertEquals($expected, $actual); + $this->assertCorrectConversion($expected, $html, $css); } - public function testStripOriginalStyleTags() + public function testInlineStylesBlock() { $html = << - a { - padding: 10px; - margin: 0; - } - - + + + + + + + + EOF; - $expected = ''; - $this->cssToInlineStyles->setStripOriginalStyleTags(); - $this->cssToInlineStyles->setHTML($html); - $actual = $this->findAndSaveNode($this->cssToInlineStyles->convert(), '//a'); - $this->assertEquals($expected, $actual); + $expected = ''; - $this->assertNull($this->findAndSaveNode($actual, '//style')); + $this->assertCorrectConversion($expected, $html); } public function testSpecificity() { - $html = ''; + $html = << + + +EOF; $css = <<runHTMLToCSS($html, $css, $expected); - } - public function testMergeOriginalStyles() - { - $html = '

text

'; - $css = <<runHTMLToCSS($html, $css, $expected); - } - - public function testXHTMLOutput() - { - $html = ''; - $css = 'a { display: block; }'; - - $this->cssToInlineStyles->setHTML($html); - $this->cssToInlineStyles->setCSS($css); - $actual = $this->cssToInlineStyles->convert(true); - - $this->assertContains('', $actual); - } - - public function testCleanup() - { - $html = '
id="foo" class="bar"
'; - $css = ' #id { display: inline; } .className { margin-right: 10px; }'; - $expected = '
id="foo" class="bar"
'; - $this->cssToInlineStyles->setCleanup(); - $this->runHTMLToCSS($html, $css, $expected); + $expected = << + +EOF; + $this->assertCorrectConversion($expected, $html, $css); } public function testEqualSpecificity() { - $html = ''; + $html = '
'; $css = ' .one { display: inline; } a > strong {} a {} a {} a {} a {} a {} a {}a {} img { display: block; }'; - $expected = ''; - $this->runHTMLToCSS($html, $css, $expected); + $expected = '
'; + + $this->assertCorrectConversion($expected, $html, $css); } public function testInvalidSelector() @@ -159,83 +194,29 @@ public function testInvalidSelector() $html = "

"; $css = ' p&@*$%& { display: inline; }'; $expected = $html; - $this->runHTMLToCSS($html, $css, $expected); - } - - public function testEncoding() - { - $html = "

" . html_entity_decode('’', 0, 'UTF-8') . "

"; - $css = ''; - $expected = '

' . chr(0xc3) . chr(0xa2) . chr(0xc2) . chr(0x80) . chr(0xc2) . chr(0x99) . '

'; - $this->cssToInlineStyles->setEncoding('ISO-8859-1'); - $this->runHTMLToCSS($html, $css, $expected); + $this->assertCorrectConversion($expected, $html, $css); } - public function testXMLHeaderIsRemoved() + private function assertCorrectConversion($expected, $html, $css = null) { - $html = '

Foo

'; - $expected = << - - -

Foo

- - - -EOF; - $this->cssToInlineStyles->setHTML($html); - $this->cssToInlineStyles->setCSS(''); - $actual = $this->cssToInlineStyles->convert(true); - - $this->assertEquals($expected, $actual); - } - - private function runHTMLToCSS($html, $css, $expected, $asXHTML = false) - { - $cssToInlineStyles = $this->cssToInlineStyles; - $cssToInlineStyles->setHTML($html); - $cssToInlineStyles->setCSS($css); - $output = $cssToInlineStyles->convert($asXHTML); - $actual = $this->stripBody($output, $asXHTML); - $this->assertEquals($expected, $actual); - } - - private function stripBody($html, $asXHTML = false) - { - $dom = new \DOMDocument(); - /*if ($asXHTML) { - $dom->loadXML($html); - } else {*/ - $dom->loadHTML($html); - /*}*/ - $xpath = new \DOMXPath($dom); - $nodelist = $xpath->query('//body/*'); - $result = ''; - for ($i = 0; $i < $nodelist->length; $i++) { - $node = $nodelist->item($i); - if ($asXHTML) { - $result .= $dom->saveXML($node); - } else { - $result .= $dom->saveHTML($node); - } - } - - return $result; + $this->assertEquals( + $expected, + $this->getBodyContent( + $this->cssToInlineStyles->convert($html, $css) + ) + ); } - private function findAndSaveNode($html, $query) + private function getBodyContent($html) { - $dom = new \DOMDocument(); - $dom->loadHTML($html); - $xpath = new \DOMXPath($dom); - $nodelist = $xpath->query($query); - if ($nodelist->length > 0) { - $node = $nodelist->item(0); + $matches = array(); + preg_match('|(.*)|ims', $html, $matches); - return $dom->saveHTML($node); - } else { + if (!isset($matches[1])) { return null; } + + return trim($matches[1]); } } diff --git a/tests/SpecificityTest.php b/tests/SpecificityTest.php deleted file mode 100644 index 44d0644..0000000 --- a/tests/SpecificityTest.php +++ /dev/null @@ -1,83 +0,0 @@ -assertEquals(array(1,2,3), $specificity->getValues()); - } - - public function testIncreaseValue() - { - $specificity = new Specificity(1, 2, 3); - $specificity->increase(1,2,3); - $this->assertEquals(array(2,4,6), $specificity->getValues()); - } - - - /** @dataProvider getCompareTestData */ - public function testCompare(Specificity $a, Specificity $b, $result) - { - $this->assertEquals($result, $a->compareTo($b)); - } - - public function getCompareTestData() - { - return array( - array(new Specificity(0, 0, 0), new Specificity(0, 0, 0), 0), - array(new Specificity(0, 0, 1), new Specificity(0, 0, 1), 0), - array(new Specificity(0, 0, 2), new Specificity(0, 0, 1), 1), - array(new Specificity(0, 0, 2), new Specificity(0, 0, 3), -1), - array(new Specificity(0, 4, 0), new Specificity(0, 4, 0), 0), - array(new Specificity(0, 6, 0), new Specificity(0, 5, 11), 1), - array(new Specificity(0, 7, 0), new Specificity(0, 8, 0), -1), - array(new Specificity(9, 0, 0), new Specificity(9, 0, 0), 0), - array(new Specificity(11, 0, 0), new Specificity(10, 11, 0), 1), - array(new Specificity(12, 11, 0), new Specificity(13, 0, 0), -1), - ); - } - - /** @dataProvider getSelectorData */ - public function testFromSelector($selector, $result) - { - $specificity = Specificity::fromSelector($selector); - $this->assertEquals($result, $specificity->getValues()); - } - - public function getSelectorData() - { - return array( - array("*", array(0,0,0)), - array("li", array(0,0,1)), - array("ul li", array(0,0,2)), - array("ul ol+li", array(0,0,3)), - array("h1 + *[rel=up]", array(0,1,1)), - array("ul ol li.red", array(0,1,3)), - array("li.red.level", array(0,2,1)), - array("#x34y", array(1,0,0)), - ); - } - - /** @dataProvider getSkippedSelectorData */ - public function testSkippedFromSelector($selector, $result) - { - $this->markTestSkipped( - 'Skipping edge cases in CSS' - ); - - $specificity = Specificity::fromSelector($selector); - $this->assertEquals($result, $specificity->getValues()); - } - - public function getSkippedSelectorData() - { - return array( - array("#s12:not(FOO)", array(1,0,1)), - ); - } -}