Add pelago/emogrifier
authorTim Düsterhus <duesterhus@woltlab.com>
Thu, 2 Jun 2016 22:08:14 +0000 (00:08 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Thu, 28 Jul 2016 20:06:18 +0000 (22:06 +0200)
15 files changed:
wcfsetup/install/files/lib/system/api/composer.json
wcfsetup/install/files/lib/system/api/composer.lock
wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php
wcfsetup/install/files/lib/system/api/composer/autoload_static.php
wcfsetup/install/files/lib/system/api/composer/installed.json
wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/CONTRIBUTING.md [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/Classes/Emogrifier.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/Configuration/PhpCodeSniffer/Standards/Emogrifier/ruleset.xml [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/LICENSE [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/Tests/Unit/EmogrifierTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/composer.json [new file with mode: 0644]

index 59da5a24e3b9b7502062adaf926538aa39c972f7..08eb7faed98362ee0aebc54e92d6fa9059639bda 100644 (file)
@@ -4,6 +4,7 @@
     },
     "require": {
         "ezyang/htmlpurifier": "4.7.*",
-        "erusev/parsedown": "1.6.*"
+        "erusev/parsedown": "1.6.*",
+        "pelago/emogrifier": "1.0.*"
     }
 }
index 78f3be231493a1697760fc7a5e5bbbb8fa616e53..370165e5ebb828009db1193bcd0634aa2747ddc0 100644 (file)
@@ -4,8 +4,8 @@
         "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": [],
index 0c50d0a79606ef58c9d4eb23dd4ac77067e1a775..0835202b04c9952b153285144d739d015a5353ab 100644 (file)
@@ -6,4 +6,5 @@ $vendorDir = dirname(dirname(__FILE__));
 $baseDir = $vendorDir;
 
 return array(
+    'Pelago\\' => array($vendorDir . '/pelago/emogrifier/Classes'),
 );
index 79d01f7cce7897e26e3cebbf9ccd172de1db67ef..22ce1f3aa86f0afb041afdeb48ab7ddc01471e62 100644 (file)
@@ -10,6 +10,20 @@ class ComposerStaticInit4a4e0e985ef68770d710dc260edc44ab
         '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 (
@@ -30,6 +44,8 @@ class ComposerStaticInit4a4e0e985ef68770d710dc260edc44ab
     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);
index 9416badb240186dd6d0997951207d4b34cce5a24..2c9d6c7777429f07c235d342241253a77f03ed8d 100644 (file)
             "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/"
     }
 ]
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore
new file mode 100644 (file)
index 0000000..6be9261
--- /dev/null
@@ -0,0 +1,24 @@
+#########################
+# 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
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml
new file mode 100644 (file)
index 0000000..961cb92
--- /dev/null
@@ -0,0 +1,31 @@
+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/
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md
new file mode 100644 (file)
index 0000000..fcad58b
--- /dev/null
@@ -0,0 +1,92 @@
+# 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
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CONTRIBUTING.md b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CONTRIBUTING.md
new file mode 100644 (file)
index 0000000..95294bf
--- /dev/null
@@ -0,0 +1,78 @@
+# 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
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Classes/Emogrifier.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Classes/Emogrifier.php
new file mode 100644 (file)
index 0000000..ca82172
--- /dev/null
@@ -0,0 +1,1271 @@
+<?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;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Configuration/PhpCodeSniffer/Standards/Emogrifier/ruleset.xml b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Configuration/PhpCodeSniffer/Standards/Emogrifier/ruleset.xml
new file mode 100644 (file)
index 0000000..83398ff
--- /dev/null
@@ -0,0 +1,136 @@
+<?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
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/LICENSE b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/LICENSE
new file mode 100644 (file)
index 0000000..766106c
--- /dev/null
@@ -0,0 +1,21 @@
+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.
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md
new file mode 100644 (file)
index 0000000..b3ea9f4
--- /dev/null
@@ -0,0 +1,198 @@
+# 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.
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Tests/Unit/EmogrifierTest.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Tests/Unit/EmogrifierTest.php
new file mode 100644 (file)
index 0000000..a535225
--- /dev/null
@@ -0,0 +1,1948 @@
+<?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 &amp; b &gt; 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 &amp; b &gt; 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 &amp; b &gt; 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
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/composer.json b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/composer.json
new file mode 100644 (file)
index 0000000..69e8ff4
--- /dev/null
@@ -0,0 +1,46 @@
+{
+    "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"
+        }
+    }
+}