},
"require": {
"ezyang/htmlpurifier": "4.7.*",
- "erusev/parsedown": "1.6.*"
+ "erusev/parsedown": "1.6.*",
+ "pelago/emogrifier": "1.0.*"
}
}
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
- "hash": "e898b55b2609b488f1b137f6b9dd8d01",
- "content-hash": "7dabe058d3ef475f63962fe1d5774a34",
+ "hash": "7403d7c709a9942dc2f75396d5fe55fe",
+ "content-hash": "42237c86b167290edb2d1cd117a93105",
"packages": [
{
"name": "erusev/parsedown",
"html"
],
"time": "2015-08-05 01:03:42"
+ },
+ {
+ "name": "pelago/emogrifier",
+ "version": "V1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/jjriv/emogrifier.git",
+ "reference": "1160bcbc523c7941d2d0dc2a9e59c51c66420b4b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/jjriv/emogrifier/zipball/1160bcbc523c7941d2d0dc2a9e59c51c66420b4b",
+ "reference": "1160bcbc523c7941d2d0dc2a9e59c51c66420b4b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "4.8.11",
+ "squizlabs/php_codesniffer": "2.3.4",
+ "typo3-ci/typo3sniffpool": "2.1.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Pelago\\": "Classes/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "John Reeve",
+ "email": "jreeve@pelagodesign.com"
+ },
+ {
+ "name": "Cameron Brooks"
+ },
+ {
+ "name": "Jaime Prado"
+ },
+ {
+ "name": "Oliver Klee",
+ "email": "typo3-coding@oliverklee.de"
+ },
+ {
+ "name": "Roman Ožana",
+ "email": "ozana@omdesign.cz"
+ }
+ ],
+ "description": "Converts CSS styles into inline style attributes in your HTML code",
+ "homepage": "http://www.pelagodesign.com/sidecar/emogrifier/",
+ "time": "2015-10-14 22:22:15"
}
],
"packages-dev": [],
$baseDir = $vendorDir;
return array(
+ 'Pelago\\' => array($vendorDir . '/pelago/emogrifier/Classes'),
);
'2cffec82183ee1cea088009cef9a6fc3' => __DIR__ . '/..' . '/ezyang/htmlpurifier/library/HTMLPurifier.composer.php',
);
+ public static $prefixLengthsPsr4 = array (
+ 'P' =>
+ array (
+ 'Pelago\\' => 7,
+ ),
+ );
+
+ public static $prefixDirsPsr4 = array (
+ 'Pelago\\' =>
+ array (
+ 0 => __DIR__ . '/..' . '/pelago/emogrifier/Classes',
+ ),
+ );
+
public static $prefixesPsr0 = array (
'P' =>
array (
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
+ $loader->prefixLengthsPsr4 = ComposerStaticInit4a4e0e985ef68770d710dc260edc44ab::$prefixLengthsPsr4;
+ $loader->prefixDirsPsr4 = ComposerStaticInit4a4e0e985ef68770d710dc260edc44ab::$prefixDirsPsr4;
$loader->prefixesPsr0 = ComposerStaticInit4a4e0e985ef68770d710dc260edc44ab::$prefixesPsr0;
}, null, ClassLoader::class);
"markdown",
"parser"
]
+ },
+ {
+ "name": "pelago/emogrifier",
+ "version": "V1.0.0",
+ "version_normalized": "1.0.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/jjriv/emogrifier.git",
+ "reference": "1160bcbc523c7941d2d0dc2a9e59c51c66420b4b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/jjriv/emogrifier/zipball/1160bcbc523c7941d2d0dc2a9e59c51c66420b4b",
+ "reference": "1160bcbc523c7941d2d0dc2a9e59c51c66420b4b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "4.8.11",
+ "squizlabs/php_codesniffer": "2.3.4",
+ "typo3-ci/typo3sniffpool": "2.1.1"
+ },
+ "time": "2015-10-14 22:22:15",
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ },
+ "installation-source": "dist",
+ "autoload": {
+ "psr-4": {
+ "Pelago\\": "Classes/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "John Reeve",
+ "email": "jreeve@pelagodesign.com"
+ },
+ {
+ "name": "Cameron Brooks"
+ },
+ {
+ "name": "Jaime Prado"
+ },
+ {
+ "name": "Oliver Klee",
+ "email": "typo3-coding@oliverklee.de"
+ },
+ {
+ "name": "Roman Ožana",
+ "email": "ozana@omdesign.cz"
+ }
+ ],
+ "description": "Converts CSS styles into inline style attributes in your HTML code",
+ "homepage": "http://www.pelagodesign.com/sidecar/emogrifier/"
}
]
--- /dev/null
+#########################
+# global ignore file
+########################
+# ignoring temporary files (left by e.g. vim)
+# ignoring by common IDE's used directories/files
+# dont ignore .rej and .orig as we want to see/clean files after conflict resolution
+#
+# for local exclude patterns please edit .git/info/exclude
+#
+*~
+*.bak
+*.idea
+*.project
+*.swp
+.buildpath
+.cache
+.project
+.session
+.settings
+.TemporaryItems
+.webprj
+nbproject
+/vendor/
+composer.lock
--- /dev/null
+sudo: false
+
+language: php
+
+cache:
+ directories:
+ - vendor
+
+env:
+ global:
+ secure: nOIIWvxRsDlkg+5H21dmVeqvFbweOAk3l3ZiyZO1m5XuGuuZR9yj10oOudee8m0hzJ7e9eoZ+dfB3t8lmK0fTRTB6w0G7RuGiQb89ief3Zhs1vOveYOgS5yfTMRym57iluxsLeCe7AxWmy7+0fWAvx1qL7bKp+THGK9yv/aj9eM=
+
+php:
+ - 5.4
+ - 5.5
+ - 5.6
+ - 7.0
+ - hhvm
+
+before_script:
+ - composer install
+ - vendor/bin/phpcs --config-set encoding utf-8
+ - if [ "$GITHUB_COMPOSER_AUTH" ]; then composer config -g github-oauth.github.com $GITHUB_COMPOSER_AUTH; fi
+
+script:
+ # Run PHP lint on all PHP files.
+ - find Classes/ Tests/ -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l
+ # Check the coding style.
+ - vendor/bin/phpcs --standard=Configuration/PhpCodeSniffer/Standards/Emogrifier/ Classes/ Tests/
+ # Run the unit tests.
+ - vendor/bin/phpunit Tests/
--- /dev/null
+# Emogrifier Change Log
+
+All notable changes to this project will be documented in this file.
+This project adheres to [Semantic Versioning](http://semver.org/).
+
+Emogrifier is in a pre-1.0 state. This means that its APIs and behavior are
+subject to breaking changes without deprecation notices.
+
+
+## [1.0.0][] (2015-10-15)
+
+### Added
+- Add branch alias ([#231](https://github.com/jjriv/emogrifier/pull/231))
+- Remove media queries which do not impact the document
+ ([#217](https://github.com/jjriv/emogrifier/pull/217))
+- Allow elements to be excluded from emogrification
+ ([#215](https://github.com/jjriv/emogrifier/pull/215))
+- Handle !important ([#214](https://github.com/jjriv/emogrifier/pull/214))
+- emogrifyBodyContent() method
+ ([#206](https://github.com/jjriv/emogrifier/pull/206))
+- Cache combinedStyles ([#211](https://github.com/jjriv/emogrifier/pull/211))
+- Allow user to define media types to keep
+ ([#200](https://github.com/jjriv/emogrifier/pull/200))
+- Ignore invalid CSS selectors
+ ([#194](https://github.com/jjriv/emogrifier/pull/194))
+- isRemoveDisplayNoneEnabled option
+ ([#162](https://github.com/jjriv/emogrifier/pull/162))
+- Allow disabling of "inline style" and "style block" parsing
+ ([#156](https://github.com/jjriv/emogrifier/pull/156))
+- Preserve @media if necessary
+ ([#62](https://github.com/jjriv/emogrifier/pull/62))
+- Add extraction of style blocks within the HTML
+- Add several new pseudo-selectors (first-child, last-child, nth-child,
+ and nth-of-type)
+
+
+### Changed
+- Make HTML5 the default document type
+ ([#245](https://github.com/jjriv/emogrifier/pull/245))
+- Make copyCssWithMediaToStyleNode private
+ ([#218](https://github.com/jjriv/emogrifier/pull/218))
+- Stop encoding umlauts and dollar signs
+ ([#170](https://github.com/jjriv/emogrifier/pull/170))
+- Convert the classes to namespaces
+ ([#41](https://github.com/jjriv/emogrifier/pull/41))
+
+
+### Deprecated
+- Support for PHP 5.4 will be removed in Emogrifier 2.0.
+
+
+### Removed
+- Drop support for PHP 5.3
+ ([#114](https://github.com/jjriv/emogrifier/pull/114))
+- Support for character sets other than UTF-8 was removed.
+
+
+### Fixed
+- Fix failing tests on Windows due to line endings
+ ([#263](https://github.com/jjriv/emogrifier/pull/263))
+- Parsing CSS declaration blocks
+ ([#261](https://github.com/jjriv/emogrifier/pull/261))
+- Fix first-child and last-child selectors
+ ([#257](https://github.com/jjriv/emogrifier/pull/257))
+- Fix parsing of CSS for data URIs
+ ([#243](https://github.com/jjriv/emogrifier/pull/243))
+- Fix multi-line media queries
+ ([#241](https://github.com/jjriv/emogrifier/pull/241))
+- Keep CSS media queries even if followed by CSS comments
+ ([#201](https://github.com/jjriv/emogrifier/pull/201))
+- Fix CSS selectors with exact attribute only
+ ([#197](https://github.com/jjriv/emogrifier/pull/197))
+- Properly handle UTF-8 characters and entities
+ ([#189](https://github.com/jjriv/emogrifier/pull/189))
+- Add mbstring extension to composer.json
+ ([#93](https://github.com/jjriv/emogrifier/pull/93))
+- Prevent incorrectly capitalized CSS selectors from being stripped
+ ([#85](https://github.com/jjriv/emogrifier/pull/85))
+- Fix CSS selectors with exact attribute only
+ ([#197](https://github.com/jjriv/emogrifier/pull/197))
+- Wrong selector extraction from minified CSS
+ ([#69](https://github.com/jjriv/emogrifier/pull/69))
+- Restore libxml error handler state after clearing
+ ([#65](https://github.com/jjriv/emogrifier/pull/65))
+- Ignore all warnings produced by DOMDocument::loadHTML()
+ ([#63](https://github.com/jjriv/emogrifier/pull/63))
+- Style tags in HTML cause an Xpath invalid query error
+ ([#60](https://github.com/jjriv/emogrifier/pull/60))
+- Fix PHP warnings with PHP 5.5
+ ([#26](https://github.com/jjriv/emogrifier/pull/26))
+- Make removal of invisible nodes operate in a case-insensitive manner
+- Fix a bug that was overwriting existing inline styles from the original HTML
--- /dev/null
+# Contributing to Emogrifier
+
+Those that wish to contribute bug fixes, new features, refactorings and
+clean-up to Emogrifier are more than welcome.
+
+When you contribute, please take the following things into account:
+
+
+## General workflow
+
+After you have submitted a pull request, the Emogrifier team will review your
+changes. This will probably result in quite a few comments on ways to improve
+your pull request. The Emogrifier project receives contributions from
+developers around the world, so we need the code to be the most consistent,
+readable, and maintainable that it can be.
+
+Please do not feel frustrated by this - instead please view this both as our
+contribution to your pull request as well as a way to learn more about
+improving code quality.
+
+If you would like to know whether an idea would fit in the general strategy of
+the Emogrifier project or would like to get feedback on the best architecture
+for your ideas, we propose you open a ticket first and discuss your ideas there
+first before investing a lot of time in writing code.
+
+
+## Install the development dependencies
+
+To install the development dependencies (PHPUnit and PHP_CodeSniffer), please
+run the following command:
+
+ composer install
+
+
+## Unit-test your changes
+
+Please cover all changes with unit tests and make sure that your code does not
+break any existing tests. We will only merge pull request that include full
+code coverage of the fixed bugs and the new features.
+
+To run the existing PHPUnit tests, run this command:
+
+ vendor/bin/phpunit Tests/
+
+
+## Coding Style
+
+Please use the same coding style (PSR-2) as the rest of the code. Indentation
+is four spaces.
+
+We will only merge pull requests that follow the project's coding style.
+
+Please check your code with the provided PHP_CodeSniffer standard:
+
+ vendor/bin/phpcs --standard=Configuration/PhpCodeSniffer/Standards/Emogrifier/ Classes/ Tests/
+
+Please make your code clean, well-readable and easy to understand.
+
+If you add new methods or fields, please add proper PHPDoc for the new
+methods/fields. Please use grammatically correct, complete sentences in the
+code documentation.
+
+
+## Git commits
+
+Git commits should have a <= 50 character summary, optionally followed by a
+blank line and a more in depth description of 79 characters per line.
+
+[Please squash related commits together](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html).
+
+If you already have a commit and work on it, you can also
+[amend the first commit](https://nathanhoad.net/git-amend-your-last-commit).
+
+Please use grammatically correct, complete sentences in the commit messages.
+
+Also, please prefix the subject line of the commit message with either
+[FEATURE], [TASK], [BUGFIX] OR [CLEANUP]. This makes it faster to see what
+a commit is about.
\ No newline at end of file
--- /dev/null
+<?php
+namespace Pelago;
+
+/**
+ * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
+ *
+ * For more information, please see the README.md file.
+ *
+ * @version 1.0.0
+ *
+ * @author Cameron Brooks
+ * @author Jaime Prado
+ * @author Oliver Klee <typo3-coding@oliverklee.de>
+ * @author Roman Ožana <ozana@omdesign.cz>
+ */
+class Emogrifier
+{
+ /**
+ * @var int
+ */
+ const CACHE_KEY_CSS = 0;
+
+ /**
+ * @var int
+ */
+ const CACHE_KEY_SELECTOR = 1;
+
+ /**
+ * @var int
+ */
+ const CACHE_KEY_XPATH = 2;
+
+ /**
+ * @var int
+ */
+ const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 3;
+
+ /**
+ * @var int
+ */
+ const CACHE_KEY_COMBINED_STYLES = 4;
+
+ /**
+ * for calculating nth-of-type and nth-child selectors
+ *
+ * @var int
+ */
+ const INDEX = 0;
+
+ /**
+ * for calculating nth-of-type and nth-child selectors
+ *
+ * @var int
+ */
+ const MULTIPLIER = 1;
+
+ /**
+ * @var string
+ */
+ const ID_ATTRIBUTE_MATCHER = '/(\\w+)?\\#([\\w\\-]+)/';
+
+ /**
+ * @var string
+ */
+ const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
+
+ /**
+ * @var string
+ */
+ const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
+
+ /**
+ * @var string
+ */
+ const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
+
+ /**
+ * @var string
+ */
+ private $html = '';
+
+ /**
+ * @var string
+ */
+ private $css = '';
+
+ /**
+ * @var bool[]
+ */
+ private $excludedSelectors = [];
+
+ /**
+ * @var string[]
+ */
+ private $unprocessableHtmlTags = ['wbr'];
+
+ /**
+ * @var bool[]
+ */
+ private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
+
+ /**
+ * @var array[]
+ */
+ private $caches = [
+ self::CACHE_KEY_CSS => [],
+ self::CACHE_KEY_SELECTOR => [],
+ self::CACHE_KEY_XPATH => [],
+ self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
+ self::CACHE_KEY_COMBINED_STYLES => [],
+ ];
+
+ /**
+ * the visited nodes with the XPath paths as array keys
+ *
+ * @var \DOMElement[]
+ */
+ private $visitedNodes = [];
+
+ /**
+ * the styles to apply to the nodes with the XPath paths as array keys for the outer array
+ * and the attribute names/values as key/value pairs for the inner array
+ *
+ * @var array[]
+ */
+ private $styleAttributesForNodes = [];
+
+ /**
+ * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
+ * If set to false, the value of the style attributes will be discarded.
+ *
+ * @var bool
+ */
+ private $isInlineStyleAttributesParsingEnabled = true;
+
+ /**
+ * Determines whether the <style> blocks in the HTML passed to this class should be parsed.
+ *
+ * If set to true, the <style> blocks will be removed from the HTML and their contents will be applied to the HTML
+ * via inline styles.
+ *
+ * If set to false, the <style> blocks will be left as they are in the HTML.
+ *
+ * @var bool
+ */
+ private $isStyleBlocksParsingEnabled = true;
+
+ /**
+ * Determines whether elements with the `display: none` property are
+ * removed from the DOM.
+ *
+ * @var bool
+ */
+ private $shouldKeepInvisibleNodes = true;
+
+ /**
+ * The constructor.
+ *
+ * @param string $html the HTML to emogrify, must be UTF-8-encoded
+ * @param string $css the CSS to merge, must be UTF-8-encoded
+ */
+ public function __construct($html = '', $css = '')
+ {
+ $this->setHtml($html);
+ $this->setCss($css);
+ }
+
+ /**
+ * The destructor.
+ */
+ public function __destruct()
+ {
+ $this->purgeVisitedNodes();
+ }
+
+ /**
+ * Sets the HTML to emogrify.
+ *
+ * @param string $html the HTML to emogrify, must be UTF-8-encoded
+ *
+ * @return void
+ */
+ public function setHtml($html)
+ {
+ $this->html = $html;
+ }
+
+ /**
+ * Sets the CSS to merge with the HTML.
+ *
+ * @param string $css the CSS to merge, must be UTF-8-encoded
+ *
+ * @return void
+ */
+ public function setCss($css)
+ {
+ $this->css = $css;
+ }
+
+ /**
+ * Applies $this->css to $this->html and returns the HTML with the CSS
+ * applied.
+ *
+ * This method places the CSS inline.
+ *
+ * @return string
+ *
+ * @throws \BadMethodCallException
+ */
+ public function emogrify()
+ {
+ if ($this->html === '') {
+ throw new \BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
+ }
+
+ $xmlDocument = $this->createXmlDocument();
+ $this->process($xmlDocument);
+
+ return $xmlDocument->saveHTML();
+ }
+
+ /**
+ * Applies $this->css to $this->html and returns only the HTML content
+ * within the <body> tag.
+ *
+ * This method places the CSS inline.
+ *
+ * @return string
+ *
+ * @throws \BadMethodCallException
+ */
+ public function emogrifyBodyContent()
+ {
+ if ($this->html === '') {
+ throw new \BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
+ }
+
+ $xmlDocument = $this->createXmlDocument();
+ $this->process($xmlDocument);
+
+ $innerDocument = new \DOMDocument();
+ foreach ($xmlDocument->documentElement->getElementsByTagName('body')->item(0)->childNodes as $childNode) {
+ $innerDocument->appendChild($innerDocument->importNode($childNode, true));
+ }
+
+ return $innerDocument->saveHTML();
+ }
+
+ /**
+ * Applies $this->css to $xmlDocument.
+ *
+ * This method places the CSS inline.
+ *
+ * @param \DOMDocument $xmlDocument
+ *
+ * @return void
+ */
+ protected function process(\DOMDocument $xmlDocument)
+ {
+ $xpath = new \DOMXPath($xmlDocument);
+ $this->clearAllCaches();
+
+ // Before be begin processing the CSS file, parse the document and normalize all existing CSS attributes.
+ // This changes 'DISPLAY: none' to 'display: none'.
+ // We wouldn't have to do this if DOMXPath supported XPath 2.0.
+ // Also store a reference of nodes with existing inline styles so we don't overwrite them.
+ $this->purgeVisitedNodes();
+
+ $nodesWithStyleAttributes = $xpath->query('//*[@style]');
+ if ($nodesWithStyleAttributes !== false) {
+ /** @var \DOMElement $node */
+ foreach ($nodesWithStyleAttributes as $node) {
+ if ($this->isInlineStyleAttributesParsingEnabled) {
+ $this->normalizeStyleAttributes($node);
+ } else {
+ $node->removeAttribute('style');
+ }
+ }
+ }
+
+ // grab any existing style blocks from the html and append them to the existing CSS
+ // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
+ $allCss = $this->css;
+
+ if ($this->isStyleBlocksParsingEnabled) {
+ $allCss .= $this->getCssFromAllStyleNodes($xpath);
+ }
+
+ $cssParts = $this->splitCssAndMediaQuery($allCss);
+ $excludedNodes = $this->getNodesToExclude($xpath);
+ $cssRules = $this->parseCssRules($cssParts['css']);
+ foreach ($cssRules as $cssRule) {
+ // query the body for the xpath selector
+ $nodesMatchingCssSelectors = $xpath->query($this->translateCssToXpath($cssRule['selector']));
+ // ignore invalid selectors
+ if ($nodesMatchingCssSelectors === false) {
+ continue;
+ }
+
+ /** @var \DOMElement $node */
+ foreach ($nodesMatchingCssSelectors as $node) {
+ if (in_array($node, $excludedNodes, true)) {
+ continue;
+ }
+
+ // if it has a style attribute, get it, process it, and append (overwrite) new stuff
+ if ($node->hasAttribute('style')) {
+ // break it up into an associative array
+ $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
+ } else {
+ $oldStyleDeclarations = [];
+ }
+ $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
+ $node->setAttribute(
+ 'style',
+ $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
+ );
+ }
+ }
+
+ if ($this->isInlineStyleAttributesParsingEnabled) {
+ $this->fillStyleAttributesWithMergedStyles();
+ }
+
+ if ($this->shouldKeepInvisibleNodes) {
+ $this->removeInvisibleNodes($xpath);
+ }
+
+ $this->copyCssWithMediaToStyleNode($xmlDocument, $xpath, $cssParts['media']);
+ }
+
+ /**
+ * Extracts and parses the individual rules from a CSS string.
+ *
+ * @param string $css a string of raw CSS code
+ *
+ * @return string[][] an array of string sub-arrays with the keys
+ * "selector" (the CSS selector(s), e.g., "*" or "h1"),
+ * "declarationsBLock" (the semicolon-separated CSS declarations for that selector(s),
+ * e.g., "color: red; height: 4px;"),
+ * and "line" (the line number e.g. 42)
+ */
+ private function parseCssRules($css)
+ {
+ $cssKey = md5($css);
+ if (!isset($this->caches[self::CACHE_KEY_CSS][$cssKey])) {
+ // process the CSS file for selectors and definitions
+ preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mis', $css, $matches, PREG_SET_ORDER);
+
+ $cssRules = [];
+ /** @var string[] $cssRule */
+ foreach ($matches as $key => $cssRule) {
+ $cssDeclaration = trim($cssRule[2]);
+ if ($cssDeclaration === '') {
+ continue;
+ }
+
+ $selectors = explode(',', $cssRule[1]);
+ foreach ($selectors as $selector) {
+ // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
+ // only allow structural pseudo-classes
+ if (strpos($selector, ':') !== false && !preg_match('/:\\S+\\-(child|type\\()/i', $selector)) {
+ continue;
+ }
+
+ $cssRules[] = [
+ 'selector' => trim($selector),
+ 'declarationsBlock' => $cssDeclaration,
+ // keep track of where it appears in the file, since order is important
+ 'line' => $key,
+ ];
+ }
+ }
+
+ usort($cssRules, [$this, 'sortBySelectorPrecedence']);
+
+ $this->caches[self::CACHE_KEY_CSS][$cssKey] = $cssRules;
+ }
+
+ return $this->caches[self::CACHE_KEY_CSS][$cssKey];
+ }
+
+ /**
+ * Disables the parsing of inline styles.
+ *
+ * @return void
+ */
+ public function disableInlineStyleAttributesParsing()
+ {
+ $this->isInlineStyleAttributesParsingEnabled = false;
+ }
+
+ /**
+ * Disables the parsing of <style> blocks.
+ *
+ * @return void
+ */
+ public function disableStyleBlocksParsing()
+ {
+ $this->isStyleBlocksParsingEnabled = false;
+ }
+
+ /**
+ * Disables the removal of elements with `display: none` properties.
+ *
+ * @return void
+ */
+ public function disableInvisibleNodeRemoval()
+ {
+ $this->shouldKeepInvisibleNodes = false;
+ }
+
+ /**
+ * Clears all caches.
+ *
+ * @return void
+ */
+ private function clearAllCaches()
+ {
+ $this->clearCache(self::CACHE_KEY_CSS);
+ $this->clearCache(self::CACHE_KEY_SELECTOR);
+ $this->clearCache(self::CACHE_KEY_XPATH);
+ $this->clearCache(self::CACHE_KEY_CSS_DECLARATIONS_BLOCK);
+ $this->clearCache(self::CACHE_KEY_COMBINED_STYLES);
+ }
+
+ /**
+ * Clears a single cache by key.
+ *
+ * @param int $key the cache key, must be CACHE_KEY_CSS, CACHE_KEY_SELECTOR, CACHE_KEY_XPATH
+ * or CACHE_KEY_CSS_DECLARATION_BLOCK
+ *
+ * @return void
+ *
+ * @throws \InvalidArgumentException
+ */
+ private function clearCache($key)
+ {
+ $allowedCacheKeys = [
+ self::CACHE_KEY_CSS,
+ self::CACHE_KEY_SELECTOR,
+ self::CACHE_KEY_XPATH,
+ self::CACHE_KEY_CSS_DECLARATIONS_BLOCK,
+ self::CACHE_KEY_COMBINED_STYLES,
+ ];
+ if (!in_array($key, $allowedCacheKeys, true)) {
+ throw new \InvalidArgumentException('Invalid cache key: ' . $key, 1391822035);
+ }
+
+ $this->caches[$key] = [];
+ }
+
+ /**
+ * Purges the visited nodes.
+ *
+ * @return void
+ */
+ private function purgeVisitedNodes()
+ {
+ $this->visitedNodes = [];
+ $this->styleAttributesForNodes = [];
+ }
+
+ /**
+ * Marks a tag for removal.
+ *
+ * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
+ * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
+ *
+ * Note: The tags will not be removed if they have any content.
+ *
+ * @param string $tagName the tag name, e.g., "p"
+ *
+ * @return void
+ */
+ public function addUnprocessableHtmlTag($tagName)
+ {
+ $this->unprocessableHtmlTags[] = $tagName;
+ }
+
+ /**
+ * Drops a tag from the removal list.
+ *
+ * @param string $tagName the tag name, e.g., "p"
+ *
+ * @return void
+ */
+ public function removeUnprocessableHtmlTag($tagName)
+ {
+ $key = array_search($tagName, $this->unprocessableHtmlTags, true);
+ if ($key !== false) {
+ unset($this->unprocessableHtmlTags[$key]);
+ }
+ }
+
+ /**
+ * Marks a media query type to keep.
+ *
+ * @param string $mediaName the media type name, e.g., "braille"
+ *
+ * @return void
+ */
+ public function addAllowedMediaType($mediaName)
+ {
+ $this->allowedMediaTypes[$mediaName] = true;
+ }
+
+ /**
+ * Drops a media query type from the allowed list.
+ *
+ * @param string $mediaName the tag name, e.g., "braille"
+ *
+ * @return void
+ */
+ public function removeAllowedMediaType($mediaName)
+ {
+ if (isset($this->allowedMediaTypes[$mediaName])) {
+ unset($this->allowedMediaTypes[$mediaName]);
+ }
+ }
+
+ /**
+ * Adds a selector to exclude nodes from emogrification.
+ *
+ * Any nodes that match the selector will not have their style altered.
+ *
+ * @param string $selector the selector to exclude, e.g., ".editor"
+ *
+ * @return void
+ */
+ public function addExcludedSelector($selector)
+ {
+ $this->excludedSelectors[$selector] = true;
+ }
+
+ /**
+ * No longer excludes the nodes matching this selector from emogrification.
+ *
+ * @param string $selector the selector to no longer exclude, e.g., ".editor"
+ *
+ * @return void
+ */
+ public function removeExcludedSelector($selector)
+ {
+ if (isset($this->excludedSelectors[$selector])) {
+ unset($this->excludedSelectors[$selector]);
+ }
+ }
+
+ /**
+ * This removes styles from your email that contain display:none.
+ * We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
+ * supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
+ * not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
+ * to lowercase.
+ *
+ * @param \DOMXPath $xpath
+ *
+ * @return void
+ */
+ private function removeInvisibleNodes(\DOMXPath $xpath)
+ {
+ $nodesWithStyleDisplayNone = $xpath->query(
+ '//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]'
+ );
+ if ($nodesWithStyleDisplayNone->length === 0) {
+ return;
+ }
+
+ // The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
+ // we don't try to call removeChild on a nonexistent child node
+ /** @var \DOMNode $node */
+ foreach ($nodesWithStyleDisplayNone as $node) {
+ if ($node->parentNode && is_callable([$node->parentNode, 'removeChild'])) {
+ $node->parentNode->removeChild($node);
+ }
+ }
+ }
+
+ /**
+ * Normalizes the value of the "style" attribute and saves it.
+ *
+ * @param \DOMElement $node
+ *
+ * @return void
+ */
+ private function normalizeStyleAttributes(\DOMElement $node)
+ {
+ $normalizedOriginalStyle = preg_replace_callback(
+ '/[A-z\\-]+(?=\\:)/S',
+ function (array $m) {
+ return strtolower($m[0]);
+ },
+ $node->getAttribute('style')
+ );
+
+ // in order to not overwrite existing style attributes in the HTML, we
+ // have to save the original HTML styles
+ $nodePath = $node->getNodePath();
+ if (!isset($this->styleAttributesForNodes[$nodePath])) {
+ $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
+ $this->visitedNodes[$nodePath] = $node;
+ }
+
+ $node->setAttribute('style', $normalizedOriginalStyle);
+ }
+
+ /**
+ * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
+ *
+ * @return void
+ */
+ private function fillStyleAttributesWithMergedStyles()
+ {
+ foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
+ $node = $this->visitedNodes[$nodePath];
+ $currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
+ $node->setAttribute(
+ 'style',
+ $this->generateStyleStringFromDeclarationsArrays(
+ $currentStyleAttributes,
+ $styleAttributesForNode
+ )
+ );
+ }
+ }
+
+ /**
+ * This method merges old or existing name/value array with new name/value array
+ * and then generates a string of the combined style suitable for placing inline.
+ * This becomes the single point for CSS string generation allowing for consistent
+ * CSS output no matter where the CSS originally came from.
+ *
+ * @param string[] $oldStyles
+ * @param string[] $newStyles
+ *
+ * @return string
+ */
+ private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles)
+ {
+ $combinedStyles = array_merge($oldStyles, $newStyles);
+ $cacheKey = serialize($combinedStyles);
+ if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
+ return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
+ }
+
+ foreach ($oldStyles as $attributeName => $attributeValue) {
+ if (isset($newStyles[$attributeName]) && strtolower(substr($attributeValue, -10)) === '!important') {
+ $combinedStyles[$attributeName] = $attributeValue;
+ }
+ }
+
+ $style = '';
+ foreach ($combinedStyles as $attributeName => $attributeValue) {
+ $style .= strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; ';
+ }
+ $trimmedStyle = rtrim($style);
+
+ $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
+
+ return $trimmedStyle;
+ }
+
+ /**
+ * Applies $css to $xmlDocument, limited to the media queries that actually apply to the document.
+ *
+ * @param \DOMDocument $xmlDocument the document to match against
+ * @param \DOMXPath $xpath
+ * @param string $css a string of CSS
+ *
+ * @return void
+ */
+ private function copyCssWithMediaToStyleNode(\DOMDocument $xmlDocument, \DOMXPath $xpath, $css)
+ {
+ if ($css === '') {
+ return;
+ }
+
+ $mediaQueriesRelevantForDocument = [];
+
+ foreach ($this->extractMediaQueriesFromCss($css) as $mediaQuery) {
+ foreach ($this->parseCssRules($mediaQuery['css']) as $selector) {
+ if ($this->existsMatchForCssSelector($xpath, $selector['selector'])) {
+ $mediaQueriesRelevantForDocument[] = $mediaQuery['query'];
+ break;
+ }
+ }
+ }
+
+ $this->addStyleElementToDocument($xmlDocument, implode($mediaQueriesRelevantForDocument));
+ }
+
+ /**
+ * Extracts the media queries from $css.
+ *
+ * @param string $css
+ *
+ * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
+ */
+ private function extractMediaQueriesFromCss($css)
+ {
+ preg_match_all('#(?<query>@media[^{]*\\{(?<css>(.*?)\\})(\\s*)\\})#s', $css, $mediaQueries);
+ $result = [];
+ foreach (array_keys($mediaQueries['css']) as $key) {
+ $result[] = [
+ 'css' => $mediaQueries['css'][$key],
+ 'query' => $mediaQueries['query'][$key],
+ ];
+ }
+ return $result;
+ }
+
+ /**
+ * Checks whether there is at least one matching element for $cssSelector.
+ *
+ * @param \DOMXPath $xpath
+ * @param string $cssSelector
+ *
+ * @return bool
+ */
+ private function existsMatchForCssSelector(\DOMXPath $xpath, $cssSelector)
+ {
+ $nodesMatchingSelector = $xpath->query($this->translateCssToXpath($cssSelector));
+
+ return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
+ }
+
+ /**
+ * Returns CSS content.
+ *
+ * @param \DOMXPath $xpath
+ *
+ * @return string
+ */
+ private function getCssFromAllStyleNodes(\DOMXPath $xpath)
+ {
+ $styleNodes = $xpath->query('//style');
+
+ if ($styleNodes === false) {
+ return '';
+ }
+
+ $css = '';
+ /** @var \DOMNode $styleNode */
+ foreach ($styleNodes as $styleNode) {
+ $css .= "\n\n" . $styleNode->nodeValue;
+ $styleNode->parentNode->removeChild($styleNode);
+ }
+
+ return $css;
+ }
+
+ /**
+ * Adds a style element with $css to $document.
+ *
+ * This method is protected to allow overriding.
+ *
+ * @see https://github.com/jjriv/emogrifier/issues/103
+ *
+ * @param \DOMDocument $document
+ * @param string $css
+ *
+ * @return void
+ */
+ protected function addStyleElementToDocument(\DOMDocument $document, $css)
+ {
+ $styleElement = $document->createElement('style', $css);
+ $styleAttribute = $document->createAttribute('type');
+ $styleAttribute->value = 'text/css';
+ $styleElement->appendChild($styleAttribute);
+
+ $head = $this->getOrCreateHeadElement($document);
+ $head->appendChild($styleElement);
+ }
+
+ /**
+ * Returns the existing or creates a new head element in $document.
+ *
+ * @param \DOMDocument $document
+ *
+ * @return \DOMNode the head element
+ */
+ private function getOrCreateHeadElement(\DOMDocument $document)
+ {
+ $head = $document->getElementsByTagName('head')->item(0);
+
+ if ($head === null) {
+ $head = $document->createElement('head');
+ $html = $document->getElementsByTagName('html')->item(0);
+ $html->insertBefore($head, $document->getElementsByTagName('body')->item(0));
+ }
+
+ return $head;
+ }
+
+ /**
+ * Splits input CSS code to an array where:
+ *
+ * - key "css" will be contains clean CSS code
+ * - key "media" will be contains all valuable media queries
+ *
+ * Example:
+ *
+ * The CSS code
+ *
+ * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
+ *
+ * will be parsed into the following array:
+ *
+ * "css" => "h1 { color:red; }"
+ * "media" => "@media { h1 {}}"
+ *
+ * @param string $css
+ *
+ * @return string[]
+ */
+ private function splitCssAndMediaQuery($css)
+ {
+ $cssWithoutComments = preg_replace('/\\/\\*.*\\*\\//sU', '', $css);
+
+ $mediaTypesExpression = '';
+ if (!empty($this->allowedMediaTypes)) {
+ $mediaTypesExpression = '|' . implode('|', array_keys($this->allowedMediaTypes));
+ }
+
+ $media = '';
+ $cssForAllowedMediaTypes = preg_replace_callback(
+ '#@media\\s+(?:only\\s)?(?:[\\s{\\(]' . $mediaTypesExpression . ')\\s?[^{]+{.*}\\s*}\\s*#misU',
+ function ($matches) use (&$media) {
+ $media .= $matches[0];
+ },
+ $cssWithoutComments
+ );
+
+ // filter the CSS
+ $search = [
+ 'import directives' => '/^\\s*@import\\s[^;]+;/misU',
+ 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
+ ];
+
+ $cleanedCss = preg_replace($search, '', $cssForAllowedMediaTypes);
+
+ return ['css' => $cleanedCss, 'media' => $media];
+ }
+
+ /**
+ * Creates a DOMDocument instance with the current HTML.
+ *
+ * @return \DOMDocument
+ */
+ private function createXmlDocument()
+ {
+ $xmlDocument = new \DOMDocument;
+ $xmlDocument->encoding = 'UTF-8';
+ $xmlDocument->strictErrorChecking = false;
+ $xmlDocument->formatOutput = true;
+ $libXmlState = libxml_use_internal_errors(true);
+ $xmlDocument->loadHTML($this->getUnifiedHtml());
+ libxml_clear_errors();
+ libxml_use_internal_errors($libXmlState);
+ $xmlDocument->normalizeDocument();
+
+ return $xmlDocument;
+ }
+
+ /**
+ * Returns the HTML with the unprocessable HTML tags removed and
+ * with added document type and Content-Type meta tag if needed.
+ *
+ * @return string the unified HTML
+ *
+ * @throws \BadMethodCallException
+ */
+ private function getUnifiedHtml()
+ {
+ $htmlWithoutUnprocessableTags = $this->removeUnprocessableTags($this->html);
+ $htmlWithDocumentType = $this->ensureDocumentType($htmlWithoutUnprocessableTags);
+
+ return $this->addContentTypeMetaTag($htmlWithDocumentType);
+ }
+
+ /**
+ * Removes the unprocessable tags from $html (if this feature is enabled).
+ *
+ * @param string $html
+ *
+ * @return string the reworked HTML with the unprocessable tags removed
+ */
+ private function removeUnprocessableTags($html)
+ {
+ if (empty($this->unprocessableHtmlTags)) {
+ return $html;
+ }
+
+ $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
+
+ return preg_replace(
+ '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
+ '',
+ $html
+ );
+ }
+
+ /**
+ * Makes sure that the passed HTML has a document type.
+ *
+ * @param string $html
+ *
+ * @return string HTML with document type
+ */
+ private function ensureDocumentType($html)
+ {
+ $hasDocumentType = stripos($html, '<!DOCTYPE') !== false;
+ if ($hasDocumentType) {
+ return $html;
+ }
+
+ return self::DEFAULT_DOCUMENT_TYPE . $html;
+ }
+
+ /**
+ * Adds a Content-Type meta tag for the charset.
+ *
+ * @param string $html
+ *
+ * @return string the HTML with the meta tag added
+ */
+ private function addContentTypeMetaTag($html)
+ {
+ $hasContentTypeMetaTag = stristr($html, 'Content-Type') !== false;
+ if ($hasContentTypeMetaTag) {
+ return $html;
+
+ }
+
+ // We are trying to insert the meta tag to the right spot in the DOM.
+ // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
+ $hasHeadTag = stripos($html, '<head') !== false;
+ $hasHtmlTag = stripos($html, '<html') !== false;
+
+ if ($hasHeadTag) {
+ $reworkedHtml = preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
+ } elseif ($hasHtmlTag) {
+ $reworkedHtml = preg_replace(
+ '/<html(.*?)>/i',
+ '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
+ $html
+ );
+ } else {
+ $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
+ }
+
+ return $reworkedHtml;
+ }
+
+ /**
+ * @param string[] $a
+ * @param string[] $b
+ *
+ * @return int
+ */
+ private function sortBySelectorPrecedence(array $a, array $b)
+ {
+ $precedenceA = $this->getCssSelectorPrecedence($a['selector']);
+ $precedenceB = $this->getCssSelectorPrecedence($b['selector']);
+
+ // We want these sorted in ascending order so selectors with lesser precedence get processed first and
+ // selectors with greater precedence get sorted last.
+ $precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
+ $precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
+ return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
+ }
+
+ /**
+ * @param string $selector
+ *
+ * @return int
+ */
+ private function getCssSelectorPrecedence($selector)
+ {
+ $selectorKey = md5($selector);
+ if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
+ $precedence = 0;
+ $value = 100;
+ // ids: worth 100, classes: worth 10, elements: worth 1
+ $search = ['\\#','\\.',''];
+
+ foreach ($search as $s) {
+ if (trim($selector) === '') {
+ break;
+ }
+ $number = 0;
+ $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
+ $precedence += ($value * $number);
+ $value /= 10;
+ }
+ $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
+ }
+
+ return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
+ }
+
+ /**
+ * Maps a CSS selector to an XPath query string.
+ *
+ * @see http://plasmasturm.org/log/444/
+ *
+ * @param string $cssSelector a CSS selector
+ *
+ * @return string the corresponding XPath selector
+ */
+ private function translateCssToXpath($cssSelector)
+ {
+ $paddedSelector = ' ' . $cssSelector . ' ';
+ $lowercasePaddedSelector = preg_replace_callback(
+ '/\\s+\\w+\\s+/',
+ function (array $matches) {
+ return strtolower($matches[0]);
+ },
+ $paddedSelector
+ );
+ $trimmedLowercaseSelector = trim($lowercasePaddedSelector);
+ $xpathKey = md5($trimmedLowercaseSelector);
+ if (!isset($this->caches[self::CACHE_KEY_XPATH][$xpathKey])) {
+ $cssSelectorMatches = [
+ 'child' => '/\\s+>\\s+/',
+ 'adjacent sibling' => '/\\s+\\+\\s+/',
+ 'descendant' => '/\\s+/',
+ ':first-child' => '/([^\\/]+):first-child/i',
+ ':last-child' => '/([^\\/]+):last-child/i',
+ 'attribute only' => '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/',
+ 'attribute' => '/(\\w)\\[(\\w+)\\]/',
+ 'exact attribute' => '/(\\w)\\[(\\w+)\\=[\'"]?(\\w+)[\'"]?\\]/',
+ ];
+ $xPathReplacements = [
+ 'child' => '/',
+ 'adjacent sibling' => '/following-sibling::*[1]/self::',
+ 'descendant' => '//',
+ ':first-child' => '\\1/*[1]',
+ ':last-child' => '\\1/*[last()]',
+ 'attribute only' => '*[@\\1]',
+ 'attribute' => '\\1[@\\2]',
+ 'exact attribute' => '\\1[@\\2="\\3"]',
+ ];
+
+ $roughXpath = '//' . preg_replace($cssSelectorMatches, $xPathReplacements, $trimmedLowercaseSelector);
+
+ $xpathWithIdAttributeMatchers = preg_replace_callback(
+ self::ID_ATTRIBUTE_MATCHER,
+ [$this, 'matchIdAttributes'],
+ $roughXpath
+ );
+ $xpathWithIdAttributeAndClassMatchers = preg_replace_callback(
+ self::CLASS_ATTRIBUTE_MATCHER,
+ [$this, 'matchClassAttributes'],
+ $xpathWithIdAttributeMatchers
+ );
+
+ // Advanced selectors are going to require a bit more advanced emogrification.
+ // When we required PHP 5.3, we could do this with closures.
+ $xpathWithIdAttributeAndClassMatchers = preg_replace_callback(
+ '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
+ [$this, 'translateNthChild'],
+ $xpathWithIdAttributeAndClassMatchers
+ );
+ $finalXpath = preg_replace_callback(
+ '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
+ [$this, 'translateNthOfType'],
+ $xpathWithIdAttributeAndClassMatchers
+ );
+
+ $this->caches[self::CACHE_KEY_SELECTOR][$xpathKey] = $finalXpath;
+ }
+ return $this->caches[self::CACHE_KEY_SELECTOR][$xpathKey];
+ }
+
+ /**
+ * @param string[] $match
+ *
+ * @return string
+ */
+ private function matchIdAttributes(array $match)
+ {
+ return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
+ }
+
+ /**
+ * @param string[] $match
+ *
+ * @return string
+ */
+ private function matchClassAttributes(array $match)
+ {
+ return ($match[1] !== '' ? $match[1] : '*') . '[contains(concat(" ",@class," "),concat(" ","' .
+ implode(
+ '"," "))][contains(concat(" ",@class," "),concat(" ","',
+ explode('.', substr($match[2], 1))
+ ) . '"," "))]';
+ }
+
+ /**
+ * @param string[] $match
+ *
+ * @return string
+ */
+ private function translateNthChild(array $match)
+ {
+ $parseResult = $this->parseNth($match);
+
+ if (isset($parseResult[self::MULTIPLIER])) {
+ if ($parseResult[self::MULTIPLIER] < 0) {
+ $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
+ $xPathExpression = sprintf(
+ '*[(last() - position()) mod %u = %u]/self::%s',
+ $parseResult[self::MULTIPLIER],
+ $parseResult[self::INDEX],
+ $match[1]
+ );
+ } else {
+ $xPathExpression = sprintf(
+ '*[position() mod %u = %u]/self::%s',
+ $parseResult[self::MULTIPLIER],
+ $parseResult[self::INDEX],
+ $match[1]
+ );
+ }
+ } else {
+ $xPathExpression = sprintf('*[%u]/self::%s', $parseResult[self::INDEX], $match[1]);
+ }
+
+ return $xPathExpression;
+ }
+
+ /**
+ * @param string[] $match
+ *
+ * @return string
+ */
+ private function translateNthOfType(array $match)
+ {
+ $parseResult = $this->parseNth($match);
+
+ if (isset($parseResult[self::MULTIPLIER])) {
+ if ($parseResult[self::MULTIPLIER] < 0) {
+ $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
+ $xPathExpression = sprintf(
+ '%s[(last() - position()) mod %u = %u]',
+ $match[1],
+ $parseResult[self::MULTIPLIER],
+ $parseResult[self::INDEX]
+ );
+ } else {
+ $xPathExpression = sprintf(
+ '%s[position() mod %u = %u]',
+ $match[1],
+ $parseResult[self::MULTIPLIER],
+ $parseResult[self::INDEX]
+ );
+ }
+ } else {
+ $xPathExpression = sprintf('%s[%u]', $match[1], $parseResult[self::INDEX]);
+ }
+
+ return $xPathExpression;
+ }
+
+ /**
+ * @param string[] $match
+ *
+ * @return int[]
+ */
+ private function parseNth(array $match)
+ {
+ if (in_array(strtolower($match[2]), ['even','odd'], true)) {
+ // we have "even" or "odd"
+ $index = strtolower($match[2]) === 'even' ? 0 : 1;
+ return [self::MULTIPLIER => 2, self::INDEX => $index];
+ }
+ if (stripos($match[2], 'n') === false) {
+ // if there is a multiplier
+ $index = (int) str_replace(' ', '', $match[2]);
+ return [self::INDEX => $index];
+ }
+
+ if (isset($match[3])) {
+ $multipleTerm = str_replace($match[3], '', $match[2]);
+ $index = (int) str_replace(' ', '', $match[3]);
+ } else {
+ $multipleTerm = $match[2];
+ $index = 0;
+ }
+
+ $multiplier = str_ireplace('n', '', $multipleTerm);
+
+ if ($multiplier === '') {
+ $multiplier = 1;
+ } elseif ($multiplier === '0') {
+ return [self::INDEX => $index];
+ } else {
+ $multiplier = (int) $multiplier;
+ }
+
+ while ($index < 0) {
+ $index += abs($multiplier);
+ }
+
+ return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
+ }
+
+ /**
+ * Parses a CSS declaration block into property name/value pairs.
+ *
+ * Example:
+ *
+ * The declaration block
+ *
+ * "color: #000; font-weight: bold;"
+ *
+ * will be parsed into the following array:
+ *
+ * "color" => "#000"
+ * "font-weight" => "bold"
+ *
+ * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
+ *
+ * @return string[]
+ * the CSS declarations with the property names as array keys and the property values as array values
+ */
+ private function parseCssDeclarationsBlock($cssDeclarationsBlock)
+ {
+ if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
+ return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
+ }
+
+ $properties = [];
+ $declarations = preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
+
+ foreach ($declarations as $declaration) {
+ $matches = [];
+ if (!preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches)) {
+ continue;
+ }
+
+ $propertyName = strtolower($matches[1]);
+ $propertyValue = $matches[2];
+ $properties[$propertyName] = $propertyValue;
+ }
+ $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
+
+ return $properties;
+ }
+
+ /**
+ * Find the nodes that are not to be emogrified.
+ *
+ * @param \DOMXPath $xpath
+ *
+ * @return \DOMElement[]
+ */
+ private function getNodesToExclude(\DOMXPath $xpath)
+ {
+ $excludedNodes = [];
+ foreach (array_keys($this->excludedSelectors) as $selectorToExclude) {
+ foreach ($xpath->query($this->translateCssToXpath($selectorToExclude)) as $node) {
+ $excludedNodes[] = $node;
+ }
+ }
+
+ return $excludedNodes;
+ }
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<ruleset name="PPW Coding Standard">
+ <description>This is the coding standard used for the Emogrifier code.
+ This standard has been tested with to work with PHP_CodeSniffer >= 2.3.0.
+ </description>
+
+ <!--The complete PSR-2 ruleset-->
+ <rule ref="PSR2"/>
+
+ <!-- Arrays -->
+ <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
+ <rule ref="Squiz.Arrays.ArrayBracketSpacing"/>
+
+ <!-- Classes -->
+ <rule ref="Generic.Classes.DuplicateClassName"/>
+ <rule ref="Squiz.Classes.ClassFileName"/>
+ <rule ref="Squiz.Classes.DuplicateProperty"/>
+ <rule ref="Squiz.Classes.LowercaseClassKeywords"/>
+ <rule ref="Squiz.Classes.SelfMemberReference"/>
+
+ <!-- Code analysis -->
+ <rule ref="Generic.CodeAnalysis.EmptyStatement"/>
+ <rule ref="Generic.CodeAnalysis.ForLoopShouldBeWhileLoop"/>
+ <rule ref="Generic.CodeAnalysis.ForLoopWithTestFunctionCall"/>
+ <rule ref="Generic.CodeAnalysis.JumbledIncrementer"/>
+ <rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
+ <rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
+ <rule ref="Generic.CodeAnalysis.UnusedFunctionParameter"/>
+ <rule ref="Generic.CodeAnalysis.UselessOverridingMethod"/>
+
+ <!-- Commenting -->
+ <rule ref="Generic.Commenting.Fixme"/>
+ <rule ref="Generic.Commenting.Todo"/>
+ <rule ref="PEAR.Commenting.InlineComment"/>
+ <rule ref="Squiz.Commenting.DocCommentAlignment"/>
+ <rule ref="Squiz.Commenting.EmptyCatchComment"/>
+ <rule ref="Squiz.Commenting.FunctionCommentThrowTag"/>
+ <rule ref="Squiz.Commenting.PostStatementComment"/>
+ <rule ref="TYPO3SniffPool.Commenting.ClassComment"/>
+ <rule ref="TYPO3SniffPool.Commenting.DoubleSlashCommentsInNewLine"/>
+ <rule ref="TYPO3SniffPool.Commenting.SpaceAfterDoubleSlash"/>
+
+ <!-- Control structures -->
+ <rule ref="PEAR.ControlStructures.ControlSignature"/>
+ <rule ref="TYPO3SniffPool.ControlStructures.DisallowEachInLoopCondition"/>
+ <rule ref="TYPO3SniffPool.ControlStructures.DisallowElseIfConstruct"/>
+ <rule ref="TYPO3SniffPool.ControlStructures.ExtraBracesByAssignmentInLoop"/>
+ <rule ref="TYPO3SniffPool.ControlStructures.SwitchDeclaration"/>
+ <rule ref="TYPO3SniffPool.ControlStructures.TernaryConditionalOperator"/>
+ <rule ref="TYPO3SniffPool.ControlStructures.UnusedVariableInForEachLoop"/>
+
+ <!-- Debug -->
+ <rule ref="Generic.Debug.ClosureLinter"/>
+ <rule ref="TYPO3SniffPool.Debug.DebugCode"/>
+
+ <!-- Files -->
+ <rule ref="Generic.Files.OneClassPerFile"/>
+ <rule ref="Generic.Files.OneInterfacePerFile"/>
+ <rule ref="TYPO3SniffPool.Files.FileExtension"/>
+ <rule ref="TYPO3SniffPool.Files.Filename"/>
+ <rule ref="TYPO3SniffPool.Files.IncludingFile"/>
+ <rule ref="Zend.Files.ClosingTag"/>
+
+ <!-- Formatting -->
+ <rule ref="Generic.Formatting.SpaceAfterCast"/>
+ <rule ref="PEAR.Formatting.MultiLineAssignment"/>
+
+ <!-- Functions -->
+ <rule ref="Generic.Functions.CallTimePassByReference"/>
+ <rule ref="Squiz.Functions.FunctionDuplicateArgument"/>
+ <rule ref="Squiz.Functions.GlobalFunction"/>
+
+ <!-- Metrics -->
+ <!-- Enable this rule when the cyclomatic complexity of all methods is sufficiently low. -->
+ <!--<rule ref="Generic.Metrics.CyclomaticComplexity"/>-->
+ <rule ref="Generic.Metrics.NestingLevel"/>
+
+ <!-- Naming conventions -->
+ <rule ref="Generic.NamingConventions.ConstructorName"/>
+ <rule ref="PEAR.NamingConventions.ValidClassName"/>
+ <rule ref="TYPO3SniffPool.NamingConventions.ValidFunctionName"/>
+ <rule ref="TYPO3SniffPool.NamingConventions.ValidVariableName"/>
+
+ <!-- Objects -->
+ <rule ref="Squiz.Objects.ObjectMemberComma"/>
+
+ <!-- Operators -->
+ <rule ref="Squiz.Operators.IncrementDecrementUsage"/>
+ <rule ref="Squiz.Operators.ValidLogicalOperators"/>
+
+ <!-- PHP -->
+ <rule ref="Generic.PHP.CharacterBeforePHPOpeningTag"/>
+ <rule ref="Generic.PHP.DeprecatedFunctions"/>
+ <rule ref="Generic.PHP.DisallowShortOpenTag"/>
+ <rule ref="Generic.PHP.ForbiddenFunctions"/>
+ <rule ref="Generic.PHP.NoSilencedErrors"/>
+ <rule ref="Squiz.PHP.CommentedOutCode">
+ <properties>
+ <property name="maxPercentage" value="70"/>
+ </properties>
+ </rule>
+ <rule ref="Squiz.PHP.DisallowMultipleAssignments"/>
+ <rule ref="Squiz.PHP.DisallowSizeFunctionsInLoops"/>
+ <rule ref="Squiz.PHP.DiscouragedFunctions"/>
+ <rule ref="Squiz.PHP.Eval"/>
+ <rule ref="Squiz.PHP.ForbiddenFunctions"/>
+ <rule ref="Squiz.PHP.GlobalKeyword"/>
+ <rule ref="Squiz.PHP.Heredoc"/>
+ <rule ref="Squiz.PHP.InnerFunctions"/>
+ <rule ref="Squiz.PHP.LowercasePHPFunctions"/>
+ <rule ref="Squiz.PHP.NonExecutableCode"/>
+
+ <!-- Scope -->
+ <rule ref="Squiz.Scope.MemberVarScope"/>
+ <rule ref="Squiz.Scope.StaticThisUsage"/>
+ <rule ref="TYPO3SniffPool.Scope.AlwaysReturn">
+ <exclude-pattern>*/Tests/*</exclude-pattern>
+ </rule>
+
+ <!--Strings-->
+ <rule ref="Squiz.Strings.DoubleQuoteUsage"/>
+ <rule ref="TYPO3SniffPool.Strings.ConcatenationSpacing"/>
+ <rule ref="TYPO3SniffPool.Strings.UnnecessaryStringConcat"/>
+
+ <!-- Whitespace -->
+ <rule ref="PEAR.WhiteSpace.ObjectOperatorIndent"/>
+ <rule ref="PEAR.WhiteSpace.ScopeClosingBrace"/>
+ <rule ref="Squiz.WhiteSpace.CastSpacing"/>
+ <rule ref="Squiz.WhiteSpace.LogicalOperatorSpacing"/>
+ <rule ref="Squiz.WhiteSpace.OperatorSpacing"/>
+ <rule ref="Squiz.WhiteSpace.PropertyLabelSpacing"/>
+ <rule ref="Squiz.WhiteSpace.SemicolonSpacing"/>
+ <rule ref="TYPO3SniffPool.WhiteSpace.NoWhitespaceAtInDecrement"/>
+ <rule ref="TYPO3SniffPool.WhiteSpace.ScopeClosingBrace"/>
+ <rule ref="TYPO3SniffPool.WhiteSpace.WhitespaceAfterCommentSigns"/>
+</ruleset>
\ No newline at end of file
--- /dev/null
+Emogrifier is copyright (c) 2008-2014 Pelago and licensed under the MIT license.
+
+
+The MIT License (MIT)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--- /dev/null
+# Emogrifier
+
+[![Build Status](https://travis-ci.org/jjriv/emogrifier.svg?branch=master)](https://travis-ci.org/jjriv/emogrifier)
+[![Latest Stable Version](https://poser.pugx.org/pelago/emogrifier/v/stable.svg)](https://packagist.org/packages/pelago/emogrifier)
+[![Total Downloads](https://poser.pugx.org/pelago/emogrifier/downloads.svg)](https://packagist.org/packages/pelago/emogrifier)
+[![Latest Unstable Version](https://poser.pugx.org/pelago/emogrifier/v/unstable.svg)](https://packagist.org/packages/pelago/emogrifier)
+[![License](https://poser.pugx.org/pelago/emogrifier/license.svg)](https://packagist.org/packages/pelago/emogrifier)
+
+_n. e•mog•ri•fi•er [\ē-'mä-grƏ-,fī-Ər\] - a utility for changing completely the
+nature or appearance of HTML email, esp. in a particularly fantastic or bizarre
+manner_
+
+Emogrifier converts CSS styles into inline style attributes in your HTML code.
+This ensures proper display on email and mobile device readers that lack
+stylesheet support.
+
+This utility was developed as part of [Intervals](http://www.myintervals.com/)
+to deal with the problems posed by certain email clients (namely Outlook 2007
+and GoogleMail) when it comes to the way they handle styling contained in HTML
+emails. As many web developers and designers already know, certain email
+clients are notorious for their lack of CSS support. While attempts are being
+made to develop common [email standards](http://www.email-standards.org/),
+implementation is still a ways off.
+
+The primary problem with uncooperative email clients is that most tend to only
+regard inline CSS, discarding all `<style>` elements and links to stylesheets
+in `<link>` elements. Emogrifier solves this problem by converting CSS styles
+into inline style attributes in your HTML code.
+
+- [How it works](#how-it-works)
+- [Usage](#usage)
+- [Installing with Composer](#installing-with-composer)
+- [Usage](#usage)
+- [Supported CSS selectors](#supported-css-selectors)
+- [Caveats](#caveats)
+- [Maintainer](#maintainer)
+- [Contributing](#contributing)
+
+
+## How it Works
+
+Emogrifier automagically transmogrifies your HTML by parsing your CSS and
+inserting your CSS definitions into tags within your HTML based on your CSS
+selectors.
+
+
+## Usage
+
+First, you provide Emogrifier with the HTML and CSS you would like to merge.
+This can happen directly during instantiation:
+
+ $html = '<html><h1>Hello world!</h1></html>';
+ $css = 'h1 {font-size: 32px;}';
+ $emogrifier = new \Pelago\Emogrifier($html, $css);
+
+You could also use the setters for providing this data after instantiation:
+
+ $emogrifier = new \Pelago\Emogrifier();
+
+ $html = '<html><h1>Hello world!</h1></html>';
+ $css = 'h1 {font-size: 32px;}';
+
+ $emogrifier->setHtml($html);
+ $emogrifier->setCss($css);
+
+After you have set the HTML and CSS, you can call the `emogrify` method to
+merge both:
+
+ $mergedHtml = $emogrifier->emogrify();
+
+Emogrifier automatically adds a Content-Type meta tag to set the charset for
+the document (if it is not provided).
+
+If you would like to get back only the content of the BODY element instead of
+the complete HTML document, you can use the `emogrifyBodyContent` instead:
+
+ $bodyContent = $emogrifier->emogrifyBodyContent();
+
+
+## Options
+
+There are several options that you can set on the Emogrifier object before
+calling the `emogrify` method:
+
+* `$emogrifier->disableStyleBlocksParsing()` - By default, Emogrifier will grab
+ all `<style>` blocks in the HTML and will apply the CSS styles as inline
+ "style" attributes to the HTML. The `<style>` blocks will then be removed
+ from the HTML. If you want to disable this functionality so that Emogrifier
+ leaves these `<style>` blocks in the HTML and does not parse them, you should
+ use this option.
+* `$emogrifier->disableInlineStylesParsing()` - By default, Emogrifier
+ preserves all of the "style" attributes on tags in the HTML you pass to it.
+ However if you want to discard all existing inline styles in the HTML before
+ the CSS is applied, you should use this option.
+* `$emogrifier->disableInvisibleNodeRemoval()` - By default, Emogrifier removes
+ elements from the DOM that have the style attribute `display: none;`. If
+ you would like to keep invisible elements in the DOM, use this option.
+* `$emogrifier->addAllowedMediaType(string $mediaName)` - By default, Emogrifier
+ will keep only media types `all`, `screen` and `print`. If you want to keep
+ some others, you can use this method to define them.
+* `$emogrifier->removeAllowedMediaType(string $mediaName)` - You can use this
+ method to remove media types that Emogrifier keeps.
+* `$emogrifier->addExcludedSelector(string $selector)` - Keeps elements from
+ being affected by emogrification.
+
+
+## Requirements
+
+* PHP from 5.4 to 7.0 (with the mbstring extension)
+* or HHVM
+
+
+## Installing with Composer
+
+Download the [`composer.phar`](https://getcomposer.org/composer.phar) locally
+or install [Composer](https://getcomposer.org/) globally:
+
+ curl -s https://getcomposer.org/installer | php
+
+Run the following command for a local installation:
+
+ php composer.phar require pelago/emogrifier:@dev
+
+Or for a global installation, run the following command:
+
+ composer require pelago/emogrifier:@dev
+
+You can also add follow lines to your `composer.json` and run the
+`composer update` command:
+
+ "require": {
+ "pelago/emogrifier": "@dev"
+ }
+
+See https://getcomposer.org/ for more information and documentation.
+
+
+## Supported CSS selectors
+
+Emogrifier currently support the following
+[CSS selectors](http://www.w3.org/TR/CSS2/selector.html):
+
+ * ID
+ * class
+ * type
+ * descendant
+ * child
+ * adjacent
+ * attribute presence
+ * attribute value
+ * attribute only
+ * first-child
+ * last-child
+
+The following selectors are not implemented yet:
+
+ * universal
+
+
+## Caveats
+
+* Emogrifier requires the HTML and the CSS to be UTF-8. Encodings like
+ ISO8859-1 or ISO8859-15 are not supported.
+* Emogrifier now preserves all valuable @media queries. Media queries
+ can be very useful in responsive email design. See
+ [media query support](https://litmus.com/help/email-clients/media-query-support/).
+* Emogrifier will grab existing inline style attributes _and_ will
+ grab `<style>` blocks from your HTML, but it will not grab CSS files
+ referenced in <link> elements. (The problem email clients are going to ignore
+ these tags anyway, so why leave them in your HTML?)
+* Even with styles inline, certain CSS properties are ignored by certain email
+ clients. For more information, refer to these resources:
+ * [http://www.email-standards.org/](http://www.email-standards.org/)
+ * [https://www.campaignmonitor.com/css/](https://www.campaignmonitor.com/css/)
+ * [http://templates.mailchimp.com/resources/email-client-css-support/](http://templates.mailchimp.com/resources/email-client-css-support/)
+* All CSS attributes that apply to a node will be applied, even if they are
+ redundant. For example, if you define a font attribute _and_ a font-size
+ attribute, both attributes will be applied to that node (in other words, the
+ more specific attribute will not be combined into the more general
+ attribute).
+* There's a good chance you might encounter problems if your HTML is not
+ well-formed and valid (DOMDocument might complain). If you get problems like
+ this, consider running your HTML through
+ [Tidy](http://php.net/manual/en/book.tidy.php) before you pass it to
+ Emogrifier.
+* Emogrifier automatically converts the provided (X)HTML into HTML5, i.e.,
+ self-closing tags will lose their slash. To keep your HTML valid, it is
+ recommended to use HTML5 instead of one of the XHTML variants.
+* Emogrifier only supports CSS1 level selectors and a few CSS2 level selectors
+ (but not all of them). It does not support pseudo selectors. (Emogrifier
+ works by converting CSS selectors to XPath selectors, and pseudo selectors
+ cannot be converted accurately).
+
+
+## Maintainer
+
+Emogrifier is maintained by the good people at
+[Pelago](http://www.pelagodesign.com/), info AT pelagodesign DOT com.
--- /dev/null
+<?php
+namespace Pelago\Tests\Unit;
+
+use Pelago\Emogrifier;
+
+/**
+ * Test case.
+ *
+ * @author Oliver Klee <typo3-coding@oliverklee.de>
+ */
+class EmogrifierTest extends \PHPUnit_Framework_TestCase
+{
+ /**
+ * @var string
+ */
+ const LF = "\n";
+
+ /**
+ * @var string
+ */
+ private $html4TransitionalDocumentType = '';
+
+ /**
+ * @var string
+ */
+ private $xhtml1StrictDocumentType = '';
+
+ /**
+ * @var string
+ */
+ private $html5DocumentType = '<!DOCTYPE html>';
+
+ /**
+ * @var Emogrifier
+ */
+ private $subject = null;
+
+ /**
+ * Sets up the test case.
+ *
+ * @return void
+ */
+ protected function setUp()
+ {
+ $this->html4TransitionalDocumentType = '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" ' .
+ '"http://www.w3.org/TR/REC-html40/loose.dtd">';
+ $this->xhtml1StrictDocumentType = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" ' .
+ '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">';
+
+ $this->subject = new Emogrifier();
+ }
+
+ /**
+ * @test
+ *
+ * @expectedException \BadMethodCallException
+ */
+ public function emogrifyForNoDataSetReturnsThrowsException()
+ {
+ $this->subject->emogrify();
+ }
+
+ /**
+ * @test
+ *
+ * @expectedException \BadMethodCallException
+ */
+ public function emogrifyForEmptyHtmlAndEmptyCssThrowsException()
+ {
+ $this->subject->setHtml('');
+ $this->subject->setCss('');
+
+ $this->subject->emogrify();
+ }
+
+ /**
+ * @test
+ *
+ * @expectedException \BadMethodCallException
+ */
+ public function emogrifyBodyContentForNoDataSetReturnsThrowsException()
+ {
+ $this->subject->emogrifyBodyContent();
+ }
+
+ /**
+ * @test
+ *
+ * @expectedException \BadMethodCallException
+ */
+ public function emogrifyBodyContentForEmptyHtmlAndEmptyCssThrowsException()
+ {
+ $this->subject->setHtml('');
+ $this->subject->setCss('');
+
+ $this->subject->emogrifyBodyContent();
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAddsHtmlTagIfNoHtmlTagAndNoHeadTagAreProvided()
+ {
+ $this->subject->setHtml('<p>Hello</p>');
+
+ $emogrifiedHtml = $this->subject->emogrify();
+
+ self::assertContains(
+ '<html>',
+ $emogrifiedHtml
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAddsHtmlTagIfHeadTagIsProvidedButNoHtmlTaqg()
+ {
+ $this->subject->setHtml('<head><title>Hello</title></head><p>World</p>');
+
+ $emogrifiedHtml = $this->subject->emogrify();
+
+ self::assertContains(
+ '<html>',
+ $emogrifiedHtml
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAddsHeadTagIfNoHtmlTagAndNoHeadTagAreProvided()
+ {
+ $this->subject->setHtml('<p>Hello</p>');
+
+ $emogrifiedHtml = $this->subject->emogrify();
+
+ self::assertContains(
+ '<head>',
+ $emogrifiedHtml
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAddsHtmlTagIfHtmlTagIsProvidedButNoHeadTaqg()
+ {
+ $this->subject->setHtml('<html></head><p>World</p></html>');
+
+ $emogrifiedHtml = $this->subject->emogrify();
+
+ self::assertContains(
+ '<head>',
+ $emogrifiedHtml
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsDollarSignsAndSquareBrackets()
+ {
+ $templateMarker = '$[USER:NAME]$';
+
+ $html = $this->html5DocumentType . '<html><p>' . $templateMarker . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $templateMarker,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsUtf8UmlautsInHtml5()
+ {
+ $umlautString = 'Küss die Hand, schöne Frau.';
+
+ $html = $this->html5DocumentType . '<html><p>' . $umlautString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $umlautString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsUtf8UmlautsInXhtml()
+ {
+ $umlautString = 'Öösel läks õunu täis ämber uhkelt ümber.';
+
+ $html = $this->xhtml1StrictDocumentType . '<html<p>' . $umlautString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $umlautString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsUtf8UmlautsInHtml4()
+ {
+ $umlautString = 'Öösel läks õunu täis ämber uhkelt ümber.';
+
+ $html = $this->html4TransitionalDocumentType . '<html><p>' . $umlautString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $umlautString,
+ $umlautString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsHtmlEntities()
+ {
+ $entityString = 'a & b > c';
+
+ $html = $this->html5DocumentType . '<html><p>' . $entityString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $entityString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsHtmlEntitiesInXhtml()
+ {
+ $entityString = 'a & b > c';
+
+ $html = $this->xhtml1StrictDocumentType . '<html<p>' . $entityString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $entityString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsHtmlEntitiesInHtml4()
+ {
+ $entityString = 'a & b > c';
+
+ $html = $this->html4TransitionalDocumentType . '<html><p>' . $entityString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $entityString,
+ $entityString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsUtf8UmlautsWithoutDocumentType()
+ {
+ $umlautString = 'Küss die Hand, schöne Frau.';
+
+ $html = '<html><head></head><p>' . $umlautString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $umlautString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsUtf8UmlautsWithoutDocumentTypeAndWithoutHtmlAndWithoutHead()
+ {
+ $umlautString = 'Küss die Hand, schöne Frau.';
+
+ $html = '<p>' . $umlautString . '</p>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $umlautString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsUtf8UmlautsWithoutDocumentTypeAndWithHtmlAndWithoutHead()
+ {
+ $umlautString = 'Küss die Hand, schöne Frau.';
+
+ $html = '<html><p>' . $umlautString . '</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $umlautString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsUtf8UmlautsWithoutDocumentTypeAndWithoutHtmlAndWithHead()
+ {
+ $umlautString = 'Küss die Hand, schöne Frau.';
+
+ $html = '<head></head><p>' . $umlautString . '</p>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $umlautString,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyForHtmlTagOnlyAndEmptyCssByDefaultAddsHtml5DocumentType()
+ {
+ $html = '<html></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('');
+
+ self::assertContains(
+ $this->html5DocumentType,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyForHtmlTagWithXhtml1StrictDocumentTypeKeepsDocumentType()
+ {
+ $html = $this->xhtml1StrictDocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $this->xhtml1StrictDocumentType,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyForHtmlTagWithXhtml5DocumentTypeKeepsDocumentType()
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $this->html5DocumentType,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAddsContentTypeMetaTag()
+ {
+ $html = $this->html5DocumentType . '<p>Hello</p>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyForExistingContentTypeMetaTagNotAddsSecondContentTypeMetaTag()
+ {
+ $html = $this->html5DocumentType .
+ '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' .
+ '<body><p>Hello</p></body></html>';
+ $this->subject->setHtml($html);
+
+ $numberOfContentTypeMetaTags = substr_count($this->subject->emogrify(), 'Content-Type');
+ self::assertSame(
+ 1,
+ $numberOfContentTypeMetaTags
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyByDefaultRemovesWbrTag()
+ {
+ $html = $this->html5DocumentType . '<html>foo<wbr/>bar</html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ 'foobar',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function addUnprocessableTagCausesGivenEmptyTagToBeRemoved()
+ {
+ $this->subject->addUnprocessableHtmlTag('p');
+
+ $html = $this->html5DocumentType . '<html><p></p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertNotContains(
+ '<p>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function addUnprocessableTagNotRemovesGivenTagWithContent()
+ {
+ $this->subject->addUnprocessableHtmlTag('p');
+
+ $html = $this->html5DocumentType . '<html><p>foobar</p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ '<p>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function removeUnprocessableHtmlTagCausesTagToStayAgain()
+ {
+ $this->subject->addUnprocessableHtmlTag('p');
+ $this->subject->removeUnprocessableHtmlTag('p');
+
+ $html = $this->html5DocumentType . '<html><p>foo<br/><span>bar</span></p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ '<p>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyCanAddMatchingElementRuleOnHtmlElementFromCss()
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+ $styleRule = 'color: #000;';
+ $this->subject->setCss('html {' . $styleRule . '}');
+
+ self::assertContains(
+ '<html style="' . $styleRule . '">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyNotAddsNotMatchingElementRuleOnHtmlElementFromCss()
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('p {color:#000;}');
+
+ self::assertContains(
+ '<html>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyCanMatchTwoElements()
+ {
+ $html = $this->html5DocumentType . '<html><p></p><p></p></html>';
+ $this->subject->setHtml($html);
+ $styleRule = 'color: #000;';
+ $this->subject->setCss('p {' . $styleRule . '}');
+
+ self::assertSame(
+ 2,
+ substr_count($this->subject->emogrify(), '<p style="' . $styleRule . '">')
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyCanAssignTwoStyleRulesFromSameMatcherToElement()
+ {
+ $html = $this->html5DocumentType . '<html><p></p></html>';
+ $this->subject->setHtml($html);
+ $styleRulesIn = 'color:#000; text-align:left;';
+ $styleRulesOut = 'color: #000; text-align: left;';
+ $this->subject->setCss('p {' . $styleRulesIn . '}');
+
+ self::assertContains(
+ '<p style="' . $styleRulesOut . '">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyCanMatchAttributeOnlySelector()
+ {
+ $html = $this->html5DocumentType . '<html><p hidden="hidden"></p></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('[hidden] { color:red; }');
+
+ self::assertContains(
+ '<p hidden="hidden" style="color: red;">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyCanAssignStyleRulesFromTwoIdenticalMatchersToElement()
+ {
+ $html = $this->html5DocumentType . '<html><p></p></html>';
+ $this->subject->setHtml($html);
+ $styleRule1 = 'color: #000;';
+ $styleRule2 = 'text-align: left;';
+ $this->subject->setCss('p {' . $styleRule1 . '} p {' . $styleRule2 . '}');
+
+ self::assertContains(
+ '<p style="' . $styleRule1 . ' ' . $styleRule2 . '">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyCanAssignStyleRulesFromTwoDifferentMatchersToElement()
+ {
+ $html = $this->html5DocumentType . '<html><p class="x"></p></html>';
+ $this->subject->setHtml($html);
+ $styleRule1 = 'color: #000;';
+ $styleRule2 = 'text-align: left;';
+ $this->subject->setCss('p {' . $styleRule1 . '} .x {' . $styleRule2 . '}');
+
+ self::assertContains(
+ '<p class="x" style="' . $styleRule1 . ' ' . $styleRule2 . '">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * Data provide for selectors.
+ *
+ * @return string[][]
+ */
+ public function selectorDataProvider()
+ {
+ $styleRule = 'color: red;';
+ $styleAttribute = 'style="' . $styleRule . '"';
+
+ return [
+ 'universal selector HTML'
+ => ['* {' . $styleRule . '} ', '#<html id="html" ' . $styleAttribute . '>#'],
+ 'universal selector BODY'
+ => ['* {' . $styleRule . '} ', '#<body ' . $styleAttribute . '>#'],
+ 'universal selector P'
+ => ['* {' . $styleRule . '} ', '#<p[^>]*' . $styleAttribute . '>#'],
+ 'type selector matches first P'
+ => ['p {' . $styleRule . '} ', '#<p class="p-1" ' . $styleAttribute . '>#'],
+ 'type selector matches second P'
+ => ['p {' . $styleRule . '} ', '#<p class="p-2" ' . $styleAttribute . '>#'],
+ 'descendant selector P SPAN'
+ => ['p span {' . $styleRule . '} ', '#<span ' . $styleAttribute . '>#'],
+ 'descendant selector BODY SPAN'
+ => ['body span {' . $styleRule . '} ', '#<span ' . $styleAttribute . '>#'],
+ 'child selector P > SPAN matches direct child'
+ => ['p > span {' . $styleRule . '} ', '#<span ' . $styleAttribute . '>#'],
+ 'child selector BODY > SPAN not matches grandchild'
+ => ['body > span {' . $styleRule . '} ', '#<span>#'],
+ 'adjacent selector P + P not matches first P' => ['p + p {' . $styleRule . '} ', '#<p class="p-1">#'],
+ 'adjacent selector P + P matches second P'
+ => ['p + p {' . $styleRule . '} ', '#<p class="p-2" style="' . $styleRule . '">#'],
+ 'adjacent selector P + P matches third P'
+ => ['p + p {' . $styleRule . '} ', '#<p class="p-3" style="' . $styleRule . '">#'],
+ 'ID selector #HTML' => ['#html {' . $styleRule . '} ', '#<html id="html" ' . $styleAttribute . '>#'],
+ 'type and ID selector HTML#HTML'
+ => ['html#html {' . $styleRule . '} ', '#<html id="html" ' . $styleAttribute . '>#'],
+ 'class selector .P-1' => ['.p-1 {' . $styleRule . '} ', '#<p class="p-1" ' . $styleAttribute . '>#'],
+ 'type and class selector P.P-1'
+ => ['p.p-1 {' . $styleRule . '} ', '#<p class="p-1" ' . $styleAttribute . '>#'],
+ 'attribute presence selector SPAN[title] matches element with matching attribute'
+ => ['span[title] {' . $styleRule . '} ', '#<span title="bonjour" ' . $styleAttribute . '>#'],
+ 'attribute presence selector SPAN[title] not matches element without any attributes'
+ => ['span[title] {' . $styleRule . '} ', '#<span>#'],
+ 'attribute value selector [id="html"] matches element with matching attribute value' => [
+ '[id="html"] {' . $styleRule . '} ', '#<html id="html" ' . $styleAttribute . '>#'
+ ],
+ 'attribute value selector SPAN[title] matches element with matching attribute value' => [
+ 'span[title="bonjour"] {' . $styleRule . '} ', '#<span title="bonjour" ' . $styleAttribute . '>#'
+ ],
+ 'attribute value selector SPAN[title] not matches element with other attribute value'
+ => ['span[title="bonjour"] {' . $styleRule . '} ', '#<span title="buenas dias">#'],
+ 'attribute value selector SPAN[title] not matches element without any attributes'
+ => ['span[title="bonjour"] {' . $styleRule . '} ', '#<span>#'],
+ 'BODY:first-child matches first child'
+ => ['body:first-child {' . $styleRule . '} ', '#<p class="p-1" style="' . $styleRule . '">#'],
+ 'BODY:first-child not matches middle child'
+ => ['body:first-child {' . $styleRule . '} ', '#<p class="p-2">#'],
+ 'BODY:first-child not matches last child'
+ => ['body:first-child {' . $styleRule . '} ', '#<p class="p-3">#'],
+ 'BODY:last-child not matches first child' => ['body:last-child {' . $styleRule . '} ', '#<p class="p-1">#'],
+ 'BODY:last-child not matches middle child'
+ => ['body:last-child {' . $styleRule . '} ', '#<p class="p-2">#'],
+ 'BODY:last-child matches last child'
+ => ['body:last-child {' . $styleRule . '} ', '#<p class="p-3" style="' . $styleRule . '">#'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css the complete CSS
+ * @param string $htmlRegularExpression regular expression for the the HTML that needs to be contained in the HTML
+ *
+ * @dataProvider selectorDataProvider
+ */
+ public function emogrifierMatchesSelectors($css, $htmlRegularExpression)
+ {
+ $html = $this->html5DocumentType .
+ '<html id="html">' .
+ ' <body>' .
+ ' <p class="p-1"><span>some text</span></p>' .
+ ' <p class="p-2"><span title="bonjour">some</span> text</p>' .
+ ' <p class="p-3"><span title="buenas dias">some</span> more text</p>' .
+ ' </body>' .
+ '</html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ $result = $this->subject->emogrify();
+
+ self::assertRegExp(
+ $htmlRegularExpression,
+ $result
+ );
+ }
+
+ /**
+ * Data provider for emogrifyDropsWhitespaceFromCssDeclarations.
+ *
+ * @return string[][]
+ */
+ public function cssDeclarationWhitespaceDroppingDataProvider()
+ {
+ return [
+ 'no whitespace, trailing semicolon' => ['color:#000;', 'color: #000;'],
+ 'no whitespace, no trailing semicolon' => ['color:#000', 'color: #000;'],
+ 'space after colon, no trailing semicolon' => ['color: #000', 'color: #000;'],
+ 'space before colon, no trailing semicolon' => ['color :#000', 'color: #000;'],
+ 'space before property name, no trailing semicolon' => [' color:#000', 'color: #000;'],
+ 'space before trailing semicolon' => [' color:#000 ;', 'color: #000;'],
+ 'space after trailing semicolon' => [' color:#000; ', 'color: #000;'],
+ 'space after property value, no trailing semicolon' => [' color:#000 ', 'color: #000;'],
+ 'space after property value, trailing semicolon' => [' color:#000; ', 'color: #000;'],
+ 'newline before property name, trailing semicolon' => ["\ncolor:#222;", 'color: #222;'],
+ 'newline after property semicolon' => ["color:#222;\n", 'color: #222;'],
+ 'newline before colon, trailing semicolon' => ["color\n:#333;", 'color: #333;'],
+ 'newline after colon, trailing semicolon' => ["color:\n#333;", 'color: #333;'],
+ 'newline after semicolon' => ["color:#333\n;", 'color: #333;'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $cssDeclaration the CSS declaration block (without the curly braces)
+ * @param string $expectedStyleAttributeContent the expected value of the style attribute
+ *
+ * @dataProvider cssDeclarationWhitespaceDroppingDataProvider
+ */
+ public function emogrifyDropsLeadingAndTrailingWhitespaceFromCssDeclarations(
+ $cssDeclaration,
+ $expectedStyleAttributeContent
+ ) {
+ $html = $this->html5DocumentType . '<html></html>';
+ $css = 'html {' . $cssDeclaration . '}';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ $result = $this->subject->emogrify();
+
+ self::assertContains(
+ 'html style="' . $expectedStyleAttributeContent . '">',
+ $result
+ );
+ }
+
+ /**
+ * Data provider for emogrifyFormatsCssDeclarations.
+ *
+ * @return string[][]
+ */
+ public function formattedCssDeclarationDataProvider()
+ {
+ return [
+ 'one declaration' => ['color: #000;', 'color: #000;'],
+ 'one declaration with dash in property name' => ['font-weight: bold;', 'font-weight: bold;'],
+ 'one declaration with space in property value' => ['margin: 0 4px;', 'margin: 0 4px;'],
+ 'two declarations separated by semicolon' => ['color: #000;width: 3px;', 'color: #000; width: 3px;'],
+ 'two declarations separated by semicolon and space'
+ => ['color: #000; width: 3px;', 'color: #000; width: 3px;'],
+ 'two declarations separated by semicolon and linefeed' => [
+ 'color: #000;' . self::LF . 'width: 3px;', 'color: #000; width: 3px;'
+ ],
+ 'two declarations separated by semicolon and Windows line ending' => [
+ "color: #000;\r\nwidth: 3px;", 'color: #000; width: 3px;'
+ ],
+ 'one declaration with leading dash in property name' => [
+ '-webkit-text-size-adjust:none;', '-webkit-text-size-adjust: none;'
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $cssDeclarationBlock the CSS declaration block (without the curly braces)
+ * @param string $expectedStyleAttributeContent the expected value of the style attribute
+ *
+ * @dataProvider formattedCssDeclarationDataProvider
+ */
+ public function emogrifyFormatsCssDeclarations($cssDeclarationBlock, $expectedStyleAttributeContent)
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $css = 'html {' . $cssDeclarationBlock . '}';
+
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ 'html style="' . $expectedStyleAttributeContent . '">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * Data provider for emogrifyInvalidDeclaration.
+ *
+ * @return string[][]
+ */
+ public function invalidDeclarationDataProvider()
+ {
+ return [
+ 'missing dash in property name' => ['font weight: bold;'],
+ 'invalid character in property name' => ['-9webkit-text-size-adjust:none;'],
+ 'missing :' => ['-webkit-text-size-adjust none'],
+ 'missing value' => ['-webkit-text-size-adjust :'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $cssDeclarationBlock the CSS declaration block (without the curly braces)
+ *
+ * @dataProvider invalidDeclarationDataProvider
+ */
+ public function emogrifyDropsInvalidDeclaration($cssDeclarationBlock)
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $css = 'html {' . $cssDeclarationBlock . '}';
+
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ '<html style="">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsExistingStyleAttributes()
+ {
+ $styleAttribute = 'style="color: #ccc;"';
+ $html = $this->html5DocumentType . '<html ' . $styleAttribute . '></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $styleAttribute,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAddsCssAfterExistingStyle()
+ {
+ $styleAttributeValue = 'color: #ccc;';
+ $html = $this->html5DocumentType . '<html style="' . $styleAttributeValue . '"></html>';
+ $this->subject->setHtml($html);
+
+ $cssDeclarations = 'margin: 0 2px;';
+ $css = 'html {' . $cssDeclarations . '}';
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ 'style="' . $styleAttributeValue . ' ' . $cssDeclarations . '"',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyCanMatchMinifiedCss()
+ {
+ $html = $this->html5DocumentType . '<html><p></p></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('p{color:blue;}html{color:red;}');
+
+ self::assertContains(
+ '<html style="color: red;">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyLowercasesAttributeNamesFromStyleAttributes()
+ {
+ $html = $this->html5DocumentType . '<html style="COLOR:#ccc;"></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ 'style="color: #ccc;"',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyLowerCasesAttributeNames()
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+ $cssIn = 'html {mArGiN:0 2pX;}';
+ $cssOut = 'margin: 0 2pX;';
+ $this->subject->setCss($cssIn);
+
+ self::assertContains(
+ 'style="' . $cssOut . '"',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyPreservesCaseForAttributeValuesFromPassedInCss()
+ {
+ $css = 'content: \'Hello World\';';
+ $html = $this->html5DocumentType . '<html><body><p>target</p></body></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('p {' . $css . '}');
+
+ self::assertContains(
+ '<p style="' . $css . '">target</p>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyPreservesCaseForAttributeValuesFromParsedStyleBlock()
+ {
+ $css = 'content: \'Hello World\';';
+ $html = $this->html5DocumentType . '<html><head><style>p {' .
+ $css . '}</style></head><body><p>target</p></body></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ '<p style="' . $css . '">target</p>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyRemovesStyleNodes()
+ {
+ $html = $this->html5DocumentType . '<html><style type="text/css"></style></html>';
+ $this->subject->setHtml($html);
+
+ self::assertNotContains(
+ '<style>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyIgnoresInvalidCssSelector()
+ {
+ $html = $this->html5DocumentType .
+ '<html><style type="text/css">p{color:red;} <style data-x="1">html{cursor:text;}</style></html>';
+ $this->subject->setHtml($html);
+
+ $hasError = false;
+ set_error_handler(function ($errorNumber, $errorMessage) use (&$hasError) {
+ if ($errorMessage === 'DOMXPath::query(): Invalid expression') {
+ return true;
+ }
+
+ $hasError = true;
+ return true;
+ });
+
+ $this->subject->emogrify();
+ restore_error_handler();
+
+ self::assertFalse(
+ $hasError
+ );
+ }
+
+ /**
+ * Data provider for things that should be left out when applying the CSS.
+ *
+ * @return array[]
+ */
+ public function unneededCssThingsDataProvider()
+ {
+ return [
+ 'CSS comments with one asterisk' => ['p {color: #000;/* black */}', 'black'],
+ 'CSS comments with two asterisks' => ['p {color: #000;/** black */}', 'black'],
+ '@import directive' => ['@import "foo.css";', '@import'],
+ 'style in "aural" media type rule' => ['@media aural {p {color: #000;}}', '#000'],
+ 'style in "braille" media type rule' => ['@media braille {p {color: #000;}}', '#000'],
+ 'style in "embossed" media type rule' => ['@media embossed {p {color: #000;}}', '#000'],
+ 'style in "handheld" media type rule' => ['@media handheld {p {color: #000;}}', '#000'],
+ 'style in "projection" media type rule' => ['@media projection {p {color: #000;}}', '#000'],
+ 'style in "speech" media type rule' => ['@media speech {p {color: #000;}}', '#000'],
+ 'style in "tty" media type rule' => ['@media tty {p {color: #000;}}', '#000'],
+ 'style in "tv" media type rule' => ['@media tv {p {color: #000;}}', '#000'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ * @param string $markerNotExpectedInHtml
+ *
+ * @dataProvider unneededCssThingsDataProvider
+ */
+ public function emogrifyFiltersUnneededCssThings($css, $markerNotExpectedInHtml)
+ {
+ $html = $this->html5DocumentType . '<html><p>foo</p></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertNotContains(
+ $markerNotExpectedInHtml,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * Data provider for media rules.
+ *
+ * @return array[]
+ */
+ public function mediaRulesDataProvider()
+ {
+ return [
+ 'style in "only all" media type rule' => ['@media only all {p {color: #000;}}'],
+ 'style in "only screen" media type rule' => ['@media only screen {p {color: #000;}}'],
+ 'style in media type rule' => ['@media {p {color: #000;}}'],
+ 'style in "screen" media type rule' => ['@media screen {p {color: #000;}}'],
+ 'style in "print" media type rule' => ['@media print {p {color: #000;}}'],
+ 'style in "all" media type rule' => ['@media all {p {color: #000;}}'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider mediaRulesDataProvider
+ */
+ public function emogrifyKeepsMediaRules($css)
+ {
+ $html = $this->html5DocumentType . '<html><p>foo</p></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $css,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function removeAllowedMediaTypeRemovesStylesForTheGivenMediaType()
+ {
+ $css = '@media screen { html {} }';
+
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+ $this->subject->removeAllowedMediaType('screen');
+
+ self::assertNotContains(
+ $css,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function addAllowedMediaTypeKeepsStylesForTheGivenMediaType()
+ {
+ $css = '@media braille { html { some-property: value; } }';
+
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+ $this->subject->addAllowedMediaType('braille');
+
+ self::assertContains(
+ $css,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAddsMissingHeadElement()
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('@media all { html {} }');
+
+ self::assertContains(
+ '<head>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepExistingHeadElementContent()
+ {
+ $html = $this->html5DocumentType . '<html><head><!-- original content --></head></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('@media all { html {} }');
+
+ self::assertContains(
+ '<!-- original content -->',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepExistingHeadElementAddStyleElement()
+ {
+ $html = $this->html5DocumentType . '<html><head><!-- original content --></head></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss('@media all { html {} }');
+
+ self::assertContains(
+ '<style type="text/css">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * Valid media query which need to be preserved
+ *
+ * @return array[]
+ */
+ public function validMediaPreserveDataProvider()
+ {
+ return [
+ 'style in "only screen and size" media type rule' => [
+ '@media only screen and (min-device-width: 320px) and (max-device-width: 480px) { h1 { color:red; } }',
+ ],
+ 'style in "screen size" media type rule' => [
+ '@media screen and (min-device-width: 320px) and (max-device-width: 480px) { h1 { color:red; } }',
+ ],
+ 'style in "only screen and screen size" media type rule' => [
+ '@media only screen and (min-device-width: 320px) and (max-device-width: 480px) { h1 { color:red; } }',
+ ],
+ 'style in "all and screen size" media type rule' => [
+ '@media all and (min-device-width: 320px) and (max-device-width: 480px) { h1 { color:red; } }',
+ ],
+ 'style in "only all and" media type rule' => [
+ '@media only all and (min-device-width: 320px) and (max-device-width: 480px) { h1 { color:red; } }',
+ ],
+ 'style in "all" media type rule' => ['@media all {p {color: #000;}}'],
+ 'style in "only screen" media type rule' => ['@media only screen { h1 { color:red; } }'],
+ 'style in "only all" media type rule' => ['@media only all { h1 { color:red; } }'],
+ 'style in "screen" media type rule' => ['@media screen { h1 { color:red; } }'],
+ 'style in media type rule without specification' => ['@media { h1 { color:red; } }'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider validMediaPreserveDataProvider
+ */
+ public function emogrifyWithValidMediaQueryContainsInnerCss($css)
+ {
+ $html = $this->html5DocumentType . PHP_EOL . '<html><h1></h1><p></p></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $css,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider validMediaPreserveDataProvider
+ */
+ public function emogrifyForHtmlWithValidMediaQueryContainsInnerCss($css)
+ {
+ $html = $this->html5DocumentType . PHP_EOL . '<html><style type="text/css">' . $css .
+ '</style><h1></h1><p></p></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $css,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider validMediaPreserveDataProvider
+ */
+ public function emogrifyWithValidMediaQueryNotContainsInlineCss($css)
+ {
+ $html = $this->html5DocumentType . PHP_EOL . '<html><h1></h1></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertNotContains(
+ 'style="color:red"',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * Invalid media query which need to be strip
+ *
+ * @return array[]
+ */
+ public function invalidMediaPreserveDataProvider()
+ {
+ return [
+ 'style in "braille" type rule' => ['@media braille { h1 { color:red; } }'],
+ 'style in "embossed" type rule' => ['@media embossed { h1 { color:red; } }'],
+ 'style in "handheld" type rule' => ['@media handheld { h1 { color:red; } }'],
+ 'style in "projection" type rule' => ['@media projection { h1 { color:red; } }'],
+ 'style in "speech" type rule' => ['@media speech { h1 { color:red; } }'],
+ 'style in "tty" type rule' => ['@media tty { h1 { color:red; } }'],
+ 'style in "tv" type rule' => ['@media tv { h1 { color:red; } }'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider invalidMediaPreserveDataProvider
+ */
+ public function emogrifyWithInvalidMediaQueryaNotContainsInnerCss($css)
+ {
+ $html = $this->html5DocumentType . PHP_EOL . '<html><h1></h1></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertNotContains(
+ $css,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider invalidMediaPreserveDataProvider
+ */
+ public function emogrifyWithInValidMediaQueryNotContainsInlineCss($css)
+ {
+ $html = $this->html5DocumentType . PHP_EOL . '<html><h1></h1></html>';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertNotContains(
+ 'style="color: red"',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider invalidMediaPreserveDataProvider
+ */
+ public function emogrifyFromHtmlWithInValidMediaQueryNotContainsInnerCss($css)
+ {
+ $html = $this->html5DocumentType . PHP_EOL . '<html><style type="text/css">' . $css .
+ '</style><h1></h1></html>';
+ $this->subject->setHtml($html);
+
+ self::assertNotContains(
+ $css,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param string $css
+ *
+ * @dataProvider invalidMediaPreserveDataProvider
+ */
+ public function emogrifyFromHtmlWithInValidMediaQueryNotContainsInlineCss($css)
+ {
+ $html = $this->html5DocumentType . PHP_EOL . '<html><style type="text/css">' . $css .
+ '</style><h1></h1></html>';
+ $this->subject->setHtml($html);
+
+ self::assertNotContains(
+ 'style="color: red"',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAppliesCssFromStyleNodes()
+ {
+ $styleAttributeValue = 'color: #ccc;';
+ $html = $this->html5DocumentType .
+ '<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ '<html style="' . $styleAttributeValue . '">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyWhenDisabledNotAppliesCssFromStyleBlocks()
+ {
+ $styleAttributeValue = 'color: #ccc;';
+ $html = $this->html5DocumentType .
+ '<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>';
+ $this->subject->setHtml($html);
+ $this->subject->disableStyleBlocksParsing();
+
+ self::assertNotContains(
+ '<html style="' . $styleAttributeValue . '">',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyWhenStyleBlocksParsingDisabledKeepInlineStyles()
+ {
+ $styleAttributeValue = 'text-align: center;';
+ $html = $this->html5DocumentType . '<html><head><style type="text/css">p { color: #ccc; }</style></head>' .
+ '<body><p style="' . $styleAttributeValue . '">paragraph</p></body></html>';
+ $expected = '<p style="' . $styleAttributeValue . '">';
+ $this->subject->setHtml($html);
+ $this->subject->disableStyleBlocksParsing();
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyWhenDisabledNotAppliesCssFromInlineStyles()
+ {
+ $styleAttributeValue = 'color: #ccc;';
+ $html = $this->html5DocumentType . '<html style="' . $styleAttributeValue . '"></html>';
+ $this->subject->setHtml($html);
+ $this->subject->disableInlineStyleAttributesParsing();
+
+ self::assertNotContains(
+ '<html style',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyWhenInlineStyleAttributesParsingDisabledKeepStyleBlockStyles()
+ {
+ $styleAttributeValue = 'color: #ccc;';
+ $html = $this->html5DocumentType .
+ '<html><head><style type="text/css">p { ' . $styleAttributeValue . ' }</style></head>' .
+ '<body><p style="text-align: center;">paragraph</p></body></html>';
+ $expected = '<p style="' . $styleAttributeValue . '">';
+ $this->subject->setHtml($html);
+ $this->subject->disableInlineStyleAttributesParsing();
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAppliesCssWithUpperCaseSelector()
+ {
+ $html = $this->html5DocumentType .
+ '<html><style type="text/css">P { color:#ccc; }</style><body><p>paragraph</p></body></html>';
+ $expected = '<p style="color: #ccc;">';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * Emogrify was handling case differently for passed in CSS vs CSS parsed from style blocks.
+ * @test
+ */
+ public function emogrifyAppliesCssWithMixedCaseAttributesInStyleBlock()
+ {
+ $html = $this->html5DocumentType .
+ '<html><head><style>#topWrap p {padding-bottom: 1px;PADDING-TOP: 0;}</style></head>' .
+ '<body><div id="topWrap"><p style="text-align: center;">some content</p></div></body></html>';
+ $expected = '<p style="text-align: center; padding-bottom: 1px; padding-top: 0;">';
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * Passed in CSS sets the order, but style block CSS overrides values.
+ * @test
+ */
+ public function emogrifyMergesCssWithMixedCaseAttribute()
+ {
+ $css = 'p { margin: 0; padding-TOP: 0; PADDING-bottom: 1PX;}';
+ $html = $this->html5DocumentType .
+ '<html><head><style>#topWrap p {padding-bottom: 3px;PADDING-TOP: 1px;}</style></head>' .
+ '<body><div id="topWrap"><p style="text-align: center;">some content</p></div></body></html>';
+ $expected = '<p style="text-align: center; margin: 0; padding-top: 1px; padding-bottom: 3px;">';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyMergesCssWithMixedUnits()
+ {
+ $css = 'p { margin: 1px; padding-bottom:0;}';
+ $html = $this->html5DocumentType .
+ '<html><head><style>#topWrap p {margin:0;padding-bottom: 1px;}</style></head>' .
+ '<body><div id="topWrap"><p style="text-align: center;">some content</p></div></body></html>';
+ $expected = '<p style="text-align: center; margin: 0; padding-bottom: 1px;">';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyByDefaultRemovesElementsWithDisplayNoneFromExternalCss()
+ {
+ $css = 'div.foo { display: none; }';
+ $html = $this->html5DocumentType . '<html><body><div class="bar"></div><div class="foo"></div></body></html>';
+
+ $expected = '<div class="bar"></div>';
+
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyByDefaultRemovesElementsWithDisplayNoneInStyleAttribute()
+ {
+ $html = $this->html5DocumentType .
+ '<html><body><div class="bar"></div><div class="foobar" style="display: none;"></div>' .
+ '</body></html>';
+
+ $expected = '<div class="bar"></div>';
+
+ $this->subject->setHtml($html);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAfterDisableInvisibleNodeRemovalPreservesInvisibleElements()
+ {
+ $css = 'div.foo { display: none; }';
+ $html = $this->html5DocumentType . '<html><body><div class="bar"></div><div class="foo"></div></body></html>';
+
+ $expected = '<div class="foo" style="display: none;">';
+
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+ $this->subject->disableInvisibleNodeRemoval();
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyKeepsCssMediaQueriesWithCssCommentAfterMediaQuery()
+ {
+ $css = '@media only screen and (max-width: 480px) { body { color: #ffffff } /* some comment */ }';
+ $html = $this->html5DocumentType . '<html><body></body></html>';
+
+ $expected = '@media only screen and (max-width: 480px)';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyForXhtmlDocumentTypeConvertsXmlSelfClosingTagsToNonXmlSelfClosingTag()
+ {
+ $this->subject->setHtml($this->xhtml1StrictDocumentType . '<html><body><br/></body></html>');
+
+ self::assertContains(
+ '<body><br></body>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyForHtml5DocumentTypeKeepsNonXmlSelfClosingTagsAsNonXmlSelfClosing()
+ {
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><br></body></html>');
+
+ self::assertContains(
+ '<body><br></body>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyForHtml5DocumentTypeConvertXmlSelfClosingTagsToNonXmlSelfClosingTag()
+ {
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><br/></body></html>');
+
+ self::assertContains(
+ '<body><br></body>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyAutomaticallyClosesUnclosedTag()
+ {
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p></body></html>');
+
+ self::assertContains(
+ '<body><p></p></body>',
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyReturnsCompleteHtmlDocument()
+ {
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p></p></body></html>');
+
+ self::assertSame(
+ $this->html5DocumentType . self::LF .
+ '<html>' . self::LF .
+ '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' . self::LF .
+ '<body><p></p></body>' . self::LF .
+ '</html>' . self::LF,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyBodyContentReturnsBodyContentFromHtml()
+ {
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p></p></body></html>');
+ self::assertSame(
+ '<p></p>' . self::LF,
+ $this->subject->emogrifyBodyContent()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyBodyContentReturnsBodyContentFromContent()
+ {
+ $this->subject->setHtml('<p></p>');
+ self::assertSame(
+ '<p></p>' . self::LF,
+ $this->subject->emogrifyBodyContent()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function importantInExternalCssOverwritesInlineCss()
+ {
+ $css = 'p { margin: 1px !important; }';
+ $html = $this->html5DocumentType .
+ '<html><head</head><body><p style="margin: 2px;">some content</p></body></html>';
+ $expected = '<p style="margin: 1px !important;">';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function importantInExternalCssKeepsInlineCssForOtherAttributes()
+ {
+ $css = 'p { margin: 1px !important; }';
+ $html = $this->html5DocumentType .
+ '<html><head</head><body><p style="margin: 2px; text-align: center;">some content</p></body></html>';
+ $expected = '<p style="margin: 1px !important; text-align: center;">';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emogrifyHandlesImportantStyleTagCaseInsensitive()
+ {
+ $css = 'p { margin: 1px !ImPorTant; }';
+ $html = $this->html5DocumentType .
+ '<html><head</head><body><p style="margin: 2px;">some content</p></body></html>';
+ $expected = '<p style="margin: 1px !ImPorTant;">';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function irrelevantMediaQueriesAreRemoved()
+ {
+ $uselessQuery = '@media all and (max-width: 500px) { em { color:red; } }';
+ $this->subject->setCss($uselessQuery);
+
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p></p></body></html>');
+ $result = $this->subject->emogrify();
+
+ self::assertNotContains(
+ $uselessQuery,
+ $result
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function relevantMediaQueriesAreRetained()
+ {
+ $usefulQuery = '@media all and (max-width: 500px) { p { color:red; } }';
+ $this->subject->setCss($usefulQuery);
+
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p></p></body></html>');
+ $result = $this->subject->emogrify();
+
+ self::assertContains(
+ $usefulQuery,
+ $result
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function importantStyleRuleFromInlineCssOverwritesImportantStyleRuleFromExternalCss()
+ {
+ $css = 'p { margin: 1px !important; padding: 1px;}';
+ $html = $this->html5DocumentType .
+ '<html><head</head><body><p style="margin: 2px !important; text-align: center;">some content</p>' .
+ '</body></html>';
+ $expected = '<p style="margin: 2px !important; text-align: center; padding: 1px;">';
+ $this->subject->setHtml($html);
+ $this->subject->setCss($css);
+
+ self::assertContains(
+ $expected,
+ $this->subject->emogrify()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function addExcludedSelectorRemovesMatchingElementsFromEmogrification()
+ {
+ $css = 'p { margin: 0; }';
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p class="x"></p></body></html>');
+ $this->subject->setCss($css);
+ $this->subject->addExcludedSelector('p.x');
+ $html = $this->subject->emogrify();
+
+ self::assertContains(
+ '<p class="x"></p>',
+ $html
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function addExcludedSelectorExcludesMatchingElementEventWithWhitespaceAroundSelector()
+ {
+ $css = 'p { margin: 0; }';
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p class="x"></p></body></html>');
+ $this->subject->setCss($css);
+ $this->subject->addExcludedSelector(' p.x ');
+ $html = $this->subject->emogrify();
+
+ self::assertContains(
+ '<p class="x"></p>',
+ $html
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function addExcludedSelectorKeepsNonMatchingElementsInEmogrification()
+ {
+ $css = 'p { margin: 0; }';
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p></p></body></html>');
+ $this->subject->setCss($css);
+ $this->subject->addExcludedSelector('p.x');
+ $html = $this->subject->emogrify();
+
+ self::assertContains(
+ '<p style="margin: 0;"></p>',
+ $html
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function removeExcludedSelectorGetsMatchingElementsToBeEmogrifiedAgain()
+ {
+ $css = 'p { margin: 0; }';
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p class="x"></p></body></html>');
+ $this->subject->setCss($css);
+ $this->subject->addExcludedSelector('p.x');
+ $this->subject->removeExcludedSelector('p.x');
+ $html = $this->subject->emogrify();
+
+ self::assertContains(
+ '<p class="x" style="margin: 0;"></p>',
+ $html
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function emptyMediaQueriesAreRemoved()
+ {
+ $emptyQuery = '@media all and (max-width: 500px) { }';
+ $this->subject->setCss($emptyQuery);
+
+ $this->subject->setHtml($this->html5DocumentType . '<html><body><p></p></body></html>');
+ $result = $this->subject->emogrify();
+
+ self::assertNotContains(
+ $emptyQuery,
+ $result
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function multiLineMediaQueryWithWindowsLineEndingsIsAppliedOnlyOnce()
+ {
+ $css = "@media all {\r\n" .
+ ".medium {font-size:18px;}\r\n" .
+ ".small {font-size:14px;}\r\n" .
+ '}';
+ $this->subject->setCss($css);
+ $this->subject->setHtml($this->html5DocumentType . '<html><body>' .
+ '<p class="medium">medium</p>' .
+ '<p class="small">small</p>' .
+ '</body></html>');
+
+ $result = $this->subject->emogrify();
+
+ self::assertSame(
+ 1,
+ substr_count($result, '<style type="text/css">' . $css . '</style>')
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function multiLineMediaQueryWithUnixLineEndingsIsAppliedOnlyOnce()
+ {
+ $css = "@media all {\n" .
+ ".medium {font-size:18px;}\n" .
+ ".small {font-size:14px;}\n" .
+ '}';
+ $this->subject->setCss($css);
+ $this->subject->setHtml(
+ $this->html5DocumentType . '<html><body>' .
+ '<p class="medium">medium</p>' .
+ '<p class="small">small</p>' .
+ '</body></html>'
+ );
+
+ $result = $this->subject->emogrify();
+
+ self::assertSame(
+ 1,
+ substr_count($result, '<style type="text/css">' . $css . '</style>')
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function multipleMediaQueriesAreAppliedOnlyOnce()
+ {
+ $css = "@media all {\n" .
+ ".medium {font-size:18px;\n" .
+ ".small {font-size:14px;}\n" .
+ '}' .
+ "@media screen {\n" .
+ ".medium {font-size:24px;}\n" .
+ ".small {font-size:18px;}\n" .
+ '}';
+ $this->subject->setCss($css);
+ $this->subject->setHtml(
+ $this->html5DocumentType . '<html><body>' .
+ '<p class="medium">medium</p>' .
+ '<p class="small">small</p>' .
+ '</body></html>'
+ );
+
+ $result = $this->subject->emogrify();
+
+ self::assertSame(
+ 1,
+ substr_count($result, '<style type="text/css">' . $css . '</style>')
+ );
+ }
+
+ /**
+ * @return string[][]
+ */
+ public function dataUriMediaTypeDataProvider()
+ {
+ return [
+ 'nothing' => [''],
+ ';charset=utf-8' => [';charset=utf-8'],
+ ';base64' => [';base64'],
+ ';charset=utf-8;base64' => [';charset=utf-8;base64'],
+ ];
+ }
+
+ /**
+ * @test
+ * @param string $dataUriMediaType
+ * @dataProvider dataUriMediaTypeDataProvider
+ */
+ public function dataUrisAreConserved($dataUriMediaType)
+ {
+ $html = $this->html5DocumentType . '<html></html>';
+ $this->subject->setHtml($html);
+ $styleRule = 'background-image: url(data:image/png' . $dataUriMediaType .
+ ',iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAABUk' .
+ 'lEQVQ4y81UsY6CQBCdWXBjYWFMjEgAE0piY8c38B9+iX+ksaHCgs5YWEhIrJCQYGJBomiC7lzhVcfqEa+5KXfey3s783bRdd00TR' .
+ 'VFAQAAICJEhN/q8Xjoug7D4RA+qsFgwDjn9QYiTiaT+Xx+OByOx+NqtapjWq0WjEajekPTtCAIiIiIyrKMoqiOMQxDlVqyLMt1XQ' .
+ 'A4nU6z2Wy9XkthEnK/3zdN8znC/X7v+36WZfJ7120vFos4joUQRHS5XDabzXK5bGrbtu1er/dtTFU1TWu3202VHceZTqe3242Itt' .
+ 'ut53nj8bip8m6345wLIQCgKIowDIuikAoz6Wm3233mjHPe6XRe5UROJqImIWPwh/pvZMbYM2GKorx5oUw6m+v1miTJ+XzO8/x+v7' .
+ '+UtizrM8+GYahVVSFik9/jxy6rqlJN02SM1cmI+GbbQghd178AAO2FXws6LwMAAAAASUVORK5CYII=);';
+ $this->subject->setCss('html {' . $styleRule . '}');
+
+ $result = $this->subject->emogrify();
+
+ self::assertContains(
+ '<html style="' . $styleRule . '">',
+ $result
+ );
+ }
+}
--- /dev/null
+{
+ "name": "pelago/emogrifier",
+ "description": "Converts CSS styles into inline style attributes in your HTML code",
+ "tags": ["email", "css", "pre-processing"],
+ "license": "MIT",
+ "homepage": "http://www.pelagodesign.com/sidecar/emogrifier/",
+ "authors": [
+ {
+ "name": "John Reeve",
+ "email": "jreeve@pelagodesign.com"
+ },
+ {
+ "name": "Cameron Brooks"
+ },
+ {
+ "name": "Jaime Prado"
+ },
+ {
+ "name": "Oliver Klee",
+ "email": "typo3-coding@oliverklee.de"
+ },
+ {
+ "name": "Roman Ožana",
+ "email": "ozana@omdesign.cz"
+ }
+ ],
+ "require": {
+ "php": ">=5.4.0",
+ "ext-mbstring": "*"
+ },
+ "require-dev": {
+ "squizlabs/php_codesniffer": "2.3.4",
+ "typo3-ci/typo3sniffpool": "2.1.1",
+ "phpunit/phpunit": "4.8.11"
+ },
+ "autoload": {
+ "psr-4": {
+ "Pelago\\": "Classes/"
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.1.x-dev"
+ }
+ }
+}