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
-[](https://travis-ci.org/tijsverkoyen/CssToInlineStyles)
+[](https://travis-ci.org/tijsverkoyen/CssToInlineStyles) [](https://scrutinizer-ci.com/g/tijsverkoyen/CssToInlineStyles/?branch=master) [](https://scrutinizer-ci.com/g/tijsverkoyen/CssToInlineStyles/?branch=master) [](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
+
+