Update to pelago/emogrifier 2.1.*
authorTim Düsterhus <duesterhus@woltlab.com>
Mon, 10 Dec 2018 12:05:34 +0000 (13:05 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Mon, 10 Dec 2018 12:05:34 +0000 (13:05 +0100)
117 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/.github/CONTRIBUTING.md
wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore
wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml
wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md
wcfsetup/install/files/lib/system/api/pelago/emogrifier/Classes/Emogrifier.php [deleted file]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/Configuration/PhpCodeSniffer/Standards/Emogrifier/ruleset.xml [deleted file]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/LICENSE
wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md
wcfsetup/install/files/lib/system/api/pelago/emogrifier/Tests/Unit/EmogrifierTest.php [deleted file]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/composer.json
wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/php-cs-fixer.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/phpmd.xml [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/phpcs.xml.dist [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssConcatenator.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssInliner.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/AbstractHtmlProcessor.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/CssToAttributeConverter.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/HtmlNormalizer.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Support/Traits/AssertCss.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/CssInlinerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/CssConcatenatorTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/AbstractHtmlProcessorTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/CssToAttributeConverterTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/Fixtures/TestingHtmlProcessor.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/HtmlNormalizerTest.php [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/tests/Unit/Support/Traits/AssertCssTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/.gitignore [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/CHANGELOG.md [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/CssSelectorConverter.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExceptionInterface.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExpressionErrorException.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/InternalErrorException.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ParseException.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/SyntaxErrorException.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/LICENSE [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/AbstractNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/AttributeNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ClassNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/CombinedSelectorNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ElementNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/FunctionNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/HashNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NegationNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NodeInterface.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/PseudoNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/SelectorNode.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/Specificity.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/CommentHandler.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HandlerInterface.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HashHandler.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/IdentifierHandler.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/NumberHandler.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/StringHandler.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/WhitespaceHandler.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Parser.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/ParserInterface.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Reader.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ClassParser.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ElementParser.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/HashParser.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Token.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/TokenStream.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/Tokenizer.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/README.md [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/CssSelectorConverterTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AbstractNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AttributeNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ClassNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/CombinedSelectorNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ElementNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/FunctionNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/HashNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/NegationNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/PseudoNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SelectorNodeTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SpecificityTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/AbstractHandlerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/CommentHandlerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/HashHandlerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/IdentifierHandlerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/NumberHandlerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/StringHandlerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/WhitespaceHandlerTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ParserTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ReaderTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ClassParserTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ElementParserTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/EmptyStringParserTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/HashParserTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/TokenStreamTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/ids.html [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/lang.xml [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/shakespear.html [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/TranslatorTest.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AbstractExtension.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/CombinationExtension.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/ExtensionInterface.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/FunctionExtension.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/HtmlExtension.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/NodeExtension.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/PseudoClassExtension.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Translator.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/TranslatorInterface.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/XPathExpr.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/composer.json [new file with mode: 0644]
wcfsetup/install/files/lib/system/api/symfony/css-selector/phpunit.xml.dist [new file with mode: 0644]

index 46fb8ac6246d63adc2e5c6b26b1d261947f08fd1..65ecab83eacfb3a0d2718b83997e348372490ddc 100644 (file)
@@ -6,7 +6,7 @@
     "require": {
         "ezyang/htmlpurifier": "4.10.*",
         "erusev/parsedown": "1.7.*",
-        "pelago/emogrifier": "2.0.*",
+        "pelago/emogrifier": "2.1.*",
         "chrisjean/php-ico": "1.0.*",
         "true/punycode": "~2.0",
         "pear/net_idna2": "^0.2.0",
index e479a1cc3ac80b33c5cceff0e82815ff8d31f393..89eaf185da69949ada0ff1751ddc40272955967b 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "8bb68a1df86b612a3b7196301df662b3",
+    "content-hash": "c2cc66e87530c77c42e08cef2e6af144",
     "packages": [
         {
             "name": "chrisjean/php-ico",
         },
         {
             "name": "pelago/emogrifier",
-            "version": "v2.0.0",
+            "version": "v2.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/MyIntervals/emogrifier.git",
-                "reference": "8babf8ddbf348f26b29674e2f84db66ff7e3d95e"
+                "reference": "8ee7fb5ad772915451ed3415c1992bd3697d4983"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/8babf8ddbf348f26b29674e2f84db66ff7e3d95e",
-                "reference": "8babf8ddbf348f26b29674e2f84db66ff7e3d95e",
+                "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/8ee7fb5ad772915451ed3415c1992bd3697d4983",
+                "reference": "8ee7fb5ad772915451ed3415c1992bd3697d4983",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.0 || ~7.0.0 || ~7.1.0 || ~7.2.0"
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "php": "^5.5.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0",
+                "symfony/css-selector": "^3.4.0 || ^4.0.0"
             },
             "require-dev": {
+                "friendsofphp/php-cs-fixer": "^2.2.0",
+                "phpmd/phpmd": "^2.6.0",
                 "phpunit/phpunit": "^4.8.0",
-                "squizlabs/php_codesniffer": "^3.1.0"
+                "squizlabs/php_codesniffer": "^3.3.2"
             },
             "type": "library",
             "extra": {
             },
             "autoload": {
                 "psr-4": {
-                    "Pelago\\": "Classes/"
+                    "Pelago\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 {
                     "name": "Jaime Prado"
                 },
-                {
-                    "name": "Roman Ožana",
-                    "email": "ozana@omdesign.cz"
-                },
                 {
                     "name": "Oliver Klee",
                     "email": "github@oliverklee.de"
                 {
                     "name": "Zoli Szabó",
                     "email": "zoli.szabo+github@gmail.com"
+                },
+                {
+                    "name": "Jake Hotson",
+                    "email": "jake@qzdesign.co.uk"
                 }
             ],
             "description": "Converts CSS styles into inline style attributes in your HTML code",
                 "email",
                 "pre-processing"
             ],
-            "time": "2018-01-05T23:30:21+00:00"
+            "time": "2018-12-10T10:36:30+00:00"
+        },
+        {
+            "name": "symfony/css-selector",
+            "version": "v4.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/css-selector.git",
+                "reference": "aa9fa526ba1b2ec087ffdfb32753803d999fcfcd"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/aa9fa526ba1b2ec087ffdfb32753803d999fcfcd",
+                "reference": "aa9fa526ba1b2ec087ffdfb32753803d999fcfcd",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.2-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\CssSelector\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jean-François Simon",
+                    "email": "jeanfrancois.simon@sensiolabs.com"
+                },
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony CssSelector Component",
+            "homepage": "https://symfony.com",
+            "time": "2018-11-11T19:52:12+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
index e71c2cfc7441085298457f95acccc8177505e37f..1cae62ff682cfcede40cf75740f2a63ef3052da5 100644 (file)
@@ -8,6 +8,7 @@ $baseDir = $vendorDir;
 return array(
     'TrueBV\\' => array($vendorDir . '/true/punycode/src'),
     'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
-    'Pelago\\' => array($vendorDir . '/pelago/emogrifier/Classes'),
+    'Symfony\\Component\\CssSelector\\' => array($vendorDir . '/symfony/css-selector'),
+    'Pelago\\' => array($vendorDir . '/pelago/emogrifier/src'),
     'Leafo\\ScssPhp\\' => array($vendorDir . '/leafo/scssphp/src'),
 );
index 52ee683b4e367f332a4e7a04c31737b528e64a12..acad2076063c537b4e73fa2dee31af32b6775142 100644 (file)
@@ -19,6 +19,7 @@ class ComposerStaticInit4a4e0e985ef68770d710dc260edc44ab
         'S' => 
         array (
             'Symfony\\Polyfill\\Mbstring\\' => 26,
+            'Symfony\\Component\\CssSelector\\' => 30,
         ),
         'P' => 
         array (
@@ -39,9 +40,13 @@ class ComposerStaticInit4a4e0e985ef68770d710dc260edc44ab
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
         ),
+        'Symfony\\Component\\CssSelector\\' => 
+        array (
+            0 => __DIR__ . '/..' . '/symfony/css-selector',
+        ),
         'Pelago\\' => 
         array (
-            0 => __DIR__ . '/..' . '/pelago/emogrifier/Classes',
+            0 => __DIR__ . '/..' . '/pelago/emogrifier/src',
         ),
         'Leafo\\ScssPhp\\' => 
         array (
index 070b11b6df3e251e5bed534a15fe6c882a903b2c..f36b09bd4cf2c7e49452d5b363cb25e1ff51a519 100644 (file)
     },
     {
         "name": "pelago/emogrifier",
-        "version": "v2.0.0",
-        "version_normalized": "2.0.0.0",
+        "version": "v2.1.1",
+        "version_normalized": "2.1.1.0",
         "source": {
             "type": "git",
             "url": "https://github.com/MyIntervals/emogrifier.git",
-            "reference": "8babf8ddbf348f26b29674e2f84db66ff7e3d95e"
+            "reference": "8ee7fb5ad772915451ed3415c1992bd3697d4983"
         },
         "dist": {
             "type": "zip",
-            "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/8babf8ddbf348f26b29674e2f84db66ff7e3d95e",
-            "reference": "8babf8ddbf348f26b29674e2f84db66ff7e3d95e",
+            "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/8ee7fb5ad772915451ed3415c1992bd3697d4983",
+            "reference": "8ee7fb5ad772915451ed3415c1992bd3697d4983",
             "shasum": ""
         },
         "require": {
-            "php": "^5.5.0 || ~7.0.0 || ~7.1.0 || ~7.2.0"
+            "ext-dom": "*",
+            "ext-libxml": "*",
+            "php": "^5.5.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0",
+            "symfony/css-selector": "^3.4.0 || ^4.0.0"
         },
         "require-dev": {
+            "friendsofphp/php-cs-fixer": "^2.2.0",
+            "phpmd/phpmd": "^2.6.0",
             "phpunit/phpunit": "^4.8.0",
-            "squizlabs/php_codesniffer": "^3.1.0"
+            "squizlabs/php_codesniffer": "^3.3.2"
         },
-        "time": "2018-01-05T23:30:21+00:00",
+        "time": "2018-12-10T10:36:30+00:00",
         "type": "library",
         "extra": {
             "branch-alias": {
         "installation-source": "dist",
         "autoload": {
             "psr-4": {
-                "Pelago\\": "Classes/"
+                "Pelago\\": "src/"
             }
         },
         "notification-url": "https://packagist.org/downloads/",
             {
                 "name": "Jaime Prado"
             },
-            {
-                "name": "Roman Ožana",
-                "email": "ozana@omdesign.cz"
-            },
             {
                 "name": "Oliver Klee",
                 "email": "github@oliverklee.de"
             {
                 "name": "Zoli Szabó",
                 "email": "zoli.szabo+github@gmail.com"
+            },
+            {
+                "name": "Jake Hotson",
+                "email": "jake@qzdesign.co.uk"
             }
         ],
         "description": "Converts CSS styles into inline style attributes in your HTML code",
             "pre-processing"
         ]
     },
+    {
+        "name": "symfony/css-selector",
+        "version": "v4.2.1",
+        "version_normalized": "4.2.1.0",
+        "source": {
+            "type": "git",
+            "url": "https://github.com/symfony/css-selector.git",
+            "reference": "aa9fa526ba1b2ec087ffdfb32753803d999fcfcd"
+        },
+        "dist": {
+            "type": "zip",
+            "url": "https://api.github.com/repos/symfony/css-selector/zipball/aa9fa526ba1b2ec087ffdfb32753803d999fcfcd",
+            "reference": "aa9fa526ba1b2ec087ffdfb32753803d999fcfcd",
+            "shasum": ""
+        },
+        "require": {
+            "php": "^7.1.3"
+        },
+        "time": "2018-11-11T19:52:12+00:00",
+        "type": "library",
+        "extra": {
+            "branch-alias": {
+                "dev-master": "4.2-dev"
+            }
+        },
+        "installation-source": "dist",
+        "autoload": {
+            "psr-4": {
+                "Symfony\\Component\\CssSelector\\": ""
+            },
+            "exclude-from-classmap": [
+                "/Tests/"
+            ]
+        },
+        "notification-url": "https://packagist.org/downloads/",
+        "license": [
+            "MIT"
+        ],
+        "authors": [
+            {
+                "name": "Jean-François Simon",
+                "email": "jeanfrancois.simon@sensiolabs.com"
+            },
+            {
+                "name": "Fabien Potencier",
+                "email": "fabien@symfony.com"
+            },
+            {
+                "name": "Symfony Community",
+                "homepage": "https://symfony.com/contributors"
+            }
+        ],
+        "description": "Symfony CssSelector Component",
+        "homepage": "https://symfony.com"
+    },
     {
         "name": "symfony/polyfill-mbstring",
         "version": "v1.10.0",
index 5347d662bc1a5ac45d1f87fba35c2e31f10a8beb..6287d6549c63789e8cc197c8c637c2de0849cd42 100644 (file)
@@ -9,7 +9,7 @@ When you contribute, please take the following things into account:
 ## Contributor Code of Conduct
 
 Please note that this project is released with a
-[Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this
+[Contributor Code of Conduct](../CODE_OF_CONDUCT.md). By participating in this
 project, you agree to abide by its terms.
 
 
@@ -59,20 +59,30 @@ 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:
+run the following commands:
 
-    composer install
+```shell
+composer install
+composer require --dev slevomat/coding-standard:^4.0
+```
+
+Note that the development dependencies (in particular, for PHP_CodeSniffer)
+require PHP 7.0 or later.  The second command installs the PHP_CodeSniffer
+dependencies and should be omitted if specifically testing against an earlier
+version of PHP, however you will not be able to run the static code analysis.
 
 
 ## 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
+break any existing tests. We will only merge pull requests 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/
+```shell
+composer ci:tests:unit
+```
 
 
 ## Coding Style
@@ -82,9 +92,11 @@ 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:
+Please check your code with the provided static code analysis tools:
 
-    vendor/bin/phpcs --standard=Configuration/PhpCodeSniffer/Standards/Emogrifier/ Classes/ Tests/
+```shell
+composer ci:static
+```
 
 Please make your code clean, well-readable and easy to understand.
 
@@ -92,17 +104,18 @@ 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.
 
+You can autoformat your code using the following command:
+
+```shell
+composer php:fix
+```
+
 
 ## Git commits
 
 Commit message 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
index 6be926125b02a0cfe1581a7011f90c2314fa6028..8bfbb851e45f024c5ff024791dc619fda140fc18 100644 (file)
@@ -20,5 +20,6 @@
 .TemporaryItems
 .webprj
 nbproject
+/.php_cs.cache
 /vendor/
 composer.lock
index da143168cef2fda3062ae85eb9694481121a7712..f3a6c22ea5f7c5e17f7222942ee8c7b09bc0466a 100644 (file)
@@ -8,6 +8,7 @@ php:
 - 7.0
 - 7.1
 - 7.2
+- 7.3
 
 cache:
   directories:
@@ -16,35 +17,49 @@ cache:
 
 env:
   matrix:
-  - DEPENDENCIES=latest
-  - DEPENDENCIES=oldest
+  - DEPENDENCIES_PREFERENCE="--prefer-lowest"
+  - DEPENDENCIES_PREFERENCE=""
+
+before_install:
+- phpenv config-rm xdebug.ini || echo "xdebug not available"
 
 install:
 - >
+  export IGNORE_PLATFORM_REQS="$(composer php:version |grep -q '^7.3' && printf -- --ignore-platform-reqs)";
   echo;
-  if [ "$DEPENDENCIES" = "latest" ]; then
-    echo "Installing the latest dependencies";
-    composer update --with-dependencies --prefer-stable --prefer-dist
-  else
-    echo "Installing the lowest dependencies";
-    composer update --with-dependencies --prefer-stable --prefer-dist --prefer-lowest
-  fi;
+  echo "Updating the dependencies";
+  composer update $IGNORE_PLATFORM_REQS --with-dependencies $DEPENDENCIES_PREFERENCE;
   composer show;
 
-before_script:
-  - |
-    if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then
-      phpenv config-rm xdebug.ini
-    else
-      echo "xdebug.ini does not exist"
-    fi
-  - 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/
+- >
+  echo;
+  echo "Validating the composer.json";
+  composer validate --no-check-all --no-check-lock --strict;
+
+- >
+  echo;
+  echo "Linting all PHP files";
+  composer ci:php:lint;
+
+- >
+  echo;
+  echo "Running the unit tests";
+  composer ci:tests:unit;
+
+- >
+  echo;
+  echo "Running PHPMD";
+  composer ci:php:md;
+
+- >
+  echo;
+  function version_gte() { test "$(printf '%s\n' "$@" | sort -n -t. -r | head -n 1)" = "$1"; };
+  if version_gte $(composer php:version) 7; then
+    echo "Installing slevomat/coding-standard only for PHP 7.x";
+    composer require $IGNORE_PLATFORM_REQS --dev slevomat/coding-standard:^4.0 $DEPENDENCIES_PREFERENCE;
+    echo "Running PHP_CodeSniffer";
+    composer ci:php:sniff;
+  else
+    echo "Skipped PHP_CodeSniffer due to insufficient PHP version: $(composer php:version)";
+  fi;
index 255db41f7936374ecc599ef8370ec9e70ca14a4d..a3866a4bbdaec13baedb59fd538d423eb8b04390 100644 (file)
@@ -3,6 +3,132 @@
 All notable changes to this project will be documented in this file.
 This project adheres to [Semantic Versioning](https://semver.org/).
 
+## x.y.z
+
+### Added
+
+### Changed
+
+### Deprecated
+
+### Removed
+
+### Fixed
+
+## 2.1.1
+
+### Changed
+- Add a test that a missing document type gets added
+  ([#641](https://github.com/MyIntervals/emogrifier/pull/641))
+
+### Fixed
+- Keep the `style` element the `head`
+  ([#642](https://github.com/MyIntervals/emogrifier/pull/642))
+
+## 2.1.0
+
+### Added
+- PHP 7.3 support
+  ([#638](https://github.com/MyIntervals/emogrifier/pull/638))
+  - Allow PHP 7.3 in `composer.json`
+  - Test in Travis for PHP 7.3
+- Add a `renderBodyContent()` method
+  ([#633](https://github.com/MyIntervals/emogrifier/pull/633))
+- Add a `getDomDocument()` method
+  ([#630](https://github.com/MyIntervals/emogrifier/pull/630))
+- Add a Composer script for PHP CS Fixer 
+  ([#607](https://github.com/MyIntervals/emogrifier/pull/607))
+- Copy matching rules with dynamic pseudo-classes or pseudo-elements in
+  selectors to the style element
+  ([#280](https://github.com/MyIntervals/emogrifier/issues/280),
+  [#562](https://github.com/MyIntervals/emogrifier/pull/562),
+  [#567](https://github.com/MyIntervals/emogrifier/pull/567))
+- Add a CssToAttributeConverter
+  ([#546](https://github.com/jjriv/emogrifier/pull/546))
+- Expose the DOMDocument in AbstractHtmlProcessor
+  ([#520](https://github.com/jjriv/emogrifier/pull/520))
+- Add an HtmlNormalizer class
+  ([#513](https://github.com/jjriv/emogrifier/pull/513),
+  [#516](https://github.com/jjriv/emogrifier/pull/516))
+- Add a CssInliner class
+  ([#514](https://github.com/jjriv/emogrifier/pull/514),
+  [#522](https://github.com/jjriv/emogrifier/pull/522))
+- Composer scripts for the various CI build steps
+- Validate the composer.json on Travis
+  ([#476](https://github.com/jjriv/emogrifier/pull/476))
+
+### Changed
+- Mark the work-in-progress classes as `@internal`
+  ([#640](https://github.com/MyIntervals/emogrifier/pull/640))
+- Remove the unprocessable tags from the DOM, not from the raw HTML
+  ([#627](https://github.com/MyIntervals/emogrifier/pull/627))
+- Reject empty HTML in `setHtml()`
+  ([#622](https://github.com/MyIntervals/emogrifier/pull/622))
+- Stop passing the DOM document around
+  ([#618](https://github.com/MyIntervals/emogrifier/pull/618))
+- Improve performance by using explicit namespaces for PHP functions
+  ([#573](https://github.com/MyIntervals/emogrifier/pull/573),
+  [#576](https://github.com/MyIntervals/emogrifier/pull/576))
+- Add type hint checking to the code sniffs
+  ([#566](https://github.com/MyIntervals/emogrifier/pull/566))
+- Check the code with PHPMD
+  ([#561](https://github.com/jjriv/emogrifier/pull/561))
+- Add the cyclomatic complexity to the checked code sniffs
+  ([#558](https://github.com/jjriv/emogrifier/pull/558))
+- Use the Symfony CSS selector component
+  ([#540](https://github.com/jjriv/emogrifier/pull/540))
+
+### Deprecated
+- Support for PHP 5.5 will be removed in Emogrifier 3.0.
+- Support for PHP 5.6 will be removed in Emogrifier 4.0.
+- The removal of invisible nodes will be removed in Emogrifier 3.0.
+  ([#473](https://github.com/jjriv/emogrifier/pull/473))
+- Converting CSS styles to (non-CSS) HTML attributes will be removed
+  in Emogrifier 3.0. Please use the new CssToAttributeConverter instead.
+  ([#474](https://github.com/jjriv/emogrifier/pull/474))
+- Emogrifier 3.x.y will be the last release that supports usage without
+  Composer (i.e., you can still require the class file).
+  Starting with version 4.0, Emogrifier will only work with Composer.
+- The Emogrifier class will be superseded by CssInliner class in
+  Emogrifier 3.0. For this, the Emogrifier class will be deprecated for
+  version 3.0 and removed for version 4.0.
+
+### Removed
+- Drop the `@version` PHPDoc annotations
+  ([#637](https://github.com/MyIntervals/emogrifier/pull/637))
+- Drop the destructors
+  ([#619](https://github.com/MyIntervals/emogrifier/pull/619))
+
+### Fixed
+- Add required XML PHP extension to `composer.json`
+  ([#614](https://github.com/MyIntervals/emogrifier/pull/614))
+- Add required DOM PHP extension to `composer.json`
+  ([#595](https://github.com/MyIntervals/emogrifier/pull/595))
+- Escape hyphens in regular expressions
+  ([#588](https://github.com/MyIntervals/emogrifier/pull/588))
+- Fix Travis for PHP 5.x
+  ([#589](https://github.com/MyIntervals/emogrifier/pull/589))
+- Allow CSS between empty `@media` rule and another `@media` rule
+  ([#534](https://github.com/MyIntervals/emogrifier/pull/534))
+- Allow additional whitespace in media-query-list of disallowed `@media` rules
+  ([#532](https://github.com/MyIntervals/emogrifier/pull/532))
+- Allow multiple minified `@import` rules in the CSS without error (note:
+  `@import`s are currently ignored,
+  [#527](https://github.com/MyIntervals/emogrifier/pull/527))
+- Style property ordering when multiple mixed individual and shorthand
+  properties apply ([#511](https://github.com/MyIntervals/emogrifier/pull/511),
+  [#508](https://github.com/MyIntervals/emogrifier/issues/508))
+- Calculation of selector precedence for selectors involving pseudo-classes
+  and/or attributes ([#502](https://github.com/MyIntervals/emogrifier/pull/502))
+- Allow `@charset` in the CSS without error (note: its value is currently
+  ignored, [#507](https://github.com/MyIntervals/emogrifier/pull/507))
+- Allow attribute selectors in descendants
+  ([#506](https://github.com/MyIntervals/emogrifier/pull/506),
+  [#381](https://github.com/MyIntervals/emogrifier/issues/381))
+- Allow adjacent sibling CSS selector combinator in minified CSS
+  ([#505](https://github.com/MyIntervals/emogrifier/pull/505))
+- Allow CSS property values containing newlines
+  ([#504](https://github.com/MyIntervals/emogrifier/pull/504))
 
 ## 2.0.0
 
@@ -19,7 +145,6 @@ This project adheres to [Semantic Versioning](https://semver.org/).
 - Debug mode. Throw debug exceptions only if debug is active.
   ([#392](https://github.com/MyIntervals/emogrifier/pull/392))
 
-
 ### Changed
 - Test with latest and oldest dependencies on Travis
   ([#463](https://github.com/MyIntervals/emogrifier/pull/463))
@@ -28,19 +153,16 @@ This project adheres to [Semantic Versioning](https://semver.org/).
 - Optimize the string operations
   ([#430](https://github.com/MyIntervals/emogrifier/pull/430))
 
-
 ### Deprecated
 - Support for PHP 5.5 will be removed in Emogrifier 3.0.
 - Support for PHP 5.6 will be removed in Emogrifier 4.0.
 
-
 ### Removed
 - Drop support for PHP 5.4
   ([#422](https://github.com/MyIntervals/emogrifier/pull/422))
 - Drop support for HHVM
   ([#386](https://github.com/MyIntervals/emogrifier/pull/386))
 
-
 ### Fixed
 - Handle invalid/unrecognized selectors in media query blocks
   ([#442](https://github.com/MyIntervals/emogrifier/pull/442))
@@ -59,31 +181,22 @@ This project adheres to [Semantic Versioning](https://semver.org/).
 - Silence purposefully ignored PHP Warnings
   ([#400](https://github.com/MyIntervals/emogrifier/pull/400))
 
-
-### Security
-
-
-
 ## 1.2.0 (2017-03-02)
 
 ### Added
 - Handling invalid xPath expression warnings
   ([#361](https://github.com/MyIntervals/emogrifier/pull/361))
 
-
 ### Deprecated
 - Support for PHP 5.5 will be removed in Emogrifier 3.0.
 - Support for PHP 5.4 will be removed in Emogrifier 2.0.
 
-
 ### Fixed
 - Allow colon (`:`) and semi-colon (`;`) when using the `*=` selector
   ([#371](https://github.com/MyIntervals/emogrifier/pull/371))
 - Ignore "auto" width and height
   ([#365](https://github.com/MyIntervals/emogrifier/pull/365))
 
-
-
 ## 1.1.0 (2016-09-18)
 
 ### Added
@@ -102,18 +215,15 @@ This project adheres to [Semantic Versioning](https://semver.org/).
 - Add CSS to HTML attribute mapper
   ([#288](https://github.com/MyIntervals/emogrifier/pull/288))
 
-
 ### Changed
 - Remove composer dependency from PHP mbstring extension
   (Actual code dependency were removed a lot of time ago)
   ([#295](https://github.com/MyIntervals/emogrifier/pull/295))
 
-
 ### Deprecated
 - Support for PHP 5.5 will be removed in Emogrifier 3.0.
 - Support for PHP 5.4 will be removed in Emogrifier 2.0.
 
-
 ### Fixed
 - Method emogrifyBodyContent() doesn't keeps utf8 umlauts
   ([#349](https://github.com/MyIntervals/emogrifier/pull/349))
@@ -131,8 +241,6 @@ This project adheres to [Semantic Versioning](https://semver.org/).
 - Second !important rule needs to overwrite the first one
   ([#292](https://github.com/MyIntervals/emogrifier/pull/292))
 
-
-
 ## 1.0.0 (2015-10-15)
 
 ### Added
@@ -159,7 +267,6 @@ This project adheres to [Semantic Versioning](https://semver.org/).
 - 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/MyIntervals/emogrifier/pull/245))
@@ -170,17 +277,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
 - Convert the classes to namespaces
   ([#41](https://github.com/MyIntervals/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/MyIntervals/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/MyIntervals/emogrifier/pull/263))
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
deleted file mode 100644 (file)
index 01c800a..0000000
+++ /dev/null
@@ -1,1814 +0,0 @@
-<?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 2.0.0
- *
- * @author Cameron Brooks
- * @author Jaime Prado
- * @author Oliver Klee <github@oliverklee.de>
- * @author Roman Ožana <ozana@omdesign.cz>
- * @author Sander Kruger <s.kruger@invessel.com>
- * @author Zoli Szabó <zoli.szabo+github@gmail.com>
- */
-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 mixed[]
-     */
-    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 string[][]
-     */
-    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;
-
-    /**
-     * @var string[]
-     */
-    private $xPathRules = [
-        // attribute presence
-        '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/' => '*[@\\1]',
-        // type and attribute exact value
-        '/(\\w)\\[(\\w+)\\=[\'"]?([\\w\\s]+)[\'"]?\\]/' => '\\1[@\\2="\\3"]',
-        // type and attribute value with ~ (one word within a whitespace-separated list of words)
-        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/'
-        => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
-        // type and attribute value with | (either exact value match or prefix followed by a hyphen)
-        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/'
-        => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
-        // type and attribute value with ^ (prefix match)
-        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
-        // type and attribute value with * (substring match)
-        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
-        // adjacent sibling
-        '/\\s+\\+\\s+/' => '/following-sibling::*[1]/self::',
-        // child
-        '/\\s*>\\s*/' => '/',
-        // descendant
-        '/\\s+(?=.*[^\\]]{1}$)/' => '//',
-        // type and :first-child
-        '/([^\\/]+):first-child/i' => '*[1]/self::\\1',
-        // type and :last-child
-        '/([^\\/]+):last-child/i' => '*[last()]/self::\\1',
-
-        // The following matcher will break things if it is placed before the adjacent matcher.
-        // So one of the matchers matches either too much or not enough.
-        // type and attribute value with $ (suffix match)
-        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w-_\\s\\/]+)[\'"]?\\]/'
-        => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
-    ];
-
-    /**
-     * Determines whether CSS styles that have an equivalent HTML attribute
-     * should be mapped and attached to those elements.
-     *
-     * @var bool
-     */
-    private $shouldMapCssToHtml = false;
-
-    /**
-     * This multi-level array contains simple mappings of CSS properties to
-     * HTML attributes. If a mapping only applies to certain HTML nodes or
-     * only for certain values, the mapping is an object with a whitelist
-     * of nodes and values.
-     *
-     * @var mixed[][]
-     */
-    private $cssToHtmlMap = [
-        'background-color' => [
-            'attribute' => 'bgcolor',
-        ],
-        'text-align' => [
-            'attribute' => 'align',
-            'nodes' => ['p', 'div', 'td'],
-            'values' => ['left', 'right', 'center', 'justify'],
-        ],
-        'float' => [
-            'attribute' => 'align',
-            'nodes' => ['table', 'img'],
-            'values' => ['left', 'right'],
-        ],
-        'border-spacing' => [
-            'attribute' => 'cellspacing',
-            'nodes' => ['table'],
-        ],
-    ];
-
-    /**
-     * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
-     *
-     * @var bool
-     */
-    private $debug = false;
-
-    /**
-     * 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()
-    {
-        return $this->createAndProcessXmlDocument()->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()
-    {
-        $xmlDocument = $this->createAndProcessXmlDocument();
-        $bodyNodeHtml = $xmlDocument->saveHTML($this->getBodyElement($xmlDocument));
-
-        return str_replace(['<body>', '</body>'], '', $bodyNodeHtml);
-    }
-
-    /**
-     * Creates an XML document from $this->html and emogrifies ist.
-     *
-     * @return \DOMDocument
-     *
-     * @throws \BadMethodCallException
-     */
-    private function createAndProcessXmlDocument()
-    {
-        if ($this->html === '') {
-            throw new \BadMethodCallException('Please set some HTML first.', 1390393096);
-        }
-
-        $xmlDocument = $this->createRawXmlDocument();
-        $this->ensureExistenceOfBodyElement($xmlDocument);
-        $this->process($xmlDocument);
-
-        return $xmlDocument;
-    }
-
-    /**
-     * Applies $this->css to $xmlDocument.
-     *
-     * This method places the CSS inline.
-     *
-     * @param \DOMDocument $xmlDocument
-     *
-     * @return void
-     *
-     * @throws \InvalidArgumentException
-     */
-    protected function process(\DOMDocument $xmlDocument)
-    {
-        $xPath = new \DOMXPath($xmlDocument);
-        $this->clearAllCaches();
-        $this->purgeVisitedNodes();
-        set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING);
-
-        $this->normalizeStyleAttributesOfAllNodes($xPath);
-
-        // 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) {
-            // There's no real way to test "PHP Warning" output generated by the following XPath query unless PHPUnit
-            // converts it to an exception. Unfortunately, this would only apply to tests and not work for production
-            // executions, which can still flood logs/output unnecessarily. Instead, Emogrifier's error handler should
-            // always throw an exception and it must be caught here and only rethrown if in debug mode.
-            try {
-                // \DOMXPath::query will always return a DOMNodeList or an exception when errors are caught.
-                $nodesMatchingCssSelectors = $xPath->query($this->translateCssToXpath($cssRule['selector']));
-            } catch (\InvalidArgumentException $e) {
-                if ($this->debug) {
-                    throw $e;
-                }
-                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->shouldMapCssToHtml) {
-            $this->mapAllInlineStylesToHtmlAttributes($xPath);
-        }
-
-        if ($this->shouldKeepInvisibleNodes) {
-            $this->removeInvisibleNodes($xPath);
-        }
-
-        $this->removeImportantAnnotationFromAllInlineStyles($xPath);
-
-        $this->copyCssWithMediaToStyleNode($xmlDocument, $xPath, $cssParts['media']);
-
-        restore_error_handler();
-    }
-
-    /**
-     * Searches for all nodes with a style attribute, transforms the CSS found
-     * to HTML attributes and adds those attributes to each node.
-     *
-     * @param \DOMXPath $xPath
-     *
-     * @return void
-     */
-    private function mapAllInlineStylesToHtmlAttributes(\DOMXPath $xPath)
-    {
-        /** @var \DOMElement $node */
-        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
-            $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
-            $this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
-        }
-    }
-
-    /**
-     * Searches for all nodes with a style attribute and removes the "!important" annotations out of
-     * the inline style declarations, eventually by rearranging declarations.
-     *
-     * @param \DOMXPath $xPath
-     *
-     * @return void
-     */
-    private function removeImportantAnnotationFromAllInlineStyles(\DOMXPath $xPath)
-    {
-        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
-            $this->removeImportantAnnotationFromNodeInlineStyle($node);
-        }
-    }
-
-    /**
-     * Removes the "!important" annotations out of the inline style declarations,
-     * eventually by rearranging declarations.
-     * Rearranging needed when !important shorthand properties are followed by some of their
-     * not !important expanded-version properties.
-     * For example "font: 12px serif !important; font-size: 13px;" must be reordered
-     * to "font-size: 13px; font: 12px serif;" in order to remain correct.
-     *
-     * @param \DOMElement $node
-     *
-     * @return void
-     */
-    private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node)
-    {
-        $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
-        $regularStyleDeclarations = [];
-        $importantStyleDeclarations = [];
-        foreach ($inlineStyleDeclarations as $property => $value) {
-            if ($this->attributeValueIsImportant($value)) {
-                $importantStyleDeclarations[$property] = trim(str_replace('!important', '', $value));
-            } else {
-                $regularStyleDeclarations[$property] = $value;
-            }
-        }
-        $inlineStyleDeclarationsInNewOrder = array_merge(
-            $regularStyleDeclarations,
-            $importantStyleDeclarations
-        );
-        $node->setAttribute(
-            'style',
-            $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
-        );
-    }
-
-    /**
-     * Returns a list with all DOM nodes that have a style attribute.
-     *
-     * @param \DOMXPath $xPath
-     *
-     * @return \DOMNodeList
-     */
-    private function getAllNodesWithStyleAttribute(\DOMXPath $xPath)
-    {
-        return $xPath->query('//*[@style]');
-    }
-
-    /**
-     * Applies $styles to $node.
-     *
-     * This method maps CSS styles to HTML attributes and adds those to the
-     * node.
-     *
-     * @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
-     * @param \DOMElement $node node to apply styles to
-     *
-     * @return void
-     */
-    private function mapCssToHtmlAttributes(array $styles, \DOMElement $node)
-    {
-        foreach ($styles as $property => $value) {
-            // Strip !important indicator
-            $value = trim(str_replace('!important', '', $value));
-            $this->mapCssToHtmlAttribute($property, $value, $node);
-        }
-    }
-
-    /**
-     * Tries to apply the CSS style to $node as an attribute.
-     *
-     * This method maps a CSS rule to HTML attributes and adds those to the node.
-     *
-     * @param string $property the name of the CSS property to map
-     * @param string $value the value of the style rule to map
-     * @param \DOMElement $node node to apply styles to
-     *
-     * @return void
-     */
-    private function mapCssToHtmlAttribute($property, $value, \DOMElement $node)
-    {
-        if (!$this->mapSimpleCssProperty($property, $value, $node)) {
-            $this->mapComplexCssProperty($property, $value, $node);
-        }
-    }
-
-    /**
-     * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
-     *
-     * @param string $property the name of the CSS property to map
-     * @param string $value the value of the style rule to map
-     * @param \DOMElement $node node to apply styles to
-     *
-     * @return bool true if the property cab be mapped using the simple mapping table
-     */
-    private function mapSimpleCssProperty($property, $value, \DOMElement $node)
-    {
-        if (!isset($this->cssToHtmlMap[$property])) {
-            return false;
-        }
-
-        $mapping = $this->cssToHtmlMap[$property];
-        $nodesMatch = !isset($mapping['nodes']) || in_array($node->nodeName, $mapping['nodes'], true);
-        $valuesMatch = !isset($mapping['values']) || in_array($value, $mapping['values'], true);
-        if (!$nodesMatch || !$valuesMatch) {
-            return false;
-        }
-
-        $node->setAttribute($mapping['attribute'], $value);
-
-        return true;
-    }
-
-    /**
-     * Maps CSS properties that need special transformation to an HTML attribute.
-     *
-     * @param string $property the name of the CSS property to map
-     * @param string $value the value of the style rule to map
-     * @param \DOMElement $node node to apply styles to
-     *
-     * @return void
-     */
-    private function mapComplexCssProperty($property, $value, \DOMElement $node)
-    {
-        $nodeName = $node->nodeName;
-        $isTable = $nodeName === 'table';
-        $isImage = $nodeName === 'img';
-        $isTableOrImage = $isTable || $isImage;
-
-        switch ($property) {
-            case 'background':
-                // Parse out the color, if any
-                $styles = explode(' ', $value);
-                $first = $styles[0];
-                if (!is_numeric($first[0]) && strpos($first, 'url') !== 0) {
-                    // This is not a position or image, assume it's a color
-                    $node->setAttribute('bgcolor', $first);
-                }
-                break;
-            case 'width':
-                // intentional fall-through
-            case 'height':
-                // Only parse values in px and %, but not values like "auto".
-                if (preg_match('/^\d+(px|%)$/', $value)) {
-                    // Remove 'px'. This regex only conserves numbers and %
-                    $number = preg_replace('/[^0-9.%]/', '', $value);
-                    $node->setAttribute($property, $number);
-                }
-                break;
-            case 'margin':
-                if ($isTableOrImage) {
-                    $margins = $this->parseCssShorthandValue($value);
-                    if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
-                        $node->setAttribute('align', 'center');
-                    }
-                }
-                break;
-            case 'border':
-                if ($isTableOrImage) {
-                    if ($value === 'none' || $value === '0') {
-                        $node->setAttribute('border', '0');
-                    }
-                }
-                break;
-            default:
-        }
-    }
-
-    /**
-     * Parses a shorthand CSS value and splits it into individual values
-     *
-     * @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
-     *                      For example: padding: 0 auto;
-     *                      '0 auto' is split into top: 0, left: auto, bottom: 0,
-     *                      right: auto.
-     *
-     * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
-     */
-    private function parseCssShorthandValue($value)
-    {
-        $values = preg_split('/\\s+/', $value);
-
-        $css = [];
-        $css['top'] = $values[0];
-        $css['right'] = (count($values) > 1) ? $values[1] : $css['top'];
-        $css['bottom'] = (count($values) > 2) ? $values[2] : $css['top'];
-        $css['left'] = (count($values) > 3) ? $values[3] : $css['right'];
-
-        return $css;
-    }
-
-    /**
-     * 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^{}]*)([^{]+){([^}]*)}/mi', $css, $matches, PREG_SET_ORDER);
-
-            $cssRules = [];
-            /** @var string[][] $matches */
-            /** @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
-                    $hasPseudoElement = strpos($selector, '::') !== false;
-                    $hasAnyPseudoClass = (bool)preg_match('/:[a-zA-Z]/', $selector);
-                    $hasSupportedPseudoClass = (bool)preg_match(
-                        '/:(\\S+\\-(child|type\\()|not\\([[:ascii:]]*\\))/i',
-                        $selector
-                    );
-                    if ($hasPseudoElement || ($hasAnyPseudoClass && !$hasSupportedPseudoClass)) {
-                        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;
-    }
-
-    /**
-     * Enables the attachment/override of HTML attributes for which a
-     * corresponding CSS property has been set.
-     *
-     * @return void
-     */
-    public function enableCssToHtmlMapping()
-    {
-        $this->shouldMapCssToHtml = true;
-    }
-
-    /**
-     * 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);
-            }
-        }
-    }
-
-    /**
-     * Parses the document and normalizes 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 stores a reference of nodes with existing inline styles so we don't overwrite them.
-     *
-     * @param \DOMXPath $xPath
-     *
-     * @return void
-     */
-    private function normalizeStyleAttributesOfAllNodes(\DOMXPath $xPath)
-    {
-        /** @var \DOMElement $node */
-        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
-            if ($this->isInlineStyleAttributesParsingEnabled) {
-                $this->normalizeStyleAttributes($node);
-            }
-            // Remove style attribute in every case, so we can add them back (if inline style attributes
-            // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
-            // else original inline style rules may remain at the beginning of the final inline style definition
-            // of a node, which may give not the desired results
-            $node->removeAttribute('style');
-        }
-    }
-
-    /**
-     * 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])) {
-                continue;
-            }
-
-            $newAttributeValue = $newStyles[$attributeName];
-            if ($this->attributeValueIsImportant($attributeValue)
-                && !$this->attributeValueIsImportant($newAttributeValue)
-            ) {
-                $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;
-    }
-
-    /**
-     * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
-     *
-     * @param string[] $styleDeclarations
-     *
-     * @return string
-     */
-    private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations)
-    {
-        return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
-    }
-
-    /**
-     * Checks whether $attributeValue is marked as !important.
-     *
-     * @param string $attributeValue
-     *
-     * @return bool
-     */
-    private function attributeValueIsImportant($attributeValue)
-    {
-        return strtolower(substr(trim($attributeValue), -10)) === '!important';
-    }
-
-    /**
-     * 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 while skipping empty media queries.
-     *
-     * @param string $css
-     *
-     * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
-     */
-    private function extractMediaQueriesFromCss($css)
-    {
-        preg_match_all('/@media\\b[^{]*({((?:[^{}]+|(?1))*)})/', $css, $rawMediaQueries, PREG_SET_ORDER);
-        $parsedQueries = [];
-
-        /** @var string[][] $rawMediaQueries */
-        foreach ($rawMediaQueries as $mediaQuery) {
-            if ($mediaQuery[2] !== '') {
-                $parsedQueries[] = [
-                    'css' => $mediaQuery[2],
-                    'query' => $mediaQuery[0],
-                ];
-            }
-        }
-
-        return $parsedQueries;
-    }
-
-    /**
-     * Checks whether there is at least one matching element for $cssSelector.
-     * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
-     * just not implemented/recognized yet by Emogrifier).
-     *
-     * @param \DOMXPath $xPath
-     * @param string $cssSelector
-     *
-     * @return bool
-     *
-     * @throws \InvalidArgumentException
-     */
-    private function existsMatchForCssSelector(\DOMXPath $xPath, $cssSelector)
-    {
-        try {
-            $nodesMatchingSelector = $xPath->query($this->translateCssToXpath($cssSelector));
-        } catch (\InvalidArgumentException $e) {
-            if ($this->debug) {
-                throw $e;
-            }
-            return true;
-        }
-
-        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);
-
-        $bodyElement = $this->getBodyElement($document);
-        $bodyElement->appendChild($styleElement);
-    }
-
-    /**
-     * Checks that $document has a BODY element and adds it if it is missing.
-     *
-     * @param \DOMDocument $document
-     */
-    private function ensureExistenceOfBodyElement(\DOMDocument $document)
-    {
-        if ($document->getElementsByTagName('body')->item(0) !== null) {
-            return;
-        }
-
-        $htmlElement = $document->getElementsByTagName('html')->item(0);
-
-        $htmlElement->appendChild($document->createElement('body'));
-    }
-
-    /**
-     * Returns the BODY element.
-     *
-     * This method assumes that there always is a BODY element.
-     *
-     * @param \DOMDocument $document
-     *
-     * @return \DOMElement
-     *
-     * @throws \BadMethodCallException
-     */
-    private function getBodyElement(\DOMDocument $document)
-    {
-        $bodyElement = $document->getElementsByTagName('body')->item(0);
-        if ($bodyElement === null) {
-            throw new \BadMethodCallException(
-                'getBodyElement method may only be called after ensureExistenceOfBodyElement has been called.',
-                1508173775427
-            );
-        }
-
-        return $bodyElement;
-    }
-
-    /**
-     * 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{\\(]\\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 createRawXmlDocument()
-    {
-        $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 = stripos($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])) {
-            return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
-        }
-
-        $hasNotSelector = (bool)preg_match(
-            '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/',
-            $trimmedLowercaseSelector,
-            $matches
-        );
-        if (!$hasNotSelector) {
-            $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector);
-        } else {
-            /** @var string[] $matches */
-            $partBeforeNot = $matches[1];
-            $notContents = $matches[2];
-            $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) .
-                '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]';
-        }
-        $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $xPath;
-
-        return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
-    }
-
-    /**
-     * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector.
-     *
-     * @param string $trimmedLowercaseSelector
-     *
-     * @return string
-     */
-    private function translateCssToXpathPass($trimmedLowercaseSelector)
-    {
-        return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
-            $trimmedLowercaseSelector,
-            [$this, 'matchClassAttributes']
-        );
-    }
-
-    /**
-     * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector for inline usage.
-     *
-     * @param string $trimmedLowercaseSelector
-     *
-     * @return string
-     */
-    private function translateCssToXpathPassInline($trimmedLowercaseSelector)
-    {
-        return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
-            $trimmedLowercaseSelector,
-            [$this, 'matchClassAttributesInline']
-        );
-    }
-
-    /**
-     * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector while using
-     * $matchClassAttributesCallback as to match the class attributes.
-     *
-     * @param string $trimmedLowercaseSelector
-     * @param callable $matchClassAttributesCallback
-     *
-     * @return string
-     */
-    private function translateCssToXpathPassWithMatchClassAttributesCallback(
-        $trimmedLowercaseSelector,
-        callable $matchClassAttributesCallback
-    ) {
-        $roughXpath = preg_replace(array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector);
-        $xPathWithIdAttributeMatchers = preg_replace_callback(
-            self::ID_ATTRIBUTE_MATCHER,
-            [$this, 'matchIdAttributes'],
-            $roughXpath
-        );
-        $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
-            self::CLASS_ATTRIBUTE_MATCHER,
-            $matchClassAttributesCallback,
-            $xPathWithIdAttributeMatchers
-        );
-
-        // Advanced selectors are going to require a bit more advanced emogrification.
-        $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
-        );
-
-        return $finalXpath;
-    }
-
-    /**
-     * @param string[] $match
-     *
-     * @return string
-     */
-    private function matchIdAttributes(array $match)
-    {
-        return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
-    }
-
-    /**
-     * @param string[] $match
-     *
-     * @return string xPath class attribute query wrapped in element selector
-     */
-    private function matchClassAttributes(array $match)
-    {
-        return ($match[1] !== '' ? $match[1] : '*') . '[' . $this->matchClassAttributesInline($match) . ']';
-    }
-
-    /**
-     * @param string[] $match
-     *
-     * @return string xPath class attribute query
-     */
-    private function matchClassAttributesInline(array $match)
-    {
-        return '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 %1%u = %2$u]/self::%3$s',
-                    $parseResult[self::MULTIPLIER],
-                    $parseResult[self::INDEX],
-                    $match[1]
-                );
-            } else {
-                $xPathExpression = sprintf(
-                    '*[position() mod %1$u = %2$u]/self::%3$s',
-                    $parseResult[self::MULTIPLIER],
-                    $parseResult[self::INDEX],
-                    $match[1]
-                );
-            }
-        } else {
-            $xPathExpression = sprintf('*[%1$u]/self::%2$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(
-                    '%1$s[(last() - position()) mod %2$u = %3$u]',
-                    $match[1],
-                    $parseResult[self::MULTIPLIER],
-                    $parseResult[self::INDEX]
-                );
-            } else {
-                $xPathExpression = sprintf(
-                    '%1$s[position() mod %2$u = %3$u]',
-                    $match[1],
-                    $parseResult[self::MULTIPLIER],
-                    $parseResult[self::INDEX]
-                );
-            }
-        } else {
-            $xPathExpression = sprintf('%1$s[%2$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[]
-     *
-     * @throws \InvalidArgumentException
-     */
-    private function getNodesToExclude(\DOMXPath $xPath)
-    {
-        $excludedNodes = [];
-        foreach (array_keys($this->excludedSelectors) as $selectorToExclude) {
-            try {
-                $matchingNodes = $xPath->query($this->translateCssToXpath($selectorToExclude));
-            } catch (\InvalidArgumentException $e) {
-                if ($this->debug) {
-                    throw $e;
-                }
-                continue;
-            }
-            foreach ($matchingNodes as $node) {
-                $excludedNodes[] = $node;
-            }
-        }
-
-        return $excludedNodes;
-    }
-
-    /**
-     * Handles invalid xPath expression warnings, generated during the process() method,
-     * during querying \DOMDocument and trigger \InvalidArgumentException with invalid selector
-     * or \RuntimeException, depending on the source of the warning.
-     *
-     * @param int $type
-     * @param string $message
-     * @param string $file
-     * @param int $line
-     * @param array $context
-     *
-     * @return bool always false
-     *
-     * @throws \InvalidArgumentException
-     * @throws \RuntimeException
-     */
-    public function handleXpathQueryWarnings( // @codingStandardsIgnoreLine
-        $type,
-        $message,
-        $file,
-        $line,
-        array $context
-    ) {
-        $selector = '';
-        if (isset($context['cssRule']['selector'])) {
-            // warnings generated by invalid/unrecognized selectors in method process()
-            $selector = $context['cssRule']['selector'];
-        } elseif (isset($context['selectorToExclude'])) {
-            // warnings generated by invalid/unrecognized selectors in method getNodesToExclude()
-            $selector = $context['selectorToExclude'];
-        } elseif (isset($context['cssSelector'])) {
-            // warnings generated by invalid/unrecognized selectors in method existsMatchForCssSelector()
-            $selector = $context['cssSelector'];
-        }
-
-        if ($selector !== '') {
-            throw new \InvalidArgumentException(
-                sprintf('%1$s in selector >> %2$s << in %3$s on line %4$u', $message, $selector, $file, $line),
-                1509279985
-            );
-        }
-
-        // Catches eventual warnings generated by method getAllNodesWithStyleAttribute()
-        if (isset($context['xPath'])) {
-            throw new \RuntimeException(
-                sprintf('%1$s in %2$s on line %3$u', $message, $file, $line),
-                1509280067
-            );
-        }
-
-        // the normal error handling continues when handler return false
-        return false;
-    }
-
-    /**
-     * Sets the debug mode.
-     *
-     * @param bool $debug set to true to enable debug mode
-     *
-     * @return void
-     */
-    public function setDebug($debug)
-    {
-        $this->debug = $debug;
-    }
-}
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
deleted file mode 100644 (file)
index ff86aaa..0000000
+++ /dev/null
@@ -1,117 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<ruleset name="Emogrifier 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.AssignmentInCondition"/>
-    <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"/>
-
-    <!-- Control structures -->
-    <rule ref="PEAR.ControlStructures.ControlSignature"/>
-
-    <!-- Debug -->
-    <rule ref="Generic.Debug.ClosureLinter"/>
-
-    <!-- Files -->
-    <rule ref="Generic.Files.OneClassPerFile"/>
-    <rule ref="Generic.Files.OneInterfacePerFile"/>
-    <rule ref="Generic.Files.OneObjectStructurePerFile"/>
-    <rule ref="Zend.Files.ClosingTag"/>
-
-    <!-- Formatting -->
-    <rule ref="Generic.Formatting.NoSpaceAfterCast"/>
-    <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"/>
-
-    <!-- Objects -->
-    <rule ref="Squiz.Objects.ObjectMemberComma"/>
-
-    <!-- Operators -->
-    <rule ref="Squiz.Operators.IncrementDecrementUsage"/>
-    <rule ref="Squiz.Operators.ValidLogicalOperators"/>
-
-    <!-- PHP -->
-    <rule ref="Generic.PHP.BacktickOperator"/>
-    <rule ref="Generic.PHP.CharacterBeforePHPOpeningTag"/>
-    <rule ref="Generic.PHP.DeprecatedFunctions"/>
-    <rule ref="Generic.PHP.DisallowAlternativePHPTags"/>
-    <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"/>
-
-    <!--Strings-->
-    <rule ref="Squiz.Strings.DoubleQuoteUsage"/>
-
-    <!-- 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"/>
-</ruleset>
\ No newline at end of file
index 766106c6d70e54f913726b7a06030bbc96094b28..577abe9a15011693eea51a772a1d9e3ed1ae147f 100644 (file)
@@ -1,21 +1,21 @@
-Emogrifier is copyright (c) 2008-2014 Pelago and licensed under the MIT license.
+MIT License
 
+Copyright (c) 2008-2018 Pelago
 
-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:
+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.
+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.
index b94b73751fa9c7ca4452f04777e167539347f81c..eee5b79c40e961bc2a6f8a427e9710bb0cb4d2c1 100644 (file)
@@ -34,16 +34,15 @@ into inline style attributes in your HTML code.
 - [Installing with Composer](#installing-with-composer)
 - [Supported CSS selectors](#supported-css-selectors)
 - [Caveats](#caveats)
+- [Processing HTML](#processing-html)
 - [Maintainers](#maintainers)
 
-
 ## 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.
 
-
 ## Installation
 
 For installing emogrifier, either add pelago/emogrifier to your
@@ -53,7 +52,6 @@ project's composer.json, or you can use composer as below:
 composer require pelago/emogrifier
 ```
 
-
 ## Usage
 
 First, you provide Emogrifier with the HTML and CSS you would like to merge.
@@ -77,7 +75,6 @@ $emogrifier->setHtml($html);
 $emogrifier->setCss($css);
 ```
 
-
 After you have set the HTML and CSS, you can call the `emogrify` method to
 merge both:
 
@@ -95,7 +92,6 @@ 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
@@ -116,6 +112,8 @@ calling the `emogrify` method:
 * `$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.
+  Note: This option will be removed in Emogrifier 3.0. HTML tags with
+  `display: none;` then will always be retained.
 * `$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.
@@ -126,8 +124,9 @@ calling the `emogrify` method:
 * `$emogrifier->enableCssToHtmlMapping()` - Some email clients don't support CSS
   well, even if inline and prefer HTML attributes. This function allows you to
   put properties such as height, width, background color and font color in your
-  CSS while the transformed content will have all the available HTML tags set.
-
+  CSS while the transformed content will have all the available HTML
+  attributes set. This option will be removed in Emogrifier 3.0. Please use the
+  `CssToAttributeConverter` class instead.
 
 ## Installing with Composer
 
@@ -141,13 +140,13 @@ curl -s https://getcomposer.org/installer | php
 Run the following command for a local installation:
 
 ```bash
-php composer.phar require pelago/emogrifier:^2.0.0
+php composer.phar require pelago/emogrifier:^2.1.0
 ```
 
 Or for a global installation, run the following command:
 
 ```bash
-composer require pelago/emogrifier:^2.0.0
+composer require pelago/emogrifier:^2.1.0
 ```
 
 You can also add follow lines to your `composer.json` and run the
@@ -155,13 +154,12 @@ You can also add follow lines to your `composer.json` and run the
 
 ```json
 "require": {
-  "pelago/emogrifier": "^2.0.0"
+  "pelago/emogrifier": "^2.1.0"
 }
 ```
 
 See https://getcomposer.org/ for more information and documentation.
 
-
 ## Supported CSS selectors
 
 Emogrifier currently supports the following
@@ -196,7 +194,6 @@ The following selectors are not implemented yet:
    (some of them will never be supported)
  * [pseudo-elements](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements)
 
-
 ## Caveats
 
 * Emogrifier requires the HTML and the CSS to be UTF-8. Encodings like
@@ -231,27 +228,67 @@ The following selectors are not implemented yet:
   works by converting CSS selectors to XPath selectors, and pseudo selectors
   cannot be converted accurately).
 
+## Processing HTML
+
+The Emogrifier package also provides classes for (post-)processing the HTML
+generated by `emogrify` (and it also works on any other HTML).
+
+### Normalizing and cleaning up HTML
+
+The `HtmlNormalizer` class normalizes the given HTML in the following ways:
+
+- add a document type (HTML5) if missing
+- disentangle incorrectly nested tags
+- add HEAD and BODY elements (if they are missing)
+- reformat the HTML
+
+The class can be used like this:
+
+```php
+$normalizer = new \Pelago\Emogrifier\HtmlProcessor\HtmlNormalizer($rawHtml);
+$cleanHtml = $normalizer->render();
+```
+
+### Converting CSS styles to visual HTML attributes
+
+The `CssToAttributeConverter` converts a few style attributes values to visual
+HTML attributes. This allows to get at least a bit of visual styling for email
+clients that do not support CSS well. For example, `style="width: 100px"`
+will be converted to `width="100"`.
+
+The class can be used like this:
+
+```php
+$converter = new \Pelago\Emogrifier\HtmlProcessor\CssToAttributeConverter($rawHtml);
+$visualHtml = $converter->convertCssToVisualAttributes()->render();
+```
+
+### Technology preview of new classes
+
+Currently, a refactoring effort is underway, aiming towards replacing the
+grown-over-time `Emogrifier` class with the new `CssInliner` class and moving
+additional HTML processing into separate `CssProcessor` classes (which will
+inherit from `AbstractHtmlProcessor`). You can try the new classes, but be
+aware that the APIs of the new classes still are subject to change. 
 
 ## Steps to release a new version
 
 1. Create a pull request "Prepare release of version x.y.z" with the following
    changes.
-2. Set the new version number in the `@version` annotation in the class PHPDoc
-   of [Emogrifier.php](Classes/Emogrifier.php).
-3. In the [composer.json](composer.json), update the `branch-alias` entry to
+1. In the [composer.json](composer.json), update the `branch-alias` entry to
    point to the release _after_ the upcoming release.
-4. In the [README.md](README.md), update the version numbers in the section
+1. In the [README.md](README.md), update the version numbers in the section
    [Installing with Composer](#installing-with-composer).
-5. In the [CHANGELOG.md](CHANGELOG.md), set the version number and remove any
+1. In the [CHANGELOG.md](CHANGELOG.md), set the version number and remove any
    empty sections.
-6. Have the pull request reviewed and merged.
-7. In the [Releases tab](https://github.com/MyIntervals/emogrifier/releases),
+1. Have the pull request reviewed and merged.
+1. In the [Releases tab](https://github.com/MyIntervals/emogrifier/releases),
    create a new release and copy the change log entries to the new release.
-8. Post about the new release on social media.
-
+1. Post about the new release on social media.
 
 ## Maintainers
 
 * [Oliver Klee](https://github.com/oliverklee)
 * [Zoli Szabó](https://github.com/zoliszabo)
+* [Jake Hotson](https://github.com/JakeQZ)
 * [John Reeve](https://github.com/jjriv)
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
deleted file mode 100644 (file)
index 307e239..0000000
+++ /dev/null
@@ -1,2390 +0,0 @@
-<?php
-
-namespace Pelago\Tests\Unit;
-
-use Pelago\Emogrifier;
-
-/**
- * Test case.
- *
- * @author Oliver Klee <github@oliverklee.de>
- * @author Zoli Szabó <zoli.szabo+github@gmail.com>
- */
-class EmogrifierTest extends \PHPUnit_Framework_TestCase
-{
-    /**
-     * @var string
-     */
-    const LF = "\n";
-
-    /**
-     * @var string
-     */
-    private $html5DocumentType = '<!DOCTYPE html>';
-
-    /**
-     * @var Emogrifier
-     */
-    private $subject = null;
-
-    /**
-     * Sets up the test case.
-     *
-     * @return void
-     */
-    protected function setUp()
-    {
-        $this->subject = new Emogrifier();
-        $this->subject->setDebug(true);
-    }
-
-    /**
-     * @test
-     *
-     * @expectedException \BadMethodCallException
-     */
-    public function emogrifyForNoDataSetThrowsException()
-    {
-        $this->subject->emogrify();
-    }
-
-    /**
-     * @test
-     *
-     * @expectedException \BadMethodCallException
-     */
-    public function emogrifyForEmptyHtmlAndEmptyCssThrowsException()
-    {
-        $this->subject->setHtml('');
-        $this->subject->setCss('');
-
-        $this->subject->emogrify();
-    }
-
-    /**
-     * @test
-     *
-     * @expectedException \BadMethodCallException
-     */
-    public function emogrifyBodyContentForNoDataSetThrowsException()
-    {
-        $this->subject->emogrifyBodyContent();
-    }
-
-    /**
-     * @test
-     *
-     * @expectedException \BadMethodCallException
-     */
-    public function emogrifyBodyContentForEmptyHtmlAndEmptyCssThrowsException()
-    {
-        $this->subject->setHtml('');
-        $this->subject->setCss('');
-
-        $this->subject->emogrifyBodyContent();
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function contentWithoutHtmlTagDataProvider()
-    {
-        return [
-            'doctype only' => ['<!DOCTYPE html>'],
-            'body content only' => ['<p>Hello</p>'],
-            'HEAD element' => ['<head></head>'],
-            'BODY element' => ['<body></body>'],
-            'HEAD AND BODY element' => ['<head></head><body></body>'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $html
-     * @dataProvider contentWithoutHtmlTagDataProvider
-     */
-    public function emogrifyAddsMissingHtmlTag($html)
-    {
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<html>', $result);
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function contentWithoutHeadTagDataProvider()
-    {
-        return [
-            'doctype only' => ['<!DOCTYPE html>'],
-            'body content only' => ['<p>Hello</p>'],
-            'BODY element' => ['<body></body>'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $html
-     * @dataProvider contentWithoutHeadTagDataProvider
-     */
-    public function emogrifyAddsMissingHeadTag($html)
-    {
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<head>', $result);
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function contentWithoutBodyTagDataProvider()
-    {
-        return [
-            'doctype only' => ['<!DOCTYPE html>'],
-            'HEAD element' => ['<head></head>'],
-            'body content only' => ['<p>Hello</p>'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $html
-     * @dataProvider contentWithoutBodyTagDataProvider
-     */
-    public function emogrifyAddsMissingBodyTag($html)
-    {
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<body>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyPutsMissingBodyElementAroundBodyContent()
-    {
-        $this->subject->setHtml('<p>Hello</p>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<body><p>Hello</p></body>', $result);
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function specialCharactersDataProvider()
-    {
-        return [
-            'template markers with dollar signs & square brackets' => ['$[USER:NAME]$'],
-            'UTF-8 umlauts' => ['Küss die Hand, schöne Frau.'],
-            'HTML entities' => ['a &amp; b &gt; c'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $codeNotToBeChanged
-     * @dataProvider specialCharactersDataProvider
-     */
-    public function emogrifyKeepsSpecialCharacters($codeNotToBeChanged)
-    {
-        $html = '<html><p>' . $codeNotToBeChanged . '</p></html>';
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains($codeNotToBeChanged, $result);
-    }
-
-    /**
-     * @test
-     * @param string $codeNotToBeChanged
-     * @dataProvider specialCharactersDataProvider
-     */
-    public function emogrifyBodyContentKeepsSpecialCharacters($codeNotToBeChanged)
-    {
-        $html = '<html><p>' . $codeNotToBeChanged . '</p></html>';
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrifyBodyContent();
-
-        self::assertContains($codeNotToBeChanged, $result);
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function documentTypeDataProvider()
-    {
-        return [
-            'HTML5' => ['<!DOCTYPE html>'],
-            'XHTML 1 strict' => [
-                '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" ' .
-                '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'
-            ],
-            'HTML 4 transitional' => [
-                '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" ' .
-                '"http://www.w3.org/TR/REC-html40/loose.dtd">'
-            ],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $documentType
-     * @dataProvider documentTypeDataProvider
-     */
-    public function emogrifyForHtmlWithDocumentTypeKeepsDocumentType($documentType)
-    {
-        $html = $documentType . '<html></html>';
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains($documentType, $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAddsMissingContentTypeMetaTag()
-    {
-        $this->subject->setHtml('<p>Hello</p>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<meta http-equiv="Content-Type" content="text/html; charset=utf-8">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyNotAddsSecondContentTypeMetaTag()
-    {
-        $html = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>';
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        $numberOfContentTypeMetaTags = substr_count($result, 'Content-Type');
-        self::assertSame(1, $numberOfContentTypeMetaTags);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyByDefaultRemovesWbrTag()
-    {
-        $html = '<html>foo<wbr/>bar</html>';
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('<wbr', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function addUnprocessableTagRemovesEmptyTag()
-    {
-        $this->subject->setHtml('<html><p></p></html>');
-
-        $this->subject->addUnprocessableHtmlTag('p');
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('<p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function addUnprocessableTagNotRemovesNonEmptyTag()
-    {
-        $this->subject->setHtml('<html><p>foobar</p></html>');
-
-        $this->subject->addUnprocessableHtmlTag('p');
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function removeUnprocessableHtmlTagKeepsTagAgainAgain()
-    {
-        $this->subject->setHtml('<html><p></p></html>');
-
-        $this->subject->addUnprocessableHtmlTag('p');
-        $this->subject->removeUnprocessableHtmlTag('p');
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p>', $result);
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function matchedCssDataProvider()
-    {
-        // The sprintf placeholders %1$s and %2$s will automatically be replaced with CSS declarations
-        // like 'color: red;' or 'text-align: left;'.
-        return [
-            'two declarations from one rule can apply to the same element' => [
-                'html { %1$s %2$s }',
-                '<html style="%1$s %2$s">',
-            ],
-            'two identical matchers with different rules get combined' => [
-                'p { %1$s } p { %2$s }',
-                '<p class="p-1" style="%1$s %2$s">',
-            ],
-            'two different matchers rules matching the same element get combined' => [
-                'p { %1$s } .p-1 { %2$s }',
-                '<p class="p-1" style="%1$s %2$s">',
-            ],
-            'type => one element' => ['html { %1$s }', '<html style="%1$s">'],
-            'type => first matching element' => ['p { %1$s }', '<p class="p-1" style="%1$s">'],
-            'type => second matching element' => ['p { %1$s }', '<p class="p-2" style="%1$s">'],
-            'class => with class' => ['.p-2 { %1$s }', '<p class="p-2" style="%1$s">'],
-            'two classes s=> with both classes' => [
-                '.p-5.additional-class { %1$s }',
-                '<p class="p-5 additional-class" style="%1$s">'
-            ],
-            'type & class => type with class' => ['p.p-2 { %1$s }', '<p class="p-2" style="%1$s">'],
-            'ID => with ID' => ['#p4 { %1$s }', '<p class="p-4" id="p4" style="%1$s">'],
-            'type & ID => type with ID' => ['p#p4 { %1$s }', '<p class="p-4" id="p4" style="%1$s">'],
-            'universal => HTML' => ['* { %1$s }', '<html style="%1$s">'],
-            'attribute presence => with attribute' => ['[title] { %1$s }', '<span title="bonjour" style="%1$s">'],
-            'attribute exact value, double quotes => with exact attribute match' => [
-                '[title="bonjour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'attribute exact value, single quotes => with exact match' => [
-                '[title=\'bonjour\'] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            // broken: attribute exact value without quotes => with exact match
-            // broken: attribute exact two-word value, double quotes => with exact attribute value match
-            // broken: attribute exact two-word value, single quotes => with exact attribute value match
-            // broken: attribute exact value with ~, double quotes => exact attribute match
-            // broken: attribute exact value with ~, single quotes => exact attribute match
-            // broken: attribute exact value with ~, no quotes => exact attribute match
-            // broken: attribute value with |, double quotes => with exact match
-            // broken: attribute value with |, single quotes => with exact match
-            // broken: attribute value with |, no quotes => with exact match
-            // broken: attribute value with ^, double quotes => with exact match
-            // broken: attribute value with ^, single quotes => with exact match
-            // broken: attribute value with ^, no quotes => with exact match
-            // broken: attribute value with $, double quotes => with exact match
-            // broken: attribute value with $, single quotes => with exact match
-            // broken: attribute value with $, no quotes => with exact match
-            // broken: attribute value with *, double quotes => with exact match
-            // broken: attribute value with *, single quotes => with exact match
-            // broken: attribute value with *, no quotes => with exact match
-            'type & attribute exact value, double quotes => with type & exact attribute value match' => [
-                'span[title="bonjour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute exact value, single quotes => with type & exact attribute value match' => [
-                'span[title=\'bonjour\'] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute exact value without quotes => with type & exact attribute value match' => [
-                'span[title=bonjour] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute exact two-word value, double quotes => with type & exact attribute value match' => [
-                'span[title="buenas dias"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute exact four-word value, double quotes => with type & exact attribute value match' => [
-                'span[title="buenas dias bom dia"] { %1$s }',
-                '<span title="buenas dias bom dia" style="%1$s">',
-            ],
-            'type & attribute exact two-word value, single quotes => with type & exact attribute value match' => [
-                'span[title=\'buenas dias\'] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute exact four-word value, single quotes => with type & exact attribute value match' => [
-                'span[title=\'buenas dias bom dia\'] { %1$s }',
-                '<span title="buenas dias bom dia" style="%1$s">',
-            ],
-            'type & attribute value with ~, double quotes => with type & exact attribute match' => [
-                'span[title~="bonjour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with ~, single quotes => with type & exact attribute match' => [
-                'span[title~=\'bonjour\'] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with ~, no quotes => with type & exact attribute match' => [
-                'span[title~=bonjour] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with ~, double quotes => with type & word as 1st of 2 in attribute' => [
-                'span[title~="buenas"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute value with ~, double quotes => with type & word as 2nd of 2 in attribute' => [
-                'span[title~="dias"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute value with ~, double quotes => with type & word as 1st of 4 in attribute' => [
-                'span[title~="buenas"] { %1$s }',
-                '<span title="buenas dias bom dia" style="%1$s">',
-            ],
-            'type & attribute value with ~, double quotes => with type & word as 2nd of 4 in attribute' => [
-                'span[title~="dias"] { %1$s }',
-                '<span title="buenas dias bom dia" style="%1$s">',
-            ],
-            'type & attribute value with ~, double quotes => with type & word as last of 4 in attribute' => [
-                'span[title~="dia"] { %1$s }',
-                '<span title="buenas dias bom dia" style="%1$s">',
-            ],
-            'type & attribute value with |, double quotes => with exact match' => [
-                'span[title|="bonjour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with |, single quotes => with exact match' => [
-                'span[title|=\'bonjour\'] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with |, no quotes => with exact match' => [
-                'span[title|=bonjour] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & two-word attribute value with |, double quotes => with exact match' => [
-                'span[title|="buenas dias"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute value with |, double quotes => with match before hyphen & another word' => [
-                'span[title|="avez"] { %1$s }',
-                '<span title="avez-vous" style="%1$s">',
-            ],
-            'type & attribute value with ^, double quotes => with exact match' => [
-                'span[title^="bonjour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with ^, single quotes => with exact match' => [
-                'span[title^=\'bonjour\'] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with ^, no quotes => with exact match' => [
-                'span[title^=bonjour] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            // broken: type & two-word attribute value with ^, double quotes => with exact match
-            'type & attribute value with ^, double quotes => with prefix math' => [
-                'span[title^="bon"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with ^, double quotes => with match before another word' => [
-                'span[title^="buenas"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute value with $, double quotes => with exact match' => [
-                'span[title$="bonjour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with $, single quotes => with exact match' => [
-                'span[title$=\'bonjour\'] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with $, no quotes => with exact match' => [
-                'span[title$=bonjour] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & two-word attribute value with $, double quotes => with exact match' => [
-                'span[title$="buenas dias"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute value with $, double quotes => with suffix math' => [
-                'span[title$="jour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with $, double quotes => with match after another word' => [
-                'span[title$="dias"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & two-word attribute value with *, double quotes => with exact match' => [
-                'span[title*="buenas dias"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute value with *, double quotes => with prefix math' => [
-                'span[title*="bon"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with *, double quotes => with suffix math' => [
-                'span[title*="jour"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with *, double quotes => with substring math' => [
-                'span[title*="njo"] { %1$s }',
-                '<span title="bonjour" style="%1$s">',
-            ],
-            'type & attribute value with *, double quotes => with match before another word' => [
-                'span[title*="buenas"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & attribute value with *, double quotes => with match after another word' => [
-                'span[title*="dias"] { %1$s }',
-                '<span title="buenas dias" style="%1$s">',
-            ],
-            'type & special characters attribute value with *, double quotes => with substring match' => [
-                'span[title*=": subtitle; author"] { %1$s }',
-                '<span title="title: subtitle; author" style="%1$s">',
-            ],
-            'adjacent => 2nd of many' => ['p + p { %1$s }', '<p class="p-2" style="%1$s">'],
-            'adjacent => last of many' => ['p + p { %1$s }', '<p class="p-6" style="%1$s">'],
-            'child (with spaces around >) => direct child' => ['p > span { %1$s }', '<span style="%1$s">'],
-            'child (without space after >) => direct child' => ['p >span { %1$s }', '<span style="%1$s">'],
-            'child (without space before >) => direct child' => ['p> span { %1$s }', '<span style="%1$s">'],
-            'child (without space before or after >) => direct child' => ['p>span { %1$s }', '<span style="%1$s">'],
-            'descendant => child' => ['p span { %1$s }', '<span style="%1$s">'],
-            'descendant => grandchild' => ['body span { %1$s }', '<span style="%1$s">'],
-            // broken: first-child => 1st of many
-            'type & :first-child => 1st of many' => ['p:first-child { %1$s }', '<p class="p-1" style="%1$s">'],
-            // broken: last-child => last of many
-            'type & :last-child => last of many' => ['p:last-child { %1$s }', '<p class="p-6" style="%1$s">'],
-            // broken: :not with type => other type
-            // broken: :not with class => no class
-            // broken: :not with class => other class
-            'type & :not with class => without class' => ['span:not(.foo) { %1$s }', '<span style="%1$s">'],
-            'type & :not with class => with other class' => ['p:not(.foo) { %1$s }', '<p class="p-1" style="%1$s">'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $css CSS statements, potentially with %1$s and $2$s placeholders for a CSS declaration
-     * @param string $expectedHtml HTML, potentially with %1$s and $2$s placeholders for a CSS declaration
-     * @dataProvider matchedCssDataProvider
-     */
-    public function emogrifyAppliesCssToMatchingElements($css, $expectedHtml)
-    {
-        $cssDeclaration1 = 'color: red;';
-        $cssDeclaration2 = 'text-align: left;';
-        $html = '
-            <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>
-                    <p class="p-4" id="p4"><span title="avez-vous">some</span> more text</p>
-                    <p class="p-5 additional-class"><span title="buenas dias bom dia">some</span> more text</p>
-                    <p class="p-6"><span title="title: subtitle; author">some</span> more text</p>
-                </body>
-            </html>
-            ';
-        $this->subject->setHtml($html);
-        $this->subject->setCss(sprintf($css, $cssDeclaration1, $cssDeclaration2));
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(sprintf($expectedHtml, $cssDeclaration1, $cssDeclaration2), $result);
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function nonMatchedCssDataProvider()
-    {
-        // The sprintf placeholders %1$s and %2$s will automatically be replaced with CSS declarations
-        // like 'color: red;' or 'text-align: left;'.
-        return [
-            'type => not other type' => ['html { %1$s }', '<body>'],
-            'class => not other class' => ['.p-2 { %1$s }', '<p class="p-1">'],
-            'class => not without class' => ['.p-2 { %1$s }', '<body>'],
-            'two classes => not only first class' => ['.p-1.another-class { %1$s }', '<p class="p-1">'],
-            'two classes => not only second class' => ['.another-class.p-1 { %1$s }', '<p class="p-1">'],
-            'type & class => not only type' => ['html.p-1 { %1$s }', '<html>'],
-            'type & class => not only class' => ['html.p-1 { %1$s }', '<p class="p-1">'],
-            'ID => not other ID' => ['#yeah { %1$s }', '<p class="p-4" id="p4">'],
-            'ID => not without ID' => ['#yeah { %1$s }', '<span>'],
-            'type & ID => not other type with that ID' => ['html#p4 { %1$s }', '<p class="p-4" id="p4">'],
-            'type & ID => not that type with other ID' => ['p#p5 { %1$s }', '<p class="p-4" id="p4">'],
-            'attribute presence => not element without that attribute' => ['[title] { %1$s }', '<span>'],
-            'attribute exact value => not element without that attribute' => ['[title="bonjour"] { %1$s }', '<span>'],
-            'attribute exact value => not element with different attribute value' => [
-                '[title="hi"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'attribute exact value => not element with only substring match in attribute value' => [
-                '[title="njo"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'type & attribute value with ~ => not element with only prefix match in attribute value' => [
-                'span[title~="bon"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'type & attribute value with |, double quotes => not element with match after another word & hyphen' => [
-                'span[title|="vous"] { %1$s }',
-                '<span title="avez-vous">',
-            ],
-            'type & attribute value with ^ => not element with only substring match in attribute value' => [
-                'span[title^="njo"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'type & attribute value with ^, double quotes => not element with only suffix match in attribute value' => [
-                'span[title^="jour"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'type & attribute value with $ => not element with only substring match in attribute value' => [
-                'span[title$="njo"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'type & attribute value with $, double quotes => not element with only prefix match in attribute value' => [
-                'span[title$="bon"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'type & attribute value with * => not element with different attribute value' => [
-                'span[title*="hi"] { %1$s }',
-                '<span title="bonjour">',
-            ],
-            'adjacent => not 1st of many' => ['p + p { %1$s }', '<p class="p-1">'],
-            'child => not grandchild' => ['html > span { %1$s }', '<span>'],
-            'child => not parent' => ['span > html { %1$s }', '<html>'],
-            'descendant => not sibling' => ['span span { %1$s }', '<span>'],
-            'descendant => not parent' => ['p body { %1$s }', '<body>'],
-            'type & :first-child => not 2nd of many' => ['p:first-child { %1$s }', '<p class="p-2">'],
-            'type & :first-child => not last of many' => ['p:first-child { %1$s }', '<p class="p-6">'],
-            'type & :last-child => not 1st of many' => ['p:last-child { %1$s }', '<p class="p-1">'],
-            'type & :last-child => not 2nd of many' => ['p:last-child { %1$s }', '<p class="p-2">'],
-            'type & :not with class => not with class' => ['p:not(.p-1) { %1$s }', '<p class="p-1">'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $css CSS statements, potentially with %1$s and $2$s placeholders for a CSS declaration
-     * @param string $expectedHtml HTML, potentially with %1$s and $2$s placeholders for a CSS declaration
-     * @dataProvider nonMatchedCssDataProvider
-     */
-    public function emogrifyNotAppliesCssToNonMatchingElements($css, $expectedHtml)
-    {
-        $cssDeclaration1 = 'color: red;';
-        $cssDeclaration2 = 'text-align: left;';
-        $html = '
-            <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>
-                    <p class="p-4" id="p4"><span title="avez-vous">some</span> more text</p>
-                    <p class="p-5 additional-class"><span title="buenas dias bom dia">some</span> more text</p>
-                    <p class="p-6"><span title="title: subtitle; author">some</span> more text</p>
-                </body>
-            </html>
-            ';
-        $this->subject->setHtml($html);
-        $this->subject->setCss(sprintf($css, $cssDeclaration1, $cssDeclaration2));
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(sprintf($expectedHtml, $cssDeclaration1, $cssDeclaration2), $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
-    ) {
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss('html {' . $cssDeclaration . '}');
-
-        $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 & space'
-            => ['color: #000; width: 3px;', 'color: #000; width: 3px;'],
-            'two declarations separated by semicolon & linefeed' => [
-                'color: #000;' . self::LF . 'width: 3px;',
-                'color: #000; width: 3px;'
-            ],
-            'two declarations separated by semicolon & 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)
-    {
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss('html {' . $cssDeclarationBlock . '}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            'html style="' . $expectedStyleAttributeContent . '">',
-            $result
-        );
-    }
-
-    /**
-     * 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)
-    {
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss('html {' . $cssDeclarationBlock . '}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<html style="">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyKeepsExistingStyleAttributes()
-    {
-        $styleAttribute = 'style="color: #ccc;"';
-        $this->subject->setHtml('<html ' . $styleAttribute . '></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains($styleAttribute, $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAddsCssBeforeExistingStyle()
-    {
-        $styleAttributeValue = 'color: #ccc;';
-        $this->subject->setHtml('<html style="' . $styleAttributeValue . '"></html>');
-        $cssDeclarations = 'margin: 0 2px;';
-        $css = 'html {' . $cssDeclarations . '}';
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            'style="' . $cssDeclarations . ' ' . $styleAttributeValue . '"',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyCanMatchMinifiedCss()
-    {
-        $this->subject->setHtml('<html><p></p></html>');
-        $this->subject->setCss('p{color:blue;}html{color:red;}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<html style="color: red;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyLowercasesAttributeNamesFromStyleAttributes()
-    {
-        $this->subject->setHtml('<html style="COLOR:#ccc;"></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('style="color: #ccc;"', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyLowerCasesAttributeNames()
-    {
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss('html {mArGiN:0 2pX;}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('style="margin: 0 2pX;"', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyPreservesCaseForAttributeValuesFromPassedInCss()
-    {
-        $cssDeclaration = 'content: \'Hello World\';';
-        $this->subject->setHtml('<html><body><p>target</p></body></html>');
-        $this->subject->setCss('p {' . $cssDeclaration . '}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="' . $cssDeclaration . '">target</p>',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyPreservesCaseForAttributeValuesFromParsedStyleBlock()
-    {
-        $cssDeclaration = 'content: \'Hello World\';';
-        $this->subject->setHtml(
-            '<html><head><style>p {' . $cssDeclaration . '}</style></head><body><p>target</p></body></html>'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="' . $cssDeclaration . '">target</p>',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyRemovesStyleNodes()
-    {
-        $this->subject->setHtml('<html><style type="text/css"></style></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('<style', $result);
-    }
-
-    /**
-     * @test
-     *
-     * @expectedException \InvalidArgumentException
-     */
-    public function emogrifyInDebugModeForInvalidCssSelectorThrowsException()
-    {
-        $this->subject->setDebug(true);
-
-        $this->subject->setHtml(
-            '<html><style type="text/css">p{color:red;} <style data-x="1">html{cursor:text;}</style></html>'
-        );
-
-        $this->subject->emogrify();
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyNotInDebugModeIgnoresInvalidCssSelectors()
-    {
-        $this->subject->setDebug(false);
-
-        $html = '<html><style type="text/css">' .
-            'p{color:red;} <style data-x="1">html{cursor:text;} p{background-color:blue;}</style> ' .
-            '<body><p></p></body></html>';
-        $this->subject->setHtml($html);
-
-        $html = $this->subject->emogrify();
-
-        self::assertContains('color: red', $html);
-        self::assertContains('background-color: blue', $html);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyByDefaultIgnoresInvalidCssSelectors()
-    {
-        $subject = new Emogrifier();
-
-        $html = '<html><style type="text/css">' .
-            'p{color:red;} <style data-x="1">html{cursor:text;} p{background-color:blue;}</style> ' .
-            '<body><p></p></body></html>';
-        $subject->setHtml($html);
-
-        $html = $subject->emogrify();
-        self::assertContains('color: red', $html);
-        self::assertContains('background-color: blue', $html);
-    }
-
-    /**
-     * Data provider for things that should be left out when applying the CSS.
-     *
-     * @return string[][]
-     */
-    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)
-    {
-        $this->subject->setHtml('<html><p>foo</p></html>');
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains($markerNotExpectedInHtml, $result);
-    }
-
-    /**
-     * Data provider for media rules.
-     *
-     * @return string[][]
-     */
-    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)
-    {
-        $this->subject->setHtml('<html><p>foo</p></html>');
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains($css, $result);
-    }
-
-    /**
-     * @test
-     */
-    public function removeAllowedMediaTypeRemovesStylesForTheGivenMediaType()
-    {
-        $css = '@media screen { html {} }';
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss($css);
-        $this->subject->removeAllowedMediaType('screen');
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains($css, $result);
-    }
-
-    /**
-     * @test
-     */
-    public function addAllowedMediaTypeKeepsStylesForTheGivenMediaType()
-    {
-        $css = '@media braille { html { some-property: value; } }';
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss($css);
-        $this->subject->addAllowedMediaType('braille');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains($css, $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAddsMissingHeadElement()
-    {
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss('@media all { html {} }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<head>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyKeepExistingHeadElementContent()
-    {
-        $this->subject->setHtml('<html><head><!-- original content --></head></html>');
-        $this->subject->setCss('@media all { html {} }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<!-- original content -->', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAddsStyleElementToBody()
-    {
-        $html = $this->html5DocumentType . '<html><head><!-- original content --></head></html>';
-        $this->subject->setHtml($html);
-        $this->subject->setCss('@media all { html {} }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<body><style type="text/css">', $result);
-    }
-
-    /**
-     * Valid media query which need to be preserved
-     *
-     * @return string[][]
-     */
-    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 "print" media type rule' => ['@media print { * { color:#000 !important; } }'],
-            'style in media type rule without specification' => ['@media { h1 { color:red; } }'],
-            'style with multiple media type rules' => [
-                '@media all { p { color: #000; } }' .
-                '@media only screen { h1 { color:red; } }' .
-                '@media only all { h1 { color:red; } }' .
-                '@media print { * { color:#000 !important; } }' .
-                '@media { h1 { color:red; } }'
-            ],
-        ];
-    }
-
-    /**
-     * @test
-     *
-     * @param string $css
-     *
-     * @dataProvider validMediaPreserveDataProvider
-     */
-    public function emogrifyWithValidMediaQueryContainsInnerCss($css)
-    {
-        $this->subject->setHtml('<html><h1></h1><p></p></html>');
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<style type="text/css">' . $css . '</style>', $result);
-    }
-
-    /**
-     * @test
-     *
-     * @param string $css
-     *
-     * @dataProvider validMediaPreserveDataProvider
-     */
-    public function emogrifyWithValidMinifiedMediaQueryContainsInnerCss($css)
-    {
-        // Minify CSS by removing unnecessary whitespace.
-        $css = preg_replace('/\\s*{\\s*/', '{', $css);
-        $css = preg_replace('/;?\\s*}\\s*/', '}', $css);
-        $css = preg_replace('/@media{/', '@media {', $css);
-
-        $this->subject->setHtml('<html><h1></h1><p></p></html>');
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<style type="text/css">' . $css . '</style>', $result);
-    }
-
-    /**
-     * @test
-     *
-     * @param string $css
-     *
-     * @dataProvider validMediaPreserveDataProvider
-     */
-    public function emogrifyForHtmlWithValidMediaQueryContainsInnerCss($css)
-    {
-        $this->subject->setHtml('<html><style type="text/css">' . $css . '</style><h1></h1><p></p></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<style type="text/css">' . $css . '</style>', $result);
-    }
-
-    /**
-     * @test
-     *
-     * @param string $css
-     *
-     * @dataProvider validMediaPreserveDataProvider
-     */
-    public function emogrifyWithValidMediaQueryNotContainsInlineCss($css)
-    {
-        $this->subject->setHtml('<html><h1></h1></html>');
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('style="color:red"', $result);
-    }
-
-    /**
-     * Invalid media query which need to be strip
-     *
-     * @return string[][]
-     */
-    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 emogrifyWithInvalidMediaQueryNotContainsInnerCss($css)
-    {
-        $this->subject->setHtml('<html><h1></h1></html>');
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains($css, $result);
-    }
-
-    /**
-     * @test
-     *
-     * @param string $css
-     *
-     * @dataProvider invalidMediaPreserveDataProvider
-     */
-    public function emogrifyWithInvalidMediaQueryNotContainsInlineCss($css)
-    {
-        $this->subject->setHtml('<html><h1></h1></html>');
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('style="color: red"', $result);
-    }
-
-    /**
-     * @test
-     *
-     * @param string $css
-     *
-     * @dataProvider invalidMediaPreserveDataProvider
-     */
-    public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInnerCss($css)
-    {
-        $this->subject->setHtml('<html><style type="text/css">' . $css . '</style><h1></h1></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains($css, $result);
-    }
-
-    /**
-     * @test
-     *
-     * @param string $css
-     *
-     * @dataProvider invalidMediaPreserveDataProvider
-     */
-    public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInlineCss($css)
-    {
-        $this->subject->setHtml('<html><style type="text/css">' . $css . '</style><h1></h1></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('style="color: red"', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyIgnoresEmptyMediaQuery()
-    {
-        $this->subject->setHtml('<html><h1></h1></html>');
-        $this->subject->setCss('@media screen {} @media tv { h1 { color: red; } }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('style="color: red"', $result);
-        self::assertNotContains('@media screen', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyIgnoresMediaQueryWithWhitespaceOnly()
-    {
-        $this->subject->setHtml('<html><h1></h1></html>');
-        $this->subject->setCss('@media screen { } @media tv { h1 { color: red; } }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('style="color: red"', $result);
-        self::assertNotContains('@media screen', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAppliesCssFromStyleNodes()
-    {
-        $styleAttributeValue = 'color: #ccc;';
-        $this->subject->setHtml('<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<html style="' . $styleAttributeValue . '">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyWhenDisabledNotAppliesCssFromStyleBlocks()
-    {
-        $styleAttributeValue = 'color: #ccc;';
-        $this->subject->setHtml('<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>');
-        $this->subject->disableStyleBlocksParsing();
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains(
-            '<html style="' . $styleAttributeValue . '">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyWhenStyleBlocksParsingDisabledKeepInlineStyles()
-    {
-        $styleAttributeValue = 'text-align: center;';
-        $this->subject->setHtml(
-            '<html><head><style type="text/css">p { color: #ccc; }</style></head>' .
-            '<body><p style="' . $styleAttributeValue . '">paragraph</p></body></html>'
-        );
-        $this->subject->disableStyleBlocksParsing();
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="' . $styleAttributeValue . '">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyWhenDisabledNotAppliesCssFromInlineStyles()
-    {
-        $this->subject->setHtml('<html style="color: #ccc;"></html>');
-        $this->subject->disableInlineStyleAttributesParsing();
-
-        $result = $this->subject->emogrify();
-
-        self::assertNotContains('<html style', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyWhenInlineStyleAttributesParsingDisabledKeepStyleBlockStyles()
-    {
-        $styleAttributeValue = 'color: #ccc;';
-        $this->subject->setHtml(
-            '<html><head><style type="text/css">p { ' . $styleAttributeValue . ' }</style></head>' .
-            '<body><p style="text-align: center;">paragraph</p></body></html>'
-        );
-        $this->subject->disableInlineStyleAttributesParsing();
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="' . $styleAttributeValue . '">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAppliesCssWithUpperCaseSelector()
-    {
-        $this->subject->setHtml(
-            '<html><style type="text/css">P { color:#ccc; }</style><body><p>paragraph</p></body></html>'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="color: #ccc;">', $result);
-    }
-
-    /**
-     * Emogrify was handling case differently for passed in CSS vs CSS parsed from style blocks.
-     *
-     * @test
-     */
-    public function emogrifyAppliesCssWithMixedCaseAttributesInStyleBlock()
-    {
-        $this->subject->setHtml(
-            '<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>'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="padding-bottom: 1px; padding-top: 0; text-align: center;">', $result);
-    }
-
-    /**
-     * Passed in CSS sets the order, but style block CSS overrides values.
-     *
-     * @test
-     */
-    public function emogrifyMergesCssWithMixedCaseAttribute()
-    {
-        $this->subject->setHtml(
-            '<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>'
-        );
-        $this->subject->setCss('p { margin: 0; padding-TOP: 0; PADDING-bottom: 1PX;}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="margin: 0; padding-top: 1px; padding-bottom: 3px; text-align: center;">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyMergesCssWithMixedUnits()
-    {
-        $this->subject->setHtml(
-            '<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>'
-        );
-        $this->subject->setCss('p { margin: 1px; padding-bottom:0;}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="margin: 0; padding-bottom: 1px; text-align: center;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyByDefaultRemovesElementsWithDisplayNoneFromExternalCss()
-    {
-        $this->subject->setHtml('<html><body><div class="bar"></div><div class="foo"></div></body></html>');
-        $this->subject->setCss('div.foo { display: none; }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<div class="bar"></div>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyByDefaultRemovesElementsWithDisplayNoneInStyleAttribute()
-    {
-        $this->subject->setHtml(
-            '<html><body><div class="bar"></div><div class="foobar" style="display: none;"></div>' .
-            '</body></html>'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<div class="bar"></div>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAfterDisableInvisibleNodeRemovalPreservesInvisibleElements()
-    {
-        $this->subject->setHtml('<html><body><div class="bar"></div><div class="foo"></div></body></html>');
-        $this->subject->setCss('div.foo { display: none; }');
-
-        $this->subject->disableInvisibleNodeRemoval();
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<div class="foo" style="display: none;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyKeepsCssMediaQueriesWithCssCommentAfterMediaQuery()
-    {
-        $this->subject->setHtml('<html><body></body></html>');
-        $this->subject->setCss(
-            '@media only screen and (max-width: 480px) { body { color: #ffffff } /* some comment */ }'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('@media only screen and (max-width: 480px)', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyForXhtmlDocumentTypeConvertsXmlSelfClosingTagsToNonXmlSelfClosingTag()
-    {
-        $this->subject->setHtml(
-            '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" ' .
-            '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">' .
-            '<html><body><br/></body></html>'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<body><br></body>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyForHtml5DocumentTypeKeepsNonXmlSelfClosingTagsAsNonXmlSelfClosing()
-    {
-        $this->subject->setHtml($this->html5DocumentType . '<html><body><br></body></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<body><br></body>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyForHtml5DocumentTypeConvertXmlSelfClosingTagsToNonXmlSelfClosingTag()
-    {
-        $this->subject->setHtml($this->html5DocumentType . '<html><body><br/></body></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<body><br></body>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyAutomaticallyClosesUnclosedTag()
-    {
-        $this->subject->setHtml('<html><body><p></body></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<body><p></p></body>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyReturnsCompleteHtmlDocument()
-    {
-        $this->subject->setHtml('<html><body><p></p></body></html>');
-
-        $result = $this->subject->emogrify();
-
-        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,
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyBodyContentReturnsBodyContentFromHtml()
-    {
-        $this->subject->setHtml('<html><body><p></p></body></html>');
-
-        $result = $this->subject->emogrifyBodyContent();
-
-        self::assertSame('<p></p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyBodyContentReturnsBodyContentFromContent()
-    {
-        $this->subject->setHtml('<p></p>');
-
-        $result = $this->subject->emogrifyBodyContent();
-
-        self::assertSame('<p></p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function importantInExternalCssOverwritesInlineCss()
-    {
-        $this->subject->setHtml('<html><head</head><body><p style="margin: 2px;">some content</p></body></html>');
-        $this->subject->setCss('p { margin: 1px !important; }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="margin: 1px;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function importantInExternalCssKeepsInlineCssForOtherAttributes()
-    {
-        $this->subject->setHtml(
-            '<html><head</head><body><p style="margin: 2px; text-align: center;">some content</p></body></html>'
-        );
-        $this->subject->setCss('p { margin: 1px !important; }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="text-align: center; margin: 1px;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyHandlesImportantStyleTagCaseInsensitive()
-    {
-        $this->subject->setHtml('<html><head</head><body><p style="margin: 2px;">some content</p></body></html>');
-        $this->subject->setCss('p { margin: 1px !ImPorTant; }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="margin: 1px !ImPorTant;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function secondImportantStyleOverwritesFirstOne()
-    {
-        $this->subject->setHtml('<html><head</head><body><p>some content</p></body></html>');
-        $this->subject->setCss('p { margin: 1px !important; } p { margin: 2px !important; }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="margin: 2px;">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function secondNonImportantStyleOverwritesFirstOne()
-    {
-        $this->subject->setHtml('<html><head</head><body><p>some content</p></body></html>');
-        $this->subject->setCss('p { margin: 1px; } p { margin: 2px; }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="margin: 2px;">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function secondNonImportantStyleNotOverwritesFirstImportantOne()
-    {
-        $this->subject->setHtml('<html><head</head><body><p>some content</p></body></html>');
-        $this->subject->setCss('p { margin: 1px !important; } p { margin: 2px; }');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<p style="margin: 1px;">',
-            $result
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function irrelevantMediaQueriesAreRemoved()
-    {
-        $uselessQuery = '@media all and (max-width: 500px) { em { color:red; } }';
-        $this->subject->setCss($uselessQuery);
-        $this->subject->setHtml('<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('<html><body><p></p></body></html>');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains($usefulQuery, $result);
-    }
-
-    /**
-     * @test
-     */
-    public function importantStyleRuleFromInlineCssOverwritesImportantStyleRuleFromExternalCss()
-    {
-        $this->subject->setHtml(
-            '<html><head</head><body>' .
-            '<p style="margin: 2px !important; text-align: center;">some content</p>' .
-            '</body></html>'
-        );
-        $this->subject->setCss('p { margin: 1px !important; padding: 1px;}');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="padding: 1px; text-align: center; margin: 2px;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function addExcludedSelectorRemovesMatchingElementsFromEmogrification()
-    {
-        $this->subject->setHtml('<html><body><p class="x"></p></body></html>');
-        $this->subject->setCss('p { margin: 0; }');
-
-        $this->subject->addExcludedSelector('p.x');
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p class="x"></p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function addExcludedSelectorExcludesMatchingElementEventWithWhitespaceAroundSelector()
-    {
-        $this->subject->setHtml('<html><body><p class="x"></p></body></html>');
-        $this->subject->setCss('p { margin: 0; }');
-
-        $this->subject->addExcludedSelector(' p.x ');
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p class="x"></p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function addExcludedSelectorKeepsNonMatchingElementsInEmogrification()
-    {
-        $this->subject->setHtml('<html><body><p></p></body></html>');
-        $this->subject->setCss('p { margin: 0; }');
-
-        $this->subject->addExcludedSelector('p.x');
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="margin: 0;"></p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function removeExcludedSelectorGetsMatchingElementsToBeEmogrifiedAgain()
-    {
-        $this->subject->setHtml('<html><body><p class="x"></p></body></html>');
-        $this->subject->setCss('p { margin: 0; }');
-
-        $this->subject->addExcludedSelector('p.x');
-        $this->subject->removeExcludedSelector('p.x');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p class="x" style="margin: 0;"></p>', $result);
-    }
-
-    /**
-     * @test
-     *
-     * @expectedException \InvalidArgumentException
-     */
-    public function emogrifyInDebugModeThrowsInvalidArgumentExceptionForInvalidExcludedSelector()
-    {
-        $this->subject->setDebug(true);
-
-        $this->subject->setHtml('<html></html>');
-        $this->subject->addExcludedSelector('..p');
-
-        $this->subject->emogrify();
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyNotInDebugModeIgnoresInvalidExcludedSelector()
-    {
-        $this->subject->setDebug(false);
-
-        $this->subject->setHtml('<html><p class="x"></p></html>');
-        $this->subject->addExcludedSelector('..p');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p class="x"></p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyNotInDebugModeIgnoresOnlyInvalidExcludedSelector()
-    {
-        $this->subject->setDebug(false);
-
-        $this->subject->setHtml('<html><p class="x"></p><p class="y"></p><p class="z"></p></html>');
-        $this->subject->setCss('p { color: red };');
-        $this->subject->addExcludedSelector('p.x');
-        $this->subject->addExcludedSelector('..p');
-        $this->subject->addExcludedSelector('p.z');
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p class="x"></p>', $result);
-        self::assertContains('<p class="y" style="color: red;"></p>', $result);
-        self::assertContains('<p class="z"></p>', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emptyMediaQueriesAreRemoved()
-    {
-        $emptyQuery = '@media all and (max-width: 500px) { }';
-        $this->subject->setCss($emptyQuery);
-        $this->subject->setHtml('<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(
-            '<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(
-            '<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(
-            '<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)
-    {
-        $this->subject->setHtml('<html></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
-        );
-    }
-
-    /**
-     * Data provider for CSS to HTML mapping.
-     *
-     * @return string[][]
-     */
-    public function matchingCssToHtmlMappingDataProvider()
-    {
-        return [
-            'background-color => bgcolor'
-            => ['<p>hi</p>', 'p {background-color: red;}', 'p', 'bgcolor="red"'],
-            'background-color (with !important) => bgcolor'
-            => ['<p>hi</p>', 'p {background-color: red !important;}', 'p', 'bgcolor="red"'],
-            'p.text-align => align'
-            => ['<p>hi</p>', 'p {text-align: justify;}', 'p', 'align="'],
-            'div.text-align => align'
-            => ['<div>hi</div>', 'div {text-align: justify;}', 'div', 'align="'],
-            'td.text-align => align'
-            => ['<table><tr><td>hi</td></tr></table>', 'td {text-align: justify;}', 'td', 'align="'],
-            'text-align: left => align=left'
-            => ['<p>hi</p>', 'p {text-align: left;}', 'p', 'align="left"'],
-            'text-align: right => align=right'
-            => ['<p>hi</p>', 'p {text-align: right;}', 'p', 'align="right"'],
-            'text-align: center => align=center'
-            => ['<p>hi</p>', 'p {text-align: center;}', 'p', 'align="center"'],
-            'text-align: justify => align:justify'
-            => ['<p>hi</p>', 'p {text-align: justify;}', 'p', 'align="justify"'],
-            'img.float: right => align=right'
-            => ['<img>', 'img {float: right;}', 'img', 'align="right"'],
-            'img.float: left => align=left'
-            => ['<img>', 'img {float: left;}', 'img', 'align="left"'],
-            'table.float: right => align=right'
-            => ['<table></table>', 'table {float: right;}', 'table', 'align="right"'],
-            'table.float: left => align=left'
-            => ['<table></table>', 'table {float: left;}', 'table', 'align="left"'],
-            'table.border-spacing: 0 => cellspacing=0'
-            => ['<table><tr><td></td></tr></table>', 'table {border-spacing: 0;}', 'table', 'cellspacing="0"'],
-            'background => bgcolor'
-            => ['<p>Bonjour</p>', 'p {background: red top;}', 'p', 'bgcolor="red"'],
-            'width with px'
-            => ['<p>Hello</p>', 'p {width: 100px;}', 'p', 'width="100"'],
-            'width with %'
-            => ['<p>Hello</p>', 'p {width: 50%;}', 'p', 'width="50%"'],
-            'height with px'
-            => ['<p>Hello</p>', 'p {height: 100px;}', 'p', 'height="100"'],
-            'height with %'
-            => ['<p>Hello</p>', 'p {height: 50%;}', 'p', 'height="50%"'],
-            'img.margin: 0 auto (= horizontal centering) => align=center'
-            => ['<img>', 'img {margin: 0 auto;}', 'img', 'align="center"'],
-            'img.margin: auto (= horizontal centering) => align=center'
-            => ['<img>', 'img {margin: auto;}', 'img', 'align="center"'],
-            'img.margin: 10 auto 30 auto (= horizontal centering) => align=center'
-            => ['<img>', 'img {margin: 10 auto 30 auto;}', 'img', 'align="center"'],
-            'table.margin: 0 auto (= horizontal centering) => align=center'
-            => ['<table></table>', 'table {margin: 0 auto;}', 'table', 'align="center"'],
-            'table.margin: auto (= horizontal centering) => align=center'
-            => ['<table></table>', 'table {margin: auto;}', 'table', 'align="center"'],
-            'table.margin: 10 auto 30 auto (= horizontal centering) => align=center'
-            => ['<table></table>', 'table {margin: 10 auto 30 auto;}', 'table', 'align="center"'],
-            'img.border: none => border=0'
-            => ['<img>', 'img {border: none;}', 'img', 'border="0"'],
-            'img.border: 0 => border=0'
-            => ['<img>', 'img {border: none;}', 'img', 'border="0"'],
-            'table.border: none => border=0'
-            => ['<table></table>', 'table {border: none;}', 'table', 'border="0"'],
-            'table.border: 0 => border=0'
-            => ['<table></table>', 'table {border: none;}', 'table', 'border="0"'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $body The HTML
-     * @param string $css The complete CSS
-     * @param string $tagName The name of the tag that should be modified
-     * @param string $attributes The attributes that are expected on the element
-     *
-     * @dataProvider matchingCssToHtmlMappingDataProvider
-     */
-    public function emogrifierMapsSuitableCssToHtmlIfFeatureIsEnabled($body, $css, $tagName, $attributes)
-    {
-        $this->subject->setHtml('<html><body>' . $body . '</body></html>');
-        $this->subject->setCss($css);
-
-        $this->subject->enableCssToHtmlMapping();
-        $html = $this->subject->emogrify();
-
-        self::assertRegExp('/<' . preg_quote($tagName, '/') . '[^>]+' . preg_quote($attributes, '/') . '/', $html);
-    }
-
-    /**
-     * Data provider for CSS to HTML mapping.
-     *
-     * @return string[][]
-     */
-    public function notMatchingCssToHtmlMappingDataProvider()
-    {
-        return [
-            'background URL'
-            => ['<p>Hello</p>', 'p {background: url(bg.png);}', 'bgcolor'],
-            'background URL with position'
-            => ['<p>Hello</p>', 'p {background: url(bg.png) top;}', 'bgcolor'],
-            'img.margin: 10 5 30 auto (= no horizontal centering)'
-            => ['<img>', 'img {margin: 10 5 30 auto;}', 'align'],
-            'p.margin: auto'
-            => ['<p>Bonjour</p>', 'p {margin: auto;}', 'align'],
-            'p.border: none'
-            => ['<p>Bonjour</p>', 'p {border: none;}', 'border'],
-            'img.border: 1px solid black'
-            => ['<p>Bonjour</p>', 'p {border: 1px solid black;}', 'border'],
-            'span.text-align'
-            => ['<span>hi</span>', 'span {text-align: justify;}', 'align'],
-            'text-align: inherit'
-            => ['<p>hi</p>', 'p {text-align: inherit;}', 'align'],
-            'span.float'
-            => ['<span>hi</span>', 'span {float: right;}', 'align'],
-            'float: none'
-            => ['<table></table>', 'table {float: none;}', 'align'],
-            'p.border-spacing'
-            => ['<p>Hello</p>', 'p {border-spacing: 5px;}', 'cellspacing'],
-            'height: auto'
-            => ['<img src="logo.png" alt="">', 'img {width: 110px; height: auto;}', 'height'],
-            'width: auto'
-            => ['<img src="logo.png" alt="">', 'img {width: auto; height: 110px;}', 'width'],
-        ];
-    }
-
-    /**
-     * @test
-     * @param string $body the HTML
-     * @param string $css the complete CSS
-     * @param string $attribute the attribute that must not be present on this element
-     *
-     * @dataProvider notMatchingCssToHtmlMappingDataProvider
-     */
-    public function emogrifierNotMapsUnsuitableCssToHtmlIfFeatureIsEnabled($body, $css, $attribute)
-    {
-        $this->subject->setHtml('<html><body>' . $body . '</body></html>');
-        $this->subject->setCss($css);
-
-        $this->subject->enableCssToHtmlMapping();
-        $html = $this->subject->emogrify();
-
-        self::assertNotContains(
-            $attribute . '="',
-            $html
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifierNotMapsCssToHtmlIfFeatureIsNotEnabled()
-    {
-        $this->subject->setHtml('<html><body><img></body></html>');
-        $this->subject->setCss('img {float: right;}');
-
-        $html = $this->subject->emogrify();
-
-        self::assertNotContains(
-            '<img align="right',
-            $html
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifierIgnoresPseudoClassCombinedWithPseudoElement()
-    {
-        $this->subject->setHtml('<html><body><div></div></body></html>');
-        $this->subject->setCss('div:last-child::after {float: right;}');
-
-        $html = $this->subject->emogrify();
-
-        self::assertContains('<div></div>', $html);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyKeepsInlineStylePriorityVersusStyleBlockRules()
-    {
-        $this->subject->setHtml(
-            '<html><head><style>p {padding:10px};</style></head><body><p style="padding-left:20px;"></p></body></html>'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="padding: 10px; padding-left: 20px;">', $result);
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyMovesStyleElementFromHeadToBody()
-    {
-        $style = '<style type="text/css">@media all { html {  color: red; } }</style>';
-        $html = '<html><head>' . $style . '</head></html>';
-        $this->subject->setHtml($html);
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains(
-            '<body>' . $style . '</body>',
-            $result
-        );
-    }
-
-    /**
-     * Asserts that $html contains a $tagName tag with the $attribute attribute.
-     *
-     * @param string $html the HTML string we are searching in
-     * @param string $tagName the HTML tag we are looking for
-     * @param string $attribute the attribute we are looking for (with or even without a value)
-     */
-    private function assertHtmlStringContainsTagWithAttribute($html, $tagName, $attribute)
-    {
-        self::assertTrue(
-            preg_match('/<' . preg_quote($tagName, '/') . '[^>]+' . preg_quote($attribute, '/') . '/', $html) > 0
-        );
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyPrefersInlineStyleOverCssBlockStyleForHtmlAttributesMapping()
-    {
-        $this->subject->setHtml(
-            '<html><head><style>p {width:1px}</style></head><body><p style="width:2px"></p></body></html>'
-        );
-        $this->subject->enableCssToHtmlMapping();
-
-        $result = $this->subject->emogrify();
-
-        $this->assertHtmlStringContainsTagWithAttribute($result, 'p', 'width="2"');
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyCorrectsHtmlAttributesMappingWhenMultipleMatchingRulesAndLastRuleIsAuto()
-    {
-        $this->subject->setHtml(
-            '<html><head><style>p {width:1px}</style></head><body><p class="autoWidth"></p></body></html>'
-        );
-        $this->subject->setCss('p.autoWidth {width:auto}');
-        $this->subject->enableCssToHtmlMapping();
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p class="autoWidth" style="width: auto;">', $result);
-    }
-
-    /**
-     * @return string[][]
-     */
-    public function cssForImportantRuleRemovalDataProvider()
-    {
-        return [
-            'one !important rule only' => [
-                'width: 1px !important',
-                'width: 1px;'
-            ],
-            'multiple !important rules only' => [
-                'width: 1px !important; height: 1px !important',
-                'width: 1px; height: 1px;'
-            ],
-            'multiple declarations, one !important rule at the beginning' => [
-                'width: 1px !important; height: 1px; color: red',
-                'height: 1px; color: red; width: 1px;'
-            ],
-            'multiple declarations, one !important rule somewhere in the middle' => [
-                'height: 1px; width: 1px !important; color: red',
-                'height: 1px; color: red; width: 1px;'
-            ],
-            'multiple declarations, one !important rule at the end' => [
-                'height: 1px; color: red; width: 1px !important',
-                'height: 1px; color: red; width: 1px;'
-            ],
-            'multiple declarations, multiple !important rules at the beginning' => [
-                'width: 1px !important; height: 1px !important; color: red; float: left',
-                'color: red; float: left; width: 1px; height: 1px;'
-            ],
-            'multiple declarations, multiple consecutive !important rules somewhere in the middle (#1)' => [
-                'color: red; width: 1px !important; height: 1px !important; float: left',
-                'color: red; float: left; width: 1px; height: 1px;'
-            ],
-            'multiple declarations, multiple consecutive !important rules somewhere in the middle (#2)' => [
-                'color: red; width: 1px !important; height: 1px !important; float: left; clear: both',
-                'color: red; float: left; clear: both; width: 1px; height: 1px;'
-            ],
-            'multiple declarations, multiple not consecutive !important rules somewhere in the middle' => [
-                'color: red; width: 1px !important; clear: both; height: 1px !important; float: left',
-                'color: red; clear: both; float: left; width: 1px; height: 1px;'
-            ],
-            'multiple declarations, multiple !important rules at the end' => [
-                'color: red; float: left; width: 1px !important; height: 1px !important',
-                'color: red; float: left; width: 1px; height: 1px;'
-            ],
-        ];
-    }
-
-    /**
-     * @test
-     *
-     * @param string $originalStyleAttributeContent
-     * @param string $expectedStyleAttributeContent
-     *
-     * @dataProvider cssForImportantRuleRemovalDataProvider
-     */
-    public function emogrifyRemovesImportantRule($originalStyleAttributeContent, $expectedStyleAttributeContent)
-    {
-        $this->subject->setHtml(
-            '<html><head><body><p style="' . $originalStyleAttributeContent . '"></p></body></html>'
-        );
-
-        $result = $this->subject->emogrify();
-
-        self::assertContains('<p style="' . $expectedStyleAttributeContent . '">', $result);
-    }
-
-    /**
-     * @test
-     *
-     * @expectedException \InvalidArgumentException
-     */
-    public function emogrifyInDebugModeThrowsInvalidArgumentExceptionForInvalidSelectorsInMediaQueryBlocks()
-    {
-        $this->subject->setDebug(true);
-
-        $this->subject->setHtml('<html></html>');
-        $this->subject->setCss('@media screen {p^^ {color: red;}}');
-
-        $this->subject->emogrify();
-    }
-
-    /**
-     * @test
-     */
-    public function emogrifyNotInDebugModeKeepsInvalidOrUnrecognizedSelectorsInMediaQueryBlocks()
-    {
-        $this->subject->setDebug(false);
-
-        $this->subject->setHtml('<html></html>');
-        $css = '@media screen {p^^ {color: red;}}';
-        $this->subject->setCss($css);
-
-        $result = $this->subject->emogrify();
-
-        $this->assertContains($css, $result);
-    }
-}
index 016370aee5877e117c012c1a852e9dc63c4b4493..b2949f9bbd2f1ef09b7141183bc8f9d37a9f96b7 100644 (file)
     "license": "MIT",
     "authors": [
         {
-            "name": "John Reeve",
-            "email": "jreeve@pelagodesign.com"
+            "name": "Oliver Klee",
+            "email": "github@oliverklee.de"
         },
         {
-            "name": "Cameron Brooks"
+            "name": "Zoli Szabó",
+            "email": "zoli.szabo+github@gmail.com"
         },
         {
-            "name": "Jaime Prado"
+            "name": "John Reeve",
+            "email": "jreeve@pelagodesign.com"
         },
         {
-            "name": "Oliver Klee",
-            "email": "github@oliverklee.de"
+            "name": "Jake Hotson",
+            "email": "jake@qzdesign.co.uk"
         },
         {
-            "name": "Roman Ožana",
-            "email": "ozana@omdesign.cz"
+            "name": "Cameron Brooks"
         },
         {
-            "name": "Zoli Szabó",
-            "email": "zoli.szabo+github@gmail.com"
+            "name": "Jaime Prado"
         }
     ],
     "support": {
         "source": "https://github.com/MyIntervals/emogrifier"
     },
     "require": {
-        "php": "^5.5.0 || ~7.0.0 || ~7.1.0 || ~7.2.0"
+        "php": "^5.5.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0",
+        "ext-dom": "*",
+        "ext-libxml": "*",
+        "symfony/css-selector": "^3.4.0 || ^4.0.0"
     },
     "require-dev": {
-        "squizlabs/php_codesniffer": "^3.1.0",
+        "friendsofphp/php-cs-fixer": "^2.2.0",
+        "squizlabs/php_codesniffer": "^3.3.2",
+        "phpmd/phpmd": "^2.6.0",
         "phpunit/phpunit": "^4.8.0"
     },
     "autoload": {
         "psr-4": {
-            "Pelago\\": "Classes/"
+            "Pelago\\": "src/"
         }
     },
     "autoload-dev": {
         "psr-4": {
-            "Pelago\\Tests\\": "Tests/"
+            "Pelago\\Tests\\": "tests/"
+        }
+    },
+    "prefer-stable": true,
+    "config": {
+        "preferred-install": {
+            "*": "dist"
         }
     },
+    "scripts": {
+        "php:version": "php -v | grep -Po 'PHP\\s++\\K(?:\\d++\\.)*+\\d++(?:-\\w++)?+'",
+        "php:fix": "php-cs-fixer --config=config/php-cs-fixer.php fix config/ src/ tests/",
+        "ci:php:lint": "find config src tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l",
+        "ci:php:sniff": "phpcs config src tests",
+        "ci:php:md": "phpmd src text config/phpmd.xml",
+        "ci:tests:unit": "phpunit tests/",
+        "ci:tests": [
+            "@ci:tests:unit"
+        ],
+        "ci:dynamic": [
+            "@ci:tests"
+        ],
+        "ci:static": [
+            "@ci:php:lint",
+            "@ci:php:sniff",
+            "@ci:php:md"
+        ],
+        "ci": [
+            "@ci:static",
+            "@ci:dynamic"
+        ]
+    },
     "extra": {
         "branch-alias": {
             "dev-master": "2.1.x-dev"
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/php-cs-fixer.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/php-cs-fixer.php
new file mode 100644 (file)
index 0000000..4e379ee
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+
+if (PHP_SAPI !== 'cli') {
+    die('This script supports command line usage only. Please check your command.');
+}
+
+return \PhpCsFixer\Config::create()
+    ->setRiskyAllowed(true)
+    ->setRules(
+        [
+            // copied from the TYPO3 Core
+            '@PSR2' => true,
+            '@DoctrineAnnotation' => true,
+            'no_leading_import_slash' => true,
+            'no_trailing_comma_in_singleline_array' => true,
+            'no_singleline_whitespace_before_semicolons' => true,
+            'no_unused_imports' => true,
+            'concat_space' => ['spacing' => 'one'],
+            'no_whitespace_in_blank_line' => true,
+            'ordered_imports' => true,
+            'single_quote' => true,
+            'no_empty_statement' => true,
+            'no_extra_consecutive_blank_lines' => true,
+            'phpdoc_no_package' => true,
+            'phpdoc_scalar' => true,
+            'no_blank_lines_after_phpdoc' => true,
+            'array_syntax' => ['syntax' => 'short'],
+            'whitespace_after_comma_in_array' => true,
+            'function_typehint_space' => true,
+            'hash_to_slash_comment' => true,
+            'no_alias_functions' => true,
+            'lowercase_cast' => true,
+            'no_leading_namespace_whitespace' => true,
+            'native_function_casing' => true,
+            'no_short_bool_cast' => true,
+            'no_unneeded_control_parentheses' => true,
+            'phpdoc_trim' => true,
+            'no_superfluous_elseif' => true,
+            'no_useless_else' => true,
+            'phpdoc_types' => true,
+            'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'],
+            'return_type_declaration' => ['space_before' => 'none'],
+            'cast_spaces' => ['space' => 'none'],
+            'declare_equal_normalize' => ['space' => 'single'],
+            'dir_constant' => true,
+
+            // additional rules
+            'combine_consecutive_issets' => true,
+            'combine_consecutive_unsets' => true,
+            'compact_nullable_typehint' => true,
+            // PHP >= 7.0
+            // 'declare_strict_types' => true,
+            'elseif' => true,
+            'encoding' => true,
+            'escape_implicit_backslashes' => ['single_quoted' => true],
+            'is_null' => true,
+            'linebreak_after_opening_tag' => true,
+            'magic_constant_casing' => true,
+            'method_separation' => true,
+            'modernize_types_casting' => true,
+            // not yet, but maybe later to improve performance
+            // 'native_function_invocation' => true,
+            'new_with_braces' => true,
+            'no_blank_lines_after_class_opening' => true,
+            'no_empty_comment' => true,
+            'no_empty_phpdoc' => true,
+            'no_extra_blank_lines' => true,
+            'no_multiline_whitespace_before_semicolons' => true,
+            'no_php4_constructor' => true,
+            'no_short_echo_tag' => true,
+            'no_spaces_after_function_name' => true,
+            'no_spaces_inside_parenthesis' => true,
+            'no_unneeded_curly_braces' => true,
+            'no_useless_return' => true,
+            'no_whitespace_before_comma_in_array' => true,
+            'php_unit_construct' => true,
+            'php_unit_fqcn_annotation' => true,
+            'php_unit_set_up_tear_down_visibility' => true,
+            'phpdoc_add_missing_param_annotation' => true,
+            'phpdoc_indent' => true,
+            'phpdoc_separation' => true,
+            'semicolon_after_instruction' => true,
+            'short_scalar_cast' => true,
+            'space_after_semicolon' => true,
+            'standardize_not_equals' => true,
+            'psr4' => true,
+            'ternary_operator_spaces' => true,
+            // PHP >= 7.0
+            // 'ternary_to_null_coalescing' => true,
+            'trailing_comma_in_multiline_array' => true,
+            'unary_operator_spaces' => true,
+        ]
+    );
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/phpmd.xml b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/phpmd.xml
new file mode 100644 (file)
index 0000000..569409b
--- /dev/null
@@ -0,0 +1,49 @@
+<?xml version="1.0"?>
+<ruleset name="phpList">
+    <description>
+        PHPMD rules for Emogrifier
+    </description>
+
+    <!-- The commented-out rules will be enabled once the code does not generate any warnings anymore. -->
+
+    <rule ref="rulesets/cleancode.xml/BooleanArgumentFlag"/>
+    <rule ref="rulesets/cleancode.xml/StaticAccess"/>
+
+    <rule ref="rulesets/codesize.xml/CyclomaticComplexity"/>
+    <rule ref="rulesets/codesize.xml/NPathComplexity"/>
+    <rule ref="rulesets/codesize.xml/ExcessiveMethodLength"/>
+    <!--<rule ref="rulesets/codesize.xml/ExcessiveClassLength"/>-->
+    <!--<rule ref="rulesets/codesize.xml/ExcessiveParameterList"/>-->
+    <rule ref="rulesets/codesize.xml/ExcessivePublicCount"/>
+    <!--<rule ref="rulesets/codesize.xml/TooManyFields"/>-->
+    <!--<rule ref="rulesets/codesize.xml/TooManyMethods"/>-->
+    <!--<rule ref="rulesets/codesize.xml/TooManyPublicMethods"/>-->
+    <!--<rule ref="rulesets/codesize.xml/ExcessiveClassComplexity"/>-->
+
+    <rule ref="rulesets/controversial.xml/Superglobals"/>
+    <rule ref="rulesets/controversial.xml/CamelCaseClassName"/>
+    <rule ref="rulesets/controversial.xml/CamelCasePropertyName"/>
+    <rule ref="rulesets/controversial.xml/CamelCaseMethodName"/>
+    <rule ref="rulesets/controversial.xml/CamelCaseParameterName"/>
+    <rule ref="rulesets/controversial.xml/CamelCaseVariableName"/>
+
+    <rule ref="rulesets/design.xml/ExitExpression"/>
+    <rule ref="rulesets/design.xml/EvalExpression"/>
+    <rule ref="rulesets/design.xml/GotoStatement"/>
+    <rule ref="rulesets/design.xml/NumberOfChildren"/>
+    <rule ref="rulesets/design.xml/DepthOfInheritance"/>
+    <rule ref="rulesets/design.xml/CouplingBetweenObjects"/>
+    <rule ref="rulesets/design.xml/DevelopmentCodeFragment"/>
+
+    <!--<rule ref="rulesets/naming.xml/ShortVariable"/>-->
+    <!--<rule ref="rulesets/naming.xml/LongVariable"/>-->
+    <rule ref="rulesets/naming.xml/ShortMethodName"/>
+    <rule ref="rulesets/naming.xml/ConstructorWithNameAsEnclosingClass"/>
+    <rule ref="rulesets/naming.xml/ConstantNamingConventions"/>
+    <rule ref="rulesets/naming.xml/BooleanGetMethodName"/>
+
+    <rule ref="rulesets/unusedcode.xml/UnusedPrivateField"/>
+    <rule ref="rulesets/unusedcode.xml/UnusedLocalVariable"/>
+    <!--<rule ref="rulesets/unusedcode.xml/UnusedPrivateMethod"/>-->
+    <!--<rule ref="rulesets/unusedcode.xml/UnusedFormalParameter"/>-->
+</ruleset>
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/phpcs.xml.dist b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/phpcs.xml.dist
new file mode 100644 (file)
index 0000000..d87f19c
--- /dev/null
@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ruleset name="Coding Standard">
+    <description>
+        This standard requires PHP_CodeSniffer >= 3.2.0.
+    </description>
+
+    <config name="installed_paths" value="../../slevomat/coding-standard"/>
+
+    <!--The complete PSR-2 ruleset-->
+    <rule ref="PSR2"/>
+
+    <!-- Arrays -->
+    <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
+    <rule ref="Squiz.Arrays.ArrayBracketSpacing"/>
+    <rule ref="Squiz.Arrays.ArrayDeclaration.NoCommaAfterLast"/>
+
+    <!-- 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.AssignmentInCondition"/>
+    <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.FunctionComment">
+        <!-- Allow PHP-5-compatible type hinting. -->
+        <exclude name="Squiz.Commenting.FunctionComment.ScalarTypeHintMissing"/>
+        <!-- Allow no comment for self-describing parameter and exception class names. -->
+        <exclude name="Squiz.Commenting.FunctionComment.MissingParamComment"/>
+        <exclude name="Squiz.Commenting.FunctionComment.EmptyThrows"/>
+        <!-- Allow "int" rather than "integer", etc., in PHPDoc. -->
+        <exclude name="Squiz.Commenting.FunctionComment.IncorrectParamVarName"/>
+        <exclude name="Squiz.Commenting.FunctionComment.InvalidReturn"/>
+        <!-- Allow "@return" to be omitted (for methods which do not return a value). -->
+        <exclude name="Squiz.Commenting.FunctionComment.MissingReturn"/>
+        <!-- Allow parameter type, name and comment not all vertically aligned. -->
+        <exclude name="Squiz.Commenting.FunctionComment.SpacingAfterParamType"/>
+        <exclude name="Squiz.Commenting.FunctionComment.SpacingAfterParamName"/>
+        <!-- Allow parameter and exception descriptions which are not full sentences. -->
+        <exclude name="Squiz.Commenting.FunctionComment.ParamCommentNotCapital"/>
+        <exclude name="Squiz.Commenting.FunctionComment.ParamCommentFullStop"/>
+        <exclude name="Squiz.Commenting.FunctionComment.ThrowsNotCapital"/>
+        <exclude name="Squiz.Commenting.FunctionComment.ThrowsNoFullStop"/>
+    </rule>
+    <rule ref="Squiz.Commenting.FunctionCommentThrowTag"/>
+    <rule ref="Squiz.Commenting.PostStatementComment"/>
+
+    <!-- Control structures -->
+    <rule ref="PEAR.ControlStructures.ControlSignature"/>
+
+    <!-- Debug -->
+    <rule ref="Generic.Debug.ClosureLinter"/>
+
+    <!-- Files -->
+    <rule ref="Generic.Files.OneClassPerFile"/>
+    <rule ref="Generic.Files.OneInterfacePerFile"/>
+    <rule ref="Generic.Files.OneObjectStructurePerFile"/>
+    <rule ref="Zend.Files.ClosingTag"/>
+
+    <!-- Formatting -->
+    <rule ref="Generic.Formatting.NoSpaceAfterCast"/>
+    <rule ref="PEAR.Formatting.MultiLineAssignment"/>
+
+    <!-- Functions -->
+    <rule ref="Generic.Functions.CallTimePassByReference"/>
+    <rule ref="SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions"/>
+    <rule ref="Squiz.Functions.FunctionDuplicateArgument"/>
+    <rule ref="Squiz.Functions.GlobalFunction"/>
+
+    <!-- Metrics -->
+    <rule ref="Generic.Metrics.CyclomaticComplexity"/>
+    <rule ref="Generic.Metrics.NestingLevel"/>
+
+    <!-- Naming conventions -->
+    <rule ref="Generic.NamingConventions.ConstructorName"/>
+    <rule ref="PEAR.NamingConventions.ValidClassName"/>
+
+    <!-- Objects -->
+    <rule ref="Squiz.Objects.ObjectMemberComma"/>
+
+    <!-- Operators -->
+    <rule ref="Squiz.Operators.IncrementDecrementUsage"/>
+    <rule ref="Squiz.Operators.ValidLogicalOperators"/>
+
+    <!-- PHP -->
+    <rule ref="Generic.PHP.BacktickOperator"/>
+    <rule ref="Generic.PHP.CharacterBeforePHPOpeningTag"/>
+    <rule ref="Generic.PHP.DeprecatedFunctions"/>
+    <rule ref="Generic.PHP.DisallowAlternativePHPTags"/>
+    <rule ref="Generic.PHP.DisallowShortOpenTag"/>
+    <rule ref="Generic.PHP.DiscourageGoto"/>
+    <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.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"/>
+
+    <!--Strings-->
+    <rule ref="Squiz.Strings.DoubleQuoteUsage"/>
+
+    <!-- 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"/>
+</ruleset>
\ No newline at end of file
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier.php
new file mode 100644 (file)
index 0000000..08f5eb6
--- /dev/null
@@ -0,0 +1,2020 @@
+<?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.
+ *
+ * @author Cameron Brooks
+ * @author Jaime Prado
+ * @author Oliver Klee <github@oliverklee.de>
+ * @author Roman Ožana <ozana@omdesign.cz>
+ * @author Sander Kruger <s.kruger@invessel.com>
+ * @author Zoli Szabó <zoli.szabo+github@gmail.com>
+ */
+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\\-]+)+)/';
+
+    /**
+     * Regular expression component matching a static pseudo class in a selector, without the preceding ":",
+     * for which the applicable elements can be determined (by converting the selector to an XPath expression).
+     * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
+     * group, as appropriate for the usage context.)
+     *
+     * @var string
+     */
+    const PSEUDO_CLASS_MATCHER = '\\S+\\-(?:child|type\\()|not\\([[:ascii:]]*\\)';
+
+    /**
+     * @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 \DOMDocument
+     */
+    protected $domDocument = null;
+
+    /**
+     * @var string
+     */
+    private $css = '';
+
+    /**
+     * @var bool[]
+     */
+    private $excludedSelectors = [];
+
+    /**
+     * @var string[]
+     */
+    private $unprocessableHtmlTags = ['wbr'];
+
+    /**
+     * @var bool[]
+     */
+    private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
+
+    /**
+     * @var mixed[]
+     */
+    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 string[][]
+     */
+    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 $shouldRemoveInvisibleNodes = true;
+
+    /**
+     * For calculating selector precedence order.
+     * Keys are a regular expression part to match before a CSS name.
+     * Values are a multiplier factor per match to weight specificity.
+     *
+     * @var int[]
+     */
+    private $selectorPrecedenceMatchers = [
+        // IDs: worth 10000
+        '\\#' => 10000,
+        // classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
+        '(?:\\.|\\[|(?<!:):(?!not\\())' => 100,
+        // elements (not attribute values or `:not`), pseudo-elements: worth 1
+        '(?:(?<![="\':\\w\\-])|::)' => 1,
+    ];
+
+    /**
+     * @var string[]
+     */
+    private $xPathRules = [
+        // attribute presence
+        '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/' => '*[@\\1]',
+        // type and attribute exact value
+        '/(\\w)\\[(\\w+)\\=[\'"]?([\\w\\s]+)[\'"]?\\]/' => '\\1[@\\2="\\3"]',
+        // type and attribute value with ~ (one word within a whitespace-separated list of words)
+        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w\\-_\\/]+)[\'"]?\\]/'
+        => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
+        // type and attribute value with | (either exact value match or prefix followed by a hyphen)
+        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w\\-_\\s\\/]+)[\'"]?\\]/'
+        => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
+        // type and attribute value with ^ (prefix match)
+        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w\\-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
+        // type and attribute value with * (substring match)
+        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w\\-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
+        // adjacent sibling
+        '/\\s*\\+\\s*/' => '/following-sibling::*[1]/self::',
+        // child
+        '/\\s*>\\s*/' => '/',
+        // descendant (don't match spaces within already translated XPath predicates)
+        '/\\s+(?![^\\[\\]]*+\\])/' => '//',
+        // type and :first-child
+        '/([^\\/]+):first-child/i' => '*[1]/self::\\1',
+        // type and :last-child
+        '/([^\\/]+):last-child/i' => '*[last()]/self::\\1',
+
+        // The following matcher will break things if it is placed before the adjacent matcher.
+        // So one of the matchers matches either too much or not enough.
+        // type and attribute value with $ (suffix match)
+        '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w\\-_\\s\\/]+)[\'"]?\\]/'
+        => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
+    ];
+
+    /**
+     * Determines whether CSS styles that have an equivalent HTML attribute
+     * should be mapped and attached to those elements.
+     *
+     * @var bool
+     */
+    private $shouldMapCssToHtml = false;
+
+    /**
+     * This multi-level array contains simple mappings of CSS properties to
+     * HTML attributes. If a mapping only applies to certain HTML nodes or
+     * only for certain values, the mapping is an object with a whitelist
+     * of nodes and values.
+     *
+     * @var mixed[][]
+     */
+    private $cssToHtmlMap = [
+        'background-color' => [
+            'attribute' => 'bgcolor',
+        ],
+        'text-align' => [
+            'attribute' => 'align',
+            'nodes' => ['p', 'div', 'td'],
+            'values' => ['left', 'right', 'center', 'justify'],
+        ],
+        'float' => [
+            'attribute' => 'align',
+            'nodes' => ['table', 'img'],
+            'values' => ['left', 'right'],
+        ],
+        'border-spacing' => [
+            'attribute' => 'cellspacing',
+            'nodes' => ['table'],
+        ],
+    ];
+
+    /**
+     * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
+     *
+     * @var bool
+     */
+    private $debug = false;
+
+    /**
+     * @param string $unprocessedHtml the HTML to process, must be UTF-8-encoded
+     * @param string $css the CSS to merge, must be UTF-8-encoded
+     */
+    public function __construct($unprocessedHtml = '', $css = '')
+    {
+        if ($unprocessedHtml !== '') {
+            $this->setHtml($unprocessedHtml);
+        }
+        $this->setCss($css);
+    }
+
+    /**
+     * Sets the HTML to process.
+     *
+     * @param string $html the HTML to process, must be UTF-encoded, must not be empty
+     *
+     * @return void
+     *
+     * @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
+     */
+    public function setHtml($html)
+    {
+        if (!\is_string($html)) {
+            throw new \InvalidArgumentException('The provided HTML must be a string.', 1540403913);
+        }
+        if ($html === '') {
+            throw new \InvalidArgumentException('The provided HTML must not be empty.', 1540403910);
+        }
+
+        $this->createUnifiedDomDocument($html);
+    }
+
+    /**
+     * Provides access to the internal DOMDocument representation of the HTML in its current state.
+     *
+     * @return \DOMDocument
+     */
+    public function getDomDocument()
+    {
+        return $this->domDocument;
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * Renders the normalized and processed HTML.
+     *
+     * @return string
+     */
+    protected function render()
+    {
+        return $this->domDocument->saveHTML();
+    }
+
+    /**
+     * Renders the content of the BODY element of the normalized and processed HTML.
+     *
+     * @return string
+     */
+    protected function renderBodyContent()
+    {
+        $bodyNodeHtml = $this->domDocument->saveHTML($this->getBodyElement());
+
+        return \str_replace(['<body>', '</body>'], '', $bodyNodeHtml);
+    }
+
+    /**
+     * Returns the BODY element.
+     *
+     * This method assumes that there always is a BODY element.
+     *
+     * @return \DOMElement
+     */
+    private function getBodyElement()
+    {
+        return $this->domDocument->getElementsByTagName('body')->item(0);
+    }
+
+    /**
+     * Returns the HEAD element.
+     *
+     * This method assumes that there always is a HEAD element.
+     *
+     * @return \DOMElement
+     */
+    private function getHeadElement()
+    {
+        return $this->domDocument->getElementsByTagName('head')->item(0);
+    }
+
+    /**
+     * Applies $this->css to the given HTML and returns the HTML with the CSS
+     * applied.
+     *
+     * This method places the CSS inline.
+     *
+     * @return string
+     *
+     * @throws \BadMethodCallException
+     */
+    public function emogrify()
+    {
+        $this->assertExistenceOfHtml();
+
+        $this->process();
+
+        return $this->render();
+    }
+
+    /**
+     * Applies $this->css to the given HTML and returns only the HTML content
+     * within the <body> tag.
+     *
+     * This method places the CSS inline.
+     *
+     * @return string
+     *
+     * @throws \BadMethodCallException
+     */
+    public function emogrifyBodyContent()
+    {
+        $this->assertExistenceOfHtml();
+
+        $this->process();
+
+        return $this->renderBodyContent();
+    }
+
+    /**
+     * Checks that some HTML has been set, and throws an exception otherwise.
+     *
+     * @return void
+     *
+     * @throws \BadMethodCallException
+     */
+    private function assertExistenceOfHtml()
+    {
+        if ($this->domDocument === null) {
+            throw new \BadMethodCallException('Please set some HTML first.', 1390393096);
+        }
+    }
+
+    /**
+     * Creates a DOM document from the given HTML and stores it in $this->domDocument.
+     *
+     * The DOM document will always have a BODY element.
+     *
+     * @param string $html
+     *
+     * @return void
+     */
+    private function createUnifiedDomDocument($html)
+    {
+        $this->createRawDomDocument($html);
+        $this->ensureExistenceOfBodyElement();
+    }
+
+    /**
+     * Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
+     *
+     * @param string $html
+     *
+     * @return void
+     */
+    private function createRawDomDocument($html)
+    {
+        $domDocument = new \DOMDocument();
+        $domDocument->encoding = 'UTF-8';
+        $domDocument->strictErrorChecking = false;
+        $domDocument->formatOutput = true;
+        $libXmlState = \libxml_use_internal_errors(true);
+        $domDocument->loadHTML($this->prepareHtmlForDomConversion($html));
+        \libxml_clear_errors();
+        \libxml_use_internal_errors($libXmlState);
+        $domDocument->normalizeDocument();
+
+        $this->domDocument = $domDocument;
+    }
+
+    /**
+     * Returns the HTML with added document type and Content-Type meta tag if needed,
+     * ensuring that the HTML will be good for creating a DOM document from it.
+     *
+     * @param string $html
+     *
+     * @return string the unified HTML
+     */
+    private function prepareHtmlForDomConversion($html)
+    {
+        $htmlWithDocumentType = $this->ensureDocumentType($html);
+
+        return $this->addContentTypeMetaTag($htmlWithDocumentType);
+    }
+
+    /**
+     * Applies $this->css to $this->domDocument.
+     *
+     * This method places the CSS inline.
+     *
+     * @return void
+     *
+     * @throws \InvalidArgumentException
+     */
+    protected function process()
+    {
+        $this->clearAllCaches();
+        $this->purgeVisitedNodes();
+
+        $xPath = new \DOMXPath($this->domDocument);
+        \set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING);
+        $this->removeUnprocessableTags();
+        $this->normalizeStyleAttributesOfAllNodes($xPath);
+
+        // 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);
+        }
+
+        $excludedNodes = $this->getNodesToExclude($xPath);
+        $cssRules = $this->parseCssRules($allCss);
+        foreach ($cssRules['inlineable'] as $cssRule) {
+            // There's no real way to test "PHP Warning" output generated by the following XPath query unless PHPUnit
+            // converts it to an exception. Unfortunately, this would only apply to tests and not work for production
+            // executions, which can still flood logs/output unnecessarily. Instead, Emogrifier's error handler should
+            // always throw an exception and it must be caught here and only rethrown if in debug mode.
+            try {
+                // \DOMXPath::query will always return a DOMNodeList or throw an exception when errors are caught.
+                $nodesMatchingCssSelectors = $xPath->query($this->translateCssToXpath($cssRule['selector']));
+            } catch (\InvalidArgumentException $e) {
+                if ($this->debug) {
+                    throw $e;
+                }
+                continue;
+            }
+
+            /** @var \DOMElement $node */
+            foreach ($nodesMatchingCssSelectors as $node) {
+                if (\in_array($node, $excludedNodes, true)) {
+                    continue;
+                }
+                $this->copyInlineableCssToStyleAttribute($node, $cssRule);
+            }
+        }
+
+        if ($this->isInlineStyleAttributesParsingEnabled) {
+            $this->fillStyleAttributesWithMergedStyles();
+        }
+        $this->postProcess($xPath);
+
+        $this->removeImportantAnnotationFromAllInlineStyles($xPath);
+
+        $this->copyUninlineableCssToStyleNode($xPath, $cssRules['uninlineable']);
+
+        \restore_error_handler();
+    }
+
+    /**
+     * Applies some optional post-processing to the HTML in the DOM document.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return void
+     */
+    private function postProcess(\DOMXPath $xPath)
+    {
+        if ($this->shouldMapCssToHtml) {
+            $this->mapAllInlineStylesToHtmlAttributes($xPath);
+        }
+        if ($this->shouldRemoveInvisibleNodes) {
+            $this->removeInvisibleNodes($xPath);
+        }
+    }
+
+    /**
+     * Searches for all nodes with a style attribute, transforms the CSS found
+     * to HTML attributes and adds those attributes to each node.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return void
+     */
+    private function mapAllInlineStylesToHtmlAttributes(\DOMXPath $xPath)
+    {
+        /** @var \DOMElement $node */
+        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
+            $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
+            $this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
+        }
+    }
+
+    /**
+     * Searches for all nodes with a style attribute and removes the "!important" annotations out of
+     * the inline style declarations, eventually by rearranging declarations.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return void
+     */
+    private function removeImportantAnnotationFromAllInlineStyles(\DOMXPath $xPath)
+    {
+        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
+            $this->removeImportantAnnotationFromNodeInlineStyle($node);
+        }
+    }
+
+    /**
+     * Removes the "!important" annotations out of the inline style declarations,
+     * eventually by rearranging declarations.
+     * Rearranging needed when !important shorthand properties are followed by some of their
+     * not !important expanded-version properties.
+     * For example "font: 12px serif !important; font-size: 13px;" must be reordered
+     * to "font-size: 13px; font: 12px serif;" in order to remain correct.
+     *
+     * @param \DOMElement $node
+     *
+     * @return void
+     */
+    private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node)
+    {
+        $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
+        $regularStyleDeclarations = [];
+        $importantStyleDeclarations = [];
+        foreach ($inlineStyleDeclarations as $property => $value) {
+            if ($this->attributeValueIsImportant($value)) {
+                $importantStyleDeclarations[$property] = \trim(\str_replace('!important', '', $value));
+            } else {
+                $regularStyleDeclarations[$property] = $value;
+            }
+        }
+        $inlineStyleDeclarationsInNewOrder = \array_merge(
+            $regularStyleDeclarations,
+            $importantStyleDeclarations
+        );
+        $node->setAttribute(
+            'style',
+            $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
+        );
+    }
+
+    /**
+     * Returns a list with all DOM nodes that have a style attribute.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return \DOMNodeList
+     */
+    private function getAllNodesWithStyleAttribute(\DOMXPath $xPath)
+    {
+        return $xPath->query('//*[@style]');
+    }
+
+    /**
+     * Applies $styles to $node.
+     *
+     * This method maps CSS styles to HTML attributes and adds those to the
+     * node.
+     *
+     * @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return void
+     */
+    private function mapCssToHtmlAttributes(array $styles, \DOMElement $node)
+    {
+        foreach ($styles as $property => $value) {
+            // Strip !important indicator
+            $value = \trim(\str_replace('!important', '', $value));
+            $this->mapCssToHtmlAttribute($property, $value, $node);
+        }
+    }
+
+    /**
+     * Tries to apply the CSS style to $node as an attribute.
+     *
+     * This method maps a CSS rule to HTML attributes and adds those to the node.
+     *
+     * @param string $property the name of the CSS property to map
+     * @param string $value the value of the style rule to map
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return void
+     */
+    private function mapCssToHtmlAttribute($property, $value, \DOMElement $node)
+    {
+        if (!$this->mapSimpleCssProperty($property, $value, $node)) {
+            $this->mapComplexCssProperty($property, $value, $node);
+        }
+    }
+
+    /**
+     * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
+     *
+     * @param string $property the name of the CSS property to map
+     * @param string $value the value of the style rule to map
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return bool true if the property can be mapped using the simple mapping table
+     */
+    private function mapSimpleCssProperty($property, $value, \DOMElement $node)
+    {
+        if (!isset($this->cssToHtmlMap[$property])) {
+            return false;
+        }
+
+        $mapping = $this->cssToHtmlMap[$property];
+        $nodesMatch = !isset($mapping['nodes']) || \in_array($node->nodeName, $mapping['nodes'], true);
+        $valuesMatch = !isset($mapping['values']) || \in_array($value, $mapping['values'], true);
+        if (!$nodesMatch || !$valuesMatch) {
+            return false;
+        }
+
+        $node->setAttribute($mapping['attribute'], $value);
+
+        return true;
+    }
+
+    /**
+     * Maps CSS properties that need special transformation to an HTML attribute.
+     *
+     * @param string $property the name of the CSS property to map
+     * @param string $value the value of the style rule to map
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return void
+     */
+    private function mapComplexCssProperty($property, $value, \DOMElement $node)
+    {
+        switch ($property) {
+            case 'background':
+                $this->mapBackgroundProperty($node, $value);
+                break;
+            case 'width':
+                // intentional fall-through
+            case 'height':
+                $this->mapWidthOrHeightProperty($node, $value, $property);
+                break;
+            case 'margin':
+                $this->mapMarginProperty($node, $value);
+                break;
+            case 'border':
+                $this->mapBorderProperty($node, $value);
+                break;
+            default:
+        }
+    }
+
+    /**
+     * Maps the "background" CSS property to visual HTML attributes.
+     *
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     *
+     * @return void
+     */
+    private function mapBackgroundProperty(\DOMElement $node, $value)
+    {
+        // parse out the color, if any
+        $styles = \explode(' ', $value);
+        $first = $styles[0];
+        if (!\is_numeric($first[0]) && \strpos($first, 'url') !== 0) {
+            // as this is not a position or image, assume it's a color
+            $node->setAttribute('bgcolor', $first);
+        }
+    }
+
+    /**
+     * Maps the "width" or "height" CSS properties to visual HTML attributes.
+     *
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     * @param string $property the name of the CSS property to map
+     *
+     * @return void
+     */
+    private function mapWidthOrHeightProperty(\DOMElement $node, $value, $property)
+    {
+        // only parse values in px and %, but not values like "auto"
+        if (\preg_match('/^\\d+(px|%)$/', $value)) {
+            // Remove 'px'. This regex only conserves numbers and %.
+            $number = \preg_replace('/[^0-9.%]/', '', $value);
+            $node->setAttribute($property, $number);
+        }
+    }
+
+    /**
+     * Maps the "margin" CSS property to visual HTML attributes.
+     *
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     *
+     * @return void
+     */
+    private function mapMarginProperty(\DOMElement $node, $value)
+    {
+        if (!$this->isTableOrImageNode($node)) {
+            return;
+        }
+
+        $margins = $this->parseCssShorthandValue($value);
+        if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
+            $node->setAttribute('align', 'center');
+        }
+    }
+
+    /**
+     * Maps the "border" CSS property to visual HTML attributes.
+     *
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     *
+     * @return void
+     */
+    private function mapBorderProperty(\DOMElement $node, $value)
+    {
+        if (!$this->isTableOrImageNode($node)) {
+            return;
+        }
+
+        if ($value === 'none' || $value === '0') {
+            $node->setAttribute('border', '0');
+        }
+    }
+
+    /**
+     * Checks whether $node is a table or img element.
+     *
+     * @param \DOMElement $node
+     *
+     * @return bool
+     */
+    private function isTableOrImageNode(\DOMElement $node)
+    {
+        return $node->nodeName === 'table' || $node->nodeName === 'img';
+    }
+
+    /**
+     * Parses a shorthand CSS value and splits it into individual values
+     *
+     * @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
+     *                      For example: padding: 0 auto;
+     *                      '0 auto' is split into top: 0, left: auto, bottom: 0,
+     *                      right: auto.
+     *
+     * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
+     */
+    private function parseCssShorthandValue($value)
+    {
+        $values = \preg_split('/\\s+/', $value);
+
+        $css = [];
+        $css['top'] = $values[0];
+        $css['right'] = (\count($values) > 1) ? $values[1] : $css['top'];
+        $css['bottom'] = (\count($values) > 2) ? $values[2] : $css['top'];
+        $css['left'] = (\count($values) > 3) ? $values[3] : $css['right'];
+
+        return $css;
+    }
+
+    /**
+     * Extracts and parses the individual rules from a CSS string.
+     *
+     * @param string $css a string of raw CSS code
+     *
+     * @return string[][][] A 2-entry array with the key "inlineable" containing rules which can be inlined as `style`
+     *         attributes and the key "uninlineable" containing rules which cannot.  Each value is an array of string
+     *         sub-arrays with the keys
+     *         "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
+     *         or an empty string if not from a `@media` rule),
+     *         "selector" (the CSS selector, e.g., "*" or "header h1"),
+     *         "hasUnmatchablePseudo" (true if that selector contains psuedo-elements or dynamic pseudo-classes
+     *         such that the declarations cannot be applied inline),
+     *         "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
+     *         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[static::CACHE_KEY_CSS][$cssKey])) {
+            $matches = $this->getCssRuleMatches($css);
+
+            $cssRules = [
+                'inlineable' => [],
+                'uninlineable' => [],
+            ];
+            /** @var string[][] $matches */
+            /** @var string[] $cssRule */
+            foreach ($matches as $key => $cssRule) {
+                $cssDeclaration = \trim($cssRule['declarations']);
+                if ($cssDeclaration === '') {
+                    continue;
+                }
+
+                $selectors = \explode(',', $cssRule['selectors']);
+                foreach ($selectors as $selector) {
+                    // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
+                    // only allow structural pseudo-classes
+                    $hasPseudoElement = \strpos($selector, '::') !== false;
+                    $hasUnsupportedPseudoClass = (bool)\preg_match(
+                        '/:(?!' . static::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i',
+                        $selector
+                    );
+                    $hasUnmatchablePseudo = $hasPseudoElement || $hasUnsupportedPseudoClass;
+
+                    $parsedCssRule = [
+                        'media' => $cssRule['media'],
+                        'selector' => \trim($selector),
+                        'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
+                        'declarationsBlock' => $cssDeclaration,
+                        // keep track of where it appears in the file, since order is important
+                        'line' => $key,
+                    ];
+                    $ruleType = ($cssRule['media'] === '' && !$hasUnmatchablePseudo) ? 'inlineable' : 'uninlineable';
+                    $cssRules[$ruleType][] = $parsedCssRule;
+                }
+            }
+
+            \usort($cssRules['inlineable'], [$this, 'sortBySelectorPrecedence']);
+
+            $this->caches[static::CACHE_KEY_CSS][$cssKey] = $cssRules;
+        }
+
+        return $this->caches[static::CACHE_KEY_CSS][$cssKey];
+    }
+
+    /**
+     * Parses a string of CSS into the media query, selectors and declarations for each ruleset in order.
+     *
+     * @param string $css
+     *
+     * @return string[][] Array of string sub-arrays with the keys
+     *         "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
+     *         or an empty string if not from an `@media` rule),
+     *         "selectors" (the CSS selector(s), e.g., "*" or "h1, h2"),
+     *         "declarations" (the semicolon-separated CSS declarations for that/those selector(s),
+     *         e.g., "color: red; height: 4px;"),
+     */
+    private function getCssRuleMatches($css)
+    {
+        $ruleMatches = [];
+
+        $splitCss = $this->splitCssAndMediaQuery($css);
+        foreach ($splitCss as $cssPart) {
+            // process each part for selectors and definitions
+            \preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mi', $cssPart['css'], $matches, PREG_SET_ORDER);
+
+            /** @var string[][] $matches */
+            foreach ($matches as $cssRule) {
+                $ruleMatches[] = [
+                    'media' => $cssPart['media'],
+                    'selectors' => $cssRule[1],
+                    'declarations' => $cssRule[2],
+                ];
+            }
+        }
+
+        return $ruleMatches;
+    }
+
+    /**
+     * 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.
+     *
+     * @deprecated will be removed in Emogrifier 3.0
+     *
+     * @return void
+     */
+    public function disableInvisibleNodeRemoval()
+    {
+        $this->shouldRemoveInvisibleNodes = false;
+    }
+
+    /**
+     * Enables the attachment/override of HTML attributes for which a
+     * corresponding CSS property has been set.
+     *
+     * @deprecated will be removed in Emogrifier 3.0, use the CssToAttributeConverter instead
+     *
+     * @return void
+     */
+    public function enableCssToHtmlMapping()
+    {
+        $this->shouldMapCssToHtml = true;
+    }
+
+    /**
+     * Clears all caches.
+     *
+     * @return void
+     */
+    private function clearAllCaches()
+    {
+        $this->caches = [
+            static::CACHE_KEY_CSS => [],
+            static::CACHE_KEY_SELECTOR => [],
+            static::CACHE_KEY_XPATH => [],
+            static::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
+            static::CACHE_KEY_COMBINED_STYLES => [],
+        ];
+    }
+
+    /**
+     * 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);
+            }
+        }
+    }
+
+    /**
+     * Parses the document and normalizes 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 stores a reference of nodes with existing inline styles so we don't overwrite them.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return void
+     */
+    private function normalizeStyleAttributesOfAllNodes(\DOMXPath $xPath)
+    {
+        /** @var \DOMElement $node */
+        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
+            if ($this->isInlineStyleAttributesParsingEnabled) {
+                $this->normalizeStyleAttributes($node);
+            }
+            // Remove style attribute in every case, so we can add them back (if inline style attributes
+            // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
+            // else original inline style rules may remain at the beginning of the final inline style definition
+            // of a node, which may give not the desired results
+            $node->removeAttribute('style');
+        }
+    }
+
+    /**
+     * 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)
+    {
+        $cacheKey = \serialize([$oldStyles, $newStyles]);
+        if (isset($this->caches[static::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
+            return $this->caches[static::CACHE_KEY_COMBINED_STYLES][$cacheKey];
+        }
+
+        // Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
+        foreach ($oldStyles as $attributeName => $attributeValue) {
+            if (!isset($newStyles[$attributeName])) {
+                continue;
+            }
+
+            $newAttributeValue = $newStyles[$attributeName];
+            if ($this->attributeValueIsImportant($attributeValue)
+                && !$this->attributeValueIsImportant($newAttributeValue)
+            ) {
+                unset($newStyles[$attributeName]);
+            } else {
+                unset($oldStyles[$attributeName]);
+            }
+        }
+
+        $combinedStyles = \array_merge($oldStyles, $newStyles);
+
+        $style = '';
+        foreach ($combinedStyles as $attributeName => $attributeValue) {
+            $style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; ';
+        }
+        $trimmedStyle = \rtrim($style);
+
+        $this->caches[static::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
+
+        return $trimmedStyle;
+    }
+
+    /**
+     * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
+     *
+     * @param string[] $styleDeclarations
+     *
+     * @return string
+     */
+    private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations)
+    {
+        return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
+    }
+
+    /**
+     * Checks whether $attributeValue is marked as !important.
+     *
+     * @param string $attributeValue
+     *
+     * @return bool
+     */
+    private function attributeValueIsImportant($attributeValue)
+    {
+        return \strtolower(\substr(\trim($attributeValue), -10)) === '!important';
+    }
+
+    /**
+     * Copies $cssRule into the style attribute of $node.
+     *
+     * Note: This method does not check whether $cssRule matches $node.
+     *
+     * @param \DOMElement $node
+     * @param string[][] $cssRule
+     *
+     * @return void
+     */
+    private function copyInlineableCssToStyleAttribute(\DOMElement $node, array $cssRule)
+    {
+        // 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)
+        );
+    }
+
+    /**
+     * Applies $cssRules to $this->domDocument, limited to the rules that actually apply to the document.
+     *
+     * @param \DOMXPath $xPath
+     * @param string[][] $cssRules The "uninlineable" array of CSS rules returned by `parseCssRules`
+     *
+     * @return void
+     */
+    private function copyUninlineableCssToStyleNode(\DOMXPath $xPath, array $cssRules)
+    {
+        $cssRulesRelevantForDocument = \array_filter(
+            $cssRules,
+            function (array $cssRule) use ($xPath) {
+                $selector = $cssRule['selector'];
+                if ($cssRule['hasUnmatchablePseudo']) {
+                    $selector = $this->removeUnmatchablePseudoComponents($selector);
+                }
+                return $this->existsMatchForCssSelector($xPath, $selector);
+            }
+        );
+
+        if ($cssRulesRelevantForDocument === []) {
+            // avoid adding empty style element (or including unneeded class dependency)
+            return;
+        }
+
+        // support use without autoload
+        if (!\class_exists('Pelago\\Emogrifier\\CssConcatenator')) {
+            require_once __DIR__ . '/Emogrifier/CssConcatenator.php';
+        }
+
+        $cssConcatenator = new Emogrifier\CssConcatenator();
+        foreach ($cssRulesRelevantForDocument as $cssRule) {
+            $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
+        }
+
+        $this->addStyleElementToDocument($cssConcatenator->getCss());
+    }
+
+    /**
+     * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
+     *
+     * @param string $selector
+     *
+     * @return string Selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply,
+     *                or in the case of pseudo-elements will match their originating element.
+     */
+    private function removeUnmatchablePseudoComponents($selector)
+    {
+        $pseudoComponentMatcher = ':(?!' . static::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+';
+        return \preg_replace(
+            ['/(\\s|^)' . $pseudoComponentMatcher . '/i', '/' . $pseudoComponentMatcher . '/i'],
+            ['$1*', ''],
+            $selector
+        );
+    }
+
+    /**
+     * Checks whether there is at least one matching element for $cssSelector.
+     * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
+     * just not implemented/recognized yet by Emogrifier).
+     *
+     * @param \DOMXPath $xPath
+     * @param string $cssSelector
+     *
+     * @return bool
+     *
+     * @throws \InvalidArgumentException
+     */
+    private function existsMatchForCssSelector(\DOMXPath $xPath, $cssSelector)
+    {
+        try {
+            $nodesMatchingSelector = $xPath->query($this->translateCssToXpath($cssSelector));
+        } catch (\InvalidArgumentException $e) {
+            if ($this->debug) {
+                throw $e;
+            }
+            return true;
+        }
+
+        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 $this->domDocument.
+     *
+     * This method is protected to allow overriding.
+     *
+     * @see https://github.com/jjriv/emogrifier/issues/103
+     *
+     * @param string $css
+     *
+     * @return void
+     */
+    protected function addStyleElementToDocument($css)
+    {
+        $styleElement = $this->domDocument->createElement('style', $css);
+        $styleAttribute = $this->domDocument->createAttribute('type');
+        $styleAttribute->value = 'text/css';
+        $styleElement->appendChild($styleAttribute);
+
+        $headElement = $this->getHeadElement();
+        $headElement->appendChild($styleElement);
+    }
+
+    /**
+     * Checks that $this->domDocument has a BODY element and adds it if it is missing.
+     *
+     * @return void
+     */
+    private function ensureExistenceOfBodyElement()
+    {
+        if ($this->domDocument->getElementsByTagName('body')->item(0) !== null) {
+            return;
+        }
+
+        $htmlElement = $this->domDocument->getElementsByTagName('html')->item(0);
+        $htmlElement->appendChild($this->domDocument->createElement('body'));
+    }
+
+    /**
+     * Splits input CSS code into an array of parts for different media querues, in order.
+     * Each part is an array where:
+     *
+     * - key "css" will contain clean CSS code (for @media rules this will be the group rule body within "{...}")
+     * - key "media" will contain "@media " followed by the media query list, for all allowed media queries,
+     *   or an empty string for CSS not within a media query
+     *
+     * Example:
+     *
+     * The CSS code
+     *
+     *   "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
+     *
+     * will be parsed into the following array:
+     *
+     *   0 => [
+     *     "css" => "h1 { color:red; }",
+     *     "media" => ""
+     *   ],
+     *   1 => [
+     *     "css" => " h1 {}",
+     *     "media" => "@media "
+     *   ]
+     *
+     * @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));
+        }
+
+        $mediaRuleBodyMatcher = '[^{]*+{(?:[^{}]*+{.*})?\\s*+}\\s*+';
+
+        $cssSplitForAllowedMediaTypes = \preg_split(
+            '#(@media\\s++(?:only\\s++)?+(?:(?=[{\\(])' . $mediaTypesExpression . ')' . $mediaRuleBodyMatcher
+            . ')#misU',
+            $cssWithoutComments,
+            -1,
+            PREG_SPLIT_DELIM_CAPTURE
+        );
+
+        // filter the CSS outside/between allowed @media rules
+        $cssCleaningMatchers = [
+            'import/charset directives' => '/\\s*+@(?:import|charset)\\s[^;]++;/i',
+            'remaining media enclosures' => '/\\s*+@media\\s' . $mediaRuleBodyMatcher . '/isU',
+        ];
+
+        $splitCss = [];
+        foreach ($cssSplitForAllowedMediaTypes as $index => $cssPart) {
+            $isMediaRule = $index % 2 !== 0;
+            if ($isMediaRule) {
+                \preg_match('/^([^{]*+){(.*)}[^}]*+$/s', $cssPart, $matches);
+                $splitCss[] = [
+                    'css' => $matches[2],
+                    'media' => $matches[1],
+                ];
+            } else {
+                $cleanedCss = \trim(\preg_replace($cssCleaningMatchers, '', $cssPart));
+                if ($cleanedCss !== '') {
+                    $splitCss[] = [
+                        'css' => $cleanedCss,
+                        'media' => '',
+                    ];
+                }
+            }
+        }
+        return $splitCss;
+    }
+
+    /**
+     * Removes empty unprocessable tags from the DOM document.
+     *
+     * @return void
+     */
+    private function removeUnprocessableTags()
+    {
+        foreach ($this->unprocessableHtmlTags as $tagName) {
+            $nodes = $this->domDocument->getElementsByTagName($tagName);
+            /** @var \DOMNode $node */
+            foreach ($nodes as $node) {
+                $hasContent = $node->hasChildNodes() || $node->hasChildNodes();
+                if (!$hasContent) {
+                    $node->parentNode->removeChild($node);
+                }
+            }
+        }
+    }
+
+    /**
+     * 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 static::DEFAULT_DOCUMENT_TYPE . $html;
+    }
+
+    /**
+     * Adds a Content-Type meta tag for the charset.
+     *
+     * This method also ensures that there is a HEAD element.
+     *
+     * @param string $html
+     *
+     * @return string the HTML with the meta tag added
+     */
+    private function addContentTypeMetaTag($html)
+    {
+        $hasContentTypeMetaTag = \stripos($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>' . static::CONTENT_TYPE_META_TAG, $html);
+        } elseif ($hasHtmlTag) {
+            $reworkedHtml = \preg_replace(
+                '/<html(.*?)>/i',
+                '<html$1><head>' . static::CONTENT_TYPE_META_TAG . '</head>',
+                $html
+            );
+        } else {
+            $reworkedHtml = static::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[static::CACHE_KEY_SELECTOR][$selectorKey])) {
+            $precedence = 0;
+            foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
+                if (\trim($selector) === '') {
+                    break;
+                }
+                $number = 0;
+                $selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number);
+                $precedence += ($value * $number);
+            }
+            $this->caches[static::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
+        }
+
+        return $this->caches[static::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[static::CACHE_KEY_XPATH][$xPathKey])) {
+            return $this->caches[static::CACHE_KEY_SELECTOR][$xPathKey];
+        }
+
+        $hasNotSelector = (bool)\preg_match(
+            '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/',
+            $trimmedLowercaseSelector,
+            $matches
+        );
+        if (!$hasNotSelector) {
+            $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector);
+        } else {
+            /** @var string[] $matches */
+            list(, $partBeforeNot, $notContents) = $matches;
+            $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) .
+                '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]';
+        }
+        $this->caches[static::CACHE_KEY_SELECTOR][$xPathKey] = $xPath;
+
+        return $this->caches[static::CACHE_KEY_SELECTOR][$xPathKey];
+    }
+
+    /**
+     * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector.
+     *
+     * @param string $trimmedLowercaseSelector
+     *
+     * @return string
+     */
+    private function translateCssToXpathPass($trimmedLowercaseSelector)
+    {
+        return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
+            $trimmedLowercaseSelector,
+            [$this, 'matchClassAttributes']
+        );
+    }
+
+    /**
+     * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector for inline usage.
+     *
+     * @param string $trimmedLowercaseSelector
+     *
+     * @return string
+     */
+    private function translateCssToXpathPassInline($trimmedLowercaseSelector)
+    {
+        return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
+            $trimmedLowercaseSelector,
+            [$this, 'matchClassAttributesInline']
+        );
+    }
+
+    /**
+     * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector while using
+     * $matchClassAttributesCallback as to match the class attributes.
+     *
+     * @param string $trimmedLowercaseSelector
+     * @param callable $matchClassAttributesCallback
+     *
+     * @return string
+     */
+    private function translateCssToXpathPassWithMatchClassAttributesCallback(
+        $trimmedLowercaseSelector,
+        callable $matchClassAttributesCallback
+    ) {
+        $roughXpath = \preg_replace(\array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector);
+        $xPathWithIdAttributeMatchers = \preg_replace_callback(
+            static::ID_ATTRIBUTE_MATCHER,
+            [$this, 'matchIdAttributes'],
+            $roughXpath
+        );
+        $xPathWithIdAttributeAndClassMatchers = \preg_replace_callback(
+            static::CLASS_ATTRIBUTE_MATCHER,
+            $matchClassAttributesCallback,
+            $xPathWithIdAttributeMatchers
+        );
+
+        // Advanced selectors are going to require a bit more advanced emogrification.
+        $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
+        );
+
+        return $finalXpath;
+    }
+
+    /**
+     * @param string[] $match
+     *
+     * @return string
+     */
+    private function matchIdAttributes(array $match)
+    {
+        return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
+    }
+
+    /**
+     * @param string[] $match
+     *
+     * @return string xPath class attribute query wrapped in element selector
+     */
+    private function matchClassAttributes(array $match)
+    {
+        return ($match[1] !== '' ? $match[1] : '*') . '[' . $this->matchClassAttributesInline($match) . ']';
+    }
+
+    /**
+     * @param string[] $match
+     *
+     * @return string xPath class attribute query
+     */
+    private function matchClassAttributesInline(array $match)
+    {
+        return '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[static::MULTIPLIER])) {
+            if ($parseResult[static::MULTIPLIER] < 0) {
+                $parseResult[static::MULTIPLIER] = \abs($parseResult[static::MULTIPLIER]);
+                $xPathExpression = \sprintf(
+                    '*[(last() - position()) mod %1%u = %2$u]/static::%3$s',
+                    $parseResult[static::MULTIPLIER],
+                    $parseResult[static::INDEX],
+                    $match[1]
+                );
+            } else {
+                $xPathExpression = \sprintf(
+                    '*[position() mod %1$u = %2$u]/static::%3$s',
+                    $parseResult[static::MULTIPLIER],
+                    $parseResult[static::INDEX],
+                    $match[1]
+                );
+            }
+        } else {
+            $xPathExpression = \sprintf('*[%1$u]/static::%2$s', $parseResult[static::INDEX], $match[1]);
+        }
+
+        return $xPathExpression;
+    }
+
+    /**
+     * @param string[] $match
+     *
+     * @return string
+     */
+    private function translateNthOfType(array $match)
+    {
+        $parseResult = $this->parseNth($match);
+
+        if (isset($parseResult[static::MULTIPLIER])) {
+            if ($parseResult[static::MULTIPLIER] < 0) {
+                $parseResult[static::MULTIPLIER] = \abs($parseResult[static::MULTIPLIER]);
+                $xPathExpression = \sprintf(
+                    '%1$s[(last() - position()) mod %2$u = %3$u]',
+                    $match[1],
+                    $parseResult[static::MULTIPLIER],
+                    $parseResult[static::INDEX]
+                );
+            } else {
+                $xPathExpression = \sprintf(
+                    '%1$s[position() mod %2$u = %3$u]',
+                    $match[1],
+                    $parseResult[static::MULTIPLIER],
+                    $parseResult[static::INDEX]
+                );
+            }
+        } else {
+            $xPathExpression = \sprintf('%1$s[%2$u]', $match[1], $parseResult[static::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 [static::MULTIPLIER => 2, static::INDEX => $index];
+        }
+        if (\stripos($match[2], 'n') === false) {
+            // if there is a multiplier
+            $index = (int)\str_replace(' ', '', $match[2]);
+            return [static::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 [static::INDEX => $index];
+        } else {
+            $multiplier = (int)$multiplier;
+        }
+
+        while ($index < 0) {
+            $index += \abs($multiplier);
+        }
+
+        return [static::MULTIPLIER => $multiplier, static::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[static::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
+            return $this->caches[static::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*(.+)$/s', \trim($declaration), $matches)) {
+                continue;
+            }
+
+            $propertyName = \strtolower($matches[1]);
+            $propertyValue = $matches[2];
+            $properties[$propertyName] = $propertyValue;
+        }
+        $this->caches[static::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
+
+        return $properties;
+    }
+
+    /**
+     * Find the nodes that are not to be emogrified.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return \DOMElement[]
+     *
+     * @throws \InvalidArgumentException
+     */
+    private function getNodesToExclude(\DOMXPath $xPath)
+    {
+        $excludedNodes = [];
+        foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
+            try {
+                $matchingNodes = $xPath->query($this->translateCssToXpath($selectorToExclude));
+            } catch (\InvalidArgumentException $e) {
+                if ($this->debug) {
+                    throw $e;
+                }
+                continue;
+            }
+            foreach ($matchingNodes as $node) {
+                $excludedNodes[] = $node;
+            }
+        }
+
+        return $excludedNodes;
+    }
+
+    /**
+     * Handles invalid xPath expression warnings, generated during the process() method,
+     * during querying \DOMDocument and trigger an \InvalidArgumentException with an invalid selector
+     * or \RuntimeException, depending on the source of the warning.
+     *
+     * @param int $type
+     * @param string $message
+     * @param string $file
+     * @param int $line
+     * @param array $context
+     *
+     * @return bool always false
+     *
+     * @throws \InvalidArgumentException
+     * @throws \RuntimeException
+     */
+    public function handleXpathQueryWarnings(// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
+        $type,
+        $message,
+        $file,
+        $line,
+        array $context
+    ) {
+        $selector = '';
+        if (isset($context['cssRule']['selector'])) {
+            // warnings generated by invalid/unrecognized selectors in method process()
+            $selector = $context['cssRule']['selector'];
+        } elseif (isset($context['selectorToExclude'])) {
+            // warnings generated by invalid/unrecognized selectors in method getNodesToExclude()
+            $selector = $context['selectorToExclude'];
+        } elseif (isset($context['cssSelector'])) {
+            // warnings generated by invalid/unrecognized selectors in method existsMatchForCssSelector()
+            $selector = $context['cssSelector'];
+        }
+
+        if ($selector !== '') {
+            throw new \InvalidArgumentException(
+                \sprintf('%1$s in selector >> %2$s << in %3$s on line %4$u', $message, $selector, $file, $line),
+                1509279985
+            );
+        }
+
+        // Catches eventual warnings generated by method getAllNodesWithStyleAttribute()
+        if (isset($context['xPath'])) {
+            throw new \RuntimeException(
+                \sprintf('%1$s in %2$s on line %3$u', $message, $file, $line),
+                1509280067
+            );
+        }
+
+        // the normal error handling continues when handler return false
+        return false;
+    }
+
+    /**
+     * Sets the debug mode.
+     *
+     * @param bool $debug set to true to enable debug mode
+     *
+     * @return void
+     */
+    public function setDebug($debug)
+    {
+        $this->debug = $debug;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssConcatenator.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssConcatenator.php
new file mode 100644 (file)
index 0000000..3ebd33b
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+
+namespace Pelago\Emogrifier;
+
+/**
+ * Facilitates building a CSS string by appending rule blocks one at a time, checking whether the media query,
+ * selectors, or declarations block are the same as those from the preceding block and combining blocks in such cases.
+ *
+ * Example:
+ *  $concatenator = new CssConcatenator();
+ *  $concatenator->append(['body'], 'color: blue;');
+ *  $concatenator->append(['body'], 'font-size: 16px;');
+ *  $concatenator->append(['p'], 'margin: 1em 0;');
+ *  $concatenator->append(['ul', 'ol'], 'margin: 1em 0;');
+ *  $concatenator->append(['body'], 'font-size: 14px;', '@media screen and (max-width: 400px)');
+ *  $concatenator->append(['ul', 'ol'], 'margin: 0.75em 0;', '@media screen and (max-width: 400px)');
+ *  $css = $concatenator->getCss();
+ *
+ * `$css` (if unminified) would contain the following CSS:
+ * ` body {
+ * `   color: blue;
+ * `   font-size: 16px;
+ * ` }
+ * ` p, ul, ol {
+ * `   margin: 1em 0;
+ * ` }
+ * ` @media screen and (max-width: 400px) {
+ * `   body {
+ * `     font-size: 14px;
+ * `   }
+ * `   ul, ol {
+ * `     margin: 0.75em 0;
+ * `   }
+ * ` }
+ *
+ * @author Jake Hotson <jake.github@qzdesign.co.uk>
+ */
+class CssConcatenator
+{
+    /**
+     * Array of media rules in order.  Each element is an object with the following properties:
+     * - string `media` - The media query string, e.g. "@media screen and (max-width:639px)", or an empty string for
+     *   rules not within a media query block;
+     * - \stdClass[] `ruleBlocks` - Array of rule blocks in order, where each element is an object with the following
+     *   properties:
+     *   - mixed[] `selectorsAsKeys` - Array whose keys are selectors for the rule block (values are of no
+     *     significance);
+     *   - string `declarationsBlock` - The property declarations, e.g. "margin-top: 0.5em; padding: 0".
+     *
+     * @var \stdClass[]
+     */
+    private $mediaRules = [];
+
+    /**
+     * Appends a declaration block to the CSS.
+     *
+     * @param string[] $selectors Array of selectors for the rule, e.g. ["ul", "ol", "p:first-child"].
+     * @param string $declarationsBlock The property declarations, e.g. "margin-top: 0.5em; padding: 0".
+     * @param string $media The media query for the rule, e.g. "@media screen and (max-width:639px)",
+     *                      or an empty string if none.
+     */
+    public function append(array $selectors, $declarationsBlock, $media = '')
+    {
+        $selectorsAsKeys = \array_flip($selectors);
+
+        $mediaRule = $this->getOrCreateMediaRuleToAppendTo($media);
+        $lastRuleBlock = \end($mediaRule->ruleBlocks);
+
+        $hasSameDeclarationsAsLastRule = $lastRuleBlock !== false
+            && $declarationsBlock === $lastRuleBlock->declarationsBlock;
+        if ($hasSameDeclarationsAsLastRule) {
+            $lastRuleBlock->selectorsAsKeys += $selectorsAsKeys;
+        } else {
+            $hasSameSelectorsAsLastRule = $lastRuleBlock !== false
+                && static::hasEquivalentSelectors($selectorsAsKeys, $lastRuleBlock->selectorsAsKeys);
+            if ($hasSameSelectorsAsLastRule) {
+                $lastDeclarationsBlockWithoutSemicolon = \rtrim(\rtrim($lastRuleBlock->declarationsBlock), ';');
+                $lastRuleBlock->declarationsBlock = $lastDeclarationsBlockWithoutSemicolon . ';' . $declarationsBlock;
+            } else {
+                $mediaRule->ruleBlocks[] = (object)\compact('selectorsAsKeys', 'declarationsBlock');
+            }
+        }
+    }
+
+    /**
+     * @return string
+     */
+    public function getCss()
+    {
+        return \implode('', \array_map([$this, 'getMediaRuleCss'], $this->mediaRules));
+    }
+
+    /**
+     * @param string $media The media query for rules to be appended, e.g. "@media screen and (max-width:639px)",
+     *                      or an empty string if none.
+     *
+     * @return \stdClass Object with properties as described for elements of `$mediaRules`.
+     */
+    private function getOrCreateMediaRuleToAppendTo($media)
+    {
+        $lastMediaRule = \end($this->mediaRules);
+        if ($lastMediaRule !== false && $media === $lastMediaRule->media) {
+            return $lastMediaRule;
+        }
+
+        $newMediaRule = (object)[
+            'media' => $media,
+            'ruleBlocks' => [],
+        ];
+        $this->mediaRules[] = $newMediaRule;
+        return $newMediaRule;
+    }
+
+    /**
+     * Tests if two sets of selectors are equivalent (i.e. the same selectors, possibly in a different order).
+     *
+     * @param mixed[] $selectorsAsKeys1 Array in which the selectors are the keys, and the values are of no
+     *                                  significance.
+     * @param mixed[] $selectorsAsKeys2 Another such array.
+     *
+     * @return bool
+     */
+    private static function hasEquivalentSelectors(array $selectorsAsKeys1, array $selectorsAsKeys2)
+    {
+        return \count($selectorsAsKeys1) === \count($selectorsAsKeys2)
+            && \count($selectorsAsKeys1) === \count($selectorsAsKeys1 + $selectorsAsKeys2);
+    }
+
+    /**
+     * @param \stdClass $mediaRule Object with properties as described for elements of `$mediaRules`.
+     *
+     * @return string CSS for the media rule.
+     */
+    private static function getMediaRuleCss(\stdClass $mediaRule)
+    {
+        $css = \implode('', \array_map([static::class, 'getRuleBlockCss'], $mediaRule->ruleBlocks));
+        if ($mediaRule->media !== '') {
+            $css = $mediaRule->media . '{' . $css . '}';
+        }
+        return $css;
+    }
+
+    /**
+     * @param \stdClass $ruleBlock Object with properties as described for elements of the `ruleBlocks` property of
+     *                            elements of `$mediaRules`.
+     *
+     * @return string CSS for the rule block.
+     */
+    private static function getRuleBlockCss(\stdClass $ruleBlock)
+    {
+        $selectors = \array_keys($ruleBlock->selectorsAsKeys);
+        return \implode(',', $selectors) . '{' . $ruleBlock->declarationsBlock . '}';
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssInliner.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssInliner.php
new file mode 100644 (file)
index 0000000..847767e
--- /dev/null
@@ -0,0 +1,1346 @@
+<?php
+
+namespace Pelago\Emogrifier;
+
+use Symfony\Component\CssSelector\CssSelectorConverter;
+use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
+
+/**
+ * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
+ *
+ * For Emogrifier 3.0.0, this will be the successor to the \Pelago\Emogrifier class (which then will be deprecated).
+ *
+ * For more information, please see the README.md file.
+ *
+ * @internal This class currently is a new technology preview, and its API is still in flux. Don't use it in production.
+ *
+ * @author Cameron Brooks
+ * @author Jaime Prado
+ * @author Oliver Klee <github@oliverklee.de>
+ * @author Roman Ožana <ozana@omdesign.cz>
+ * @author Sander Kruger <s.kruger@invessel.com>
+ * @author Zoli Szabó <zoli.szabo+github@gmail.com>
+ */
+class CssInliner
+{
+    /**
+     * @var int
+     */
+    const CACHE_KEY_CSS = 0;
+
+    /**
+     * @var int
+     */
+    const CACHE_KEY_SELECTOR = 1;
+
+    /**
+     * @var int
+     */
+    const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 2;
+
+    /**
+     * @var int
+     */
+    const CACHE_KEY_COMBINED_STYLES = 3;
+
+    /**
+     * Regular expression component matching a static pseudo class in a selector, without the preceding ":",
+     * for which the applicable elements can be determined (by converting the selector to an XPath expression).
+     * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
+     * group, as appropriate for the usage context.)
+     *
+     * @var string
+     */
+    const PSEUDO_CLASS_MATCHER = '\\S+\\-(?:child|type\\()|not\\([[:ascii:]]*\\)';
+
+    /**
+     * @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 \DOMDocument
+     */
+    protected $domDocument = null;
+
+    /**
+     * @var string
+     */
+    private $css = '';
+
+    /**
+     * @var bool[]
+     */
+    private $excludedSelectors = [];
+
+    /**
+     * @var string[]
+     */
+    private $unprocessableHtmlTags = ['wbr'];
+
+    /**
+     * @var bool[]
+     */
+    private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
+
+    /**
+     * @var mixed[]
+     */
+    private $caches = [
+        self::CACHE_KEY_CSS => [],
+        self::CACHE_KEY_SELECTOR => [],
+        self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
+        self::CACHE_KEY_COMBINED_STYLES => [],
+    ];
+
+    /**
+     * @var CssSelectorConverter
+     */
+    private $cssSelectorConverter = null;
+
+    /**
+     * 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 string[][]
+     */
+    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 $shouldRemoveInvisibleNodes = true;
+
+    /**
+     * For calculating selector precedence order.
+     * Keys are a regular expression part to match before a CSS name.
+     * Values are a multiplier factor per match to weight specificity.
+     *
+     * @var int[]
+     */
+    private $selectorPrecedenceMatchers = [
+        // IDs: worth 10000
+        '\\#' => 10000,
+        // classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
+        '(?:\\.|\\[|(?<!:):(?!not\\())' => 100,
+        // elements (not attribute values or `:not`), pseudo-elements: worth 1
+        '(?:(?<![="\':\\w\\-])|::)' => 1,
+    ];
+
+    /**
+     * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
+     *
+     * @var bool
+     */
+    private $debug = false;
+
+    /**
+     * @param string $unprocessedHtml raw HTML, must be UTF-encoded, must not be empty
+     *
+     * @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
+     */
+    public function __construct($unprocessedHtml)
+    {
+        if (!\is_string($unprocessedHtml)) {
+            throw new \InvalidArgumentException('The provided HTML must be a string.', 1540403176);
+        }
+        if ($unprocessedHtml === '') {
+            throw new \InvalidArgumentException('The provided HTML must not be empty.', 1540403181);
+        }
+
+        $this->cssSelectorConverter = new CssSelectorConverter();
+
+        $this->setHtml($unprocessedHtml);
+    }
+
+    /**
+     * Sets the HTML to process.
+     *
+     * @param string $html the HTML to process, must be UTF-8-encoded
+     *
+     * @return void
+     */
+    private function setHtml($html)
+    {
+        $this->createUnifiedDomDocument($html);
+    }
+
+    /**
+     * Provides access to the internal DOMDocument representation of the HTML in its current state.
+     *
+     * @return \DOMDocument
+     */
+    public function getDomDocument()
+    {
+        return $this->domDocument;
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * Renders the normalized and processed HTML.
+     *
+     * @return string
+     */
+    public function render()
+    {
+        return $this->domDocument->saveHTML();
+    }
+
+    /**
+     * Renders the content of the BODY element of the normalized and processed HTML.
+     *
+     * @return string
+     */
+    public function renderBodyContent()
+    {
+        $bodyNodeHtml = $this->domDocument->saveHTML($this->getBodyElement());
+
+        return \str_replace(['<body>', '</body>'], '', $bodyNodeHtml);
+    }
+
+    /**
+     * Returns the BODY element.
+     *
+     * This method assumes that there always is a BODY element.
+     *
+     * @return \DOMElement
+     */
+    private function getBodyElement()
+    {
+        return $this->domDocument->getElementsByTagName('body')->item(0);
+    }
+
+    /**
+     * Returns the HEAD element.
+     *
+     * This method assumes that there always is a HEAD element.
+     *
+     * @return \DOMElement
+     */
+    private function getHeadElement()
+    {
+        return $this->domDocument->getElementsByTagName('head')->item(0);
+    }
+
+    /**
+     * Applies $this->css to the given HTML and returns the HTML with the CSS
+     * applied.
+     *
+     * This method places the CSS inline.
+     *
+     * @return string
+     *
+     * @throws SyntaxErrorException
+     */
+    public function emogrify()
+    {
+        $this->process();
+
+        return $this->render();
+    }
+
+    /**
+     * Applies $this->css to the given HTML and returns only the HTML content
+     * within the <body> tag.
+     *
+     * This method places the CSS inline.
+     *
+     * @return string
+     *
+     * @throws SyntaxErrorException
+     */
+    public function emogrifyBodyContent()
+    {
+        $this->process();
+
+        return $this->renderBodyContent();
+    }
+
+    /**
+     * Creates a DOM document from the given HTML and stores it in $this->domDocument.
+     *
+     * The DOM document will always have a BODY element and a document type.
+     *
+     * @param string $html
+     *
+     * @return void
+     */
+    private function createUnifiedDomDocument($html)
+    {
+        $this->createRawDomDocument($html);
+        $this->ensureExistenceOfBodyElement();
+    }
+
+    /**
+     * Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
+     *
+     * @param string $html
+     *
+     * @return void
+     */
+    private function createRawDomDocument($html)
+    {
+        $domDocument = new \DOMDocument();
+        $domDocument->encoding = 'UTF-8';
+        $domDocument->strictErrorChecking = false;
+        $domDocument->formatOutput = true;
+        $libXmlState = \libxml_use_internal_errors(true);
+        $domDocument->loadHTML($this->prepareHtmlForDomConversion($html));
+        \libxml_clear_errors();
+        \libxml_use_internal_errors($libXmlState);
+        $domDocument->normalizeDocument();
+
+        $this->domDocument = $domDocument;
+    }
+
+    /**
+     * Returns the HTML with added document type and Content-Type meta tag if needed,
+     * ensuring that the HTML will be good for creating a DOM document from it.
+     *
+     * @param string $html
+     *
+     * @return string the unified HTML
+     */
+    private function prepareHtmlForDomConversion($html)
+    {
+        $htmlWithDocumentType = $this->ensureDocumentType($html);
+
+        return $this->addContentTypeMetaTag($htmlWithDocumentType);
+    }
+
+    /**
+     * Applies $this->css to $this->domDocument.
+     *
+     * This method places the CSS inline.
+     *
+     * @return void
+     *
+     * @throws SyntaxErrorException
+     */
+    protected function process()
+    {
+        $this->clearAllCaches();
+        $this->purgeVisitedNodes();
+
+        $xPath = new \DOMXPath($this->domDocument);
+        $this->removeUnprocessableTags();
+        $this->normalizeStyleAttributesOfAllNodes($xPath);
+
+        // 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);
+        }
+
+        $excludedNodes = $this->getNodesToExclude($xPath);
+        $cssRules = $this->parseCssRules($allCss);
+        foreach ($cssRules['inlineable'] as $cssRule) {
+            try {
+                $nodesMatchingCssSelectors = $xPath->query($this->cssSelectorConverter->toXPath($cssRule['selector']));
+            } catch (SyntaxErrorException $e) {
+                if ($this->debug) {
+                    throw $e;
+                }
+                continue;
+            }
+
+            /** @var \DOMElement $node */
+            foreach ($nodesMatchingCssSelectors as $node) {
+                if (\in_array($node, $excludedNodes, true)) {
+                    continue;
+                }
+                $this->copyInlineableCssToStyleAttribute($node, $cssRule);
+            }
+        }
+
+        if ($this->isInlineStyleAttributesParsingEnabled) {
+            $this->fillStyleAttributesWithMergedStyles();
+        }
+        $this->postProcess($xPath);
+
+        $this->removeImportantAnnotationFromAllInlineStyles($xPath);
+
+        $this->copyUninlineableCssToStyleNode($xPath, $cssRules['uninlineable']);
+    }
+
+    /**
+     * Applies some optional post-processing to the HTML in the DOM document.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return void
+     */
+    private function postProcess(\DOMXPath $xPath)
+    {
+        if ($this->shouldRemoveInvisibleNodes) {
+            $this->removeInvisibleNodes($xPath);
+        }
+    }
+
+    /**
+     * Searches for all nodes with a style attribute and removes the "!important" annotations out of
+     * the inline style declarations, eventually by rearranging declarations.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return void
+     */
+    private function removeImportantAnnotationFromAllInlineStyles(\DOMXPath $xPath)
+    {
+        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
+            $this->removeImportantAnnotationFromNodeInlineStyle($node);
+        }
+    }
+
+    /**
+     * Removes the "!important" annotations out of the inline style declarations,
+     * eventually by rearranging declarations.
+     * Rearranging needed when !important shorthand properties are followed by some of their
+     * not !important expanded-version properties.
+     * For example "font: 12px serif !important; font-size: 13px;" must be reordered
+     * to "font-size: 13px; font: 12px serif;" in order to remain correct.
+     *
+     * @param \DOMElement $node
+     *
+     * @return void
+     */
+    private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node)
+    {
+        $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
+        $regularStyleDeclarations = [];
+        $importantStyleDeclarations = [];
+        foreach ($inlineStyleDeclarations as $property => $value) {
+            if ($this->attributeValueIsImportant($value)) {
+                $importantStyleDeclarations[$property] = \trim(\str_replace('!important', '', $value));
+            } else {
+                $regularStyleDeclarations[$property] = $value;
+            }
+        }
+        $inlineStyleDeclarationsInNewOrder = \array_merge(
+            $regularStyleDeclarations,
+            $importantStyleDeclarations
+        );
+        $node->setAttribute(
+            'style',
+            $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
+        );
+    }
+
+    /**
+     * Returns a list with all DOM nodes that have a style attribute.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return \DOMNodeList
+     */
+    private function getAllNodesWithStyleAttribute(\DOMXPath $xPath)
+    {
+        return $xPath->query('//*[@style]');
+    }
+
+    /**
+     * Extracts and parses the individual rules from a CSS string.
+     *
+     * @param string $css a string of raw CSS code
+     *
+     * @return string[][][] A 2-entry array with the key "inlineable" containing rules which can be inlined as `style`
+     *         attributes and the key "uninlineable" containing rules which cannot.  Each value is an array of string
+     *         sub-arrays with the keys
+     *         "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
+     *         or an empty string if not from a `@media` rule),
+     *         "selector" (the CSS selector, e.g., "*" or "header h1"),
+     *         "hasUnmatchablePseudo" (true if that selector contains psuedo-elements or dynamic pseudo-classes
+     *         such that the declarations cannot be applied inline),
+     *         "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
+     *         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[static::CACHE_KEY_CSS][$cssKey])) {
+            $matches = $this->getCssRuleMatches($css);
+
+            $cssRules = [
+                'inlineable' => [],
+                'uninlineable' => [],
+            ];
+            /** @var string[][] $matches */
+            /** @var string[] $cssRule */
+            foreach ($matches as $key => $cssRule) {
+                $cssDeclaration = \trim($cssRule['declarations']);
+                if ($cssDeclaration === '') {
+                    continue;
+                }
+
+                $selectors = \explode(',', $cssRule['selectors']);
+                foreach ($selectors as $selector) {
+                    // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
+                    // only allow structural pseudo-classes
+                    $hasPseudoElement = \strpos($selector, '::') !== false;
+                    $hasUnsupportedPseudoClass = (bool)\preg_match(
+                        '/:(?!' . static::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i',
+                        $selector
+                    );
+                    $hasUnmatchablePseudo = $hasPseudoElement || $hasUnsupportedPseudoClass;
+
+                    $parsedCssRule = [
+                        'media' => $cssRule['media'],
+                        'selector' => \trim($selector),
+                        'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
+                        'declarationsBlock' => $cssDeclaration,
+                        // keep track of where it appears in the file, since order is important
+                        'line' => $key,
+                    ];
+                    $ruleType = ($cssRule['media'] === '' && !$hasUnmatchablePseudo) ? 'inlineable' : 'uninlineable';
+                    $cssRules[$ruleType][] = $parsedCssRule;
+                }
+            }
+
+            \usort($cssRules['inlineable'], [$this, 'sortBySelectorPrecedence']);
+
+            $this->caches[static::CACHE_KEY_CSS][$cssKey] = $cssRules;
+        }
+
+        return $this->caches[static::CACHE_KEY_CSS][$cssKey];
+    }
+
+    /**
+     * Parses a string of CSS into the media query, selectors and declarations for each ruleset in order.
+     *
+     * @param string $css
+     *
+     * @return string[][] Array of string sub-arrays with the keys
+     *         "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
+     *         or an empty string if not from an `@media` rule),
+     *         "selectors" (the CSS selector(s), e.g., "*" or "h1, h2"),
+     *         "declarations" (the semicolon-separated CSS declarations for that/those selector(s),
+     *         e.g., "color: red; height: 4px;"),
+     */
+    private function getCssRuleMatches($css)
+    {
+        $ruleMatches = [];
+
+        $splitCss = $this->splitCssAndMediaQuery($css);
+        foreach ($splitCss as $cssPart) {
+            // process each part for selectors and definitions
+            \preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mi', $cssPart['css'], $matches, PREG_SET_ORDER);
+
+            /** @var string[][] $matches */
+            foreach ($matches as $cssRule) {
+                $ruleMatches[] = [
+                    'media' => $cssPart['media'],
+                    'selectors' => $cssRule[1],
+                    'declarations' => $cssRule[2],
+                ];
+            }
+        }
+
+        return $ruleMatches;
+    }
+
+    /**
+     * 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.
+     *
+     * @deprecated will be removed in Emogrifier 3.0
+     *
+     * @return void
+     */
+    public function disableInvisibleNodeRemoval()
+    {
+        $this->shouldRemoveInvisibleNodes = false;
+    }
+
+    /**
+     * Clears all caches.
+     *
+     * @return void
+     */
+    private function clearAllCaches()
+    {
+        $this->caches = [
+            static::CACHE_KEY_CSS => [],
+            static::CACHE_KEY_SELECTOR => [],
+            static::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
+            static::CACHE_KEY_COMBINED_STYLES => [],
+        ];
+    }
+
+    /**
+     * 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);
+            }
+        }
+    }
+
+    /**
+     * Parses the document and normalizes 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 stores a reference of nodes with existing inline styles so we don't overwrite them.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return void
+     */
+    private function normalizeStyleAttributesOfAllNodes(\DOMXPath $xPath)
+    {
+        /** @var \DOMElement $node */
+        foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
+            if ($this->isInlineStyleAttributesParsingEnabled) {
+                $this->normalizeStyleAttributes($node);
+            }
+            // Remove style attribute in every case, so we can add them back (if inline style attributes
+            // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
+            // else original inline style rules may remain at the beginning of the final inline style definition
+            // of a node, which may give not the desired results
+            $node->removeAttribute('style');
+        }
+    }
+
+    /**
+     * 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)
+    {
+        $cacheKey = \serialize([$oldStyles, $newStyles]);
+        if (isset($this->caches[static::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
+            return $this->caches[static::CACHE_KEY_COMBINED_STYLES][$cacheKey];
+        }
+
+        // Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
+        foreach ($oldStyles as $attributeName => $attributeValue) {
+            if (!isset($newStyles[$attributeName])) {
+                continue;
+            }
+
+            $newAttributeValue = $newStyles[$attributeName];
+            if ($this->attributeValueIsImportant($attributeValue)
+                && !$this->attributeValueIsImportant($newAttributeValue)
+            ) {
+                unset($newStyles[$attributeName]);
+            } else {
+                unset($oldStyles[$attributeName]);
+            }
+        }
+
+        $combinedStyles = \array_merge($oldStyles, $newStyles);
+
+        $style = '';
+        foreach ($combinedStyles as $attributeName => $attributeValue) {
+            $style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; ';
+        }
+        $trimmedStyle = \rtrim($style);
+
+        $this->caches[static::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
+
+        return $trimmedStyle;
+    }
+
+    /**
+     * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
+     *
+     * @param string[] $styleDeclarations
+     *
+     * @return string
+     */
+    private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations)
+    {
+        return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
+    }
+
+    /**
+     * Checks whether $attributeValue is marked as !important.
+     *
+     * @param string $attributeValue
+     *
+     * @return bool
+     */
+    private function attributeValueIsImportant($attributeValue)
+    {
+        return \strtolower(\substr(\trim($attributeValue), -10)) === '!important';
+    }
+
+    /**
+     * Applies $cssRules to $this->domDocument, limited to the rules that actually apply to the document.
+     *
+     * @param \DOMXPath $xPath
+     * @param string[][] $cssRules The "uninlineable" array of CSS rules returned by `parseCssRules`
+     *
+     * @return void
+     */
+    private function copyUninlineableCssToStyleNode(\DOMXPath $xPath, array $cssRules)
+    {
+        $cssRulesRelevantForDocument = \array_filter(
+            $cssRules,
+            function (array $cssRule) use ($xPath) {
+                $selector = $cssRule['selector'];
+                if ($cssRule['hasUnmatchablePseudo']) {
+                    $selector = $this->removeUnmatchablePseudoComponents($selector);
+                }
+                return $this->existsMatchForCssSelector($xPath, $selector);
+            }
+        );
+
+        if ($cssRulesRelevantForDocument === []) {
+            // avoid adding empty style element (or including unneeded class dependency)
+            return;
+        }
+
+        $cssConcatenator = new CssConcatenator();
+        foreach ($cssRulesRelevantForDocument as $cssRule) {
+            $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
+        }
+
+        $this->addStyleElementToDocument($cssConcatenator->getCss());
+    }
+
+    /**
+     * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
+     *
+     * @param string $selector
+     *
+     * @return string Selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply,
+     *                or in the case of pseudo-elements will match their originating element.
+     */
+    private function removeUnmatchablePseudoComponents($selector)
+    {
+        $pseudoComponentMatcher = ':(?!' . static::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+';
+        return \preg_replace(
+            ['/(\\s|^)' . $pseudoComponentMatcher . '/i', '/' . $pseudoComponentMatcher . '/i'],
+            ['$1*', ''],
+            $selector
+        );
+    }
+
+    /**
+     * Copies $cssRule into the style attribute of $node.
+     *
+     * Note: This method does not check whether $cssRule matches $node.
+     *
+     * @param \DOMElement $node
+     * @param string[][] $cssRule
+     *
+     * @return void
+     */
+    private function copyInlineableCssToStyleAttribute(\DOMElement $node, array $cssRule)
+    {
+        // 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)
+        );
+    }
+
+    /**
+     * Checks whether there is at least one matching element for $cssSelector.
+     * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
+     * just not implemented/recognized yet by Emogrifier).
+     *
+     * @param \DOMXPath $xPath
+     * @param string $cssSelector
+     *
+     * @return bool
+     *
+     * @throws SyntaxErrorException
+     */
+    private function existsMatchForCssSelector(\DOMXPath $xPath, $cssSelector)
+    {
+        try {
+            $nodesMatchingSelector = $xPath->query($this->cssSelectorConverter->toXPath($cssSelector));
+        } catch (SyntaxErrorException $e) {
+            if ($this->debug) {
+                throw $e;
+            }
+            return true;
+        }
+
+        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 $this->domDocument.
+     *
+     * This method is protected to allow overriding.
+     *
+     * @see https://github.com/jjriv/emogrifier/issues/103
+     *
+     * @param string $css
+     *
+     * @return void
+     */
+    protected function addStyleElementToDocument($css)
+    {
+        $styleElement = $this->domDocument->createElement('style', $css);
+        $styleAttribute = $this->domDocument->createAttribute('type');
+        $styleAttribute->value = 'text/css';
+        $styleElement->appendChild($styleAttribute);
+
+        $headElement = $this->getHeadElement();
+        $headElement->appendChild($styleElement);
+    }
+
+    /**
+     * Checks that $this->domDocument has a BODY element and adds it if it is missing.
+     *
+     * @return void
+     */
+    private function ensureExistenceOfBodyElement()
+    {
+        if ($this->domDocument->getElementsByTagName('body')->item(0) !== null) {
+            return;
+        }
+
+        $htmlElement = $this->domDocument->getElementsByTagName('html')->item(0);
+        $htmlElement->appendChild($this->domDocument->createElement('body'));
+    }
+
+    /**
+     * Splits input CSS code into an array of parts for different media querues, in order.
+     * Each part is an array where:
+     *
+     * - key "css" will contain clean CSS code (for @media rules this will be the group rule body within "{...}")
+     * - key "media" will contain "@media " followed by the media query list, for all allowed media queries,
+     *   or an empty string for CSS not within a media query
+     *
+     * Example:
+     *
+     * The CSS code
+     *
+     *   "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
+     *
+     * will be parsed into the following array:
+     *
+     *   0 => [
+     *     "css" => "h1 { color:red; }",
+     *     "media" => ""
+     *   ],
+     *   1 => [
+     *     "css" => " h1 {}",
+     *     "media" => "@media "
+     *   ]
+     *
+     * @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));
+        }
+
+        $mediaRuleBodyMatcher = '[^{]*+{(?:[^{}]*+{.*})?\\s*+}\\s*+';
+
+        $cssSplitForAllowedMediaTypes = \preg_split(
+            '#(@media\\s++(?:only\\s++)?+(?:(?=[{\\(])' . $mediaTypesExpression . ')' . $mediaRuleBodyMatcher
+            . ')#misU',
+            $cssWithoutComments,
+            -1,
+            PREG_SPLIT_DELIM_CAPTURE
+        );
+
+        // filter the CSS outside/between allowed @media rules
+        $cssCleaningMatchers = [
+            'import/charset directives' => '/\\s*+@(?:import|charset)\\s[^;]++;/i',
+            'remaining media enclosures' => '/\\s*+@media\\s' . $mediaRuleBodyMatcher . '/isU',
+        ];
+
+        $splitCss = [];
+        foreach ($cssSplitForAllowedMediaTypes as $index => $cssPart) {
+            $isMediaRule = $index % 2 !== 0;
+            if ($isMediaRule) {
+                \preg_match('/^([^{]*+){(.*)}[^}]*+$/s', $cssPart, $matches);
+                $splitCss[] = [
+                    'css' => $matches[2],
+                    'media' => $matches[1],
+                ];
+            } else {
+                $cleanedCss = \trim(\preg_replace($cssCleaningMatchers, '', $cssPart));
+                if ($cleanedCss !== '') {
+                    $splitCss[] = [
+                        'css' => $cleanedCss,
+                        'media' => '',
+                    ];
+                }
+            }
+        }
+        return $splitCss;
+    }
+
+    /**
+     * Removes empty unprocessable tags from the DOM document.
+     *
+     * @return void
+     */
+    private function removeUnprocessableTags()
+    {
+        foreach ($this->unprocessableHtmlTags as $tagName) {
+            $nodes = $this->domDocument->getElementsByTagName($tagName);
+            /** @var \DOMNode $node */
+            foreach ($nodes as $node) {
+                $hasContent = $node->hasChildNodes() || $node->hasChildNodes();
+                if (!$hasContent) {
+                    $node->parentNode->removeChild($node);
+                }
+            }
+        }
+    }
+
+    /**
+     * 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 static::DEFAULT_DOCUMENT_TYPE . $html;
+    }
+
+    /**
+     * Adds a Content-Type meta tag for the charset.
+     *
+     * This method also ensures that there is a HEAD element.
+     *
+     * @param string $html
+     *
+     * @return string the HTML with the meta tag added
+     */
+    private function addContentTypeMetaTag($html)
+    {
+        $hasContentTypeMetaTag = \stripos($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>' . static::CONTENT_TYPE_META_TAG, $html);
+        } elseif ($hasHtmlTag) {
+            $reworkedHtml = \preg_replace(
+                '/<html(.*?)>/i',
+                '<html$1><head>' . static::CONTENT_TYPE_META_TAG . '</head>',
+                $html
+            );
+        } else {
+            $reworkedHtml = static::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[static::CACHE_KEY_SELECTOR][$selectorKey])) {
+            $precedence = 0;
+            foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
+                if (\trim($selector) === '') {
+                    break;
+                }
+                $number = 0;
+                $selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number);
+                $precedence += ($value * $number);
+            }
+            $this->caches[static::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
+        }
+
+        return $this->caches[static::CACHE_KEY_SELECTOR][$selectorKey];
+    }
+
+    /**
+     * 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[static::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
+            return $this->caches[static::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*(.+)$/s', \trim($declaration), $matches)) {
+                continue;
+            }
+
+            $propertyName = \strtolower($matches[1]);
+            $propertyValue = $matches[2];
+            $properties[$propertyName] = $propertyValue;
+        }
+        $this->caches[static::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
+
+        return $properties;
+    }
+
+    /**
+     * Find the nodes that are not to be emogrified.
+     *
+     * @param \DOMXPath $xPath
+     *
+     * @return \DOMElement[]
+     *
+     * @throws SyntaxErrorException
+     */
+    private function getNodesToExclude(\DOMXPath $xPath)
+    {
+        $excludedNodes = [];
+        foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
+            try {
+                $matchingNodes = $xPath->query($this->cssSelectorConverter->toXPath($selectorToExclude));
+            } catch (SyntaxErrorException $e) {
+                if ($this->debug) {
+                    throw $e;
+                }
+                continue;
+            }
+            foreach ($matchingNodes as $node) {
+                $excludedNodes[] = $node;
+            }
+        }
+
+        return $excludedNodes;
+    }
+
+    /**
+     * Sets the debug mode.
+     *
+     * @param bool $debug set to true to enable debug mode
+     *
+     * @return void
+     */
+    public function setDebug($debug)
+    {
+        $this->debug = $debug;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/AbstractHtmlProcessor.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/AbstractHtmlProcessor.php
new file mode 100644 (file)
index 0000000..c5a25ee
--- /dev/null
@@ -0,0 +1,221 @@
+<?php
+
+namespace Pelago\Emogrifier\HtmlProcessor;
+
+/**
+ * Base class for HTML processor that e.g., can remove, add or modify nodes or attributes.
+ *
+ * The "vanilla" subclass is the HtmlNormalizer.
+ *
+ * @internal This class currently is a new technology preview, and its API is still in flux. Don't use it in production.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ */
+abstract class AbstractHtmlProcessor
+{
+    /**
+     * @var string
+     */
+    const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
+
+    /**
+     * @var string
+     */
+    const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
+
+    /**
+     * @var \DOMDocument
+     */
+    protected $domDocument = null;
+
+    /**
+     * @param string $unprocessedHtml raw HTML, must be UTF-encoded, must not be empty
+     *
+     * @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
+     */
+    public function __construct($unprocessedHtml)
+    {
+        if (!\is_string($unprocessedHtml)) {
+            throw new \InvalidArgumentException('The provided HTML must be a string.', 1515459744);
+        }
+        if ($unprocessedHtml === '') {
+            throw new \InvalidArgumentException('The provided HTML must not be empty.', 1515763647);
+        }
+
+        $this->setHtml($unprocessedHtml);
+    }
+
+    /**
+     * Sets the HTML to process.
+     *
+     * @param string $html the HTML to process, must be UTF-8-encoded
+     *
+     * @return void
+     */
+    private function setHtml($html)
+    {
+        $this->createUnifiedDomDocument($html);
+    }
+
+    /**
+     * Provides access to the internal DOMDocument representation of the HTML in its current state.
+     *
+     * @return \DOMDocument
+     */
+    public function getDomDocument()
+    {
+        return $this->domDocument;
+    }
+
+    /**
+     * Renders the normalized and processed HTML.
+     *
+     * @return string
+     */
+    public function render()
+    {
+        return $this->domDocument->saveHTML();
+    }
+
+    /**
+     * Renders the content of the BODY element of the normalized and processed HTML.
+     *
+     * @return string
+     */
+    public function renderBodyContent()
+    {
+        $bodyNodeHtml = $this->domDocument->saveHTML($this->getBodyElement());
+
+        return \str_replace(['<body>', '</body>'], '', $bodyNodeHtml);
+    }
+
+    /**
+     * Returns the BODY element.
+     *
+     * This method assumes that there always is a BODY element.
+     *
+     * @return \DOMElement
+     */
+    private function getBodyElement()
+    {
+        return $this->domDocument->getElementsByTagName('body')->item(0);
+    }
+
+    /**
+     * Creates a DOM document from the given HTML and stores it in $this->domDocument.
+     *
+     * The DOM document will always have a BODY element and a document type.
+     *
+     * @param string $html
+     *
+     * @return void
+     */
+    private function createUnifiedDomDocument($html)
+    {
+        $this->createRawDomDocument($html);
+        $this->ensureExistenceOfBodyElement();
+    }
+
+    /**
+     * Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
+     *
+     * @param string $html
+     *
+     * @return void
+     */
+    private function createRawDomDocument($html)
+    {
+        $domDocument = new \DOMDocument();
+        $domDocument->strictErrorChecking = false;
+        $domDocument->formatOutput = true;
+        $libXmlState = \libxml_use_internal_errors(true);
+        $domDocument->loadHTML($this->prepareHtmlForDomConversion($html));
+        \libxml_clear_errors();
+        \libxml_use_internal_errors($libXmlState);
+
+        $this->domDocument = $domDocument;
+    }
+
+    /**
+     * Returns the HTML with added document type and Content-Type meta tag if needed,
+     * ensuring that the HTML will be good for creating a DOM document from it.
+     *
+     * @param string $html
+     *
+     * @return string the unified HTML
+     */
+    private function prepareHtmlForDomConversion($html)
+    {
+        $htmlWithDocumentType = $this->ensureDocumentType($html);
+
+        return $this->addContentTypeMetaTag($htmlWithDocumentType);
+    }
+
+    /**
+     * 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 static::DEFAULT_DOCUMENT_TYPE . $html;
+    }
+
+    /**
+     * Adds a Content-Type meta tag for the charset.
+     *
+     * This method also ensures that there is a HEAD element.
+
+     * @param string $html
+     *
+     * @return string the HTML with the meta tag added
+     */
+    private function addContentTypeMetaTag($html)
+    {
+        $hasContentTypeMetaTag = \stripos($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>' . static::CONTENT_TYPE_META_TAG, $html);
+        } elseif ($hasHtmlTag) {
+            $reworkedHtml = \preg_replace(
+                '/<html(.*?)>/i',
+                '<html$1><head>' . static::CONTENT_TYPE_META_TAG . '</head>',
+                $html
+            );
+        } else {
+            $reworkedHtml = static::CONTENT_TYPE_META_TAG . $html;
+        }
+
+        return $reworkedHtml;
+    }
+
+    /**
+     * Checks that $this->domDocument has a BODY element and adds it if it is missing.
+     *
+     * @return void
+     */
+    private function ensureExistenceOfBodyElement()
+    {
+        if ($this->domDocument->getElementsByTagName('body')->item(0) !== null) {
+            return;
+        }
+
+        $htmlElement = $this->domDocument->getElementsByTagName('html')->item(0);
+        $htmlElement->appendChild($this->domDocument->createElement('body'));
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/CssToAttributeConverter.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/CssToAttributeConverter.php
new file mode 100644 (file)
index 0000000..97a94b1
--- /dev/null
@@ -0,0 +1,320 @@
+<?php
+
+namespace Pelago\Emogrifier\HtmlProcessor;
+
+/**
+ * This HtmlProcessor can convert style HTML attributes to the corresponding other visual HTML attributes,
+ * e.g. it converts style="width: 100px" to width="100".
+ *
+ * It will only add attributes, but leaves the style attribute untouched.
+ *
+ * To trigger the conversion, call the convertCssToVisualAttributes method.
+ *
+ * @internal This class currently is a new technology preview, and its API is still in flux. Don't use it in production.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ */
+class CssToAttributeConverter extends AbstractHtmlProcessor
+{
+    /**
+     * This multi-level array contains simple mappings of CSS properties to
+     * HTML attributes. If a mapping only applies to certain HTML nodes or
+     * only for certain values, the mapping is an object with a whitelist
+     * of nodes and values.
+     *
+     * @var mixed[][]
+     */
+    private $cssToHtmlMap = [
+        'background-color' => [
+            'attribute' => 'bgcolor',
+        ],
+        'text-align' => [
+            'attribute' => 'align',
+            'nodes' => ['p', 'div', 'td'],
+            'values' => ['left', 'right', 'center', 'justify'],
+        ],
+        'float' => [
+            'attribute' => 'align',
+            'nodes' => ['table', 'img'],
+            'values' => ['left', 'right'],
+        ],
+        'border-spacing' => [
+            'attribute' => 'cellspacing',
+            'nodes' => ['table'],
+        ],
+    ];
+
+    /**
+     * @var string[][]
+     */
+    private static $parsedCssCache = [];
+
+    /**
+     * Maps the CSS from the style nodes to visual HTML attributes.
+     *
+     * @return CssToAttributeConverter fluent interface
+     */
+    public function convertCssToVisualAttributes()
+    {
+        /** @var \DOMElement $node */
+        foreach ($this->getAllNodesWithStyleAttribute() as $node) {
+            $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
+            $this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Returns a list with all DOM nodes that have a style attribute.
+     *
+     * @return \DOMNodeList
+     */
+    private function getAllNodesWithStyleAttribute()
+    {
+        $xPath = new \DOMXPath($this->domDocument);
+
+        return $xPath->query('//*[@style]');
+    }
+
+    /**
+     * 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(self::$parsedCssCache[$cssDeclarationsBlock])) {
+            return self::$parsedCssCache[$cssDeclarationsBlock];
+        }
+
+        $properties = [];
+        $declarations = \preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
+
+        foreach ($declarations as $declaration) {
+            $matches = [];
+            if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
+                continue;
+            }
+
+            $propertyName = \strtolower($matches[1]);
+            $propertyValue = $matches[2];
+            $properties[$propertyName] = $propertyValue;
+        }
+        self::$parsedCssCache[$cssDeclarationsBlock] = $properties;
+
+        return $properties;
+    }
+
+    /**
+     * Applies $styles to $node.
+     *
+     * This method maps CSS styles to HTML attributes and adds those to the
+     * node.
+     *
+     * @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return void
+     */
+    private function mapCssToHtmlAttributes(array $styles, \DOMElement $node)
+    {
+        foreach ($styles as $property => $value) {
+            // Strip !important indicator
+            $value = \trim(\str_replace('!important', '', $value));
+            $this->mapCssToHtmlAttribute($property, $value, $node);
+        }
+    }
+
+    /**
+     * Tries to apply the CSS style to $node as an attribute.
+     *
+     * This method maps a CSS rule to HTML attributes and adds those to the node.
+     *
+     * @param string $property the name of the CSS property to map
+     * @param string $value the value of the style rule to map
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return void
+     */
+    private function mapCssToHtmlAttribute($property, $value, \DOMElement $node)
+    {
+        if (!$this->mapSimpleCssProperty($property, $value, $node)) {
+            $this->mapComplexCssProperty($property, $value, $node);
+        }
+    }
+
+    /**
+     * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
+     *
+     * @param string $property the name of the CSS property to map
+     * @param string $value the value of the style rule to map
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return bool true if the property can be mapped using the simple mapping table
+     */
+    private function mapSimpleCssProperty($property, $value, \DOMElement $node)
+    {
+        if (!isset($this->cssToHtmlMap[$property])) {
+            return false;
+        }
+
+        $mapping = $this->cssToHtmlMap[$property];
+        $nodesMatch = !isset($mapping['nodes']) || \in_array($node->nodeName, $mapping['nodes'], true);
+        $valuesMatch = !isset($mapping['values']) || \in_array($value, $mapping['values'], true);
+        if (!$nodesMatch || !$valuesMatch) {
+            return false;
+        }
+
+        $node->setAttribute($mapping['attribute'], $value);
+
+        return true;
+    }
+
+    /**
+     * Maps CSS properties that need special transformation to an HTML attribute.
+     *
+     * @param string $property the name of the CSS property to map
+     * @param string $value the value of the style rule to map
+     * @param \DOMElement $node node to apply styles to
+     *
+     * @return void
+     */
+    private function mapComplexCssProperty($property, $value, \DOMElement $node)
+    {
+        switch ($property) {
+            case 'background':
+                $this->mapBackgroundProperty($node, $value);
+                break;
+            case 'width':
+                // intentional fall-through
+            case 'height':
+                $this->mapWidthOrHeightProperty($node, $value, $property);
+                break;
+            case 'margin':
+                $this->mapMarginProperty($node, $value);
+                break;
+            case 'border':
+                $this->mapBorderProperty($node, $value);
+                break;
+            default:
+        }
+    }
+
+    /**
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     *
+     * @return void
+     */
+    private function mapBackgroundProperty(\DOMElement $node, $value)
+    {
+        // parse out the color, if any
+        $styles = \explode(' ', $value);
+        $first = $styles[0];
+        if (!\is_numeric($first[0]) && \strpos($first, 'url') !== 0) {
+            // as this is not a position or image, assume it's a color
+            $node->setAttribute('bgcolor', $first);
+        }
+    }
+
+    /**
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     * @param string $property the name of the CSS property to map
+     *
+     * @return void
+     */
+    private function mapWidthOrHeightProperty(\DOMElement $node, $value, $property)
+    {
+        // only parse values in px and %, but not values like "auto"
+        if (!\preg_match('/^(\\d+)(px|%)$/', $value)) {
+            return;
+        }
+
+        $number = \preg_replace('/[^0-9.%]/', '', $value);
+        $node->setAttribute($property, $number);
+    }
+
+    /**
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     *
+     * @return void
+     */
+    private function mapMarginProperty(\DOMElement $node, $value)
+    {
+        if (!$this->isTableOrImageNode($node)) {
+            return;
+        }
+
+        $margins = $this->parseCssShorthandValue($value);
+        if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
+            $node->setAttribute('align', 'center');
+        }
+    }
+
+    /**
+     * @param \DOMElement $node node to apply styles to
+     * @param string $value the value of the style rule to map
+     *
+     * @return void
+     */
+    private function mapBorderProperty(\DOMElement $node, $value)
+    {
+        if (!$this->isTableOrImageNode($node)) {
+            return;
+        }
+
+        if ($value === 'none' || $value === '0') {
+            $node->setAttribute('border', '0');
+        }
+    }
+
+    /**
+     * @param \DOMElement $node
+     *
+     * @return bool
+     */
+    private function isTableOrImageNode(\DOMElement $node)
+    {
+        return $node->nodeName === 'table' || $node->nodeName === 'img';
+    }
+
+    /**
+     * Parses a shorthand CSS value and splits it into individual values
+     *
+     * @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
+     *                      For example: padding: 0 auto;
+     *                      '0 auto' is split into top: 0, left: auto, bottom: 0,
+     *                      right: auto.
+     *
+     * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
+     */
+    private function parseCssShorthandValue($value)
+    {
+        $values = \preg_split('/\\s+/', $value);
+
+        $css = [];
+        $css['top'] = $values[0];
+        $css['right'] = (\count($values) > 1) ? $values[1] : $css['top'];
+        $css['bottom'] = (\count($values) > 2) ? $values[2] : $css['top'];
+        $css['left'] = (\count($values) > 3) ? $values[3] : $css['right'];
+
+        return $css;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/HtmlNormalizer.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/HtmlNormalizer.php
new file mode 100644 (file)
index 0000000..ad3abff
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace Pelago\Emogrifier\HtmlProcessor;
+
+/**
+ * Normalizes HTML:
+ * - add a document type (HTML5) if missing
+ * - disentangle incorrectly nested tags
+ * - add HEAD and BODY elements (if they are missing)
+ * - reformat the HTML
+ *
+ * @internal This class currently is a new technology preview, and its API is still in flux. Don't use it in production.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ */
+class HtmlNormalizer extends AbstractHtmlProcessor
+{
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Support/Traits/AssertCss.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Support/Traits/AssertCss.php
new file mode 100644 (file)
index 0000000..959ee64
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+namespace Pelago\Tests\Support\Traits;
+
+/**
+ * Provides assertion methods for use with CSS content where whitespace may vary.
+ *
+ * @author Jake Hotson <jake.github@qzdesign.co.uk>
+ */
+trait AssertCss
+{
+    /**
+     * Processing of @media rules may involve removal of some unnecessary whitespace from the CSS placed in the <style>
+     * element added to the docuemnt, due to the way that certain parts are `trim`med.  Notably, whitespace either side
+     * of "{", "}" and "," or at the beginning of the CSS may be removed.
+     *
+     * This method helps takes care of that, by converting a search needle for an exact match into a regular expression
+     * that allows for such whitespace removal, so that the tests themselves do not need to be written less humanly
+     * readable and can use inputs containing extra whitespace.
+     *
+     * @param string $needle Needle that would be used with `assertContains` or `assertNotContains`.
+     *
+     * @return string Needle to use with `assertRegExp` or `assertNotRegExp` instead.
+     */
+    private static function getCssNeedleRegExp($needle)
+    {
+        $needleMatcher = \preg_replace_callback(
+            '/\\s*+([{},])\\s*+|(^\\s++)|(>)\\s*+|(?:(?!\\s*+[{},]|^\\s)[^>])++/',
+            function (array $matches) {
+                if (isset($matches[1]) && $matches[1] !== '') {
+                    // matched possibly some whitespace, followed by "{", "}" or ",", then possibly more whitespace
+                    return '\\s*+' . \preg_quote($matches[1], '/') . '\\s*+';
+                }
+                if (isset($matches[2]) && $matches[2] !== '') {
+                    // matched whitespace at start
+                    return '\\s*+';
+                }
+                if (isset($matches[3]) && $matches[3] !== '') {
+                    // matched ">" (e.g. end of <style> tag) followed by possibly some whitespace
+                    return \preg_quote($matches[3], '/') . '\\s*+';
+                }
+                // matched any other sequence which could not overlap with the above
+                return \preg_quote($matches[0], '/');
+            },
+            $needle
+        );
+        return '/' . $needleMatcher . '/';
+    }
+
+    /**
+     * Like `assertContains` but allows for removal of some unnecessary whitespace from the CSS.
+     *
+     * @param string $needle
+     * @param string $haystack
+     */
+    private static function assertContainsCss($needle, $haystack)
+    {
+        static::assertRegExp(
+            static::getCssNeedleRegExp($needle),
+            $haystack,
+            'Plain text needle: "' . $needle . '"'
+        );
+    }
+
+    /**
+     * Like `assertNotContains` and also enforces the assertion with removal of some unnecessary whitespace from the
+     * CSS.
+     *
+     * @param string $needle
+     * @param string $haystack
+     */
+    private static function assertNotContainsCss($needle, $haystack)
+    {
+        static::assertNotRegExp(
+            static::getCssNeedleRegExp($needle),
+            $haystack,
+            'Plain text needle: "' . $needle . '"'
+        );
+    }
+
+    /**
+     * Asserts that a string of CSS occurs exactly a certain number of times in the result, allowing for removal of some
+     * unnecessary whitespace.
+     *
+     * @param int $expectedCount
+     * @param string $needle
+     * @param string $haystack
+     */
+    private static function assertContainsCssCount(
+        $expectedCount,
+        $needle,
+        $haystack
+    ) {
+        static::assertSame(
+            $expectedCount,
+            \preg_match_all(static::getCssNeedleRegExp($needle), $haystack),
+            'Plain text needle: "' . $needle . "\"\nHaystack: \"" . $haystack . '"'
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/CssInlinerTest.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/CssInlinerTest.php
new file mode 100644 (file)
index 0000000..e6fb9c1
--- /dev/null
@@ -0,0 +1,2930 @@
+<?php
+
+namespace Pelago\Emogrifer\Tests\Unit;
+
+use Pelago\Emogrifier\CssInliner;
+use Pelago\Tests\Support\Traits\AssertCss;
+
+/**
+ * Test case.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ * @author Zoli Szabó <zoli.szabo+github@gmail.com>
+ */
+class CssInlinerTest extends \PHPUnit_Framework_TestCase
+{
+    use AssertCss;
+
+    /**
+     * @var string Common HTML markup with a variety of elements and attributes for testing with
+     */
+    const COMMON_TEST_HTML = '
+        <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>
+                <p class="p-4" id="p4"><span title="avez-vous">some</span> more <span id="text">text</span></p>
+                <p class="p-5 additional-class"><span title="buenas dias bom dia">some</span> more text</p>
+                <p class="p-6"><span title="title: subtitle; author">some</span> more text</p>
+            </body>
+        </html>
+    ';
+
+    /**
+     * @var string
+     */
+    private $html5DocumentType = '<!DOCTYPE html>';
+
+    /**
+     * Builds a subject with the given HTML and debug mode enabled.
+     *
+     * @param string $html
+     *
+     * @return CssInliner
+     */
+    private function buildDebugSubject($html)
+    {
+        $subject = new CssInliner($html);
+        $subject->setDebug(true);
+
+        return $subject;
+    }
+
+    /**
+     * @test
+     */
+    public function renderFormatsGivenHtml()
+    {
+        $rawHtml = '<!DOCTYPE HTML>' .
+            '<html>' .
+            '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' .
+            '<body></body>' .
+            '</html>';
+        $formattedHtml = "<!DOCTYPE HTML>\n" .
+            "<html>\n" .
+            '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' . "\n" .
+            "<body></body>\n" .
+            "</html>\n";
+
+        $subject = $this->buildDebugSubject($rawHtml);
+
+        static::assertSame($formattedHtml, $subject->render());
+    }
+
+    /**
+     * @test
+     */
+    public function renderBodyContentForEmptyBodyReturnsEmptyString()
+    {
+        $subject = $this->buildDebugSubject('<html><body></body></html>');
+
+        $result = $subject->renderBodyContent();
+
+        static::assertSame('', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderBodyContentReturnsBodyContent()
+    {
+        $bodyContent = '<p>Hello world</p>';
+        $subject = $this->buildDebugSubject('<html><body>' . $bodyContent . '</body></html>');
+
+        $result = $subject->renderBodyContent();
+
+        static::assertSame($bodyContent, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getDomDocumentReturnsDomDocument()
+    {
+        $subject = new CssInliner('<html></html>');
+
+        static::assertInstanceOf(\DOMDocument::class, $subject->getDomDocument());
+    }
+
+    /**
+     * @test
+     */
+    public function getDomDocumentWithNormalizedHtmlRepresentsTheGivenHtml()
+    {
+        $html = "<!DOCTYPE html>\n<html>\n<head>" .
+            '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' .
+            "</head>\n<body>\n<br>\n</body>\n</html>\n";
+        $subject = new CssInliner($html);
+
+        $domDocument = $subject->getDomDocument();
+
+        self::assertSame($html, $domDocument->saveHTML());
+    }
+
+    /**
+     * @test
+     *
+     * @return array[]
+     */
+    public function nonHtmlDataProvider()
+    {
+        return [
+            'empty string' => [''],
+            'null' => [null],
+            'integer' => [2],
+            'float' => [3.14159],
+            'object' => [new \stdClass()],
+        ];
+    }
+
+    /**
+     * @test
+     * @expectedException \InvalidArgumentException
+     *
+     * @param mixed $html
+     *
+     * @dataProvider nonHtmlDataProvider
+     */
+    public function constructorWithNoHtmlDataThrowsException($html)
+    {
+        new CssInliner($html);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutHtmlTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'body content only' => ['<p>Hello</p>'],
+            'HEAD element' => ['<head></head>'],
+            'BODY element' => ['<body></body>'],
+            'HEAD AND BODY element' => ['<head></head><body></body>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutHtmlTagDataProvider
+     */
+    public function renderAddsMissingHtmlTag($html)
+    {
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->render();
+
+        static::assertContains('<html>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutHeadTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'body content only' => ['<p>Hello</p>'],
+            'BODY element' => ['<body></body>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutHeadTagDataProvider
+     */
+    public function renderAddsMissingHeadTag($html)
+    {
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->render();
+
+        static::assertContains('<head>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutBodyTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'HEAD element' => ['<head></head>'],
+            'body content only' => ['<p>Hello</p>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutBodyTagDataProvider
+     */
+    public function renderAddsMissingBodyTag($html)
+    {
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->render();
+
+        static::assertContains('<body>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderPutsMissingBodyElementAroundBodyContent()
+    {
+        $subject = $this->buildDebugSubject('<p>Hello</p>');
+
+        $result = $subject->render();
+
+        static::assertContains('<body><p>Hello</p></body>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function specialCharactersDataProvider()
+    {
+        return [
+            'template markers with dollar signs & square brackets' => ['$[USER:NAME]$'],
+            'UTF-8 umlauts' => ['Küss die Hand, schöne Frau.'],
+            'HTML entities' => ['a &amp; b &gt; c'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $codeNotToBeChanged
+     *
+     * @dataProvider specialCharactersDataProvider
+     */
+    public function renderKeepsSpecialCharacters($codeNotToBeChanged)
+    {
+        $html = '<html><p>' . $codeNotToBeChanged . '</p></html>';
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->render();
+
+        static::assertContains($codeNotToBeChanged, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addsMissingHtml5DocumentType()
+    {
+        $subject = $this->buildDebugSubject('<html><h1>foo</h1></html>');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<!DOCTYPE html>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $codeNotToBeChanged
+     *
+     * @dataProvider specialCharactersDataProvider
+     */
+    public function emogrifyBodyContentKeepsSpecialCharacters($codeNotToBeChanged)
+    {
+        $html = '<html><p>' . $codeNotToBeChanged . '</p></html>';
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->emogrifyBodyContent();
+
+        static::assertContains($codeNotToBeChanged, $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function documentTypeDataProvider()
+    {
+        return [
+            'HTML5' => ['<!DOCTYPE html>'],
+            'XHTML 1 strict' => [
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" ' .
+                '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
+            ],
+            'HTML 4 transitional' => [
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" ' .
+                '"http://www.w3.org/TR/REC-html40/loose.dtd">',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $documentType
+     *
+     * @dataProvider documentTypeDataProvider
+     */
+    public function renderForHtmlWithDocumentTypeKeepsDocumentType($documentType)
+    {
+        $html = $documentType . '<html></html>';
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->render();
+
+        static::assertContains($documentType, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderAddsMissingContentTypeMetaTag()
+    {
+        $subject = $this->buildDebugSubject('<p>Hello</p>');
+
+        $result = $subject->render();
+
+        static::assertContains('<meta http-equiv="Content-Type" content="text/html; charset=utf-8">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderNotAddsSecondContentTypeMetaTag()
+    {
+        $html = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>';
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->render();
+
+        $numberOfContentTypeMetaTags = \substr_count($result, 'Content-Type');
+        static::assertSame(1, $numberOfContentTypeMetaTags);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultRemovesWbrTag()
+    {
+        $html = '<html>foo<wbr/>bar</html>';
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('<wbr', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addUnprocessableTagRemovesEmptyTag()
+    {
+        $subject = $this->buildDebugSubject('<html><p></p></html>');
+
+        $subject->addUnprocessableHtmlTag('p');
+        $result = $subject->emogrify();
+
+        static::assertNotContains('<p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addUnprocessableTagNotRemovesNonEmptyTag()
+    {
+        $subject = $this->buildDebugSubject('<html><p>foobar</p></html>');
+
+        $subject->addUnprocessableHtmlTag('p');
+        $result = $subject->emogrify();
+
+        static::assertContains('<p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function removeUnprocessableHtmlTagKeepsTagAgainAgain()
+    {
+        $subject = $this->buildDebugSubject('<html><p></p></html>');
+
+        $subject->addUnprocessableHtmlTag('p');
+        $subject->removeUnprocessableHtmlTag('p');
+        $result = $subject->emogrify();
+
+        static::assertContains('<p>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function matchedCssDataProvider()
+    {
+        // The sprintf placeholders %1$s and %2$s will automatically be replaced with CSS declarations
+        // like 'color: red;' or 'text-align: left;'.
+        return [
+            'two declarations from one rule can apply to the same element' => [
+                'html { %1$s %2$s }',
+                '<html style="%1$s %2$s">',
+            ],
+            'two identical matchers with different rules get combined' => [
+                'p { %1$s } p { %2$s }',
+                '<p class="p-1" style="%1$s %2$s">',
+            ],
+            'two different matchers rules matching the same element get combined' => [
+                'p { %1$s } .p-1 { %2$s }',
+                '<p class="p-1" style="%1$s %2$s">',
+            ],
+            'type => one element' => ['html { %1$s }', '<html style="%1$s">'],
+            'type (case-insensitive) => one element' => ['HTML { %1$s }', '<html style="%1$s">'],
+            'type => first matching element' => ['p { %1$s }', '<p class="p-1" style="%1$s">'],
+            'type => second matching element' => ['p { %1$s }', '<p class="p-2" style="%1$s">'],
+            'class => with class' => ['.p-2 { %1$s }', '<p class="p-2" style="%1$s">'],
+            'two classes s=> with both classes' => [
+                '.p-5.additional-class { %1$s }',
+                '<p class="p-5 additional-class" style="%1$s">',
+            ],
+            'type & class => type with class' => ['p.p-2 { %1$s }', '<p class="p-2" style="%1$s">'],
+            'ID => with ID' => ['#p4 { %1$s }', '<p class="p-4" id="p4" style="%1$s">'],
+            'type & ID => type with ID' => ['p#p4 { %1$s }', '<p class="p-4" id="p4" style="%1$s">'],
+            'universal => HTML' => ['* { %1$s }', '<html style="%1$s">'],
+            'attribute presence => with attribute' => ['[title] { %1$s }', '<span title="bonjour" style="%1$s">'],
+            'attribute exact value, double quotes => with exact attribute match' => [
+                '[title="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'attribute exact value, single quotes => with exact match' => [
+                '[title=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            // broken: attribute exact value without quotes => with exact match
+            // broken: attribute exact two-word value, double quotes => with exact attribute value match
+            // broken: attribute exact two-word value, single quotes => with exact attribute value match
+            // broken: attribute exact value with ~, double quotes => exact attribute match
+            // broken: attribute exact value with ~, single quotes => exact attribute match
+            // broken: attribute exact value with ~, no quotes => exact attribute match
+            // broken: attribute value with |, double quotes => with exact match
+            // broken: attribute value with |, single quotes => with exact match
+            // broken: attribute value with |, no quotes => with exact match
+            // broken: attribute value with ^, double quotes => with exact match
+            // broken: attribute value with ^, single quotes => with exact match
+            // broken: attribute value with ^, no quotes => with exact match
+            // broken: attribute value with $, double quotes => with exact match
+            // broken: attribute value with $, single quotes => with exact match
+            // broken: attribute value with $, no quotes => with exact match
+            // broken: attribute value with *, double quotes => with exact match
+            // broken: attribute value with *, single quotes => with exact match
+            // broken: attribute value with *, no quotes => with exact match
+            // broken: type & attribute presence => with type & attribute
+            'type & attribute exact value, double quotes => with type & exact attribute value match' => [
+                'span[title="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute exact value, single quotes => with type & exact attribute value match' => [
+                'span[title=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute exact value without quotes => with type & exact attribute value match' => [
+                'span[title=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute exact two-word value, double quotes => with type & exact attribute value match' => [
+                'span[title="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute exact four-word value, double quotes => with type & exact attribute value match' => [
+                'span[title="buenas dias bom dia"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute exact two-word value, single quotes => with type & exact attribute value match' => [
+                'span[title=\'buenas dias\'] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute exact four-word value, single quotes => with type & exact attribute value match' => [
+                'span[title=\'buenas dias bom dia\'] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & exact attribute match' => [
+                'span[title~="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ~, single quotes => with type & exact attribute match' => [
+                'span[title~=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ~, no quotes => with type & exact attribute match' => [
+                'span[title~=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 1st of 2 in attribute' => [
+                'span[title~="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 2nd of 2 in attribute' => [
+                'span[title~="dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 1st of 4 in attribute' => [
+                'span[title~="buenas"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 2nd of 4 in attribute' => [
+                'span[title~="dias"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as last of 4 in attribute' => [
+                'span[title~="dia"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with |, double quotes => with exact match' => [
+                'span[title|="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with |, single quotes => with exact match' => [
+                'span[title|=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with |, no quotes => with exact match' => [
+                'span[title|=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & two-word attribute value with |, double quotes => with exact match' => [
+                'span[title|="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with |, double quotes => with match before hyphen & another word' => [
+                'span[title|="avez"] { %1$s }',
+                '<span title="avez-vous" style="%1$s">',
+            ],
+            'type & attribute value with ^, double quotes => with exact match' => [
+                'span[title^="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ^, single quotes => with exact match' => [
+                'span[title^=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ^, no quotes => with exact match' => [
+                'span[title^=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            // broken: type & two-word attribute value with ^, double quotes => with exact match
+            'type & attribute value with ^, double quotes => with prefix math' => [
+                'span[title^="bon"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ^, double quotes => with match before another word' => [
+                'span[title^="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with $, double quotes => with exact match' => [
+                'span[title$="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with $, single quotes => with exact match' => [
+                'span[title$=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with $, no quotes => with exact match' => [
+                'span[title$=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & two-word attribute value with $, double quotes => with exact match' => [
+                'span[title$="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with $, double quotes => with suffix math' => [
+                'span[title$="jour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with $, double quotes => with match after another word' => [
+                'span[title$="dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & two-word attribute value with *, double quotes => with exact match' => [
+                'span[title*="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with prefix math' => [
+                'span[title*="bon"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with suffix math' => [
+                'span[title*="jour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with substring math' => [
+                'span[title*="njo"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with match before another word' => [
+                'span[title*="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with match after another word' => [
+                'span[title*="dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & special characters attribute value with *, double quotes => with substring match' => [
+                'span[title*=": subtitle; author"] { %1$s }',
+                '<span title="title: subtitle; author" style="%1$s">',
+            ],
+            'adjacent => 2nd of many' => ['p + p { %1$s }', '<p class="p-2" style="%1$s">'],
+            'adjacent => last of many' => ['p + p { %1$s }', '<p class="p-6" style="%1$s">'],
+            'adjacent (without space after +) => last of many' => ['p +p { %1$s }', '<p class="p-6" style="%1$s">'],
+            'adjacent (without space before +) => last of many' => ['p+ p { %1$s }', '<p class="p-6" style="%1$s">'],
+            'adjacent (without space before or after +) => last of many' => [
+                'p+p { %1$s }',
+                '<p class="p-6" style="%1$s">',
+            ],
+            'child (with spaces around >) => direct child' => ['p > span { %1$s }', '<span style="%1$s">'],
+            'child (without space after >) => direct child' => ['p >span { %1$s }', '<span style="%1$s">'],
+            'child (without space before >) => direct child' => ['p> span { %1$s }', '<span style="%1$s">'],
+            'child (without space before or after >) => direct child' => ['p>span { %1$s }', '<span style="%1$s">'],
+            'descendant => child' => ['p span { %1$s }', '<span style="%1$s">'],
+            'descendant => grandchild' => ['body span { %1$s }', '<span style="%1$s">'],
+            // broken: descendent attribute presence => with attribute
+            // broken: descendent attribute exact value => with exact attribute match
+            // broken: descendent type & attribute presence => with type & attribute
+            'descendent type & attribute exact value => with type & exact attribute match' => [
+                'body span[title="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'descendent type & attribute exact two-word value => with type & exact attribute match' => [
+                'body span[title="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'descendent type & attribute value with ~ => with type & exact attribute match' => [
+                'body span[title~="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'descendent type & attribute value with ~ => with type & word as 1st of 2 in attribute' => [
+                'body span[title~="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'descendant of type & class: type & attribute exact value, no quotes => with type & exact match (#381)' => [
+                'p.p-2 span[title=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'descendant of attribute presence => parent with attribute' => [
+                '[class] span { %1$s }',
+                '<p class="p-1"><span style="%1$s">',
+            ],
+            'descendant of attribute exact value => parent with type & exact attribute match' => [
+                '[id="p4"] span { %1$s }',
+                '<p class="p-4" id="p4"><span title="avez-vous" style="%1$s">',
+            ],
+            // broken: descendant of type & attribute presence => parent with type & attribute
+            'descendant of type & attribute exact value => parent with type & exact attribute match' => [
+                'p[id="p4"] span { %1$s }',
+                '<p class="p-4" id="p4"><span title="avez-vous" style="%1$s">',
+            ],
+            // broken: descendant of type & attribute exact two-word value => parent with type & exact attribute match
+            //         (exact match doesn't currently match hyphens, which would be needed to match the class attribute)
+            'descendant of type & attribute value with ~ => parent with type & exact attribute match' => [
+                'p[class~="p-1"] span { %1$s }',
+                '<p class="p-1"><span style="%1$s">',
+            ],
+            'descendant of type & attribute value with ~ => parent with type & word as 1st of 2 in attribute' => [
+                'p[class~="p-5"] span { %1$s }',
+                '<p class="p-5 additional-class"><span title="buenas dias bom dia" style="%1$s">',
+            ],
+            // broken: first-child => 1st of many
+            'type & :first-child => 1st of many' => ['p:first-child { %1$s }', '<p class="p-1" style="%1$s">'],
+            // broken: last-child => last of many
+            'type & :last-child => last of many' => ['p:last-child { %1$s }', '<p class="p-6" style="%1$s">'],
+            // broken: :not with type => other type
+            // broken: :not with class => no class
+            // broken: :not with class => other class
+            'type & :not with class => without class' => ['span:not(.foo) { %1$s }', '<span style="%1$s">'],
+            'type & :not with class => with other class' => ['p:not(.foo) { %1$s }', '<p class="p-1" style="%1$s">'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css CSS statements, potentially with %1$s and $2$s placeholders for a CSS declaration
+     * @param string $expectedHtml HTML, potentially with %1$s and $2$s placeholders for a CSS declaration
+     *
+     * @dataProvider matchedCssDataProvider
+     */
+    public function emogrifyAppliesCssToMatchingElements($css, $expectedHtml)
+    {
+        $cssDeclaration1 = 'color: red;';
+        $cssDeclaration2 = 'text-align: left;';
+        $subject = $this->buildDebugSubject(static::COMMON_TEST_HTML);
+        $subject->setCss(\sprintf($css, $cssDeclaration1, $cssDeclaration2));
+
+        $result = $subject->emogrify();
+
+        static::assertContains(\sprintf($expectedHtml, $cssDeclaration1, $cssDeclaration2), $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function nonMatchedCssDataProvider()
+    {
+        // The sprintf placeholders %1$s and %2$s will automatically be replaced with CSS declarations
+        // like 'color: red;' or 'text-align: left;'.
+        return [
+            'type => not other type' => ['html { %1$s }', '<body>'],
+            'class => not other class' => ['.p-2 { %1$s }', '<p class="p-1">'],
+            'class => not without class' => ['.p-2 { %1$s }', '<body>'],
+            'two classes => not only first class' => ['.p-1.another-class { %1$s }', '<p class="p-1">'],
+            'two classes => not only second class' => ['.another-class.p-1 { %1$s }', '<p class="p-1">'],
+            'type & class => not only type' => ['html.p-1 { %1$s }', '<html>'],
+            'type & class => not only class' => ['html.p-1 { %1$s }', '<p class="p-1">'],
+            'ID => not other ID' => ['#yeah { %1$s }', '<p class="p-4" id="p4">'],
+            'ID => not without ID' => ['#yeah { %1$s }', '<span>'],
+            'type & ID => not other type with that ID' => ['html#p4 { %1$s }', '<p class="p-4" id="p4">'],
+            'type & ID => not that type with other ID' => ['p#p5 { %1$s }', '<p class="p-4" id="p4">'],
+            'attribute presence => not element without that attribute' => ['[title] { %1$s }', '<span>'],
+            'attribute exact value => not element without that attribute' => ['[title="bonjour"] { %1$s }', '<span>'],
+            'attribute exact value => not element with different attribute value' => [
+                '[title="hi"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'attribute exact value => not element with only substring match in attribute value' => [
+                '[title="njo"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with ~ => not element with only prefix match in attribute value' => [
+                'span[title~="bon"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with |, double quotes => not element with match after another word & hyphen' => [
+                'span[title|="vous"] { %1$s }',
+                '<span title="avez-vous">',
+            ],
+            'type & attribute value with ^ => not element with only substring match in attribute value' => [
+                'span[title^="njo"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with ^, double quotes => not element with only suffix match in attribute value' => [
+                'span[title^="jour"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with $ => not element with only substring match in attribute value' => [
+                'span[title$="njo"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with $, double quotes => not element with only prefix match in attribute value' => [
+                'span[title$="bon"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with * => not element with different attribute value' => [
+                'span[title*="hi"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'adjacent => not 1st of many' => ['p + p { %1$s }', '<p class="p-1">'],
+            'child => not grandchild' => ['html > span { %1$s }', '<span>'],
+            'child => not parent' => ['span > html { %1$s }', '<html>'],
+            'descendant => not sibling' => ['span span { %1$s }', '<span>'],
+            'descendant => not parent' => ['p body { %1$s }', '<body>'],
+            'type & :first-child => not 2nd of many' => ['p:first-child { %1$s }', '<p class="p-2">'],
+            'type & :first-child => not last of many' => ['p:first-child { %1$s }', '<p class="p-6">'],
+            'type & :last-child => not 1st of many' => ['p:last-child { %1$s }', '<p class="p-1">'],
+            'type & :last-child => not 2nd of many' => ['p:last-child { %1$s }', '<p class="p-2">'],
+            'type & :not with class => not with class' => ['p:not(.p-1) { %1$s }', '<p class="p-1">'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css CSS statements, potentially with %1$s and $2$s placeholders for a CSS declaration
+     * @param string $expectedHtml HTML, potentially with %1$s and $2$s placeholders for a CSS declaration
+     *
+     * @dataProvider nonMatchedCssDataProvider
+     */
+    public function emogrifyNotAppliesCssToNonMatchingElements($css, $expectedHtml)
+    {
+        $cssDeclaration1 = 'color: red;';
+        $cssDeclaration2 = 'text-align: left;';
+        $subject = $this->buildDebugSubject(static::COMMON_TEST_HTML);
+        $subject->setCss(\sprintf($css, $cssDeclaration1, $cssDeclaration2));
+
+        $result = $subject->emogrify();
+
+        static::assertContains(\sprintf($expectedHtml, $cssDeclaration1, $cssDeclaration2), $result);
+    }
+
+    /**
+     * Provides data to test the following selector specificity ordering:
+     *     * < t < 2t < . < .+t < .+2t < 2. < 2.+t < 2.+2t
+     *     < # < #+t < #+2t < #+. < #+.+t < #+.+2t < #+2. < #+2.+t < #+2.+2t
+     *     < 2# < 2#+t < 2#+2t < 2#+. < 2#+.+t < 2#+.+2t < 2#+2. < 2#+2.+t < 2#+2.+2t
+     * where '*' is the universal selector, 't' is a type selector, '.' is a class selector, and '#' is an ID selector.
+     *
+     * Also confirm up to 99 class selectors are supported (much beyond this would require a more complex comparator).
+     *
+     * Specificity ordering for selectors involving pseudo-classes, attributes and `:not` is covered through the
+     * combination of these tests and the equal specificity tests and thus does not require explicit separate testing.
+     *
+     * @return string[][]
+     */
+    public function differentCssSelectorSpecificityDataProvider()
+    {
+        /**
+         * @var string[] Selectors targeting `<span id="text">` with increasing specificity
+         */
+        $selectors = [
+            'universal' => '*',
+            'type' => 'span',
+            '2 types' => 'p span',
+            'class' => '.p-4 *',
+            'class & type' => '.p-4 span',
+            'class & 2 types' => 'p.p-4 span',
+            '2 classes' => '.p-4.p-4 *',
+            '2 classes & type' => '.p-4.p-4 span',
+            '2 classes & 2 types' => 'p.p-4.p-4 span',
+            'ID' => '#text',
+            'ID & type' => 'span#text',
+            'ID & 2 types' => 'p span#text',
+            'ID & class' => '.p-4 #text',
+            'ID & class & type' => '.p-4 span#text',
+            'ID & class & 2 types' => 'p.p-4 span#text',
+            'ID & 2 classes' => '.p-4.p-4 #text',
+            'ID & 2 classes & type' => '.p-4.p-4 span#text',
+            'ID & 2 classes & 2 types' => 'p.p-4.p-4 span#text',
+            '2 IDs' => '#p4 #text',
+            '2 IDs & type' => '#p4 span#text',
+            '2 IDs & 2 types' => 'p#p4 span#text',
+            '2 IDs & class' => '.p-4#p4 #text',
+            '2 IDs & class & type' => '.p-4#p4 span#text',
+            '2 IDs & class & 2 types' => 'p.p-4#p4 span#text',
+            '2 IDs & 2 classes' => '.p-4.p-4#p4 #text',
+            '2 IDs & 2 classes & type' => '.p-4.p-4#p4 span#text',
+            '2 IDs & 2 classes & 2 types' => 'p.p-4.p-4#p4 span#text',
+        ];
+
+        $datasets = [];
+        $previousSelector = '';
+        $previousDescription = '';
+        foreach ($selectors as $description => $selector) {
+            if ($previousSelector !== '') {
+                $datasets[$description . ' more specific than ' . $previousDescription] = [
+                    '<span id="text"',
+                    $previousSelector,
+                    $selector,
+                ];
+            }
+            $previousSelector = $selector;
+            $previousDescription = $description;
+        }
+
+        // broken: class more specific than 99 types (requires support for chaining `:not(h1):not(h1)...`)
+        $datasets['ID more specific than 99 classes'] = [
+            '<p class="p-4" id="p4"',
+            \str_repeat('.p-4', 99),
+            '#p4',
+        ];
+
+        return $datasets;
+    }
+
+    /**
+     * @test
+     *
+     * @param string $matchedTagPart Tag expected to be matched by both selectors, without the closing '>',
+     *                               e.g. '<p class="p-1"'
+     * @param string $lessSpecificSelector A selector expression
+     * @param string $moreSpecificSelector Some other, more specific selector expression
+     *
+     * @dataProvider differentCssSelectorSpecificityDataProvider
+     */
+    public function emogrifyAppliesMoreSpecificCssSelectorToMatchingElements(
+        $matchedTagPart,
+        $lessSpecificSelector,
+        $moreSpecificSelector
+    ) {
+        $subject = $this->buildDebugSubject(static::COMMON_TEST_HTML);
+        $subject->setCss(
+            $lessSpecificSelector . ' { color: red; } ' .
+            $moreSpecificSelector . ' { color: green; } ' .
+            $moreSpecificSelector . ' { background-color: green; } ' .
+            $lessSpecificSelector . ' { background-color: red; }'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains($matchedTagPart . ' style="color: green; background-color: green;"', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function equalCssSelectorSpecificityDataProvider()
+    {
+        return [
+            // pseudo-class
+            'pseudo-class as specific as class' => ['<p class="p-1"', '*:first-child', '.p-1'],
+            'type & pseudo-class as specific as type & class' => ['<p class="p-1"', 'p:first-child', 'p.p-1'],
+            'class & pseudo-class as specific as two classes' => ['<p class="p-1"', '.p-1:first-child', '.p-1.p-1'],
+            'ID & pseudo-class as specific as ID & class' => [
+                '<span title="avez-vous"',
+                '#p4 *:first-child',
+                '#p4.p-4 *',
+            ],
+            '2 types & 2 classes & 2 IDs & pseudo-class as specific as 2 types & 3 classes & 2 IDs' => [
+                '<span id="text"',
+                'p.p-4.p-4#p4 span#text:last-child',
+                'p.p-4.p-4.p-4#p4 span#text',
+            ],
+            // attribute
+            'attribute as specific as class' => ['<span title="bonjour"', '[title="bonjour"]', '.p-2 *'],
+            'type & attribute as specific as type & class' => [
+                '<span title="bonjour"',
+                'span[title="bonjour"]',
+                '.p-2 span',
+            ],
+            'class & attribute as specific as two classes' => ['<p class="p-4" id="p4"', '.p-4[id="p4"]', '.p-4.p-4'],
+            'ID & attribute as specific as ID & class' => ['<p class="p-4" id="p4"', '#p4[id="p4"]', '#p4.p-4'],
+            '2 types & 2 classes & 2 IDs & attribute as specific as 2 types & 3 classes & 2 IDs' => [
+                '<span id="text"',
+                'p.p-4.p-4#p4[id="p4"] span#text',
+                'p.p-4.p-4.p-4#p4 span#text',
+            ],
+            // :not
+            // ideally these tests would be more minimal with just combinators and universal selectors in the :not
+            // argument, however Symfony CssSelector only supports simple (single-element) selectors here
+            ':not with type as specific as type and universal' => ['<p class="p-1"', '*:not(html)', 'html *'],
+            'type & :not with type as specific as 2 types' => ['<p class="p-1"', 'p:not(html)', 'html p'],
+            'class & :not with type as specific as type & class' => ['<p class="p-1"', '.p-1:not(html)', 'html .p-1'],
+            'ID & :not with type as specific as type & ID' => ['<p class="p-4" id="p4"', '#p4:not(html)', 'html #p4'],
+            '2 types & 2 classes & 2 IDs & :not with type as specific as 3 types & 2 classes & 2 IDs' => [
+                '<span id="text"',
+                'p.p-4.p-4#p4 span#text:not(html)',
+                'html p.p-4.p-4#p4 span#text',
+            ],
+            // argument of :not
+            ':not with type as specific as type' => ['<p class="p-1"', '*:not(h1)', 'p'],
+            ':not with class as specific as class' => ['<p class="p-1"', '*:not(.p-2)', '.p-1'],
+            ':not with ID as specific as ID' => ['<p class="p-4" id="p4"', '*:not(#p1)', '#p4'],
+            // broken: :not with 2 types & 2 classes & 2 IDs as specific as 2 types & 2 classes & 2 IDs
+            //         (`*:not(.p-1 #p1)`, i.e. with both class and ID, causes "Invalid type in selector")
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $matchedTagPart Tag expected to be matched by both selectors, without the closing '>',
+     *                               e.g. '<p class="p-1"'
+     * @param string $selector1 A selector expression
+     * @param string $selector2 Some other, equally specific selector expression
+     *
+     * @dataProvider equalCssSelectorSpecificityDataProvider
+     */
+    public function emogrifyAppliesLaterEquallySpecificCssSelectorToMatchingElements(
+        $matchedTagPart,
+        $selector1,
+        $selector2
+    ) {
+        $subject = $this->buildDebugSubject(static::COMMON_TEST_HTML);
+        $subject->setCss(
+            $selector1 . ' { color: red; } ' .
+            $selector2 . ' { color: green; } ' .
+            $selector2 . ' { background-color: red; } ' .
+            $selector1 . ' { background-color: green; }'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains($matchedTagPart . ' style="color: green; background-color: green;"', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function cssDeclarationWhitespaceDroppingDataProvider()
+    {
+        return [
+            'no whitespace, trailing semicolon' => ['color:#000;'],
+            'no whitespace, no trailing semicolon' => ['color:#000'],
+            'space after colon, no trailing semicolon' => ['color: #000'],
+            'space before colon, no trailing semicolon' => ['color :#000'],
+            'space before property name, no trailing semicolon' => [' color:#000'],
+            'space before trailing semicolon' => [' color:#000 ;'],
+            'space after trailing semicolon' => [' color:#000; '],
+            'space after property value, no trailing semicolon' => [' color:#000 '],
+            'space after property value, trailing semicolon' => [' color:#000; '],
+            'newline before property name, trailing semicolon' => ["\ncolor:#000;"],
+            'newline after property semicolon' => ["color:#000;\n"],
+            'newline before colon, trailing semicolon' => ["color\n:#000;"],
+            'newline after colon, trailing semicolon' => ["color:\n#000;"],
+            'newline after semicolon' => ["color:#000\n;"],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $cssDeclaration the CSS declaration block (without the curly braces)
+     *
+     * @dataProvider cssDeclarationWhitespaceDroppingDataProvider
+     */
+    public function emogrifyTrimsWhitespaceFromCssDeclarations($cssDeclaration)
+    {
+        $subject = $this->buildDebugSubject('<html></html>');
+        $subject->setCss('html {' . $cssDeclaration . '}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<html style="color: #000;">', $result);
+    }
+
+    /**
+     * @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 & space'
+            => ['color: #000; width: 3px;', 'color: #000; width: 3px;'],
+            'two declarations separated by semicolon & linefeed' => [
+                "color: #000;\nwidth: 3px;",
+                'color: #000; width: 3px;',
+            ],
+            'two declarations separated by semicolon & 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;',
+            ],
+            'one declaration with linefeed in property value' => [
+                "text-shadow:\n1px 1px 3px #000,\n1px 1px 1px #000;",
+                "text-shadow: 1px 1px 3px #000,\n1px 1px 1px #000;",
+            ],
+            'one declaration with Windows line ending in property value' => [
+                "text-shadow:\r\n1px 1px 3px #000,\r\n1px 1px 1px #000;",
+                "text-shadow: 1px 1px 3px #000,\r\n1px 1px 1px #000;",
+            ],
+        ];
+    }
+
+    /**
+     * @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)
+    {
+        $subject = $this->buildDebugSubject('<html></html>');
+        $subject->setCss('html {' . $cssDeclarationBlock . '}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<html style="' . $expectedStyleAttributeContent . '">', $result);
+    }
+
+    /**
+     * @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 emogrifyDropsInvalidCssDeclaration($cssDeclarationBlock)
+    {
+        $subject = $this->buildDebugSubject('<html></html>');
+        $subject->setCss('html {' . $cssDeclarationBlock . '}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<html style="">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleAttributes()
+    {
+        $styleAttribute = 'style="color: #ccc;"';
+        $subject = $this->buildDebugSubject('<html ' . $styleAttribute . '></html>');
+
+        $result = $subject->emogrify();
+
+        static::assertContains($styleAttribute, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAddsNewCssBeforeExistingStyle()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $subject = $this->buildDebugSubject('<html style="' . $styleAttributeValue . '"></html>');
+        $cssDeclarations = 'margin: 0 2px;';
+        $css = 'html {' . $cssDeclarations . '}';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContains('style="' . $cssDeclarations . ' ' . $styleAttributeValue . '"', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyCanMatchMinifiedCss()
+    {
+        $subject = $this->buildDebugSubject('<html><p></p></html>');
+        $subject->setCss('p{color:blue;}html{color:red;}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<html style="color: red;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyLowercasesAttributeNamesFromStyleAttributes()
+    {
+        $subject = $this->buildDebugSubject('<html style="COLOR:#ccc;"></html>');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('style="color: #ccc;"', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyLowercasesAttributeNamesFromPassedInCss()
+    {
+        $subject = $this->buildDebugSubject('<html></html>');
+        $subject->setCss('html {mArGiN:0 2pX;}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('style="margin: 0 2pX;"', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyPreservesCaseForAttributeValuesFromPassedInCss()
+    {
+        $cssDeclaration = "content: 'Hello World';";
+        $subject = $this->buildDebugSubject('<html><body><p>target</p></body></html>');
+        $subject->setCss('p {' . $cssDeclaration . '}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="' . $cssDeclaration . '">target</p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyPreservesCaseForAttributeValuesFromParsedStyleBlock()
+    {
+        $cssDeclaration = "content: 'Hello World';";
+        $subject = $this->buildDebugSubject(
+            '<html><head><style>p {' . $cssDeclaration . '}</style></head><body><p>target</p></body></html>'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="' . $cssDeclaration . '">target</p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyRemovesStyleNodes()
+    {
+        $subject = $this->buildDebugSubject('<html><style type="text/css"></style></html>');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('<style', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \Symfony\Component\CssSelector\Exception\SyntaxErrorException
+     */
+    public function emogrifyInDebugModeForInvalidCssSelectorThrowsException()
+    {
+        $subject = new CssInliner(
+            '<html><style type="text/css">p{color:red;} <style data-x="1">html{cursor:text;}</style></html>'
+        );
+        $subject->setDebug(true);
+
+        $subject->emogrify();
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeIgnoresInvalidCssSelectors()
+    {
+        $html = '<html><style type="text/css">' .
+            'p{color:red;} <style data-x="1">html{cursor:text;} p{background-color:blue;}</style> ' .
+            '<body><p></p></body></html>';
+        $subject = new CssInliner($html);
+        $subject->setDebug(false);
+
+        $html = $subject->emogrify();
+
+        static::assertContains('color: red', $html);
+        static::assertContains('background-color: blue', $html);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultIgnoresInvalidCssSelectors()
+    {
+        $html = '<html><style type="text/css">' .
+            'p{color:red;} <style data-x="1">html{cursor:text;} p{background-color:blue;}</style> ' .
+            '<body><p></p></body></html>';
+        $subject = new CssInliner($html);
+
+        $html = $subject->emogrify();
+        static::assertContains('color: red', $html);
+        static::assertContains('background-color: blue', $html);
+    }
+
+    /**
+     * Data provider for things that should be left out when applying the CSS.
+     *
+     * @return string[][]
+     */
+    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'],
+            'two @import directives, minified' => ['@import "foo.css";@import "bar.css";', '@import'],
+            '@charset directive' => ['@charset "UTF-8";', '@charset'],
+            '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'],
+            'style in "tv" media type rule with extra spaces' => [
+                '  @media  tv  {  p  {  color  :  #000  ;  }  }  ',
+                '#000',
+            ],
+            'style in "tv" media type rule with linefeeds' => [
+                "\n@media\ntv\n{\np\n{\ncolor\n:\n#000\n;\n}\n}\n",
+                '#000',
+            ],
+            'style in "tv" media type rule with Windows line endings' => [
+                "\r\n@media\r\ntv\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000\r\n;\r\n}\r\n}\r\n",
+                '#000',
+            ],
+            'style in "only tv" media type rule' => ['@media only tv {p {color: #000;}}', '#000'],
+            'style in "only tv" media type rule with extra spaces' => [
+                '  @media  only  tv  {  p  {  color  :  #000  ;  }  }  ',
+                '#000',
+            ],
+            'style in "only tv" media type rule with linefeeds' => [
+                "\n@media\nonly\ntv\n{\np\n{\ncolor\n:\n#000\n;\n}\n}\n",
+                '#000',
+            ],
+            'style in "only tv" media type rule with Windows line endings' => [
+                "\r\n@media\r\nonly\r\ntv\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000\r\n;\r\n}\r\n}\r\n",
+                '#000',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $unneededCss
+     * @param string $markerNotExpectedInHtml
+     *
+     * @dataProvider unneededCssThingsDataProvider
+     */
+    public function emogrifyFiltersUnneededCssThings($unneededCss, $markerNotExpectedInHtml)
+    {
+        $subject = $this->buildDebugSubject('<html><p>foo</p></html>');
+        $subject->setCss($unneededCss);
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains($markerNotExpectedInHtml, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $unneededCss
+     *
+     * @dataProvider unneededCssThingsDataProvider
+     */
+    public function emogrifyMatchesRuleAfterUnneededCssThing($unneededCss)
+    {
+        $subject = $this->buildDebugSubject('<html><body></body></html>');
+        $subject->setCss($unneededCss . ' body { color: green; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<body style="color: green;">', $result);
+    }
+
+    /**
+     * Data provider for media rules.
+     *
+     * @return string[][]
+     */
+    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 "only screen" media type rule with extra spaces'
+            => ['  @media  only  screen  {  p  {  color  :  #000;  }  }  '],
+            'style in "only screen" media type rule with linefeeds'
+            => ["\n@media\nonly\nscreen\n{\np\n{\ncolor\n:\n#000;\n}\n}\n"],
+            'style in "only screen" media type rule with Windows line endings'
+            => ["\r\n@media\r\nonly\r\nscreen\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000;\r\n}\r\n}\r\n"],
+            'style in media type rule' => ['@media {p {color: #000;}}'],
+            'style in media type rule with extra spaces' => ['  @media  {  p  {  color  :  #000;  }  }  '],
+            'style in media type rule with linefeeds' => ["\n@media\n{\np\n{\ncolor\n:\n#000;\n}\n}\n"],
+            'style in media type rule with Windows line endings'
+            => ["\r\n@media\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000;\r\n}\r\n}\r\n"],
+            'style in "screen" media type rule' => ['@media screen {p {color: #000;}}'],
+            'style in "screen" media type rule with extra spaces'
+            => ['  @media  screen  {  p  {  color  :  #000;  }  }  '],
+            'style in "screen" media type rule with linefeeds'
+            => ["\n@media\nscreen\n{\np\n{\ncolor\n:\n#000;\n}\n}\n"],
+            'style in "screen" media type rule with Windows line endings'
+            => ["\r\n@media\r\nscreen\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000;\r\n}\r\n}\r\n"],
+            '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)
+    {
+        $subject = $this->buildDebugSubject('<html><p>foo</p></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function orderedRulesAndSurroundingCssDataProvider()
+    {
+        $possibleSurroundingCss = [
+            'nothing' => '',
+            'space' => ' ',
+            'linefeed' => "\n",
+            'Windows line ending' => "\r\n",
+            'comment' => '/* hello */',
+            'other non-matching CSS' => 'h6 { color: #f00; }',
+            'other matching CSS' => 'p { color: #f00; }',
+            'disallowed media rule' => '@media tv { p { color: #f00; } }',
+            'allowed but non-matching media rule' => '@media screen { h6 { color: #f00; } }',
+            'non-matching CSS with pseudo-component' => 'h6:hover { color: #f00; }',
+        ];
+        $possibleCssBefore = $possibleSurroundingCss + [
+                '@import' => '@import "foo.css";',
+                '@charset' => '@charset "UTF-8";',
+            ];
+
+        $datasetsSurroundingCss = [];
+        foreach ($possibleCssBefore as $descriptionBefore => $cssBefore) {
+            foreach ($possibleSurroundingCss as $descriptionBetween => $cssBetween) {
+                foreach ($possibleSurroundingCss as $descriptionAfter => $cssAfter) {
+                    // every combination would be a ridiculous c.1000 datasets - choose a select few
+                    // test all possible CSS before once
+                    if (($cssBetween === '' && $cssAfter === '')
+                        // test all possible CSS between once
+                        || ($cssBefore === '' && $cssAfter === '')
+                        // test all possible CSS after once
+                        || ($cssBefore === '' && $cssBetween === '')
+                        // test with each possible CSS in all three positions
+                        || ($cssBefore === $cssBetween && $cssBetween === $cssAfter)
+                    ) {
+                        $description = ' with ' . $descriptionBefore . ' before, '
+                            . $descriptionBetween . ' between, '
+                            . $descriptionAfter . ' after';
+                        $datasetsSurroundingCss[$description] = [$cssBefore, $cssBetween, $cssAfter];
+                    }
+                }
+            }
+        }
+
+        $datasets = [];
+        foreach ($datasetsSurroundingCss as $description => $datasetSurroundingCss) {
+            $datasets += [
+                'two media rules' . $description => \array_merge(
+                    ['@media all { p { color: #333; } }', '@media print { p { color: #000; } }'],
+                    $datasetSurroundingCss
+                ),
+                'two rules involving pseudo-components' . $description => \array_merge(
+                    ['a:hover { color: blue; }', 'a:active { color: green; }'],
+                    $datasetSurroundingCss
+                ),
+                'media rule followed by rule involving pseudo-components' . $description => \array_merge(
+                    ['@media screen { p { color: #000; } }', 'a:hover { color: green; }'],
+                    $datasetSurroundingCss
+                ),
+                'rule involving pseudo-components followed by media rule' . $description => \array_merge(
+                    ['a:hover { color: green; }', '@media screen { p { color: #000; } }'],
+                    $datasetSurroundingCss
+                ),
+            ];
+        }
+        return $datasets;
+    }
+
+    /**
+     * @test
+     *
+     * @param string $rule1
+     * @param string $rule2
+     * @param string $cssBefore CSS to insert before the first rule
+     * @param string $cssBetween CSS to insert between the rules
+     * @param string $cssAfter CSS to insert after the second rule
+     *
+     * @dataProvider orderedRulesAndSurroundingCssDataProvider
+     */
+    public function emogrifyKeepsRulesCopiedToStyleElementInSpecifiedOrder(
+        $rule1,
+        $rule2,
+        $cssBefore,
+        $cssBetween,
+        $cssAfter
+    ) {
+        $subject = $this->buildDebugSubject('<html><p><a>foo</a></p></html>');
+        $subject->setCss($cssBefore . $rule1 . $cssBetween . $rule2 . $cssAfter);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss($rule1 . $rule2, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function removeAllowedMediaTypeRemovesStylesForTheGivenMediaType()
+    {
+        $css = '@media screen { html { some-property: value; } }';
+        $subject = $this->buildDebugSubject('<html></html>');
+        $subject->setCss($css);
+        $subject->removeAllowedMediaType('screen');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('@media', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addAllowedMediaTypeKeepsStylesForTheGivenMediaType()
+    {
+        $css = '@media braille { html { some-property: value; } }';
+        $subject = $this->buildDebugSubject('<html></html>');
+        $subject->setCss($css);
+        $subject->addAllowedMediaType('braille');
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingHeadElementContent()
+    {
+        $subject = $this->buildDebugSubject('<html><head><!-- original content --></head></html>');
+        $subject->setCss('@media all { html { some-property: value; } }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<!-- original content -->', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleElementWithMedia()
+    {
+        $html = $this->html5DocumentType . '<html><head><!-- original content --></head><body></body></html>';
+        $subject = $this->buildDebugSubject($html);
+        $subject->setCss('@media all { html { some-property: value; } }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<style type="text/css">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleElementWithMediaInHead()
+    {
+        $style = '<style type="text/css">@media all { html {  color: red; } }</style>';
+        $html = '<html><head>' . $style . '</head><body></body></html>';
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->emogrify();
+
+        static::assertRegExp('/<head>.*<style.*<\\/head>/s', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleElementWithMediaOutOfBody()
+    {
+        $style = '<style type="text/css">@media all { html {  color: red; } }</style>';
+        $html = '<html><head>' . $style . '</head><body></body></html>';
+        $subject = $this->buildDebugSubject($html);
+
+        $result = $subject->emogrify();
+
+        static::assertNotRegExp('/<body>.*<style/s', $result);
+    }
+
+    /**
+     * Valid media query which need to be preserved
+     *
+     * @return string[][]
+     */
+    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 "print" media type rule' => ['@media print { * { color:#000 !important; } }'],
+            'style in media type rule without specification' => ['@media { h1 { color:red; } }'],
+            'style with multiple media type rules' => [
+                '@media all { p { color: #000; } }' .
+                '@media only screen { h1 { color:red; } }' .
+                '@media only all { h1 { color:red; } }' .
+                '@media print { * { color:#000 !important; } }' .
+                '@media { h1 { color:red; } }',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyWithValidMediaQueryContainsInnerCss($css)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1><p></p></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss('<style type="text/css">' . $css . '</style>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyWithValidMinifiedMediaQueryContainsInnerCss($css)
+    {
+        // Minify CSS by removing unnecessary whitespace.
+        $css = \preg_replace('/\\s*{\\s*/', '{', $css);
+        $css = \preg_replace('/;?\\s*}\\s*/', '}', $css);
+        $css = \preg_replace('/@media{/', '@media {', $css);
+
+        $subject = $this->buildDebugSubject('<html><h1></h1><p></p></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<style type="text/css">' . $css . '</style>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyForHtmlWithValidMediaQueryContainsInnerCss($css)
+    {
+        $subject = $this->buildDebugSubject('<html><style type="text/css">' . $css . '</style><h1></h1><p></p></html>');
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss('<style type="text/css">' . $css . '</style>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyWithValidMediaQueryNotContainsInlineCss($css)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * Invalid media query which need to be strip
+     *
+     * @return string[][]
+     */
+    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 emogrifyWithInvalidMediaQueryNotContainsInnerCss($css)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider invalidMediaPreserveDataProvider
+     */
+    public function emogrifyWithInvalidMediaQueryNotContainsInlineCss($css)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider invalidMediaPreserveDataProvider
+     */
+    public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInnerCss($css)
+    {
+        $subject = $this->buildDebugSubject('<html><style type="text/css">' . $css . '</style><h1></h1></html>');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider invalidMediaPreserveDataProvider
+     */
+    public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInlineCss($css)
+    {
+        $subject = $this->buildDebugSubject('<html><style type="text/css">' . $css . '</style><h1></h1></html>');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyIgnoresEmptyMediaQuery()
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss('@media screen {} @media tv { h1 { color: red; } }');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+        static::assertNotContains('@media screen', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyIgnoresMediaQueryWithWhitespaceOnly()
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss('@media screen { } @media tv { h1 { color: red; } }');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+        static::assertNotContains('@media screen', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function mediaTypeDataProvider()
+    {
+        return [
+            'disallowed type' => ['tv'],
+            'allowed type' => ['screen'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     *
+     * @dataProvider mediaTypeDataProvider
+     */
+    public function emogrifyKeepsMediaRuleAfterEmptyMediaRule($emptyRuleMediaType)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss('@media ' . $emptyRuleMediaType . ' {} @media all { h1 { color: red; } }');
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss('@media all { h1 { color: red; } }', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     *
+     * @dataProvider mediaTypeDataProvider
+     */
+    public function emogrifyNotKeepsUnneededMediaRuleAfterEmptyMediaRule($emptyRuleMediaType)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss('@media ' . $emptyRuleMediaType . ' {} @media speech { h1 { color: red; } }');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('@media', $result);
+    }
+
+    /**
+     * @param string[] $precedingSelectorComponents Array of selectors to which each type of pseudo-component is
+     *                                              appended to create a selector for a CSS rule.
+     *                                              Keys are human-readable descriptions.
+     *
+     * @return string[][]
+     */
+    private function getCssRuleDatasetsWithSelectorPseudoComponents(array $precedingSelectorComponents)
+    {
+        $rulesComponents = [
+            'pseudo-element' => [
+                'selectorPseudoComponent' => '::after',
+                'declarationsBlock' => 'content: "bar";',
+            ],
+            'CSS2 pseudo-element' => [
+                'selectorPseudoComponent' => ':after',
+                'declarationsBlock' => 'content: "bar";',
+            ],
+            'hyphenated pseudo-element' => [
+                'selectorPseudoComponent' => '::first-letter',
+                'declarationsBlock' => 'color: green;',
+            ],
+            'pseudo-class' => [
+                'selectorPseudoComponent' => ':hover',
+                'declarationsBlock' => 'color: green;',
+            ],
+            'hyphenated pseudo-class' => [
+                'selectorPseudoComponent' => ':read-only',
+                'declarationsBlock' => 'color: green;',
+            ],
+            'pseudo-class with parameter' => [
+                'selectorPseudoComponent' => ':lang(en)',
+                'declarationsBlock' => 'color: green;',
+            ],
+        ];
+
+        $datasets = [];
+        foreach ($precedingSelectorComponents as $precedingComponentDescription => $precedingSelectorComponent) {
+            foreach ($rulesComponents as $pseudoComponentDescription => $ruleComponents) {
+                $datasets[$precedingComponentDescription . ' ' . $pseudoComponentDescription] = [
+                    $precedingSelectorComponent . $ruleComponents['selectorPseudoComponent']
+                    . ' { ' . $ruleComponents['declarationsBlock'] . ' }',
+                ];
+            }
+        }
+        return $datasets;
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function matchingSelectorWithPseudoComponentCssRuleDataProvider()
+    {
+        $datasetsWithSelectorPseudoComponents = $this->getCssRuleDatasetsWithSelectorPseudoComponents(
+            [
+                'lone' => '',
+                'type &' => 'a',
+                'class &' => '.a',
+                'ID &' => '#a',
+                'attribute &' => 'a[href="a"]',
+                'static pseudo-class &' => 'a:first-child',
+                'ancestor &' => 'p ',
+                'ancestor & type &' => 'p a',
+            ]
+        );
+        $datasetsWithCombinedPseudoSelectors = [
+            'pseudo-class & descendant' => ['p:hover a { color: green; }'],
+            'pseudo-class & pseudo-element' => ['a:hover::after { content: "bar"; }'],
+            'pseudo-element & pseudo-class' => ['a::after:hover { content: "bar"; }'],
+            'two pseudo-classes' => ['a:focus:hover { color: green; }'],
+        ];
+
+        return \array_merge($datasetsWithSelectorPseudoComponents, $datasetsWithCombinedPseudoSelectors);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider matchingSelectorWithPseudoComponentCssRuleDataProvider
+     */
+    public function emogrifyKeepsRuleWithPseudoComponentInMatchingSelector($css)
+    {
+        $subject = $this->buildDebugSubject('<html><p><a id="a" class="a" href="a">foo</a></p></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        self::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function nonMatchingSelectorWithPseudoComponentCssRuleDataProvider()
+    {
+        $datasetsWithSelectorPseudoComponents = $this->getCssRuleDatasetsWithSelectorPseudoComponents(
+            [
+                'type &' => 'b',
+                'class &' => '.b',
+                'ID &' => '#b',
+                'attribute &' => 'a[href="b"]',
+                'static pseudo-class &' => 'a:not(.a)',
+                'ancestor &' => 'ul ',
+                'ancestor & type &' => 'p b',
+            ]
+        );
+        $datasetsWithCombinedPseudoSelectors = [
+            'pseudo-class & descendant' => ['ul:hover a { color: green; }'],
+            'pseudo-class & pseudo-element' => ['b:hover::after { content: "bar"; }'],
+            'pseudo-element & pseudo-class' => ['b::after:hover { content: "bar"; }'],
+            'two pseudo-classes' => ['input:focus:hover { color: green; }'],
+        ];
+
+        return \array_merge($datasetsWithSelectorPseudoComponents, $datasetsWithCombinedPseudoSelectors);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider nonMatchingSelectorWithPseudoComponentCssRuleDataProvider
+     */
+    public function emogrifyNotKeepsRuleWithPseudoComponentInNonMatchingSelector($css)
+    {
+        $subject = $this->buildDebugSubject('<html><p><a id="a" class="a" href="#">foo</a></p></html>');
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        self::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsRuleInMediaQueryWithPseudoComponentInMatchingSelector()
+    {
+        $subject = $this->buildDebugSubject('<html><a>foo</a></html>');
+        $css = '@media screen { a:hover { color: green; } }';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        self::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotKeepsRuleInMediaQueryWithPseudoComponentInNonMatchingSelector()
+    {
+        $subject = $this->buildDebugSubject('<html><a>foo</a></html>');
+        $css = '@media screen { b:hover { color: green; } }';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        self::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsRuleWithPseudoComponentInMultipleMatchingSelectorsFromSingleRule()
+    {
+        $subject = $this->buildDebugSubject('<html><p>foo</p><a>bar</a></html>');
+        $css = 'p:hover, a:hover { color: green; }';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsOnlyMatchingSelectorsWithPseudoComponentFromSingleRule()
+    {
+        $subject = $this->buildDebugSubject('<html><a>foo</a></html>');
+        $subject->setCss('p:hover, a:hover { color: green; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss('<style type="text/css">a:hover { color: green; }</style>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesCssToMatchingElementsAndKeepsRuleWithPseudoComponentFromSingleRule()
+    {
+        $subject = $this->buildDebugSubject('<html><p>foo</p><a>bar</a></html>');
+        $subject->setCss('p, a:hover { color: green; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="color: green;">', $result);
+        static::assertContainsCss('<style type="text/css">a:hover { color: green; }</style>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function mediaTypesDataProvider()
+    {
+        return [
+            'disallowed type after disallowed type' => ['tv', 'speech'],
+            'allowed type after disallowed type' => ['tv', 'all'],
+            'disallowed type after allowed type' => ['screen', 'tv'],
+            'allowed type after allowed type' => ['screen', 'all'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     * @param string $mediaType
+     *
+     * @dataProvider mediaTypesDataProvider
+     */
+    public function emogrifyAppliesCssBetweenEmptyMediaRuleAndMediaRule($emptyRuleMediaType, $mediaType)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss(
+            '@media ' . $emptyRuleMediaType . ' {} h1 { color: green; } @media ' . $mediaType
+            . ' { h1 { color: red; } }'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<h1 style="color: green;">', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     * @param string $mediaType
+     *
+     * @dataProvider mediaTypesDataProvider
+     */
+    public function emogrifyAppliesCssBetweenEmptyMediaRuleAndMediaRuleWithCssAfter($emptyRuleMediaType, $mediaType)
+    {
+        $subject = $this->buildDebugSubject('<html><h1></h1></html>');
+        $subject->setCss(
+            '@media ' . $emptyRuleMediaType . ' {} h1 { color: green; } @media ' . $mediaType
+            . ' { h1 { color: red; } } h1 { font-size: 24px; }'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<h1 style="color: green; font-size: 24px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesCssFromStyleNodes()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $subject = $this->buildDebugSubject(
+            '<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<html style="' . $styleAttributeValue . '">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenDisabledNotAppliesCssFromStyleBlocks()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $subject = $this->buildDebugSubject(
+            '<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>'
+        );
+        $subject->disableStyleBlocksParsing();
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenStyleBlocksParsingDisabledKeepInlineStyles()
+    {
+        $styleAttributeValue = 'text-align: center;';
+        $subject = $this->buildDebugSubject(
+            '<html><head><style type="text/css">p { color: #ccc; }</style></head>' .
+            '<body><p style="' . $styleAttributeValue . '">paragraph</p></body></html>'
+        );
+        $subject->disableStyleBlocksParsing();
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="' . $styleAttributeValue . '">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenDisabledNotAppliesCssFromInlineStyles()
+    {
+        $subject = $this->buildDebugSubject('<html style="color: #ccc;"></html>');
+        $subject->disableInlineStyleAttributesParsing();
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('<html style', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenInlineStyleAttributesParsingDisabledKeepStyleBlockStyles()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $subject = $this->buildDebugSubject(
+            '<html><head><style type="text/css">p { ' . $styleAttributeValue . ' }</style></head>' .
+            '<body><p style="text-align: center;">paragraph</p></body></html>'
+        );
+        $subject->disableInlineStyleAttributesParsing();
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="' . $styleAttributeValue . '">', $result);
+    }
+
+    /**
+     * Emogrify was handling case differently for passed-in CSS vs. CSS parsed from style blocks.
+     *
+     * @test
+     */
+    public function emogrifyAppliesCssWithMixedCaseAttributesInStyleBlock()
+    {
+        $subject = $this->buildDebugSubject(
+            '<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>'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="padding-bottom: 1px; padding-top: 0; text-align: center;">', $result);
+    }
+
+    /**
+     * Style block CSS overrides values.
+     *
+     * @test
+     */
+    public function emogrifyMergesCssWithMixedCaseAttribute()
+    {
+        $subject = $this->buildDebugSubject(
+            '<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>'
+        );
+        $subject->setCss('p { margin: 0; padding-TOP: 0; PADDING-bottom: 1PX;}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains(
+            '<p style="margin: 0; padding-bottom: 3px; padding-top: 1px; text-align: center;">',
+            $result
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyMergesCssWithMixedUnits()
+    {
+        $subject = $this->buildDebugSubject(
+            '<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>'
+        );
+        $subject->setCss('p { margin: 1px; padding-bottom:0;}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 0; padding-bottom: 1px; text-align: center;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultRemovesElementsWithDisplayNoneFromExternalCss()
+    {
+        $subject = $this->buildDebugSubject('<html><body><div class="foo"></div></body></html>');
+        $subject->setCss('div.foo { display: none; }');
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('<div class="foo"></div>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultRemovesElementsWithDisplayNoneInStyleAttribute()
+    {
+        $subject = $this->buildDebugSubject(
+            '<html><body><div class="foobar" style="display: none;"></div>' .
+            '</body></html>'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('<div', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAfterDisableInvisibleNodeRemovalPreservesInvisibleElements()
+    {
+        $subject = $this->buildDebugSubject('<html><body><div class="foo"></div></body></html>');
+        $subject->setCss('div.foo { display: none; }');
+
+        $subject->disableInvisibleNodeRemoval();
+        $result = $subject->emogrify();
+
+        static::assertContains('<div class="foo" style="display: none;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsCssMediaQueriesWithCssCommentAfterMediaQuery()
+    {
+        $subject = $this->buildDebugSubject('<html><body></body></html>');
+        $subject->setCss(
+            '@media only screen and (max-width: 480px) { body { color: #ffffff } /* some comment */ }'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('@media only screen and (max-width: 480px)', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $documentType
+     *
+     * @dataProvider documentTypeDataProvider
+     */
+    public function renderConvertsXmlSelfClosingTagsToNonXmlSelfClosingTag($documentType)
+    {
+        $subject = $this->buildDebugSubject(
+            $documentType . '<html><body><br/></body></html>'
+        );
+
+        $result = $subject->render();
+
+        static::assertContains('<br>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderAutomaticallyClosesUnclosedTag()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p></body></html>');
+
+        $result = $subject->render();
+
+        static::assertContains('<body><p></p></body>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderReturnsCompleteHtmlDocument()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p></p></body></html>');
+
+        $result = $subject->render();
+
+        static::assertSame(
+            $this->html5DocumentType . "\n" .
+            "<html>\n" .
+            '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' . "\n" .
+            "<body><p></p></body>\n" .
+            "</html>\n",
+            $result
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyBodyContentReturnsBodyContentFromHtml()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p></p></body></html>');
+
+        $result = $subject->emogrifyBodyContent();
+
+        static::assertSame('<p></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyBodyContentReturnsBodyContentFromPartialContent()
+    {
+        $subject = $this->buildDebugSubject('<p></p>');
+
+        $result = $subject->emogrifyBodyContent();
+
+        static::assertSame('<p></p>', $result);
+    }
+
+    /**
+     * Sets HTML of subject to boilerplate HTML with a single `<p>` in `<body>` and empty `<head>`
+     *
+     * @param string $style Optional value for the style attribute of the `<p>` element
+     *
+     * @return CssInliner
+     */
+    private function buildSubjectWithBoilerplateHtml($style = '')
+    {
+        $html = '<html><head></head><body><p';
+        if ($style !== '') {
+            $html .= ' style="' . $style . '"';
+        }
+        $html .= '>some content</p></body></html>';
+
+        return $this->buildDebugSubject($html);
+    }
+
+    /**
+     * @test
+     */
+    public function importantInExternalCssOverwritesInlineCss()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml('margin: 2px;');
+        $subject->setCss('p { margin: 1px !important; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 1px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function importantInExternalCssKeepsInlineCssForOtherAttributes()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml('margin: 2px; text-align: center;');
+        $subject->setCss('p { margin: 1px !important; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="text-align: center; margin: 1px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function importantIsCaseInsensitive()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml('margin: 2px;');
+        $subject->setCss('p { margin: 1px !ImPorTant; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 1px !ImPorTant;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function secondImportantStyleOverwritesFirstOne()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml();
+        $subject->setCss('p { margin: 1px !important; } p { margin: 2px !important; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function secondNonImportantStyleOverwritesFirstOne()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml();
+        $subject->setCss('p { margin: 1px; } p { margin: 2px; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function secondNonImportantStyleNotOverwritesFirstImportantOne()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml();
+        $subject->setCss('p { margin: 1px !important; } p { margin: 2px; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 1px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesLaterShorthandStyleAfterIndividualStyle()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml();
+        $subject->setCss('p { margin-top: 1px; } p { margin: 2px; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin-top: 1px; margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesLaterOverridingStyleAfterStyleAfterOverriddenStyle()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml();
+        $subject->setCss('p { margin-top: 1px; } p { margin: 2px; } p { margin-top: 3px; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px; margin-top: 3px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesInlineOverridingStyleAfterCssStyleAfterOverriddenCssStyle()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml('margin-top: 3px;');
+        $subject->setCss('p { margin-top: 1px; } p { margin: 2px; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px; margin-top: 3px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesLaterInlineOverridingStyleAfterEarlierInlineStyle()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml('margin: 2px; margin-top: 3px;');
+        $subject->setCss('p { margin-top: 1px; }');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px; margin-top: 3px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function irrelevantMediaQueriesAreRemoved()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p></p></body></html>');
+        $uselessQuery = '@media all and (max-width: 500px) { em { color:red; } }';
+        $subject->setCss($uselessQuery);
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('@media', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function relevantMediaQueriesAreRetained()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p></p></body></html>');
+        $usefulQuery = '@media all and (max-width: 500px) { p { color:red; } }';
+        $subject->setCss($usefulQuery);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss($usefulQuery, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function importantStyleRuleFromInlineCssOverwritesImportantStyleRuleFromExternalCss()
+    {
+        $subject = $this->buildSubjectWithBoilerplateHtml('margin: 2px !important; text-align: center;');
+        $subject->setCss('p { margin: 1px !important; padding: 1px;}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="padding: 1px; text-align: center; margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addExcludedSelectorRemovesMatchingElementsFromEmogrification()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p class="x"></p></body></html>');
+        $subject->setCss('p { margin: 0; }');
+
+        $subject->addExcludedSelector('p.x');
+        $result = $subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addExcludedSelectorExcludesMatchingElementEventWithWhitespaceAroundSelector()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p class="x"></p></body></html>');
+        $subject->setCss('p { margin: 0; }');
+
+        $subject->addExcludedSelector(' p.x ');
+        $result = $subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addExcludedSelectorKeepsNonMatchingElementsInEmogrification()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p></p></body></html>');
+        $subject->setCss('p { margin: 0; }');
+
+        $subject->addExcludedSelector('p.x');
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="margin: 0;"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function removeExcludedSelectorGetsMatchingElementsToBeEmogrifiedAgain()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p class="x"></p></body></html>');
+        $subject->setCss('p { margin: 0; }');
+
+        $subject->addExcludedSelector('p.x');
+        $subject->removeExcludedSelector('p.x');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p class="x" style="margin: 0;"></p>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \Symfony\Component\CssSelector\Exception\SyntaxErrorException
+     */
+    public function emogrifyInDebugModeForInvalidExcludedSelectorThrowsException()
+    {
+        $subject = new CssInliner('<html></html>');
+        $subject->setDebug(true);
+
+        $subject->addExcludedSelector('..p');
+        $subject->emogrify();
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeIgnoresInvalidExcludedSelector()
+    {
+        $subject = new CssInliner('<html><p class="x"></p></html>');
+        $subject->setDebug(false);
+
+        $subject->addExcludedSelector('..p');
+        $result = $subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeIgnoresOnlyInvalidExcludedSelector()
+    {
+        $subject = new CssInliner('<html><p class="x"></p><p class="y"></p><p class="z"></p></html>');
+        $subject->setDebug(false);
+
+        $subject->setCss('p { color: red };');
+        $subject->addExcludedSelector('p.x');
+        $subject->addExcludedSelector('..p');
+        $subject->addExcludedSelector('p.z');
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+        static::assertContains('<p class="y" style="color: red;"></p>', $result);
+        static::assertContains('<p class="z"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emptyMediaQueriesAreRemoved()
+    {
+        $subject = $this->buildDebugSubject('<html><body><p></p></body></html>');
+        $emptyQuery = '@media all and (max-width: 500px) { }';
+        $subject->setCss($emptyQuery);
+
+        $result = $subject->emogrify();
+
+        static::assertNotContains('@media', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function multiLineMediaQueryWithWindowsLineEndingsIsAppliedOnlyOnce()
+    {
+        $subject = $this->buildDebugSubject(
+            '<html><body>' .
+            '<p class="medium">medium</p>' .
+            '<p class="small">small</p>' .
+            '</body></html>'
+        );
+        $css = "@media all {\r\n" .
+            ".medium {font-size:18px;}\r\n" .
+            ".small {font-size:14px;}\r\n" .
+            '}';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCssCount(1, $css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function multiLineMediaQueryWithUnixLineEndingsIsAppliedOnlyOnce()
+    {
+        $subject = $this->buildDebugSubject(
+            '<html><body>' .
+            '<p class="medium">medium</p>' .
+            '<p class="small">small</p>' .
+            '</body></html>'
+        );
+        $css = "@media all {\n" .
+            ".medium {font-size:18px;}\n" .
+            ".small {font-size:14px;}\n" .
+            '}';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCssCount(1, $css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function multipleMediaQueriesAreAppliedOnlyOnce()
+    {
+        $subject = $this->buildDebugSubject(
+            '<html><body>' .
+            '<p class="medium">medium</p>' .
+            '<p class="small">small</p>' .
+            '</body></html>'
+        );
+        $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" .
+            '}';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCssCount(1, $css, $result);
+    }
+
+    /**
+     * @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)
+    {
+        $subject = $this->buildDebugSubject('<html></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=);';
+        $subject->setCss('html {' . $styleRule . '}');
+
+        $result = $subject->emogrify();
+
+        static::assertContains(
+            '<html style="' . $styleRule . '">',
+            $result
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifierIgnoresPseudoClassCombinedWithPseudoElement()
+    {
+        $subject = $this->buildDebugSubject('<html><body><div></div></body></html>');
+        $subject->setCss('div:last-child::after {float: right;}');
+
+        $html = $subject->emogrify();
+
+        static::assertContains('<div></div>', $html);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsInlineStylePriorityVersusStyleBlockRules()
+    {
+        $subject = $this->buildDebugSubject(
+            '<html><head><style>p {padding:10px};</style></head><body><p style="padding-left:20px;"></p></body></html>'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="padding: 10px; padding-left: 20px;">', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function cssForImportantRuleRemovalDataProvider()
+    {
+        return [
+            'one !important rule only' => [
+                'width: 1px !important',
+                'width: 1px;',
+            ],
+            'multiple !important rules only' => [
+                'width: 1px !important; height: 1px !important',
+                'width: 1px; height: 1px;',
+            ],
+            'multiple declarations, one !important rule at the beginning' => [
+                'width: 1px !important; height: 1px; color: red',
+                'height: 1px; color: red; width: 1px;',
+            ],
+            'multiple declarations, one !important rule somewhere in the middle' => [
+                'height: 1px; width: 1px !important; color: red',
+                'height: 1px; color: red; width: 1px;',
+            ],
+            'multiple declarations, one !important rule at the end' => [
+                'height: 1px; color: red; width: 1px !important',
+                'height: 1px; color: red; width: 1px;',
+            ],
+            'multiple declarations, multiple !important rules at the beginning' => [
+                'width: 1px !important; height: 1px !important; color: red; float: left',
+                'color: red; float: left; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple consecutive !important rules somewhere in the middle (#1)' => [
+                'color: red; width: 1px !important; height: 1px !important; float: left',
+                'color: red; float: left; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple consecutive !important rules somewhere in the middle (#2)' => [
+                'color: red; width: 1px !important; height: 1px !important; float: left; clear: both',
+                'color: red; float: left; clear: both; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple not consecutive !important rules somewhere in the middle' => [
+                'color: red; width: 1px !important; clear: both; height: 1px !important; float: left',
+                'color: red; clear: both; float: left; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple !important rules at the end' => [
+                'color: red; float: left; width: 1px !important; height: 1px !important',
+                'color: red; float: left; width: 1px; height: 1px;',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $originalStyleAttributeContent
+     * @param string $expectedStyleAttributeContent
+     *
+     * @dataProvider cssForImportantRuleRemovalDataProvider
+     */
+    public function emogrifyRemovesImportantRule($originalStyleAttributeContent, $expectedStyleAttributeContent)
+    {
+        $subject = $this->buildDebugSubject(
+            '<html><head><body><p style="' . $originalStyleAttributeContent . '"></p></body></html>'
+        );
+
+        $result = $subject->emogrify();
+
+        static::assertContains('<p style="' . $expectedStyleAttributeContent . '">', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \Symfony\Component\CssSelector\Exception\SyntaxErrorException
+     */
+    public function emogrifyInDebugModeForInvalidSelectorsInMediaQueryBlocksThrowsException()
+    {
+        $subject = new CssInliner('<html></html>');
+        $subject->setDebug(true);
+
+        $subject->setCss('@media screen {p^^ {color: red;}}');
+
+        $subject->emogrify();
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeKeepsInvalidOrUnrecognizedSelectorsInMediaQueryBlocks()
+    {
+        $subject = new CssInliner('<html></html>');
+        $subject->setDebug(false);
+
+        $css = '@media screen {p^^ {color: red;}}';
+        $subject->setCss($css);
+
+        $result = $subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/CssConcatenatorTest.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/CssConcatenatorTest.php
new file mode 100644 (file)
index 0000000..c73d72e
--- /dev/null
@@ -0,0 +1,317 @@
+<?php
+
+namespace Pelago\Tests\Unit\Emogrifier;
+
+use Pelago\Emogrifier\CssConcatenator;
+
+/**
+ * Test case.
+ *
+ * @author Jake Hotson <jake.github@qzdesign.co.uk>
+ */
+class CssConcatenatorTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var CssConcatenator
+     */
+    private $subject = null;
+
+    /**
+     * @return void
+     */
+    protected function setUp()
+    {
+        $this->subject = new CssConcatenator();
+    }
+
+    /**
+     * @test
+     */
+    public function getCssInitiallyReturnsEmptyString()
+    {
+        $result = $this->subject->getCss();
+
+        static::assertSame('', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function appendSetsFirstRule()
+    {
+        $this->subject->append(['p'], 'color: green;');
+
+        $result = $this->subject->getCss();
+
+        static::assertSame('p{color: green;}', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function appendWithMediaQuerySetsFirstRuleInMediaRule()
+    {
+        $this->subject->append(['p'], 'color: green;', '@media screen');
+
+        $result = $this->subject->getCss();
+
+        static::assertSame('@media screen{p{color: green;}}', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function equivalentSelectorsDataProvider()
+    {
+        return [
+            'one selector' => [['p'], ['p']],
+            'two selectors' => [
+                ['p', 'ul'],
+                ['p', 'ul'],
+            ],
+            'two selectors in different order' => [
+                ['p', 'ul'],
+                ['ul', 'p'],
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string[] $selectors1
+     * @param string[] $selectors2
+     *
+     * @dataProvider equivalentSelectorsDataProvider
+     */
+    public function appendCombinesRulesWithEquivalentSelectors(array $selectors1, array $selectors2)
+    {
+        $this->subject->append($selectors1, 'color: green;');
+        $this->subject->append($selectors2, 'font-size: 16px;');
+
+        $result = $this->subject->getCss();
+
+        $expectedResult = \implode(',', $selectors1) . '{color: green;font-size: 16px;}';
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function appendInsertsSemicolonCombiningRulesWithoutTrailingSemicolon()
+    {
+        $this->subject->append(['p'], 'color: green');
+        $this->subject->append(['p'], 'font-size: 16px');
+
+        $result = $this->subject->getCss();
+
+        static::assertSame('p{color: green;font-size: 16px}', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function differentSelectorsDataProvider()
+    {
+        return [
+            'single selectors' => [
+                ['p'],
+                ['ul'],
+                ['p', 'ul'],
+            ],
+            'single selector and an entirely different pair' => [
+                ['p'],
+                ['ul', 'ol'],
+                ['p', 'ul', 'ol'],
+            ],
+            'single selector and a superset pair' => [
+                ['p'],
+                ['p', 'ul'],
+                ['p', 'ul'],
+            ],
+            'pair of selectors and an entirely different single' => [
+                ['p', 'ul'],
+                ['ol'],
+                ['p', 'ul', 'ol'],
+            ],
+            'pair of selectors and a subset single' => [
+                ['p', 'ul'],
+                ['ul'],
+                ['p', 'ul'],
+            ],
+            'entirely different pairs of selectors' => [
+                ['p', 'ul'],
+                ['ol', 'h1'],
+                ['p', 'ul', 'ol', 'h1'],
+            ],
+            'pairs of selectors with one common' => [
+                ['p', 'ul'],
+                ['ul', 'ol'],
+                ['p', 'ul', 'ol'],
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string[] $selectors1
+     * @param string[] $selectors2
+     * @param string[] $combinedSelectors
+     *
+     * @dataProvider differentSelectorsDataProvider
+     */
+    public function appendCombinesSameRulesWithDifferentSelectors(
+        array $selectors1,
+        array $selectors2,
+        array $combinedSelectors
+    ) {
+        $this->subject->append($selectors1, 'color: green;');
+        $this->subject->append($selectors2, 'color: green;');
+
+        $result = $this->subject->getCss();
+
+        $expectedResult = \implode(',', $combinedSelectors) . '{color: green;}';
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string[] $selectors1
+     * @param string[] $selectors2
+     *
+     * @dataProvider differentSelectorsDataProvider
+     */
+    public function appendNotCombinesDifferentRulesWithDifferentSelectors(array $selectors1, array $selectors2)
+    {
+        $this->subject->append($selectors1, 'color: green;');
+        $this->subject->append($selectors2, 'font-size: 16px;');
+
+        $result = $this->subject->getCss();
+
+        $expectedResult = \implode(',', $selectors1) . '{color: green;}'
+            . \implode(',', $selectors2) . '{font-size: 16px;}';
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function appendCombinesRulesForSameMediaQueryInMediaRule()
+    {
+        $this->subject->append(['p'], 'color: green;', '@media screen');
+        $this->subject->append(['ul'], 'font-size: 16px;', '@media screen');
+
+        $result = $this->subject->getCss();
+
+        static::assertSame('@media screen{p{color: green;}ul{font-size: 16px;}}', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string[] $selectors1
+     * @param string[] $selectors2
+     *
+     * @dataProvider equivalentSelectorsDataProvider
+     */
+    public function appendCombinesRulesWithEquivalentSelectorsWithinMediaRule(array $selectors1, array $selectors2)
+    {
+        $this->subject->append($selectors1, 'color: green;', '@media screen');
+        $this->subject->append($selectors2, 'font-size: 16px;', '@media screen');
+
+        $result = $this->subject->getCss();
+
+        $expectedResult = '@media screen{' . \implode(',', $selectors1) . '{color: green;font-size: 16px;}}';
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string[] $selectors1
+     * @param string[] $selectors2
+     * @param string[] $combinedSelectors
+     *
+     * @dataProvider differentSelectorsDataProvider
+     */
+    public function appendCombinesSameRulesWithDifferentSelectorsWithinMediaRule(
+        array $selectors1,
+        array $selectors2,
+        array $combinedSelectors
+    ) {
+        $this->subject->append($selectors1, 'color: green;', '@media screen');
+        $this->subject->append($selectors2, 'color: green;', '@media screen');
+
+        $result = $this->subject->getCss();
+
+        $expectedResult = '@media screen{' . \implode(',', $combinedSelectors) . '{color: green;}}';
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function appendNotCombinesRulesForDifferentMediaQueryInMediaRule()
+    {
+        $this->subject->append(['p'], 'color: green;', '@media screen');
+        $this->subject->append(['p'], 'color: green;', '@media print');
+
+        $result = $this->subject->getCss();
+
+        static::assertSame('@media screen{p{color: green;}}@media print{p{color: green;}}', $result);
+    }
+
+    /**
+     * @return mixed[][]
+     */
+    public function combinableRulesDataProvider()
+    {
+        return [
+            'same selectors' => [['p'], 'color: green;', ['p'], 'font-size: 16px;', ''],
+            'same declarations block' => [['p'], 'color: green;', ['ul'], 'color: green;', ''],
+            'same media query' => [['p'], 'color: green;', ['ul'], 'font-size: 16px;', '@media screen'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param array $rule1Selectors
+     * @param string $rule1DeclarationsBlock
+     * @param array $rule2Selectors
+     * @param string $rule2DeclarationsBlock
+     * @param string $media
+     *
+     * @dataProvider combinableRulesDataProvider
+     */
+    public function appendNotCombinesNonadjacentRules(
+        array $rule1Selectors,
+        $rule1DeclarationsBlock,
+        array $rule2Selectors,
+        $rule2DeclarationsBlock,
+        $media
+    ) {
+        $this->subject->append($rule1Selectors, $rule1DeclarationsBlock, $media);
+        $this->subject->append(['.intervening'], '-intervening-property: 0;');
+        $this->subject->append($rule2Selectors, $rule2DeclarationsBlock, $media);
+
+        $result = $this->subject->getCss();
+
+        $expectedRule1Css = \implode(',', $rule1Selectors) . '{' . $rule1DeclarationsBlock . '}';
+        $expectedRule2Css = \implode(',', $rule2Selectors) . '{' . $rule2DeclarationsBlock . '}';
+        if ($media !== '') {
+            $expectedRule1Css = $media . '{' . $expectedRule1Css . '}';
+            $expectedRule2Css = $media . '{' . $expectedRule2Css . '}';
+        }
+        $expectedResult = $expectedRule1Css . '.intervening{-intervening-property: 0;}' . $expectedRule2Css;
+
+        static::assertSame($expectedResult, $result);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/AbstractHtmlProcessorTest.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/AbstractHtmlProcessorTest.php
new file mode 100644 (file)
index 0000000..f5e1f1a
--- /dev/null
@@ -0,0 +1,385 @@
+<?php
+
+namespace Pelago\Tests\Unit\Emogrifier\HtmlProcessor;
+
+use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
+use Pelago\Tests\Unit\Emogrifier\HtmlProcessor\Fixtures\TestingHtmlProcessor;
+
+/**
+ * Test case.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ */
+class AbstractHtmlProcessorTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @test
+     */
+    public function fixtureIsAbstractHtmlProcessor()
+    {
+        static::assertInstanceOf(AbstractHtmlProcessor::class, new TestingHtmlProcessor('<html></html>'));
+    }
+
+    /**
+     * @test
+     */
+    public function reformatsHtml()
+    {
+        $rawHtml = '<!DOCTYPE HTML>' .
+            '<html>' .
+            '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' .
+            '<body></body>' .
+            '</html>';
+        $formattedHtml = "<!DOCTYPE HTML>\n" .
+            "<html>\n" .
+            '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' . "\n" .
+            "<body></body>\n" .
+            "</html>\n";
+
+        $subject = new TestingHtmlProcessor($rawHtml);
+
+        static::assertSame($formattedHtml, $subject->render());
+    }
+
+    /**
+     * @return array[]
+     */
+    public function nonHtmlDataProvider()
+    {
+        return [
+            'empty string' => [''],
+            'null' => [null],
+            'integer' => [2],
+            'float' => [3.14159],
+            'object' => [new \stdClass()],
+        ];
+    }
+
+    /**
+     * @test
+     * @expectedException \InvalidArgumentException
+     *
+     * @param mixed $html
+     *
+     * @dataProvider nonHtmlDataProvider
+     */
+    public function constructorWithNoHtmlDataThrowsException($html)
+    {
+        new TestingHtmlProcessor($html);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function invalidHtmlDataProvider()
+    {
+        return [
+            'broken nesting gets nested' => ['<b><i></b></i>', '<b><i></i></b>'],
+            'partial opening tag gets closed' => ['<b', '<b></b>'],
+            'only opening tag gets closed' => ['<b>', '<b></b>'],
+            'only closing tag gets removed' => ['foo</b> bar', 'foo bar'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $input
+     * @param string $expectedHtml
+     *
+     * @dataProvider invalidHtmlDataProvider
+     */
+    public function renderRepairsBrokenHtml($input, $expectedHtml)
+    {
+        $subject = new TestingHtmlProcessor($input);
+        $result = $subject->render();
+
+        static::assertContains($expectedHtml, $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutHtmlTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'body content only' => ['<p>Hello</p>'],
+            'HEAD element' => ['<head></head>'],
+            'BODY element' => ['<body></body>'],
+            'HEAD AND BODY element' => ['<head></head><body></body>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutHtmlTagDataProvider
+     */
+    public function addsMissingHtmlTag($html)
+    {
+        $subject = new TestingHtmlProcessor($html);
+
+        $result = $subject->render();
+
+        static::assertContains('<html>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutHeadTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'body content only' => ['<p>Hello</p>'],
+            'BODY element' => ['<body></body>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutHeadTagDataProvider
+     */
+    public function addsMissingHeadTag($html)
+    {
+        $subject = new TestingHtmlProcessor($html);
+
+        $result = $subject->render();
+
+        static::assertContains('<head>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutBodyTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'HEAD element' => ['<head></head>'],
+            'body content only' => ['<p>Hello</p>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutBodyTagDataProvider
+     */
+    public function addsMissingBodyTag($html)
+    {
+        $subject = new TestingHtmlProcessor($html);
+
+        $result = $subject->render();
+
+        static::assertContains('<body>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function putsMissingBodyElementAroundBodyContent()
+    {
+        $subject = new TestingHtmlProcessor('<p>Hello</p>');
+
+        $result = $subject->render();
+
+        static::assertContains('<body><p>Hello</p></body>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function specialCharactersDataProvider()
+    {
+        return [
+            'template markers with dollar signs & square brackets' => ['$[USER:NAME]$'],
+            'UTF-8 umlauts' => ['Küss die Hand, schöne Frau.'],
+            'HTML entities' => ['a &amp; b &gt; c'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $codeNotToBeChanged
+     *
+     * @dataProvider specialCharactersDataProvider
+     */
+    public function keepsSpecialCharacters($codeNotToBeChanged)
+    {
+        $html = '<html><p>' . $codeNotToBeChanged . '</p></html>';
+        $subject = new TestingHtmlProcessor($html);
+
+        $result = $subject->render();
+
+        static::assertContains($codeNotToBeChanged, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addsMissingHtml5DocumentType()
+    {
+        $subject = new TestingHtmlProcessor('<html></html>');
+
+        $result = $subject->render();
+
+        static::assertContains('<!DOCTYPE html>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function documentTypeDataProvider()
+    {
+        return [
+            'HTML5' => ['<!DOCTYPE html>'],
+            'XHTML 1.0 strict' => [
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" ' .
+                '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
+            ],
+            'XHTML 1.0 transitional' => [
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" ' .
+                '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
+            ],
+            'HTML 4 transitional' => [
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" ' .
+                '"http://www.w3.org/TR/REC-html40/loose.dtd">',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $documentType
+     *
+     * @dataProvider documentTypeDataProvider
+     */
+    public function keepsExistingDocumentType($documentType)
+    {
+        $html = $documentType . '<html></html>';
+        $subject = new TestingHtmlProcessor($html);
+
+        $result = $subject->render();
+
+        static::assertContains($documentType, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addsMissingContentTypeMetaTag()
+    {
+        $subject = new TestingHtmlProcessor('<p>Hello</p>');
+
+        $result = $subject->render();
+
+        static::assertContains('<meta http-equiv="Content-Type" content="text/html; charset=utf-8">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function notAddsSecondContentTypeMetaTag()
+    {
+        $html = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>';
+        $subject = new TestingHtmlProcessor($html);
+
+        $result = $subject->render();
+
+        $numberOfContentTypeMetaTags = \substr_count($result, 'Content-Type');
+        static::assertSame(1, $numberOfContentTypeMetaTags);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $documentType
+     *
+     * @dataProvider documentTypeDataProvider
+     */
+    public function convertsXmlSelfClosingTagsToNonXmlSelfClosingTag($documentType)
+    {
+        $subject = new TestingHtmlProcessor($documentType . '<html><body><br/></body></html>');
+
+        $result = $subject->render();
+
+        static::assertContains('<body><br></body>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $documentType
+     *
+     * @dataProvider documentTypeDataProvider
+     */
+    public function keepsNonXmlSelfClosingTags($documentType)
+    {
+        $subject = new TestingHtmlProcessor($documentType . '<html><body><br></body></html>');
+
+        $result = $subject->render();
+
+        static::assertContains('<body><br></body>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderBodyContentForEmptyBodyReturnsEmptyString()
+    {
+        $subject = new TestingHtmlProcessor('<html><body></body></html>');
+
+        $result = $subject->renderBodyContent();
+
+        static::assertSame('', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function renderBodyContentReturnsBodyContent()
+    {
+        $bodyContent = '<p>Hello world</p>';
+        $subject = new TestingHtmlProcessor('<html><body>' . $bodyContent . '</body></html>');
+
+        $result = $subject->renderBodyContent();
+
+        static::assertSame($bodyContent, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getDomDocumentReturnsDomDocument()
+    {
+        $subject = new TestingHtmlProcessor('<html></html>');
+
+        static::assertInstanceOf(\DOMDocument::class, $subject->getDomDocument());
+    }
+
+    /**
+     * @test
+     */
+    public function getDomDocumentWithNormalizedHtmlRepresentsTheGivenHtml()
+    {
+        $html = "<!DOCTYPE html>\n<html>\n<head>" .
+            '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' .
+            "</head>\n<body>\n<br>\n</body>\n</html>\n";
+        $subject = new TestingHtmlProcessor($html);
+
+        $domDocument = $subject->getDomDocument();
+
+        self::assertSame($html, $domDocument->saveHTML());
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/CssToAttributeConverterTest.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/CssToAttributeConverterTest.php
new file mode 100644 (file)
index 0000000..b961a29
--- /dev/null
@@ -0,0 +1,166 @@
+<?php
+
+namespace Pelago\Tests\Unit\Emogrifier\HtmlProcessor;
+
+use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
+use Pelago\Emogrifier\HtmlProcessor\CssToAttributeConverter;
+
+/**
+ * Test case.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ */
+class CssToAttributeConverterTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @test
+     */
+    public function classIsAbstractHtmlProcessor()
+    {
+        static::assertInstanceOf(AbstractHtmlProcessor::class, new CssToAttributeConverter('<html></html>'));
+    }
+
+    /**
+     * @test
+     */
+    public function renderWithoutConvertCssToVisualAttributesCallNotAddsVisuablAttributes()
+    {
+        $html = '<html style="text-align: right;"></html>';
+        $subject = new CssToAttributeConverter($html);
+
+        static::assertContains('<html style="text-align: right;">', $subject->render());
+    }
+
+    /**
+     * @test
+     */
+    public function convertCssToVisualAttributesUsesFluentInterface()
+    {
+        $html = '<html style="text-align: right;"></html>';
+        $subject = new CssToAttributeConverter($html);
+
+        static::assertSame($subject, $subject->convertCssToVisualAttributes());
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function matchingCssToHtmlMappingDataProvider()
+    {
+        return [
+            'background-color => bgcolor' => ['<p style="background-color: red;">hi</p>', 'bgcolor="red"'],
+            'background-color with !important => bgcolor' => [
+                '<p style="background-color: red !important;">hi</p>',
+                'bgcolor="red"',
+            ],
+            'p.text-align => align' => ['<p style="text-align: left;">hi</p>', 'align="left"'],
+            'div.text-align => align' => ['<div style="text-align: left;">hi</div>', 'align="left"'],
+            'td.text-align => align' => [
+                '<table><tr><td style="text-align: left;">hi</td></tr></table>',
+                'align="left',
+            ],
+            'text-align: left => align=left' => ['<p style="text-align: left;">hi</p>', 'align="left"'],
+            'text-align: right => align=right' => ['<p style="text-align: right;">hi</p>', 'align="right"'],
+            'text-align: center => align=center' => ['<p style="text-align: center;">hi</p>', 'align="center"'],
+            'text-align: justify => align:justify' => ['<p style="text-align: justify;">hi</p>', 'align="justify"'],
+            'img.float: right => align=right' => ['<img style="float: right;">', 'align="right"'],
+            'img.float: left => align=left' => ['<img style="float: left;">', 'align="left"'],
+            'table.float: right => align=right' => ['<table style="float: right;"></table>', 'align="right"'],
+            'table.float: left => align=left' => ['<table style="float: left;"></table>', 'align="left"'],
+            'table.border-spacing: 0 => cellspacing=0' => [
+                '<table style="border-spacing: 0;"></table>',
+                'cellspacing="0"',
+            ],
+            'background => bgcolor' => ['<p style="background: red top;">Bonjour</p>', 'bgcolor="red"'],
+            'width with px' => ['<p style="width: 100px;">Hi</p>', 'width="100"'],
+            'width with %' => ['<p style="width: 50%;">Hi</p>', 'width="50%"'],
+            'height with px' => ['<p style="height: 100px;">Hi</p>', 'height="100"'],
+            'height with %' => ['<p style="height: 50%;">Hi</p>', 'height="50%"'],
+            'img.margin: 0 auto (horizontal centering) => align=center' => [
+                '<img style="margin: 0 auto;">',
+                'align="center"',
+            ],
+            'img.margin: auto (horizontal centering) => align=center' => [
+                '<img style="margin: auto;">',
+                'align="center"',
+            ],
+            'img.margin: 10 auto 30 auto (horizontal centering) => align=center' => [
+                '<img style="margin: 10 auto 30 auto;">',
+                'align="center"',
+            ],
+            'table.margin: 0 auto (horizontal centering) => align=center' => [
+                '<table style="margin: 0 auto;"></table>',
+                'align="center"',
+            ],
+            'table.margin: auto (horizontal centering) => align=center' => [
+                '<table style="margin: auto;"></table>',
+                'align="center"',
+            ],
+            'table.margin: 10 auto 30 auto (horizontal centering) => align=center' => [
+                '<table style="margin: 10 auto 30 auto;"></table>',
+                'align="center"',
+            ],
+            'img.border: none => border=0' => ['<img style="border: none;">', 'border="0"'],
+            'img.border: 0 => border=0' => ['<img style="border: none;">', 'border="0"'],
+            'table.border: none => border=0' => ['<table style="border: none;"></table>', 'border="0"'],
+            'table.border: 0 => border=0' => ['<table style="border: 0;"></table>', 'border="0"'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $body The HTML
+     * @param string $attributes The attributes that are expected on the element
+     *
+     * @dataProvider matchingCssToHtmlMappingDataProvider
+     */
+    public function convertCssToVisualAttributesMapsSuitableCssToHtml($body, $attributes)
+    {
+        $subject = new CssToAttributeConverter('<html><body>' . $body . '</body></html>');
+
+        $subject->convertCssToVisualAttributes();
+        $html = $subject->render();
+
+        static::assertContains($attributes, $html);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function notMatchingCssToHtmlMappingDataProvider()
+    {
+        return [
+            'background URL' => ['<p style="background: url(bg.png);">Hello</p>'],
+            'background URL with position' => ['<p style="background: url(bg.png) top;">Hello</p>'],
+            'p.margin: 10 5 30 auto (no horizontal centering)' => ['<img style="margin: 10 5 30 auto;">'],
+            'p.margin: auto' => ['<p style="margin: auto;">Hi</p>'],
+            'p.border: none' => ['<p style="border: none;">Hi</p>'],
+            'img.border: 1px solid black' => ['<img style="border: 1px solid black;">'],
+            'span.text-align' => ['<span style="text-align: justify;">Hi</span>'],
+            'text-align: inherit' => ['<p style="text-align: inherit;">Hi</p>'],
+            'span.float' => ['<span style="float: right;">Hi</span>'],
+            'float: none' => ['<table style="float: none;"></table>'],
+            'p.border-spacing' => ['<p style="border-spacing: 5px;">Hi</p>'],
+            'height: auto' => ['<img src="logo.png" alt="" style="height: auto;">'],
+            'width: auto' => ['<img src="logo.png" alt="" style="width: auto;">'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $body the HTML
+     *
+     * @dataProvider notMatchingCssToHtmlMappingDataProvider
+     */
+    public function convertCssToVisualAttributesNotMapsUnsuitableCssToHtml($body)
+    {
+        $subject = new CssToAttributeConverter('<html><body>' . $body . '</body></html>');
+
+        $subject->convertCssToVisualAttributes();
+        $html = $subject->render();
+
+        static::assertContains($body, $html);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/Fixtures/TestingHtmlProcessor.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/Fixtures/TestingHtmlProcessor.php
new file mode 100644 (file)
index 0000000..112cf54
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+namespace Pelago\Tests\Unit\Emogrifier\HtmlProcessor\Fixtures;
+
+use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
+
+/**
+ * Fixture class for AbstractHtmlProcessor.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ */
+class TestingHtmlProcessor extends AbstractHtmlProcessor
+{
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/HtmlNormalizerTest.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/HtmlNormalizerTest.php
new file mode 100644 (file)
index 0000000..802e13b
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace Pelago\Tests\Unit\Emogrifier\HtmlProcessor;
+
+use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
+use Pelago\Emogrifier\HtmlProcessor\HtmlNormalizer;
+
+/**
+ * Test case.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ */
+class HtmlNormalizerTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @test
+     */
+    public function classIsAbstractHtmlProcessor()
+    {
+        static::assertInstanceOf(AbstractHtmlProcessor::class, new HtmlNormalizer('<html></html>'));
+    }
+}
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..af6bc45
--- /dev/null
@@ -0,0 +1,3114 @@
+<?php
+
+namespace Pelago\Tests\Unit;
+
+use Pelago\Emogrifier;
+use Pelago\Tests\Support\Traits\AssertCss;
+
+/**
+ * Test case.
+ *
+ * @author Oliver Klee <github@oliverklee.de>
+ * @author Zoli Szabó <zoli.szabo+github@gmail.com>
+ */
+class EmogrifierTest extends \PHPUnit_Framework_TestCase
+{
+    use AssertCss;
+
+    /**
+     * @var string Common HTML markup with a variety of elements and attributes for testing with
+     */
+    const COMMON_TEST_HTML = '
+        <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>
+                <p class="p-4" id="p4"><span title="avez-vous">some</span> more <span id="text">text</span></p>
+                <p class="p-5 additional-class"><span title="buenas dias bom dia">some</span> more text</p>
+                <p class="p-6"><span title="title: subtitle; author">some</span> more text</p>
+            </body>
+        </html>
+    ';
+
+    /**
+     * @var string
+     */
+    private $html5DocumentType = '<!DOCTYPE html>';
+
+    /**
+     * @var Emogrifier
+     */
+    private $subject = null;
+
+    /**
+     * Sets up the test case.
+     *
+     * @return void
+     */
+    protected function setUp()
+    {
+        $this->subject = new Emogrifier();
+        $this->subject->setDebug(true);
+    }
+
+    /**
+     * @test
+     */
+    public function getDomDocumentReturnsDomDocument()
+    {
+        $subject = new Emogrifier('<html></html>');
+
+        static::assertInstanceOf(\DOMDocument::class, $subject->getDomDocument());
+    }
+
+    /**
+     * @test
+     */
+    public function getDomDocumentWithNormalizedHtmlRepresentsTheGivenHtml()
+    {
+        $html = "<!DOCTYPE html>\n<html>\n<head>" .
+            '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' .
+            "</head>\n<body>\n<br>\n</body>\n</html>\n";
+        $subject = new Emogrifier($html);
+
+        $domDocument = $subject->getDomDocument();
+
+        self::assertSame($html, $domDocument->saveHTML());
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \BadMethodCallException
+     */
+    public function emogrifyForNoDataSetThrowsException()
+    {
+        $this->subject->emogrify();
+    }
+
+    /**
+     * @return array[]
+     */
+    public function nonHtmlDataProvider()
+    {
+        return [
+            'empty string' => [''],
+            'null' => [null],
+            'integer' => [2],
+            'float' => [3.14159],
+            'object' => [new \stdClass()],
+        ];
+    }
+
+    /**
+     * @test
+     * @expectedException \InvalidArgumentException
+     *
+     * @param mixed $html
+     * @dataProvider nonHtmlDataProvider
+     */
+    public function setHtmlNoHtmlDataThrowsException($html)
+    {
+        $this->subject->setHtml($html);
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \BadMethodCallException
+     */
+    public function emogrifyBodyContentForNoDataSetThrowsException()
+    {
+        $this->subject->emogrifyBodyContent();
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutHtmlTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'body content only' => ['<p>Hello</p>'],
+            'HEAD element' => ['<head></head>'],
+            'BODY element' => ['<body></body>'],
+            'HEAD AND BODY element' => ['<head></head><body></body>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutHtmlTagDataProvider
+     */
+    public function emogrifyAddsMissingHtmlTag($html)
+    {
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<html>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutHeadTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'body content only' => ['<p>Hello</p>'],
+            'BODY element' => ['<body></body>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutHeadTagDataProvider
+     */
+    public function emogrifyAddsMissingHeadTag($html)
+    {
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<head>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithoutBodyTagDataProvider()
+    {
+        return [
+            'doctype only' => ['<!DOCTYPE html>'],
+            'HEAD element' => ['<head></head>'],
+            'body content only' => ['<p>Hello</p>'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $html
+     *
+     * @dataProvider contentWithoutBodyTagDataProvider
+     */
+    public function emogrifyAddsMissingBodyTag($html)
+    {
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<body>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyPutsMissingBodyElementAroundBodyContent()
+    {
+        $this->subject->setHtml('<p>Hello</p>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<body><p>Hello</p></body>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function specialCharactersDataProvider()
+    {
+        return [
+            'template markers with dollar signs & square brackets' => ['$[USER:NAME]$'],
+            'UTF-8 umlauts' => ['Küss die Hand, schöne Frau.'],
+            'HTML entities' => ['a &amp; b &gt; c'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $codeNotToBeChanged
+     *
+     * @dataProvider specialCharactersDataProvider
+     */
+    public function emogrifyKeepsSpecialCharacters($codeNotToBeChanged)
+    {
+        $html = '<html><p>' . $codeNotToBeChanged . '</p></html>';
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains($codeNotToBeChanged, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $codeNotToBeChanged
+     *
+     * @dataProvider specialCharactersDataProvider
+     */
+    public function emogrifyBodyContentKeepsSpecialCharacters($codeNotToBeChanged)
+    {
+        $html = '<html><p>' . $codeNotToBeChanged . '</p></html>';
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrifyBodyContent();
+
+        static::assertContains($codeNotToBeChanged, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addsMissingHtml5DocumentType()
+    {
+        $this->subject->setHtml('<html></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<!DOCTYPE html>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function documentTypeDataProvider()
+    {
+        return [
+            'HTML5' => ['<!DOCTYPE html>'],
+            'XHTML 1 strict' => [
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" ' .
+                '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
+            ],
+            'HTML 4 transitional' => [
+                '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" ' .
+                '"http://www.w3.org/TR/REC-html40/loose.dtd">',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $documentType
+     *
+     * @dataProvider documentTypeDataProvider
+     */
+    public function emogrifyForHtmlWithDocumentTypeKeepsDocumentType($documentType)
+    {
+        $html = $documentType . '<html></html>';
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains($documentType, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAddsMissingContentTypeMetaTag()
+    {
+        $this->subject->setHtml('<p>Hello</p>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<meta http-equiv="Content-Type" content="text/html; charset=utf-8">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotAddsSecondContentTypeMetaTag()
+    {
+        $html = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>';
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        $numberOfContentTypeMetaTags = \substr_count($result, 'Content-Type');
+        static::assertSame(1, $numberOfContentTypeMetaTags);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultRemovesWbrTag()
+    {
+        $html = '<html>foo<wbr/>bar</html>';
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('<wbr', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addUnprocessableTagRemovesEmptyTag()
+    {
+        $this->subject->setHtml('<html><p></p></html>');
+
+        $this->subject->addUnprocessableHtmlTag('p');
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('<p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addUnprocessableTagNotRemovesNonEmptyTag()
+    {
+        $this->subject->setHtml('<html><p>foobar</p></html>');
+
+        $this->subject->addUnprocessableHtmlTag('p');
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function removeUnprocessableHtmlTagKeepsTagAgainAgain()
+    {
+        $this->subject->setHtml('<html><p></p></html>');
+
+        $this->subject->addUnprocessableHtmlTag('p');
+        $this->subject->removeUnprocessableHtmlTag('p');
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function matchedCssDataProvider()
+    {
+        // The sprintf placeholders %1$s and %2$s will automatically be replaced with CSS declarations
+        // like 'color: red;' or 'text-align: left;'.
+        return [
+            'two declarations from one rule can apply to the same element' => [
+                'html { %1$s %2$s }',
+                '<html style="%1$s %2$s">',
+            ],
+            'two identical matchers with different rules get combined' => [
+                'p { %1$s } p { %2$s }',
+                '<p class="p-1" style="%1$s %2$s">',
+            ],
+            'two different matchers rules matching the same element get combined' => [
+                'p { %1$s } .p-1 { %2$s }',
+                '<p class="p-1" style="%1$s %2$s">',
+            ],
+            'type => one element' => ['html { %1$s }', '<html style="%1$s">'],
+            'type (case-insensitive) => one element' => ['HTML { %1$s }', '<html style="%1$s">'],
+            'type => first matching element' => ['p { %1$s }', '<p class="p-1" style="%1$s">'],
+            'type => second matching element' => ['p { %1$s }', '<p class="p-2" style="%1$s">'],
+            'class => with class' => ['.p-2 { %1$s }', '<p class="p-2" style="%1$s">'],
+            'two classes s=> with both classes' => [
+                '.p-5.additional-class { %1$s }',
+                '<p class="p-5 additional-class" style="%1$s">',
+            ],
+            'type & class => type with class' => ['p.p-2 { %1$s }', '<p class="p-2" style="%1$s">'],
+            'ID => with ID' => ['#p4 { %1$s }', '<p class="p-4" id="p4" style="%1$s">'],
+            'type & ID => type with ID' => ['p#p4 { %1$s }', '<p class="p-4" id="p4" style="%1$s">'],
+            'universal => HTML' => ['* { %1$s }', '<html style="%1$s">'],
+            'attribute presence => with attribute' => ['[title] { %1$s }', '<span title="bonjour" style="%1$s">'],
+            'attribute exact value, double quotes => with exact attribute match' => [
+                '[title="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'attribute exact value, single quotes => with exact match' => [
+                '[title=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            // broken: attribute exact value without quotes => with exact match
+            // broken: attribute exact two-word value, double quotes => with exact attribute value match
+            // broken: attribute exact two-word value, single quotes => with exact attribute value match
+            // broken: attribute exact value with ~, double quotes => exact attribute match
+            // broken: attribute exact value with ~, single quotes => exact attribute match
+            // broken: attribute exact value with ~, no quotes => exact attribute match
+            // broken: attribute value with |, double quotes => with exact match
+            // broken: attribute value with |, single quotes => with exact match
+            // broken: attribute value with |, no quotes => with exact match
+            // broken: attribute value with ^, double quotes => with exact match
+            // broken: attribute value with ^, single quotes => with exact match
+            // broken: attribute value with ^, no quotes => with exact match
+            // broken: attribute value with $, double quotes => with exact match
+            // broken: attribute value with $, single quotes => with exact match
+            // broken: attribute value with $, no quotes => with exact match
+            // broken: attribute value with *, double quotes => with exact match
+            // broken: attribute value with *, single quotes => with exact match
+            // broken: attribute value with *, no quotes => with exact match
+            // broken: type & attribute presence => with type & attribute
+            'type & attribute exact value, double quotes => with type & exact attribute value match' => [
+                'span[title="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute exact value, single quotes => with type & exact attribute value match' => [
+                'span[title=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute exact value without quotes => with type & exact attribute value match' => [
+                'span[title=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute exact two-word value, double quotes => with type & exact attribute value match' => [
+                'span[title="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute exact four-word value, double quotes => with type & exact attribute value match' => [
+                'span[title="buenas dias bom dia"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute exact two-word value, single quotes => with type & exact attribute value match' => [
+                'span[title=\'buenas dias\'] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute exact four-word value, single quotes => with type & exact attribute value match' => [
+                'span[title=\'buenas dias bom dia\'] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & exact attribute match' => [
+                'span[title~="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ~, single quotes => with type & exact attribute match' => [
+                'span[title~=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ~, no quotes => with type & exact attribute match' => [
+                'span[title~=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 1st of 2 in attribute' => [
+                'span[title~="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 2nd of 2 in attribute' => [
+                'span[title~="dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 1st of 4 in attribute' => [
+                'span[title~="buenas"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as 2nd of 4 in attribute' => [
+                'span[title~="dias"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with ~, double quotes => with type & word as last of 4 in attribute' => [
+                'span[title~="dia"] { %1$s }',
+                '<span title="buenas dias bom dia" style="%1$s">',
+            ],
+            'type & attribute value with |, double quotes => with exact match' => [
+                'span[title|="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with |, single quotes => with exact match' => [
+                'span[title|=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with |, no quotes => with exact match' => [
+                'span[title|=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & two-word attribute value with |, double quotes => with exact match' => [
+                'span[title|="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with |, double quotes => with match before hyphen & another word' => [
+                'span[title|="avez"] { %1$s }',
+                '<span title="avez-vous" style="%1$s">',
+            ],
+            'type & attribute value with ^, double quotes => with exact match' => [
+                'span[title^="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ^, single quotes => with exact match' => [
+                'span[title^=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ^, no quotes => with exact match' => [
+                'span[title^=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            // broken: type & two-word attribute value with ^, double quotes => with exact match
+            'type & attribute value with ^, double quotes => with prefix math' => [
+                'span[title^="bon"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with ^, double quotes => with match before another word' => [
+                'span[title^="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with $, double quotes => with exact match' => [
+                'span[title$="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with $, single quotes => with exact match' => [
+                'span[title$=\'bonjour\'] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with $, no quotes => with exact match' => [
+                'span[title$=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & two-word attribute value with $, double quotes => with exact match' => [
+                'span[title$="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with $, double quotes => with suffix math' => [
+                'span[title$="jour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with $, double quotes => with match after another word' => [
+                'span[title$="dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & two-word attribute value with *, double quotes => with exact match' => [
+                'span[title*="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with prefix math' => [
+                'span[title*="bon"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with suffix math' => [
+                'span[title*="jour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with substring math' => [
+                'span[title*="njo"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with match before another word' => [
+                'span[title*="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & attribute value with *, double quotes => with match after another word' => [
+                'span[title*="dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'type & special characters attribute value with *, double quotes => with substring match' => [
+                'span[title*=": subtitle; author"] { %1$s }',
+                '<span title="title: subtitle; author" style="%1$s">',
+            ],
+            'adjacent => 2nd of many' => ['p + p { %1$s }', '<p class="p-2" style="%1$s">'],
+            'adjacent => last of many' => ['p + p { %1$s }', '<p class="p-6" style="%1$s">'],
+            'adjacent (without space after +) => last of many' => ['p +p { %1$s }', '<p class="p-6" style="%1$s">'],
+            'adjacent (without space before +) => last of many' => ['p+ p { %1$s }', '<p class="p-6" style="%1$s">'],
+            'adjacent (without space before or after +) => last of many' => [
+                'p+p { %1$s }',
+                '<p class="p-6" style="%1$s">',
+            ],
+            'child (with spaces around >) => direct child' => ['p > span { %1$s }', '<span style="%1$s">'],
+            'child (without space after >) => direct child' => ['p >span { %1$s }', '<span style="%1$s">'],
+            'child (without space before >) => direct child' => ['p> span { %1$s }', '<span style="%1$s">'],
+            'child (without space before or after >) => direct child' => ['p>span { %1$s }', '<span style="%1$s">'],
+            'descendant => child' => ['p span { %1$s }', '<span style="%1$s">'],
+            'descendant => grandchild' => ['body span { %1$s }', '<span style="%1$s">'],
+            // broken: descendent attribute presence => with attribute
+            // broken: descendent attribute exact value => with exact attribute match
+            // broken: descendent type & attribute presence => with type & attribute
+            'descendent type & attribute exact value => with type & exact attribute match' => [
+                'body span[title="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'descendent type & attribute exact two-word value => with type & exact attribute match' => [
+                'body span[title="buenas dias"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'descendent type & attribute value with ~ => with type & exact attribute match' => [
+                'body span[title~="bonjour"] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'descendent type & attribute value with ~ => with type & word as 1st of 2 in attribute' => [
+                'body span[title~="buenas"] { %1$s }',
+                '<span title="buenas dias" style="%1$s">',
+            ],
+            'descendant of type & class: type & attribute exact value, no quotes => with type & exact match (#381)' => [
+                'p.p-2 span[title=bonjour] { %1$s }',
+                '<span title="bonjour" style="%1$s">',
+            ],
+            'descendant of attribute presence => parent with attribute' => [
+                '[class] span { %1$s }',
+                '<p class="p-1"><span style="%1$s">',
+            ],
+            'descendant of attribute exact value => parent with type & exact attribute match' => [
+                '[id="p4"] span { %1$s }',
+                '<p class="p-4" id="p4"><span title="avez-vous" style="%1$s">',
+            ],
+            // broken: descendant of type & attribute presence => parent with type & attribute
+            'descendant of type & attribute exact value => parent with type & exact attribute match' => [
+                'p[id="p4"] span { %1$s }',
+                '<p class="p-4" id="p4"><span title="avez-vous" style="%1$s">',
+            ],
+            // broken: descendant of type & attribute exact two-word value => parent with type & exact attribute match
+            //         (exact match doesn't currently match hyphens, which would be needed to match the class attribute)
+            'descendant of type & attribute value with ~ => parent with type & exact attribute match' => [
+                'p[class~="p-1"] span { %1$s }',
+                '<p class="p-1"><span style="%1$s">',
+            ],
+            'descendant of type & attribute value with ~ => parent with type & word as 1st of 2 in attribute' => [
+                'p[class~="p-5"] span { %1$s }',
+                '<p class="p-5 additional-class"><span title="buenas dias bom dia" style="%1$s">',
+            ],
+            // broken: first-child => 1st of many
+            'type & :first-child => 1st of many' => ['p:first-child { %1$s }', '<p class="p-1" style="%1$s">'],
+            // broken: last-child => last of many
+            'type & :last-child => last of many' => ['p:last-child { %1$s }', '<p class="p-6" style="%1$s">'],
+            // broken: :not with type => other type
+            // broken: :not with class => no class
+            // broken: :not with class => other class
+            'type & :not with class => without class' => ['span:not(.foo) { %1$s }', '<span style="%1$s">'],
+            'type & :not with class => with other class' => ['p:not(.foo) { %1$s }', '<p class="p-1" style="%1$s">'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css CSS statements, potentially with %1$s and $2$s placeholders for a CSS declaration
+     * @param string $expectedHtml HTML, potentially with %1$s and $2$s placeholders for a CSS declaration
+     *
+     * @dataProvider matchedCssDataProvider
+     */
+    public function emogrifyAppliesCssToMatchingElements($css, $expectedHtml)
+    {
+        $cssDeclaration1 = 'color: red;';
+        $cssDeclaration2 = 'text-align: left;';
+        $this->subject->setHtml(static::COMMON_TEST_HTML);
+        $this->subject->setCss(\sprintf($css, $cssDeclaration1, $cssDeclaration2));
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains(\sprintf($expectedHtml, $cssDeclaration1, $cssDeclaration2), $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function nonMatchedCssDataProvider()
+    {
+        // The sprintf placeholders %1$s and %2$s will automatically be replaced with CSS declarations
+        // like 'color: red;' or 'text-align: left;'.
+        return [
+            'type => not other type' => ['html { %1$s }', '<body>'],
+            'class => not other class' => ['.p-2 { %1$s }', '<p class="p-1">'],
+            'class => not without class' => ['.p-2 { %1$s }', '<body>'],
+            'two classes => not only first class' => ['.p-1.another-class { %1$s }', '<p class="p-1">'],
+            'two classes => not only second class' => ['.another-class.p-1 { %1$s }', '<p class="p-1">'],
+            'type & class => not only type' => ['html.p-1 { %1$s }', '<html>'],
+            'type & class => not only class' => ['html.p-1 { %1$s }', '<p class="p-1">'],
+            'ID => not other ID' => ['#yeah { %1$s }', '<p class="p-4" id="p4">'],
+            'ID => not without ID' => ['#yeah { %1$s }', '<span>'],
+            'type & ID => not other type with that ID' => ['html#p4 { %1$s }', '<p class="p-4" id="p4">'],
+            'type & ID => not that type with other ID' => ['p#p5 { %1$s }', '<p class="p-4" id="p4">'],
+            'attribute presence => not element without that attribute' => ['[title] { %1$s }', '<span>'],
+            'attribute exact value => not element without that attribute' => ['[title="bonjour"] { %1$s }', '<span>'],
+            'attribute exact value => not element with different attribute value' => [
+                '[title="hi"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'attribute exact value => not element with only substring match in attribute value' => [
+                '[title="njo"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with ~ => not element with only prefix match in attribute value' => [
+                'span[title~="bon"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with |, double quotes => not element with match after another word & hyphen' => [
+                'span[title|="vous"] { %1$s }',
+                '<span title="avez-vous">',
+            ],
+            'type & attribute value with ^ => not element with only substring match in attribute value' => [
+                'span[title^="njo"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with ^, double quotes => not element with only suffix match in attribute value' => [
+                'span[title^="jour"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with $ => not element with only substring match in attribute value' => [
+                'span[title$="njo"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with $, double quotes => not element with only prefix match in attribute value' => [
+                'span[title$="bon"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'type & attribute value with * => not element with different attribute value' => [
+                'span[title*="hi"] { %1$s }',
+                '<span title="bonjour">',
+            ],
+            'adjacent => not 1st of many' => ['p + p { %1$s }', '<p class="p-1">'],
+            'child => not grandchild' => ['html > span { %1$s }', '<span>'],
+            'child => not parent' => ['span > html { %1$s }', '<html>'],
+            'descendant => not sibling' => ['span span { %1$s }', '<span>'],
+            'descendant => not parent' => ['p body { %1$s }', '<body>'],
+            'type & :first-child => not 2nd of many' => ['p:first-child { %1$s }', '<p class="p-2">'],
+            'type & :first-child => not last of many' => ['p:first-child { %1$s }', '<p class="p-6">'],
+            'type & :last-child => not 1st of many' => ['p:last-child { %1$s }', '<p class="p-1">'],
+            'type & :last-child => not 2nd of many' => ['p:last-child { %1$s }', '<p class="p-2">'],
+            'type & :not with class => not with class' => ['p:not(.p-1) { %1$s }', '<p class="p-1">'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css CSS statements, potentially with %1$s and $2$s placeholders for a CSS declaration
+     * @param string $expectedHtml HTML, potentially with %1$s and $2$s placeholders for a CSS declaration
+     *
+     * @dataProvider nonMatchedCssDataProvider
+     */
+    public function emogrifyNotAppliesCssToNonMatchingElements($css, $expectedHtml)
+    {
+        $cssDeclaration1 = 'color: red;';
+        $cssDeclaration2 = 'text-align: left;';
+        $this->subject->setHtml(static::COMMON_TEST_HTML);
+        $this->subject->setCss(\sprintf($css, $cssDeclaration1, $cssDeclaration2));
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains(\sprintf($expectedHtml, $cssDeclaration1, $cssDeclaration2), $result);
+    }
+
+    /**
+     * Provides data to test the following selector specificity ordering:
+     *     * < t < 2t < . < .+t < .+2t < 2. < 2.+t < 2.+2t
+     *     < # < #+t < #+2t < #+. < #+.+t < #+.+2t < #+2. < #+2.+t < #+2.+2t
+     *     < 2# < 2#+t < 2#+2t < 2#+. < 2#+.+t < 2#+.+2t < 2#+2. < 2#+2.+t < 2#+2.+2t
+     * where '*' is the universal selector, 't' is a type selector, '.' is a class selector, and '#' is an ID selector.
+     *
+     * Also confirm up to 99 class selectors are supported (much beyond this would require a more complex comparator).
+     *
+     * Specificity ordering for selectors involving pseudo-classes, attributes and `:not` is covered through the
+     * combination of these tests and the equal specificity tests and thus does not require explicit separate testing.
+     *
+     * @return string[][]
+     */
+    public function differentCssSelectorSpecificityDataProvider()
+    {
+        /**
+         * @var string[] Selectors targeting `<span id="text">` with increasing specificity
+         */
+        $selectors = [
+            'universal' => '*',
+            'type' => 'span',
+            '2 types' => 'p span',
+            'class' => '.p-4 *',
+            'class & type' => '.p-4 span',
+            'class & 2 types' => 'p.p-4 span',
+            '2 classes' => '.p-4.p-4 *',
+            '2 classes & type' => '.p-4.p-4 span',
+            '2 classes & 2 types' => 'p.p-4.p-4 span',
+            'ID' => '#text',
+            'ID & type' => 'span#text',
+            'ID & 2 types' => 'p span#text',
+            'ID & class' => '.p-4 #text',
+            'ID & class & type' => '.p-4 span#text',
+            'ID & class & 2 types' => 'p.p-4 span#text',
+            'ID & 2 classes' => '.p-4.p-4 #text',
+            'ID & 2 classes & type' => '.p-4.p-4 span#text',
+            'ID & 2 classes & 2 types' => 'p.p-4.p-4 span#text',
+            '2 IDs' => '#p4 #text',
+            '2 IDs & type' => '#p4 span#text',
+            '2 IDs & 2 types' => 'p#p4 span#text',
+            '2 IDs & class' => '.p-4#p4 #text',
+            '2 IDs & class & type' => '.p-4#p4 span#text',
+            '2 IDs & class & 2 types' => 'p.p-4#p4 span#text',
+            '2 IDs & 2 classes' => '.p-4.p-4#p4 #text',
+            '2 IDs & 2 classes & type' => '.p-4.p-4#p4 span#text',
+            '2 IDs & 2 classes & 2 types' => 'p.p-4.p-4#p4 span#text',
+        ];
+
+        $datasets = [];
+        $previousSelector = '';
+        $previousDescription = '';
+        foreach ($selectors as $description => $selector) {
+            if ($previousSelector !== '') {
+                $datasets[$description . ' more specific than ' . $previousDescription] = [
+                    '<span id="text"',
+                    $previousSelector,
+                    $selector,
+                ];
+            }
+            $previousSelector = $selector;
+            $previousDescription = $description;
+        }
+
+        // broken: class more specific than 99 types (requires support for chaining `:not(h1):not(h1)...`)
+        $datasets['ID more specific than 99 classes'] = [
+            '<p class="p-4" id="p4"',
+            \str_repeat('.p-4', 99),
+            '#p4',
+        ];
+
+        return $datasets;
+    }
+
+    /**
+     * @test
+     *
+     * @param string $matchedTagPart Tag expected to be matched by both selectors, without the closing '>',
+     *                               e.g. '<p class="p-1"'
+     * @param string $lessSpecificSelector A selector expression
+     * @param string $moreSpecificSelector Some other, more specific selector expression
+     *
+     * @dataProvider differentCssSelectorSpecificityDataProvider
+     */
+    public function emogrifyAppliesMoreSpecificCssSelectorToMatchingElements(
+        $matchedTagPart,
+        $lessSpecificSelector,
+        $moreSpecificSelector
+    ) {
+        $this->subject->setHtml(static::COMMON_TEST_HTML);
+        $this->subject->setCss(
+            $lessSpecificSelector . ' { color: red; } ' .
+            $moreSpecificSelector . ' { color: green; } ' .
+            $moreSpecificSelector . ' { background-color: green; } ' .
+            $lessSpecificSelector . ' { background-color: red; }'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains($matchedTagPart . ' style="color: green; background-color: green;"', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function equalCssSelectorSpecificityDataProvider()
+    {
+        return [
+            // pseudo-class
+            'pseudo-class as specific as class' => ['<p class="p-1"', '*:first-child', '.p-1'],
+            'type & pseudo-class as specific as type & class' => ['<p class="p-1"', 'p:first-child', 'p.p-1'],
+            'class & pseudo-class as specific as two classes' => ['<p class="p-1"', '.p-1:first-child', '.p-1.p-1'],
+            'ID & pseudo-class as specific as ID & class' => [
+                '<span title="avez-vous"',
+                '#p4 *:first-child',
+                '#p4.p-4 *',
+            ],
+            '2 types & 2 classes & 2 IDs & pseudo-class as specific as 2 types & 3 classes & 2 IDs' => [
+                '<span id="text"',
+                'p.p-4.p-4#p4 span#text:last-child',
+                'p.p-4.p-4.p-4#p4 span#text',
+            ],
+            // attribute
+            'attribute as specific as class' => ['<span title="bonjour"', '[title="bonjour"]', '.p-2 *'],
+            'type & attribute as specific as type & class' => [
+                '<span title="bonjour"',
+                'span[title="bonjour"]',
+                '.p-2 span',
+            ],
+            'class & attribute as specific as two classes' => ['<p class="p-4" id="p4"', '.p-4[id="p4"]', '.p-4.p-4'],
+            'ID & attribute as specific as ID & class' => ['<p class="p-4" id="p4"', '#p4[id="p4"]', '#p4.p-4'],
+            '2 types & 2 classes & 2 IDs & attribute as specific as 2 types & 3 classes & 2 IDs' => [
+                '<span id="text"',
+                'p.p-4.p-4#p4[id="p4"] span#text',
+                'p.p-4.p-4.p-4#p4 span#text',
+            ],
+            // :not
+            // ideally these tests would be more minimal with just combinators and universal selectors in the :not
+            // argument, however Symfony CssSelector only supports simple (single-element) selectors here
+            ':not with type as specific as type and universal' => ['<p class="p-1"', '*:not(html)', 'html *'],
+            'type & :not with type as specific as 2 types' => ['<p class="p-1"', 'p:not(html)', 'html p'],
+            'class & :not with type as specific as type & class' => ['<p class="p-1"', '.p-1:not(html)', 'html .p-1'],
+            'ID & :not with type as specific as type & ID' => ['<p class="p-4" id="p4"', '#p4:not(html)', 'html #p4'],
+            '2 types & 2 classes & 2 IDs & :not with type as specific as 3 types & 2 classes & 2 IDs' => [
+                '<span id="text"',
+                'p.p-4.p-4#p4 span#text:not(html)',
+                'html p.p-4.p-4#p4 span#text',
+            ],
+            // argument of :not
+            ':not with type as specific as type' => ['<p class="p-1"', '*:not(h1)', 'p'],
+            ':not with class as specific as class' => ['<p class="p-1"', '*:not(.p-2)', '.p-1'],
+            ':not with ID as specific as ID' => ['<p class="p-4" id="p4"', '*:not(#p1)', '#p4'],
+            // broken: :not with 2 types & 2 classes & 2 IDs as specific as 2 types & 2 classes & 2 IDs
+            //         (`*:not(.p-1 #p1)`, i.e. with both class and ID, causes "Invalid type in selector")
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $matchedTagPart Tag expected to be matched by both selectors, without the closing '>',
+     *                               e.g. '<p class="p-1"'
+     * @param string $selector1 A selector expression
+     * @param string $selector2 Some other, equally specific selector expression
+     *
+     * @dataProvider equalCssSelectorSpecificityDataProvider
+     */
+    public function emogrifyAppliesLaterEquallySpecificCssSelectorToMatchingElements(
+        $matchedTagPart,
+        $selector1,
+        $selector2
+    ) {
+        $this->subject->setHtml(static::COMMON_TEST_HTML);
+        $this->subject->setCss(
+            $selector1 . ' { color: red; } ' .
+            $selector2 . ' { color: green; } ' .
+            $selector2 . ' { background-color: red; } ' .
+            $selector1 . ' { background-color: green; }'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains($matchedTagPart . ' style="color: green; background-color: green;"', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function cssDeclarationWhitespaceDroppingDataProvider()
+    {
+        return [
+            'no whitespace, trailing semicolon' => ['color:#000;'],
+            'no whitespace, no trailing semicolon' => ['color:#000'],
+            'space after colon, no trailing semicolon' => ['color: #000'],
+            'space before colon, no trailing semicolon' => ['color :#000'],
+            'space before property name, no trailing semicolon' => [' color:#000'],
+            'space before trailing semicolon' => [' color:#000 ;'],
+            'space after trailing semicolon' => [' color:#000; '],
+            'space after property value, no trailing semicolon' => [' color:#000 '],
+            'space after property value, trailing semicolon' => [' color:#000; '],
+            'newline before property name, trailing semicolon' => ["\ncolor:#000;"],
+            'newline after property semicolon' => ["color:#000;\n"],
+            'newline before colon, trailing semicolon' => ["color\n:#000;"],
+            'newline after colon, trailing semicolon' => ["color:\n#000;"],
+            'newline after semicolon' => ["color:#000\n;"],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $cssDeclaration the CSS declaration block (without the curly braces)
+     *
+     * @dataProvider cssDeclarationWhitespaceDroppingDataProvider
+     */
+    public function emogrifyTrimsWhitespaceFromCssDeclarations($cssDeclaration)
+    {
+        $this->subject->setHtml('<html></html>');
+        $this->subject->setCss('html {' . $cssDeclaration . '}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<html style="color: #000;">', $result);
+    }
+
+    /**
+     * @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 & space'
+            => ['color: #000; width: 3px;', 'color: #000; width: 3px;'],
+            'two declarations separated by semicolon & linefeed' => [
+                "color: #000;\nwidth: 3px;",
+                'color: #000; width: 3px;',
+            ],
+            'two declarations separated by semicolon & 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;',
+            ],
+            'one declaration with linefeed in property value' => [
+                "text-shadow:\n1px 1px 3px #000,\n1px 1px 1px #000;",
+                "text-shadow: 1px 1px 3px #000,\n1px 1px 1px #000;",
+            ],
+            'one declaration with Windows line ending in property value' => [
+                "text-shadow:\r\n1px 1px 3px #000,\r\n1px 1px 1px #000;",
+                "text-shadow: 1px 1px 3px #000,\r\n1px 1px 1px #000;",
+            ],
+        ];
+    }
+
+    /**
+     * @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)
+    {
+        $this->subject->setHtml('<html></html>');
+        $this->subject->setCss('html {' . $cssDeclarationBlock . '}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<html style="' . $expectedStyleAttributeContent . '">', $result);
+    }
+
+    /**
+     * @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 emogrifyDropsInvalidCssDeclaration($cssDeclarationBlock)
+    {
+        $this->subject->setHtml('<html></html>');
+        $this->subject->setCss('html {' . $cssDeclarationBlock . '}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<html style="">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleAttributes()
+    {
+        $styleAttribute = 'style="color: #ccc;"';
+        $this->subject->setHtml('<html ' . $styleAttribute . '></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains($styleAttribute, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAddsNewCssBeforeExistingStyle()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $this->subject->setHtml('<html style="' . $styleAttributeValue . '"></html>');
+        $cssDeclarations = 'margin: 0 2px;';
+        $css = 'html {' . $cssDeclarations . '}';
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('style="' . $cssDeclarations . ' ' . $styleAttributeValue . '"', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyCanMatchMinifiedCss()
+    {
+        $this->subject->setHtml('<html><p></p></html>');
+        $this->subject->setCss('p{color:blue;}html{color:red;}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<html style="color: red;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyLowercasesAttributeNamesFromStyleAttributes()
+    {
+        $this->subject->setHtml('<html style="COLOR:#ccc;"></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('style="color: #ccc;"', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyLowercasesAttributeNamesFromPassedInCss()
+    {
+        $this->subject->setHtml('<html></html>');
+        $this->subject->setCss('html {mArGiN:0 2pX;}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('style="margin: 0 2pX;"', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyPreservesCaseForAttributeValuesFromPassedInCss()
+    {
+        $cssDeclaration = "content: 'Hello World';";
+        $this->subject->setHtml('<html><body><p>target</p></body></html>');
+        $this->subject->setCss('p {' . $cssDeclaration . '}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="' . $cssDeclaration . '">target</p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyPreservesCaseForAttributeValuesFromParsedStyleBlock()
+    {
+        $cssDeclaration = "content: 'Hello World';";
+        $this->subject->setHtml(
+            '<html><head><style>p {' . $cssDeclaration . '}</style></head><body><p>target</p></body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="' . $cssDeclaration . '">target</p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyRemovesStyleNodes()
+    {
+        $this->subject->setHtml('<html><style type="text/css"></style></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('<style', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \InvalidArgumentException
+     */
+    public function emogrifyInDebugModeForInvalidCssSelectorThrowsException()
+    {
+        $this->subject->setDebug(true);
+
+        $this->subject->setHtml(
+            '<html><style type="text/css">p{color:red;} <style data-x="1">html{cursor:text;}</style></html>'
+        );
+
+        $this->subject->emogrify();
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeIgnoresInvalidCssSelectors()
+    {
+        $this->subject->setDebug(false);
+
+        $html = '<html><style type="text/css">' .
+            'p{color:red;} <style data-x="1">html{cursor:text;} p{background-color:blue;}</style> ' .
+            '<body><p></p></body></html>';
+        $this->subject->setHtml($html);
+
+        $html = $this->subject->emogrify();
+
+        static::assertContains('color: red', $html);
+        static::assertContains('background-color: blue', $html);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultIgnoresInvalidCssSelectors()
+    {
+        $subject = new Emogrifier();
+
+        $html = '<html><style type="text/css">' .
+            'p{color:red;} <style data-x="1">html{cursor:text;} p{background-color:blue;}</style> ' .
+            '<body><p></p></body></html>';
+        $subject->setHtml($html);
+
+        $html = $subject->emogrify();
+        static::assertContains('color: red', $html);
+        static::assertContains('background-color: blue', $html);
+    }
+
+    /**
+     * Data provider for things that should be left out when applying the CSS.
+     *
+     * @return string[][]
+     */
+    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'],
+            'two @import directives, minified' => ['@import "foo.css";@import "bar.css";', '@import'],
+            '@charset directive' => ['@charset "UTF-8";', '@charset'],
+            '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'],
+            'style in "tv" media type rule with extra spaces' => [
+                '  @media  tv  {  p  {  color  :  #000  ;  }  }  ',
+                '#000',
+            ],
+            'style in "tv" media type rule with linefeeds' => [
+                "\n@media\ntv\n{\np\n{\ncolor\n:\n#000\n;\n}\n}\n",
+                '#000',
+            ],
+            'style in "tv" media type rule with Windows line endings' => [
+                "\r\n@media\r\ntv\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000\r\n;\r\n}\r\n}\r\n",
+                '#000',
+            ],
+            'style in "only tv" media type rule' => ['@media only tv {p {color: #000;}}', '#000'],
+            'style in "only tv" media type rule with extra spaces' => [
+                '  @media  only  tv  {  p  {  color  :  #000  ;  }  }  ',
+                '#000',
+            ],
+            'style in "only tv" media type rule with linefeeds' => [
+                "\n@media\nonly\ntv\n{\np\n{\ncolor\n:\n#000\n;\n}\n}\n",
+                '#000',
+            ],
+            'style in "only tv" media type rule with Windows line endings' => [
+                "\r\n@media\r\nonly\r\ntv\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000\r\n;\r\n}\r\n}\r\n",
+                '#000',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $unneededCss
+     * @param string $markerNotExpectedInHtml
+     *
+     * @dataProvider unneededCssThingsDataProvider
+     */
+    public function emogrifyFiltersUnneededCssThings($unneededCss, $markerNotExpectedInHtml)
+    {
+        $this->subject->setHtml('<html><p>foo</p></html>');
+        $this->subject->setCss($unneededCss);
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains($markerNotExpectedInHtml, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $unneededCss
+     *
+     * @dataProvider unneededCssThingsDataProvider
+     */
+    public function emogrifyMatchesRuleAfterUnneededCssThing($unneededCss)
+    {
+        $this->subject->setHtml('<html><body></body></html>');
+        $this->subject->setCss($unneededCss . ' body { color: green; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<body style="color: green;">', $result);
+    }
+
+    /**
+     * Data provider for media rules.
+     *
+     * @return string[][]
+     */
+    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 "only screen" media type rule with extra spaces'
+            => ['  @media  only  screen  {  p  {  color  :  #000;  }  }  '],
+            'style in "only screen" media type rule with linefeeds'
+            => ["\n@media\nonly\nscreen\n{\np\n{\ncolor\n:\n#000;\n}\n}\n"],
+            'style in "only screen" media type rule with Windows line endings'
+            => ["\r\n@media\r\nonly\r\nscreen\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000;\r\n}\r\n}\r\n"],
+            'style in media type rule' => ['@media {p {color: #000;}}'],
+            'style in media type rule with extra spaces' => ['  @media  {  p  {  color  :  #000;  }  }  '],
+            'style in media type rule with linefeeds' => ["\n@media\n{\np\n{\ncolor\n:\n#000;\n}\n}\n"],
+            'style in media type rule with Windows line endings'
+            => ["\r\n@media\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000;\r\n}\r\n}\r\n"],
+            'style in "screen" media type rule' => ['@media screen {p {color: #000;}}'],
+            'style in "screen" media type rule with extra spaces'
+            => ['  @media  screen  {  p  {  color  :  #000;  }  }  '],
+            'style in "screen" media type rule with linefeeds'
+            => ["\n@media\nscreen\n{\np\n{\ncolor\n:\n#000;\n}\n}\n"],
+            'style in "screen" media type rule with Windows line endings'
+            => ["\r\n@media\r\nscreen\r\n{\r\np\r\n{\r\ncolor\r\n:\r\n#000;\r\n}\r\n}\r\n"],
+            '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)
+    {
+        $this->subject->setHtml('<html><p>foo</p></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function orderedRulesAndSurroundingCssDataProvider()
+    {
+        $possibleSurroundingCss = [
+            'nothing' => '',
+            'space' => ' ',
+            'linefeed' => "\n",
+            'Windows line ending' => "\r\n",
+            'comment' => '/* hello */',
+            'other non-matching CSS' => 'h6 { color: #f00; }',
+            'other matching CSS' => 'p { color: #f00; }',
+            'disallowed media rule' => '@media tv { p { color: #f00; } }',
+            'allowed but non-matching media rule' => '@media screen { h6 { color: #f00; } }',
+            'non-matching CSS with pseudo-component' => 'h6:hover { color: #f00; }',
+        ];
+        $possibleCssBefore = $possibleSurroundingCss + [
+                '@import' => '@import "foo.css";',
+                '@charset' => '@charset "UTF-8";',
+            ];
+
+        $datasetsSurroundingCss = [];
+        foreach ($possibleCssBefore as $descriptionBefore => $cssBefore) {
+            foreach ($possibleSurroundingCss as $descriptionBetween => $cssBetween) {
+                foreach ($possibleSurroundingCss as $descriptionAfter => $cssAfter) {
+                    // every combination would be a ridiculous c.1000 datasets - choose a select few
+                    // test all possible CSS before once
+                    if (($cssBetween === '' && $cssAfter === '')
+                        // test all possible CSS between once
+                        || ($cssBefore === '' && $cssAfter === '')
+                        // test all possible CSS after once
+                        || ($cssBefore === '' && $cssBetween === '')
+                        // test with each possible CSS in all three positions
+                        || ($cssBefore === $cssBetween && $cssBetween === $cssAfter)
+                    ) {
+                        $description = ' with ' . $descriptionBefore . ' before, '
+                            . $descriptionBetween . ' between, '
+                            . $descriptionAfter . ' after';
+                        $datasetsSurroundingCss[$description] = [$cssBefore, $cssBetween, $cssAfter];
+                    }
+                }
+            }
+        }
+
+        $datasets = [];
+        foreach ($datasetsSurroundingCss as $description => $datasetSurroundingCss) {
+            $datasets += [
+                'two media rules' . $description => \array_merge(
+                    ['@media all { p { color: #333; } }', '@media print { p { color: #000; } }'],
+                    $datasetSurroundingCss
+                ),
+                'two rules involving pseudo-components' . $description => \array_merge(
+                    ['a:hover { color: blue; }', 'a:active { color: green; }'],
+                    $datasetSurroundingCss
+                ),
+                'media rule followed by rule involving pseudo-components' . $description => \array_merge(
+                    ['@media screen { p { color: #000; } }', 'a:hover { color: green; }'],
+                    $datasetSurroundingCss
+                ),
+                'rule involving pseudo-components followed by media rule' . $description => \array_merge(
+                    ['a:hover { color: green; }', '@media screen { p { color: #000; } }'],
+                    $datasetSurroundingCss
+                ),
+            ];
+        }
+        return $datasets;
+    }
+
+    /**
+     * @test
+     *
+     * @param string $rule1
+     * @param string $rule2
+     * @param string $cssBefore CSS to insert before the first rule
+     * @param string $cssBetween CSS to insert between the rules
+     * @param string $cssAfter CSS to insert after the second rule
+     *
+     * @dataProvider orderedRulesAndSurroundingCssDataProvider
+     */
+    public function emogrifyKeepsRulesCopiedToStyleElementInSpecifiedOrder(
+        $rule1,
+        $rule2,
+        $cssBefore,
+        $cssBetween,
+        $cssAfter
+    ) {
+        $this->subject->setHtml('<html><p><a>foo</a></p></html>');
+        $this->subject->setCss($cssBefore . $rule1 . $cssBetween . $rule2 . $cssAfter);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss($rule1 . $rule2, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function removeAllowedMediaTypeRemovesStylesForTheGivenMediaType()
+    {
+        $css = '@media screen { html { some-property: value; } }';
+        $this->subject->setHtml('<html></html>');
+        $this->subject->setCss($css);
+        $this->subject->removeAllowedMediaType('screen');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('@media', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addAllowedMediaTypeKeepsStylesForTheGivenMediaType()
+    {
+        $css = '@media braille { html { some-property: value; } }';
+        $this->subject->setHtml('<html></html>');
+        $this->subject->setCss($css);
+        $this->subject->addAllowedMediaType('braille');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingHeadElementContent()
+    {
+        $this->subject->setHtml('<html><head><!-- original content --></head></html>');
+        $this->subject->setCss('@media all { html { some-property: value; } }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<!-- original content -->', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleElementWithMedia()
+    {
+        $html = $this->html5DocumentType . '<html><head><!-- original content --></head><body></body></html>';
+        $this->subject->setHtml($html);
+        $this->subject->setCss('@media all { html { some-property: value; } }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<style type="text/css">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleElementWithMediaInHead()
+    {
+        $style = '<style type="text/css">@media all { html {  color: red; } }</style>';
+        $html = '<html><head>' . $style . '</head><body></body></html>';
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertRegExp('/<head>.*<style.*<\\/head>/s', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsExistingStyleElementWithMediaOutOfBody()
+    {
+        $style = '<style type="text/css">@media all { html {  color: red; } }</style>';
+        $html = '<html><head>' . $style . '</head><body></body></html>';
+        $this->subject->setHtml($html);
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotRegExp('/<body>.*<style/s', $result);
+    }
+
+    /**
+     * Valid media query which need to be preserved
+     *
+     * @return string[][]
+     */
+    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 "print" media type rule' => ['@media print { * { color:#000 !important; } }'],
+            'style in media type rule without specification' => ['@media { h1 { color:red; } }'],
+            'style with multiple media type rules' => [
+                '@media all { p { color: #000; } }' .
+                '@media only screen { h1 { color:red; } }' .
+                '@media only all { h1 { color:red; } }' .
+                '@media print { * { color:#000 !important; } }' .
+                '@media { h1 { color:red; } }',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyWithValidMediaQueryContainsInnerCss($css)
+    {
+        $this->subject->setHtml('<html><h1></h1><p></p></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss('<style type="text/css">' . $css . '</style>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyWithValidMinifiedMediaQueryContainsInnerCss($css)
+    {
+        // Minify CSS by removing unnecessary whitespace.
+        $css = \preg_replace('/\\s*{\\s*/', '{', $css);
+        $css = \preg_replace('/;?\\s*}\\s*/', '}', $css);
+        $css = \preg_replace('/@media{/', '@media {', $css);
+
+        $this->subject->setHtml('<html><h1></h1><p></p></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<style type="text/css">' . $css . '</style>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyForHtmlWithValidMediaQueryContainsInnerCss($css)
+    {
+        $this->subject->setHtml('<html><style type="text/css">' . $css . '</style><h1></h1><p></p></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss('<style type="text/css">' . $css . '</style>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider validMediaPreserveDataProvider
+     */
+    public function emogrifyWithValidMediaQueryNotContainsInlineCss($css)
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * Invalid media query which need to be strip
+     *
+     * @return string[][]
+     */
+    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 emogrifyWithInvalidMediaQueryNotContainsInnerCss($css)
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider invalidMediaPreserveDataProvider
+     */
+    public function emogrifyWithInvalidMediaQueryNotContainsInlineCss($css)
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider invalidMediaPreserveDataProvider
+     */
+    public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInnerCss($css)
+    {
+        $this->subject->setHtml('<html><style type="text/css">' . $css . '</style><h1></h1></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider invalidMediaPreserveDataProvider
+     */
+    public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInlineCss($css)
+    {
+        $this->subject->setHtml('<html><style type="text/css">' . $css . '</style><h1></h1></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyIgnoresEmptyMediaQuery()
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss('@media screen {} @media tv { h1 { color: red; } }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+        static::assertNotContains('@media screen', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyIgnoresMediaQueryWithWhitespaceOnly()
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss('@media screen { } @media tv { h1 { color: red; } }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+        static::assertNotContains('@media screen', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function mediaTypeDataProvider()
+    {
+        return [
+            'disallowed type' => ['tv'],
+            'allowed type' => ['screen'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     *
+     * @dataProvider mediaTypeDataProvider
+     */
+    public function emogrifyKeepsMediaRuleAfterEmptyMediaRule($emptyRuleMediaType)
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss('@media ' . $emptyRuleMediaType . ' {} @media all { h1 { color: red; } }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss('@media all { h1 { color: red; } }', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     *
+     * @dataProvider mediaTypeDataProvider
+     */
+    public function emogrifyNotKeepsUnneededMediaRuleAfterEmptyMediaRule($emptyRuleMediaType)
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss('@media ' . $emptyRuleMediaType . ' {} @media speech { h1 { color: red; } }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('@media', $result);
+    }
+
+    /**
+     * @param string[] $precedingSelectorComponents Array of selectors to which each type of pseudo-component is
+     *                                              appended to create a selector for a CSS rule.
+     *                                              Keys are human-readable descriptions.
+     *
+     * @return string[][]
+     */
+    private function getCssRuleDatasetsWithSelectorPseudoComponents(array $precedingSelectorComponents)
+    {
+        $rulesComponents = [
+            'pseudo-element' => [
+                'selectorPseudoComponent' => '::after',
+                'declarationsBlock' => 'content: "bar";',
+            ],
+            'CSS2 pseudo-element' => [
+                'selectorPseudoComponent' => ':after',
+                'declarationsBlock' => 'content: "bar";',
+            ],
+            'hyphenated pseudo-element' => [
+                'selectorPseudoComponent' => '::first-letter',
+                'declarationsBlock' => 'color: green;',
+            ],
+            'pseudo-class' => [
+                'selectorPseudoComponent' => ':hover',
+                'declarationsBlock' => 'color: green;',
+            ],
+            'hyphenated pseudo-class' => [
+                'selectorPseudoComponent' => ':read-only',
+                'declarationsBlock' => 'color: green;',
+            ],
+            'pseudo-class with parameter' => [
+                'selectorPseudoComponent' => ':lang(en)',
+                'declarationsBlock' => 'color: green;',
+            ],
+        ];
+
+        $datasets = [];
+        foreach ($precedingSelectorComponents as $precedingComponentDescription => $precedingSelectorComponent) {
+            foreach ($rulesComponents as $pseudoComponentDescription => $ruleComponents) {
+                $datasets[$precedingComponentDescription . ' ' . $pseudoComponentDescription] = [
+                    $precedingSelectorComponent . $ruleComponents['selectorPseudoComponent']
+                    . ' { ' . $ruleComponents['declarationsBlock'] . ' }',
+                ];
+            }
+        }
+        return $datasets;
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function matchingSelectorWithPseudoComponentCssRuleDataProvider()
+    {
+        $datasetsWithSelectorPseudoComponents = $this->getCssRuleDatasetsWithSelectorPseudoComponents(
+            [
+                'lone' => '',
+                'type &' => 'a',
+                'class &' => '.a',
+                'ID &' => '#a',
+                'attribute &' => 'a[href="a"]',
+                'static pseudo-class &' => 'a:first-child',
+                'ancestor &' => 'p ',
+                'ancestor & type &' => 'p a',
+            ]
+        );
+        $datasetsWithCombinedPseudoSelectors = [
+            'pseudo-class & descendant' => ['p:hover a { color: green; }'],
+            'pseudo-class & pseudo-element' => ['a:hover::after { content: "bar"; }'],
+            'pseudo-element & pseudo-class' => ['a::after:hover { content: "bar"; }'],
+            'two pseudo-classes' => ['a:focus:hover { color: green; }'],
+        ];
+
+        return \array_merge($datasetsWithSelectorPseudoComponents, $datasetsWithCombinedPseudoSelectors);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider matchingSelectorWithPseudoComponentCssRuleDataProvider
+     */
+    public function emogrifyKeepsRuleWithPseudoComponentInMatchingSelector($css)
+    {
+        $this->subject->setHtml('<html><p><a id="a" class="a" href="a">foo</a></p></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        self::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function nonMatchingSelectorWithPseudoComponentCssRuleDataProvider()
+    {
+        $datasetsWithSelectorPseudoComponents = $this->getCssRuleDatasetsWithSelectorPseudoComponents(
+            [
+                'type &' => 'b',
+                'class &' => '.b',
+                'ID &' => '#b',
+                'attribute &' => 'a[href="b"]',
+                'static pseudo-class &' => 'a:not(.a)',
+                'ancestor &' => 'ul ',
+                'ancestor & type &' => 'p b',
+            ]
+        );
+        $datasetsWithCombinedPseudoSelectors = [
+            'pseudo-class & descendant' => ['ul:hover a { color: green; }'],
+            'pseudo-class & pseudo-element' => ['b:hover::after { content: "bar"; }'],
+            'pseudo-element & pseudo-class' => ['b::after:hover { content: "bar"; }'],
+            'two pseudo-classes' => ['input:focus:hover { color: green; }'],
+        ];
+
+        return \array_merge($datasetsWithSelectorPseudoComponents, $datasetsWithCombinedPseudoSelectors);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $css
+     *
+     * @dataProvider nonMatchingSelectorWithPseudoComponentCssRuleDataProvider
+     */
+    public function emogrifyNotKeepsRuleWithPseudoComponentInNonMatchingSelector($css)
+    {
+        $this->subject->setHtml('<html><p><a id="a" class="a" href="#">foo</a></p></html>');
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        self::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsRuleInMediaQueryWithPseudoComponentInMatchingSelector()
+    {
+        $this->subject->setHtml('<html><a>foo</a></html>');
+        $css = '@media screen { a:hover { color: green; } }';
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        self::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotKeepsRuleInMediaQueryWithPseudoComponentInNonMatchingSelector()
+    {
+        $this->subject->setHtml('<html><a>foo</a></html>');
+        $css = '@media screen { b:hover { color: green; } }';
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        self::assertNotContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsRuleWithPseudoComponentInMultipleMatchingSelectorsFromSingleRule()
+    {
+        $this->subject->setHtml('<html><p>foo</p><a>bar</a></html>');
+        $css = 'p:hover, a:hover { color: green; }';
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsOnlyMatchingSelectorsWithPseudoComponentFromSingleRule()
+    {
+        $this->subject->setHtml('<html><a>foo</a></html>');
+        $this->subject->setCss('p:hover, a:hover { color: green; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss('<style type="text/css">a:hover { color: green; }</style>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesCssToMatchingElementsAndKeepsRuleWithPseudoComponentFromSingleRule()
+    {
+        $this->subject->setHtml('<html><p>foo</p><a>bar</a></html>');
+        $this->subject->setCss('p, a:hover { color: green; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="color: green;">', $result);
+        static::assertContainsCss('<style type="text/css">a:hover { color: green; }</style>', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function mediaTypesDataProvider()
+    {
+        return [
+            'disallowed type after disallowed type' => ['tv', 'speech'],
+            'allowed type after disallowed type' => ['tv', 'all'],
+            'disallowed type after allowed type' => ['screen', 'tv'],
+            'allowed type after allowed type' => ['screen', 'all'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     * @param string $mediaType
+     *
+     * @dataProvider mediaTypesDataProvider
+     */
+    public function emogrifyAppliesCssBetweenEmptyMediaRuleAndMediaRule($emptyRuleMediaType, $mediaType)
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss(
+            '@media ' . $emptyRuleMediaType . ' {} h1 { color: green; } @media ' . $mediaType
+            . ' { h1 { color: red; } }'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<h1 style="color: green;">', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $emptyRuleMediaType
+     * @param string $mediaType
+     *
+     * @dataProvider mediaTypesDataProvider
+     */
+    public function emogrifyAppliesCssBetweenEmptyMediaRuleAndMediaRuleWithCssAfter($emptyRuleMediaType, $mediaType)
+    {
+        $this->subject->setHtml('<html><h1></h1></html>');
+        $this->subject->setCss(
+            '@media ' . $emptyRuleMediaType . ' {} h1 { color: green; } @media ' . $mediaType
+            . ' { h1 { color: red; } } h1 { font-size: 24px; }'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<h1 style="color: green; font-size: 24px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesCssFromStyleNodes()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $this->subject->setHtml('<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<html style="' . $styleAttributeValue . '">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenDisabledNotAppliesCssFromStyleBlocks()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $this->subject->setHtml('<html><style type="text/css">html {' . $styleAttributeValue . '}</style></html>');
+        $this->subject->disableStyleBlocksParsing();
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('style=', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenStyleBlocksParsingDisabledKeepInlineStyles()
+    {
+        $styleAttributeValue = 'text-align: center;';
+        $this->subject->setHtml(
+            '<html><head><style type="text/css">p { color: #ccc; }</style></head>' .
+            '<body><p style="' . $styleAttributeValue . '">paragraph</p></body></html>'
+        );
+        $this->subject->disableStyleBlocksParsing();
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="' . $styleAttributeValue . '">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenDisabledNotAppliesCssFromInlineStyles()
+    {
+        $this->subject->setHtml('<html style="color: #ccc;"></html>');
+        $this->subject->disableInlineStyleAttributesParsing();
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('<html style', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyWhenInlineStyleAttributesParsingDisabledKeepStyleBlockStyles()
+    {
+        $styleAttributeValue = 'color: #ccc;';
+        $this->subject->setHtml(
+            '<html><head><style type="text/css">p { ' . $styleAttributeValue . ' }</style></head>' .
+            '<body><p style="text-align: center;">paragraph</p></body></html>'
+        );
+        $this->subject->disableInlineStyleAttributesParsing();
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="' . $styleAttributeValue . '">', $result);
+    }
+
+    /**
+     * Emogrify was handling case differently for passed-in CSS vs. CSS parsed from style blocks.
+     *
+     * @test
+     */
+    public function emogrifyAppliesCssWithMixedCaseAttributesInStyleBlock()
+    {
+        $this->subject->setHtml(
+            '<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>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="padding-bottom: 1px; padding-top: 0; text-align: center;">', $result);
+    }
+
+    /**
+     * Style block CSS overrides values.
+     *
+     * @test
+     */
+    public function emogrifyMergesCssWithMixedCaseAttribute()
+    {
+        $this->subject->setHtml(
+            '<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>'
+        );
+        $this->subject->setCss('p { margin: 0; padding-TOP: 0; PADDING-bottom: 1PX;}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains(
+            '<p style="margin: 0; padding-bottom: 3px; padding-top: 1px; text-align: center;">',
+            $result
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyMergesCssWithMixedUnits()
+    {
+        $this->subject->setHtml(
+            '<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>'
+        );
+        $this->subject->setCss('p { margin: 1px; padding-bottom:0;}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 0; padding-bottom: 1px; text-align: center;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultRemovesElementsWithDisplayNoneFromExternalCss()
+    {
+        $this->subject->setHtml('<html><body><div class="foo"></div></body></html>');
+        $this->subject->setCss('div.foo { display: none; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('<div class="foo"></div>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyByDefaultRemovesElementsWithDisplayNoneInStyleAttribute()
+    {
+        $this->subject->setHtml(
+            '<html><body><div class="foobar" style="display: none;"></div>' .
+            '</body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('<div', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAfterDisableInvisibleNodeRemovalPreservesInvisibleElements()
+    {
+        $this->subject->setHtml('<html><body><div class="foo"></div></body></html>');
+        $this->subject->setCss('div.foo { display: none; }');
+
+        $this->subject->disableInvisibleNodeRemoval();
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<div class="foo" style="display: none;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsCssMediaQueriesWithCssCommentAfterMediaQuery()
+    {
+        $this->subject->setHtml('<html><body></body></html>');
+        $this->subject->setCss(
+            '@media only screen and (max-width: 480px) { body { color: #ffffff } /* some comment */ }'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('@media only screen and (max-width: 480px)', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $documentType
+     *
+     * @dataProvider documentTypeDataProvider
+     */
+    public function emogrifyConvertsXmlSelfClosingTagsToNonXmlSelfClosingTag($documentType)
+    {
+        $this->subject->setHtml(
+            $documentType . '<html><body><br/></body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<br>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAutomaticallyClosesUnclosedTag()
+    {
+        $this->subject->setHtml('<html><body><p></body></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<body><p></p></body>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyReturnsCompleteHtmlDocument()
+    {
+        $this->subject->setHtml('<html><body><p></p></body></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertSame(
+            $this->html5DocumentType . "\n" .
+            "<html>\n" .
+            '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head>' . "\n" .
+            "<body><p></p></body>\n" .
+            "</html>\n",
+            $result
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyBodyContentReturnsBodyContentFromHtml()
+    {
+        $this->subject->setHtml('<html><body><p></p></body></html>');
+
+        $result = $this->subject->emogrifyBodyContent();
+
+        static::assertSame('<p></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyBodyContentReturnsBodyContentFromPartialContent()
+    {
+        $this->subject->setHtml('<p></p>');
+
+        $result = $this->subject->emogrifyBodyContent();
+
+        static::assertSame('<p></p>', $result);
+    }
+
+    /**
+     * Sets HTML of subject to boilerplate HTML with a single `<p>` in `<body>` and empty `<head>`
+     *
+     * @param string $style Optional value for the style attribute of the `<p>` element
+     *
+     * @return void
+     */
+    private function setSubjectBoilerplateHtml($style = '')
+    {
+        $html = '<html><head></head><body><p';
+        if ($style !== '') {
+            $html .= ' style="' . $style . '"';
+        }
+        $html .= '>some content</p></body></html>';
+        $this->subject->setHtml($html);
+    }
+
+    /**
+     * @test
+     */
+    public function importantInExternalCssOverwritesInlineCss()
+    {
+        $this->setSubjectBoilerplateHtml('margin: 2px;');
+        $this->subject->setCss('p { margin: 1px !important; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 1px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function importantInExternalCssKeepsInlineCssForOtherAttributes()
+    {
+        $this->setSubjectBoilerplateHtml('margin: 2px; text-align: center;');
+        $this->subject->setCss('p { margin: 1px !important; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="text-align: center; margin: 1px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function importantIsCaseInsensitive()
+    {
+        $this->setSubjectBoilerplateHtml('margin: 2px;');
+        $this->subject->setCss('p { margin: 1px !ImPorTant; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 1px !ImPorTant;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function secondImportantStyleOverwritesFirstOne()
+    {
+        $this->setSubjectBoilerplateHtml();
+        $this->subject->setCss('p { margin: 1px !important; } p { margin: 2px !important; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function secondNonImportantStyleOverwritesFirstOne()
+    {
+        $this->setSubjectBoilerplateHtml();
+        $this->subject->setCss('p { margin: 1px; } p { margin: 2px; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function secondNonImportantStyleNotOverwritesFirstImportantOne()
+    {
+        $this->setSubjectBoilerplateHtml();
+        $this->subject->setCss('p { margin: 1px !important; } p { margin: 2px; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 1px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesLaterShorthandStyleAfterIndividualStyle()
+    {
+        $this->setSubjectBoilerplateHtml();
+        $this->subject->setCss('p { margin-top: 1px; } p { margin: 2px; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin-top: 1px; margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesLaterOverridingStyleAfterStyleAfterOverriddenStyle()
+    {
+        $this->setSubjectBoilerplateHtml();
+        $this->subject->setCss('p { margin-top: 1px; } p { margin: 2px; } p { margin-top: 3px; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px; margin-top: 3px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesInlineOverridingStyleAfterCssStyleAfterOverriddenCssStyle()
+    {
+        $this->setSubjectBoilerplateHtml('margin-top: 3px;');
+        $this->subject->setCss('p { margin-top: 1px; } p { margin: 2px; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px; margin-top: 3px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyAppliesLaterInlineOverridingStyleAfterEarlierInlineStyle()
+    {
+        $this->setSubjectBoilerplateHtml('margin: 2px; margin-top: 3px;');
+        $this->subject->setCss('p { margin-top: 1px; }');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 2px; margin-top: 3px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function irrelevantMediaQueriesAreRemoved()
+    {
+        $uselessQuery = '@media all and (max-width: 500px) { em { color:red; } }';
+        $this->subject->setCss($uselessQuery);
+        $this->subject->setHtml('<html><body><p></p></body></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('@media', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function relevantMediaQueriesAreRetained()
+    {
+        $usefulQuery = '@media all and (max-width: 500px) { p { color:red; } }';
+        $this->subject->setCss($usefulQuery);
+        $this->subject->setHtml('<html><body><p></p></body></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss($usefulQuery, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function importantStyleRuleFromInlineCssOverwritesImportantStyleRuleFromExternalCss()
+    {
+        $this->setSubjectBoilerplateHtml('margin: 2px !important; text-align: center;');
+        $this->subject->setCss('p { margin: 1px !important; padding: 1px;}');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="padding: 1px; text-align: center; margin: 2px;">', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addExcludedSelectorRemovesMatchingElementsFromEmogrification()
+    {
+        $this->subject->setHtml('<html><body><p class="x"></p></body></html>');
+        $this->subject->setCss('p { margin: 0; }');
+
+        $this->subject->addExcludedSelector('p.x');
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addExcludedSelectorExcludesMatchingElementEventWithWhitespaceAroundSelector()
+    {
+        $this->subject->setHtml('<html><body><p class="x"></p></body></html>');
+        $this->subject->setCss('p { margin: 0; }');
+
+        $this->subject->addExcludedSelector(' p.x ');
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function addExcludedSelectorKeepsNonMatchingElementsInEmogrification()
+    {
+        $this->subject->setHtml('<html><body><p></p></body></html>');
+        $this->subject->setCss('p { margin: 0; }');
+
+        $this->subject->addExcludedSelector('p.x');
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="margin: 0;"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function removeExcludedSelectorGetsMatchingElementsToBeEmogrifiedAgain()
+    {
+        $this->subject->setHtml('<html><body><p class="x"></p></body></html>');
+        $this->subject->setCss('p { margin: 0; }');
+
+        $this->subject->addExcludedSelector('p.x');
+        $this->subject->removeExcludedSelector('p.x');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p class="x" style="margin: 0;"></p>', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \InvalidArgumentException
+     */
+    public function emogrifyInDebugModeForInvalidExcludedSelectorThrowsException()
+    {
+        $this->subject->setDebug(true);
+
+        $this->subject->setHtml('<html></html>');
+        $this->subject->addExcludedSelector('..p');
+
+        $this->subject->emogrify();
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeIgnoresInvalidExcludedSelector()
+    {
+        $this->subject->setDebug(false);
+
+        $this->subject->setHtml('<html><p class="x"></p></html>');
+        $this->subject->addExcludedSelector('..p');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeIgnoresOnlyInvalidExcludedSelector()
+    {
+        $this->subject->setDebug(false);
+
+        $this->subject->setHtml('<html><p class="x"></p><p class="y"></p><p class="z"></p></html>');
+        $this->subject->setCss('p { color: red };');
+        $this->subject->addExcludedSelector('p.x');
+        $this->subject->addExcludedSelector('..p');
+        $this->subject->addExcludedSelector('p.z');
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p class="x"></p>', $result);
+        static::assertContains('<p class="y" style="color: red;"></p>', $result);
+        static::assertContains('<p class="z"></p>', $result);
+    }
+
+    /**
+     * @test
+     */
+    public function emptyMediaQueriesAreRemoved()
+    {
+        $emptyQuery = '@media all and (max-width: 500px) { }';
+        $this->subject->setCss($emptyQuery);
+        $this->subject->setHtml('<html><body><p></p></body></html>');
+
+        $result = $this->subject->emogrify();
+
+        static::assertNotContains('@media', $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(
+            '<html><body>' .
+            '<p class="medium">medium</p>' .
+            '<p class="small">small</p>' .
+            '</body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCssCount(1, $css, $result);
+    }
+
+    /**
+     * @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(
+            '<html><body>' .
+            '<p class="medium">medium</p>' .
+            '<p class="small">small</p>' .
+            '</body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCssCount(1, $css, $result);
+    }
+
+    /**
+     * @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(
+            '<html><body>' .
+            '<p class="medium">medium</p>' .
+            '<p class="small">small</p>' .
+            '</body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCssCount(1, $css, $result);
+    }
+
+    /**
+     * @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)
+    {
+        $this->subject->setHtml('<html></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();
+
+        static::assertContains(
+            '<html style="' . $styleRule . '">',
+            $result
+        );
+    }
+
+    /**
+     * Data provider for CSS to HTML mapping.
+     *
+     * @return string[][]
+     */
+    public function matchingCssToHtmlMappingDataProvider()
+    {
+        return [
+            'background-color => bgcolor'
+            => ['<p>hi</p>', 'p {background-color: red;}', 'p', 'bgcolor="red"'],
+            'background-color (with !important) => bgcolor'
+            => ['<p>hi</p>', 'p {background-color: red !important;}', 'p', 'bgcolor="red"'],
+            'p.text-align => align'
+            => ['<p>hi</p>', 'p {text-align: justify;}', 'p', 'align="'],
+            'div.text-align => align'
+            => ['<div>hi</div>', 'div {text-align: justify;}', 'div', 'align="'],
+            'td.text-align => align'
+            => ['<table><tr><td>hi</td></tr></table>', 'td {text-align: justify;}', 'td', 'align="'],
+            'text-align: left => align=left'
+            => ['<p>hi</p>', 'p {text-align: left;}', 'p', 'align="left"'],
+            'text-align: right => align=right'
+            => ['<p>hi</p>', 'p {text-align: right;}', 'p', 'align="right"'],
+            'text-align: center => align=center'
+            => ['<p>hi</p>', 'p {text-align: center;}', 'p', 'align="center"'],
+            'text-align: justify => align:justify'
+            => ['<p>hi</p>', 'p {text-align: justify;}', 'p', 'align="justify"'],
+            'img.float: right => align=right'
+            => ['<img>', 'img {float: right;}', 'img', 'align="right"'],
+            'img.float: left => align=left'
+            => ['<img>', 'img {float: left;}', 'img', 'align="left"'],
+            'table.float: right => align=right'
+            => ['<table></table>', 'table {float: right;}', 'table', 'align="right"'],
+            'table.float: left => align=left'
+            => ['<table></table>', 'table {float: left;}', 'table', 'align="left"'],
+            'table.border-spacing: 0 => cellspacing=0'
+            => ['<table><tr><td></td></tr></table>', 'table {border-spacing: 0;}', 'table', 'cellspacing="0"'],
+            'background => bgcolor'
+            => ['<p>Bonjour</p>', 'p {background: red top;}', 'p', 'bgcolor="red"'],
+            'width with px'
+            => ['<p>Hello</p>', 'p {width: 100px;}', 'p', 'width="100"'],
+            'width with %'
+            => ['<p>Hello</p>', 'p {width: 50%;}', 'p', 'width="50%"'],
+            'height with px'
+            => ['<p>Hello</p>', 'p {height: 100px;}', 'p', 'height="100"'],
+            'height with %'
+            => ['<p>Hello</p>', 'p {height: 50%;}', 'p', 'height="50%"'],
+            'img.margin: 0 auto (= horizontal centering) => align=center'
+            => ['<img>', 'img {margin: 0 auto;}', 'img', 'align="center"'],
+            'img.margin: auto (= horizontal centering) => align=center'
+            => ['<img>', 'img {margin: auto;}', 'img', 'align="center"'],
+            'img.margin: 10 auto 30 auto (= horizontal centering) => align=center'
+            => ['<img>', 'img {margin: 10 auto 30 auto;}', 'img', 'align="center"'],
+            'table.margin: 0 auto (= horizontal centering) => align=center'
+            => ['<table></table>', 'table {margin: 0 auto;}', 'table', 'align="center"'],
+            'table.margin: auto (= horizontal centering) => align=center'
+            => ['<table></table>', 'table {margin: auto;}', 'table', 'align="center"'],
+            'table.margin: 10 auto 30 auto (= horizontal centering) => align=center'
+            => ['<table></table>', 'table {margin: 10 auto 30 auto;}', 'table', 'align="center"'],
+            'img.border: none => border=0'
+            => ['<img>', 'img {border: none;}', 'img', 'border="0"'],
+            'img.border: 0 => border=0'
+            => ['<img>', 'img {border: none;}', 'img', 'border="0"'],
+            'table.border: none => border=0'
+            => ['<table></table>', 'table {border: none;}', 'table', 'border="0"'],
+            'table.border: 0 => border=0'
+            => ['<table></table>', 'table {border: none;}', 'table', 'border="0"'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $body The HTML
+     * @param string $css The complete CSS
+     * @param string $tagName The name of the tag that should be modified
+     * @param string $attributes The attributes that are expected on the element
+     *
+     * @dataProvider matchingCssToHtmlMappingDataProvider
+     */
+    public function emogrifierMapsSuitableCssToHtmlIfFeatureIsEnabled($body, $css, $tagName, $attributes)
+    {
+        $this->subject->setHtml('<html><body>' . $body . '</body></html>');
+        $this->subject->setCss($css);
+
+        $this->subject->enableCssToHtmlMapping();
+        $html = $this->subject->emogrify();
+
+        static::assertRegExp('/<' . \preg_quote($tagName, '/') . '[^>]+' . \preg_quote($attributes, '/') . '/', $html);
+    }
+
+    /**
+     * Data provider for CSS to HTML mapping.
+     *
+     * @return string[][]
+     */
+    public function notMatchingCssToHtmlMappingDataProvider()
+    {
+        return [
+            'background URL'
+            => ['<p>Hello</p>', 'p {background: url(bg.png);}', 'bgcolor'],
+            'background URL with position'
+            => ['<p>Hello</p>', 'p {background: url(bg.png) top;}', 'bgcolor'],
+            'img.margin: 10 5 30 auto (= no horizontal centering)'
+            => ['<img>', 'img {margin: 10 5 30 auto;}', 'align'],
+            'p.margin: auto'
+            => ['<p>Bonjour</p>', 'p {margin: auto;}', 'align'],
+            'p.border: none'
+            => ['<p>Bonjour</p>', 'p {border: none;}', 'border'],
+            'img.border: 1px solid black'
+            => ['<p>Bonjour</p>', 'p {border: 1px solid black;}', 'border'],
+            'span.text-align'
+            => ['<span>hi</span>', 'span {text-align: justify;}', 'align'],
+            'text-align: inherit'
+            => ['<p>hi</p>', 'p {text-align: inherit;}', 'align'],
+            'span.float'
+            => ['<span>hi</span>', 'span {float: right;}', 'align'],
+            'float: none'
+            => ['<table></table>', 'table {float: none;}', 'align'],
+            'p.border-spacing'
+            => ['<p>Hello</p>', 'p {border-spacing: 5px;}', 'cellspacing'],
+            'height: auto'
+            => ['<img src="logo.png" alt="">', 'img {width: 110px; height: auto;}', 'height'],
+            'width: auto'
+            => ['<img src="logo.png" alt="">', 'img {width: auto; height: 110px;}', 'width'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $body the HTML
+     * @param string $css the complete CSS
+     * @param string $attribute the attribute that must not be present on this element
+     *
+     * @dataProvider notMatchingCssToHtmlMappingDataProvider
+     */
+    public function emogrifierNotMapsUnsuitableCssToHtmlIfFeatureIsEnabled($body, $css, $attribute)
+    {
+        $this->subject->setHtml('<html><body>' . $body . '</body></html>');
+        $this->subject->setCss($css);
+
+        $this->subject->enableCssToHtmlMapping();
+        $html = $this->subject->emogrify();
+
+        static::assertNotContains(
+            $attribute . '=',
+            $html
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifierNotMapsCssToHtmlIfFeatureIsNotEnabled()
+    {
+        $this->subject->setHtml('<html><body><img></body></html>');
+        $this->subject->setCss('img {float: right;}');
+
+        $html = $this->subject->emogrify();
+
+        static::assertNotContains(
+            'align=',
+            $html
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifierIgnoresPseudoClassCombinedWithPseudoElement()
+    {
+        $this->subject->setHtml('<html><body><div></div></body></html>');
+        $this->subject->setCss('div:last-child::after {float: right;}');
+
+        $html = $this->subject->emogrify();
+
+        static::assertContains('<div></div>', $html);
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyKeepsInlineStylePriorityVersusStyleBlockRules()
+    {
+        $this->subject->setHtml(
+            '<html><head><style>p {padding:10px};</style></head><body><p style="padding-left:20px;"></p></body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="padding: 10px; padding-left: 20px;">', $result);
+    }
+
+    /**
+     * Asserts that $html contains a $tagName tag with the $attribute attribute.
+     *
+     * @param string $html the HTML string we are searching in
+     * @param string $tagName the HTML tag we are looking for
+     * @param string $attribute the attribute we are looking for (with or even without a value)
+     */
+    private function assertHtmlStringContainsTagWithAttribute($html, $tagName, $attribute)
+    {
+        static::assertTrue(
+            \preg_match('/<' . \preg_quote($tagName, '/') . '[^>]+' . \preg_quote($attribute, '/') . '/', $html) > 0
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyPrefersInlineStyleOverCssBlockStyleForHtmlAttributesMapping()
+    {
+        $this->subject->setHtml(
+            '<html><head><style>p {width:1px}</style></head><body><p style="width:2px"></p></body></html>'
+        );
+        $this->subject->enableCssToHtmlMapping();
+
+        $result = $this->subject->emogrify();
+
+        $this->assertHtmlStringContainsTagWithAttribute($result, 'p', 'width="2"');
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyCorrectsHtmlAttributesMappingWhenMultipleMatchingRulesAndLastRuleIsAuto()
+    {
+        $this->subject->setHtml(
+            '<html><head><style>p {width:1px}</style></head><body><p class="autoWidth"></p></body></html>'
+        );
+        $this->subject->setCss('p.autoWidth {width:auto}');
+        $this->subject->enableCssToHtmlMapping();
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p class="autoWidth" style="width: auto;">', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function cssForImportantRuleRemovalDataProvider()
+    {
+        return [
+            'one !important rule only' => [
+                'width: 1px !important',
+                'width: 1px;',
+            ],
+            'multiple !important rules only' => [
+                'width: 1px !important; height: 1px !important',
+                'width: 1px; height: 1px;',
+            ],
+            'multiple declarations, one !important rule at the beginning' => [
+                'width: 1px !important; height: 1px; color: red',
+                'height: 1px; color: red; width: 1px;',
+            ],
+            'multiple declarations, one !important rule somewhere in the middle' => [
+                'height: 1px; width: 1px !important; color: red',
+                'height: 1px; color: red; width: 1px;',
+            ],
+            'multiple declarations, one !important rule at the end' => [
+                'height: 1px; color: red; width: 1px !important',
+                'height: 1px; color: red; width: 1px;',
+            ],
+            'multiple declarations, multiple !important rules at the beginning' => [
+                'width: 1px !important; height: 1px !important; color: red; float: left',
+                'color: red; float: left; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple consecutive !important rules somewhere in the middle (#1)' => [
+                'color: red; width: 1px !important; height: 1px !important; float: left',
+                'color: red; float: left; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple consecutive !important rules somewhere in the middle (#2)' => [
+                'color: red; width: 1px !important; height: 1px !important; float: left; clear: both',
+                'color: red; float: left; clear: both; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple not consecutive !important rules somewhere in the middle' => [
+                'color: red; width: 1px !important; clear: both; height: 1px !important; float: left',
+                'color: red; clear: both; float: left; width: 1px; height: 1px;',
+            ],
+            'multiple declarations, multiple !important rules at the end' => [
+                'color: red; float: left; width: 1px !important; height: 1px !important',
+                'color: red; float: left; width: 1px; height: 1px;',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $originalStyleAttributeContent
+     * @param string $expectedStyleAttributeContent
+     *
+     * @dataProvider cssForImportantRuleRemovalDataProvider
+     */
+    public function emogrifyRemovesImportantRule($originalStyleAttributeContent, $expectedStyleAttributeContent)
+    {
+        $this->subject->setHtml(
+            '<html><head><body><p style="' . $originalStyleAttributeContent . '"></p></body></html>'
+        );
+
+        $result = $this->subject->emogrify();
+
+        static::assertContains('<p style="' . $expectedStyleAttributeContent . '">', $result);
+    }
+
+    /**
+     * @test
+     *
+     * @expectedException \InvalidArgumentException
+     */
+    public function emogrifyInDebugModeForInvalidSelectorsInMediaQueryBlocksThrowsException()
+    {
+        $this->subject->setDebug(true);
+
+        $this->subject->setHtml('<html></html>');
+        $this->subject->setCss('@media screen {p^^ {color: red;}}');
+
+        $this->subject->emogrify();
+    }
+
+    /**
+     * @test
+     */
+    public function emogrifyNotInDebugModeKeepsInvalidOrUnrecognizedSelectorsInMediaQueryBlocks()
+    {
+        $this->subject->setDebug(false);
+
+        $this->subject->setHtml('<html></html>');
+        $css = '@media screen {p^^ {color: red;}}';
+        $this->subject->setCss($css);
+
+        $result = $this->subject->emogrify();
+
+        static::assertContainsCss($css, $result);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Support/Traits/AssertCssTest.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Support/Traits/AssertCssTest.php
new file mode 100644 (file)
index 0000000..6366ee1
--- /dev/null
@@ -0,0 +1,333 @@
+<?php
+
+namespace Pelago\Tests\Unit\Support\Traits;
+
+use Pelago\Tests\Support\Traits\AssertCss;
+
+/**
+ * Test case.
+ *
+ * @author Jake Hotson <jake.github@qzdesign.co.uk>
+ */
+class AssertCssTest extends \PHPUnit_Framework_TestCase
+{
+    use AssertCss;
+
+    /**
+     * @test
+     */
+    public function getCssNeedleRegExpEscapesAllSpecialCharacters()
+    {
+        $needle = '.\\+*?[^]$(){}=!<>|:-/';
+
+        $result = static::getCssNeedleRegExp($needle);
+
+        $resultWithWhitespaceMatchersRemoved = \str_replace('\\s*+', '', $result);
+
+        static::assertSame(
+            '/' . \preg_quote($needle, '/') . '/',
+            $resultWithWhitespaceMatchersRemoved
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function getCssNeedleRegExpNotEscapesNonSpecialCharacters()
+    {
+        $needle = \implode('', \array_merge(\range('a', 'z'), \range('A', 'Z'), \range('0 ', '9 ')))
+            . "\r\n\t `¬\"£%&_;'@~,";
+
+        $result = static::getCssNeedleRegExp($needle);
+
+        $resultWithWhitespaceMatchersRemoved = \str_replace('\\s*+', '', $result);
+
+        static::assertSame(
+            '/' . $needle . '/',
+            $resultWithWhitespaceMatchersRemoved
+        );
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function contentWithOptionalWhitespaceDataProvider()
+    {
+        return [
+            '"{" alone' => ['{', ''],
+            '"}" alone' => ['}', ''],
+            '"," alone' => [',', ''],
+            '"{" with non-special character' => ['{', 'a'],
+            '"{" with two non-special characters' => ['{', 'a0'],
+            '"{" with special character' => ['{', '.'],
+            '"{" with two special characters' => ['{', '.+'],
+            '"{" with special character and non-special character' => ['{', '.a'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $contentToInsertAround
+     * @param string $otherContent
+     *
+     * @dataProvider contentWithOptionalWhitespaceDataProvider
+     */
+    public function getCssNeedleRegExpInsertsOptionalWhitespace($contentToInsertAround, $otherContent)
+    {
+        $result = static::getCssNeedleRegExp($otherContent . $contentToInsertAround . $otherContent);
+
+        $quotedOtherContent = \preg_quote($otherContent, '/');
+        $expectedResult = '/' . $quotedOtherContent . '\\s*+' . \preg_quote($contentToInsertAround, '/') . '\\s*+'
+            . $quotedOtherContent . '/';
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getCssNeedleRegExpReplacesWhitespaceAtStartWithOptionalWhitespace()
+    {
+        $result = static::getCssNeedleRegExp(' a');
+
+        static::assertSame('/\\s*+a/', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function styleTagDataProvider()
+    {
+        return [
+            'without space after' => ['<style>a'],
+            'one space after' => ['<style> a'],
+            'two spaces after' => ['<style>  a'],
+            'linefeed after' => ["<style>\na"],
+            'Windows line ending after' => ["<style>\r\na"],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     *
+     * @dataProvider styleTagDataProvider
+     */
+    public function getCssNeedleRegExpInsertsOptionalWhitespaceAfterStyleTag($needle)
+    {
+        $result = static::getCssNeedleRegExp($needle);
+
+        static::assertSame('/\\<style\\>\\s*+a/', $result);
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function needleFoundDataProvider()
+    {
+        $cssStrings = [
+            'unminified CSS' => 'html, body { color: green; }',
+            'minified CSS' => 'html,body{color: green;}',
+            'CSS with extra spaces' => '  html  ,  body  {  color: green;  }',
+            'CSS with linefeeds' => "\nhtml\n,\nbody\n{\ncolor: green;\n}",
+            'CSS with Windows line endings' => "\r\nhtml\r\n,\r\nbody\r\n{\r\ncolor: green;\r\n}",
+        ];
+
+        $datasets = [];
+        foreach ($cssStrings as $needleDescription => $needle) {
+            foreach ($cssStrings as $haystackDescription => $haystack) {
+                $description = $needleDescription . ' in ' . $haystackDescription;
+                $datasets[$description] = [$needle, $haystack];
+                $datasets[$description . ' in <style> tag'] = [
+                    '<style>' . $needle . '</style>',
+                    '<style>' . $haystack . '</style>',
+                ];
+            }
+        }
+        return $datasets;
+    }
+
+    /**
+     * @return string[][]
+     */
+    public function needleNotFoundDataProvider()
+    {
+        return [
+            'CSS part with "{" not in CSS' => ['p {', 'body { color: green; }'],
+            'CSS part with "}" not in CSS' => ['color: red; }', 'body { color: green; }'],
+            'CSS part with "," not in CSS' => ['html, body', 'body { color: green; }'],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleFoundDataProvider
+     */
+    public function assertContainsCssPassesTestIfNeedleFound($needle, $haystack)
+    {
+        static::assertContainsCss($needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleNotFoundDataProvider
+     *
+     * @expectedException \PHPUnit_Framework_ExpectationFailedException
+     */
+    public function assertContainsCssFailsTestIfNeedleNotFound($needle, $haystack)
+    {
+        static::assertContainsCss($needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleNotFoundDataProvider
+     */
+    public function assertNotContainsCssPassesTestIfNeedleNotFound($needle, $haystack)
+    {
+        static::assertNotContainsCss($needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleFoundDataProvider
+     *
+     * @expectedException \PHPUnit_Framework_ExpectationFailedException
+     */
+    public function assertNotContainsCssFailsTestIfNeedleFound($needle, $haystack)
+    {
+        static::assertNotContainsCss($needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleNotFoundDataProvider
+     */
+    public function assertContainsCssCountPassesTestExpectingZeroIfNeedleNotFound($needle, $haystack)
+    {
+        static::assertContainsCssCount(0, $needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleFoundDataProvider
+     *
+     * @expectedException \PHPUnit_Framework_ExpectationFailedException
+     */
+    public function assertContainsCssCountFailsTestExpectingZeroIfNeedleFound($needle, $haystack)
+    {
+        static::assertContainsCssCount(0, $needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleFoundDataProvider
+     */
+    public function assertContainsCssCountPassesTestExpectingOneIfNeedleFound($needle, $haystack)
+    {
+        static::assertContainsCssCount(1, $needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleNotFoundDataProvider
+     *
+     * @expectedException \PHPUnit_Framework_ExpectationFailedException
+     */
+    public function assertContainsCssCountFailsTestExpectingOneIfNeedleNotFound($needle, $haystack)
+    {
+        static::assertContainsCssCount(1, $needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleFoundDataProvider
+     *
+     * @expectedException \PHPUnit_Framework_ExpectationFailedException
+     */
+    public function assertContainsCssCountFailsTestExpectingOneIfNeedleFoundTwice($needle, $haystack)
+    {
+        static::assertContainsCssCount(1, $needle, $haystack . $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleFoundDataProvider
+     */
+    public function assertContainsCssCountPassesTestExpectingTwoIfNeedleFoundTwice($needle, $haystack)
+    {
+        static::assertContainsCssCount(2, $needle, $haystack . $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleNotFoundDataProvider
+     *
+     * @expectedException \PHPUnit_Framework_ExpectationFailedException
+     */
+    public function assertContainsCssCountFailsTestExpectingTwoIfNeedleNotFound($needle, $haystack)
+    {
+        static::assertContainsCssCount(2, $needle, $haystack);
+    }
+
+    /**
+     * @test
+     *
+     * @param string $needle
+     * @param string $haystack
+     *
+     * @dataProvider needleFoundDataProvider
+     *
+     * @expectedException \PHPUnit_Framework_ExpectationFailedException
+     */
+    public function assertContainsCssCountFailsTestExpectingTwoIfNeedleFoundOnlyOnce($needle, $haystack)
+    {
+        static::assertContainsCssCount(2, $needle, $haystack);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/.gitignore b/wcfsetup/install/files/lib/system/api/symfony/css-selector/.gitignore
new file mode 100644 (file)
index 0000000..c49a5d8
--- /dev/null
@@ -0,0 +1,3 @@
+vendor/
+composer.lock
+phpunit.xml
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/CHANGELOG.md b/wcfsetup/install/files/lib/system/api/symfony/css-selector/CHANGELOG.md
new file mode 100644 (file)
index 0000000..4061ff2
--- /dev/null
@@ -0,0 +1,13 @@
+CHANGELOG
+=========
+
+2.8.0
+-----
+
+ * Added the `CssSelectorConverter` class as a non-static API for the component.
+ * Deprecated the `CssSelector` static API of the component.
+
+2.1.0
+-----
+
+ * none
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/CssSelectorConverter.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/CssSelectorConverter.php
new file mode 100644 (file)
index 0000000..d1aeb7e
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector;
+
+use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser;
+use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser;
+use Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser;
+use Symfony\Component\CssSelector\Parser\Shortcut\HashParser;
+use Symfony\Component\CssSelector\XPath\Extension\HtmlExtension;
+use Symfony\Component\CssSelector\XPath\Translator;
+
+/**
+ * CssSelectorConverter is the main entry point of the component and can convert CSS
+ * selectors to XPath expressions.
+ *
+ * @author Christophe Coevoet <stof@notk.org>
+ */
+class CssSelectorConverter
+{
+    private $translator;
+
+    /**
+     * @param bool $html Whether HTML support should be enabled. Disable it for XML documents
+     */
+    public function __construct(bool $html = true)
+    {
+        $this->translator = new Translator();
+
+        if ($html) {
+            $this->translator->registerExtension(new HtmlExtension($this->translator));
+        }
+
+        $this->translator
+            ->registerParserShortcut(new EmptyStringParser())
+            ->registerParserShortcut(new ElementParser())
+            ->registerParserShortcut(new ClassParser())
+            ->registerParserShortcut(new HashParser())
+        ;
+    }
+
+    /**
+     * Translates a CSS expression to its XPath equivalent.
+     *
+     * Optionally, a prefix can be added to the resulting XPath
+     * expression with the $prefix parameter.
+     *
+     * @param string $cssExpr The CSS expression
+     * @param string $prefix  An optional prefix for the XPath expression
+     *
+     * @return string
+     */
+    public function toXPath($cssExpr, $prefix = 'descendant-or-self::')
+    {
+        return $this->translator->cssToXPath($cssExpr, $prefix);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExceptionInterface.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExceptionInterface.php
new file mode 100644 (file)
index 0000000..9e25900
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Exception;
+
+/**
+ * Interface for exceptions.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+interface ExceptionInterface extends \Throwable
+{
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExpressionErrorException.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExpressionErrorException.php
new file mode 100644 (file)
index 0000000..fd5deea
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Exception;
+
+/**
+ * ParseException is thrown when a CSS selector syntax is not valid.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+class ExpressionErrorException extends ParseException
+{
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/InternalErrorException.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/InternalErrorException.php
new file mode 100644 (file)
index 0000000..e60e5ed
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Exception;
+
+/**
+ * ParseException is thrown when a CSS selector syntax is not valid.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+class InternalErrorException extends ParseException
+{
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ParseException.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ParseException.php
new file mode 100644 (file)
index 0000000..3b0b0ee
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Exception;
+
+/**
+ * ParseException is thrown when a CSS selector syntax is not valid.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ */
+class ParseException extends \Exception implements ExceptionInterface
+{
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/SyntaxErrorException.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/SyntaxErrorException.php
new file mode 100644 (file)
index 0000000..cb3158a
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Exception;
+
+use Symfony\Component\CssSelector\Parser\Token;
+
+/**
+ * ParseException is thrown when a CSS selector syntax is not valid.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+class SyntaxErrorException extends ParseException
+{
+    /**
+     * @param string $expectedValue
+     * @param Token  $foundToken
+     *
+     * @return self
+     */
+    public static function unexpectedToken($expectedValue, Token $foundToken)
+    {
+        return new self(sprintf('Expected %s, but %s found.', $expectedValue, $foundToken));
+    }
+
+    /**
+     * @param string $pseudoElement
+     * @param string $unexpectedLocation
+     *
+     * @return self
+     */
+    public static function pseudoElementFound($pseudoElement, $unexpectedLocation)
+    {
+        return new self(sprintf('Unexpected pseudo-element "::%s" found %s.', $pseudoElement, $unexpectedLocation));
+    }
+
+    /**
+     * @param int $position
+     *
+     * @return self
+     */
+    public static function unclosedString($position)
+    {
+        return new self(sprintf('Unclosed/invalid string at %s.', $position));
+    }
+
+    /**
+     * @return self
+     */
+    public static function nestedNot()
+    {
+        return new self('Got nested ::not().');
+    }
+
+    /**
+     * @return self
+     */
+    public static function stringAsFunctionArgument()
+    {
+        return new self('String not allowed as function argument.');
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/LICENSE b/wcfsetup/install/files/lib/system/api/symfony/css-selector/LICENSE
new file mode 100644 (file)
index 0000000..21d7fb9
--- /dev/null
@@ -0,0 +1,19 @@
+Copyright (c) 2004-2018 Fabien Potencier
+
+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/symfony/css-selector/Node/AbstractNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/AbstractNode.php
new file mode 100644 (file)
index 0000000..aac3f0a
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Abstract base node class.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+abstract class AbstractNode implements NodeInterface
+{
+    /**
+     * @var string
+     */
+    private $nodeName;
+
+    /**
+     * @return string
+     */
+    public function getNodeName(): string
+    {
+        if (null === $this->nodeName) {
+            $this->nodeName = preg_replace('~.*\\\\([^\\\\]+)Node$~', '$1', \get_called_class());
+        }
+
+        return $this->nodeName;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/AttributeNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/AttributeNode.php
new file mode 100644 (file)
index 0000000..bf702d9
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a "<selector>[<namespace>|<attribute> <operator> <value>]" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class AttributeNode extends AbstractNode
+{
+    private $selector;
+    private $namespace;
+    private $attribute;
+    private $operator;
+    private $value;
+
+    public function __construct(NodeInterface $selector, ?string $namespace, string $attribute, string $operator, ?string $value)
+    {
+        $this->selector = $selector;
+        $this->namespace = $namespace;
+        $this->attribute = $attribute;
+        $this->operator = $operator;
+        $this->value = $value;
+    }
+
+    public function getSelector(): NodeInterface
+    {
+        return $this->selector;
+    }
+
+    public function getNamespace(): ?string
+    {
+        return $this->namespace;
+    }
+
+    public function getAttribute(): string
+    {
+        return $this->attribute;
+    }
+
+    public function getOperator(): string
+    {
+        return $this->operator;
+    }
+
+    public function getValue(): ?string
+    {
+        return $this->value;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        $attribute = $this->namespace ? $this->namespace.'|'.$this->attribute : $this->attribute;
+
+        return 'exists' === $this->operator
+            ? sprintf('%s[%s[%s]]', $this->getNodeName(), $this->selector, $attribute)
+            : sprintf("%s[%s[%s %s '%s']]", $this->getNodeName(), $this->selector, $attribute, $this->operator, $this->value);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ClassNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ClassNode.php
new file mode 100644 (file)
index 0000000..1998b4b
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a "<selector>.<name>" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class ClassNode extends AbstractNode
+{
+    private $selector;
+    private $name;
+
+    public function __construct(NodeInterface $selector, string $name)
+    {
+        $this->selector = $selector;
+        $this->name = $name;
+    }
+
+    public function getSelector(): NodeInterface
+    {
+        return $this->selector;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        return sprintf('%s[%s.%s]', $this->getNodeName(), $this->selector, $this->name);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/CombinedSelectorNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/CombinedSelectorNode.php
new file mode 100644 (file)
index 0000000..f97fd21
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a combined node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class CombinedSelectorNode extends AbstractNode
+{
+    private $selector;
+    private $combinator;
+    private $subSelector;
+
+    public function __construct(NodeInterface $selector, string $combinator, NodeInterface $subSelector)
+    {
+        $this->selector = $selector;
+        $this->combinator = $combinator;
+        $this->subSelector = $subSelector;
+    }
+
+    public function getSelector(): NodeInterface
+    {
+        return $this->selector;
+    }
+
+    public function getCombinator(): string
+    {
+        return $this->combinator;
+    }
+
+    public function getSubSelector(): NodeInterface
+    {
+        return $this->subSelector;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        $combinator = ' ' === $this->combinator ? '<followed>' : $this->combinator;
+
+        return sprintf('%s[%s %s %s]', $this->getNodeName(), $this->selector, $combinator, $this->subSelector);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ElementNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ElementNode.php
new file mode 100644 (file)
index 0000000..8fc0be8
--- /dev/null
@@ -0,0 +1,72 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a "<namespace>|<element>" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class ElementNode extends AbstractNode
+{
+    private $namespace;
+    private $element;
+
+    /**
+     * @param string|null $namespace
+     * @param string|null $element
+     */
+    public function __construct(string $namespace = null, string $element = null)
+    {
+        $this->namespace = $namespace;
+        $this->element = $element;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getNamespace()
+    {
+        return $this->namespace;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getElement()
+    {
+        return $this->element;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return new Specificity(0, 0, $this->element ? 1 : 0);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        $element = $this->element ?: '*';
+
+        return sprintf('%s[%s]', $this->getNodeName(), $this->namespace ? $this->namespace.'|'.$element : $element);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/FunctionNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/FunctionNode.php
new file mode 100644 (file)
index 0000000..89b6437
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+use Symfony\Component\CssSelector\Parser\Token;
+
+/**
+ * Represents a "<selector>:<name>(<arguments>)" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class FunctionNode extends AbstractNode
+{
+    private $selector;
+    private $name;
+    private $arguments;
+
+    /**
+     * @param NodeInterface $selector
+     * @param string        $name
+     * @param Token[]       $arguments
+     */
+    public function __construct(NodeInterface $selector, string $name, array $arguments = array())
+    {
+        $this->selector = $selector;
+        $this->name = strtolower($name);
+        $this->arguments = $arguments;
+    }
+
+    public function getSelector(): NodeInterface
+    {
+        return $this->selector;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    /**
+     * @return Token[]
+     */
+    public function getArguments()
+    {
+        return $this->arguments;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        $arguments = implode(', ', array_map(function (Token $token) {
+            return "'".$token->getValue()."'";
+        }, $this->arguments));
+
+        return sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : '');
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/HashNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/HashNode.php
new file mode 100644 (file)
index 0000000..f73fa2e
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a "<selector>#<id>" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class HashNode extends AbstractNode
+{
+    private $selector;
+    private $id;
+
+    public function __construct(NodeInterface $selector, string $id)
+    {
+        $this->selector = $selector;
+        $this->id = $id;
+    }
+
+    public function getSelector(): NodeInterface
+    {
+        return $this->selector;
+    }
+
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->selector->getSpecificity()->plus(new Specificity(1, 0, 0));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        return sprintf('%s[%s#%s]', $this->getNodeName(), $this->selector, $this->id);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NegationNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NegationNode.php
new file mode 100644 (file)
index 0000000..9d3e9bc
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a "<selector>:not(<identifier>)" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class NegationNode extends AbstractNode
+{
+    private $selector;
+    private $subSelector;
+
+    public function __construct(NodeInterface $selector, NodeInterface $subSelector)
+    {
+        $this->selector = $selector;
+        $this->subSelector = $subSelector;
+    }
+
+    /**
+     * @return NodeInterface
+     */
+    public function getSelector()
+    {
+        return $this->selector;
+    }
+
+    /**
+     * @return NodeInterface
+     */
+    public function getSubSelector()
+    {
+        return $this->subSelector;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        return sprintf('%s[%s:not(%s)]', $this->getNodeName(), $this->selector, $this->subSelector);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NodeInterface.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NodeInterface.php
new file mode 100644 (file)
index 0000000..b078d26
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Interface for nodes.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+interface NodeInterface
+{
+    public function getNodeName(): string;
+
+    public function getSpecificity(): Specificity;
+
+    public function __toString(): string;
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/PseudoNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/PseudoNode.php
new file mode 100644 (file)
index 0000000..7d4a011
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a "<selector>:<identifier>" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class PseudoNode extends AbstractNode
+{
+    private $selector;
+    private $identifier;
+
+    public function __construct(NodeInterface $selector, string $identifier)
+    {
+        $this->selector = $selector;
+        $this->identifier = strtolower($identifier);
+    }
+
+    public function getSelector(): NodeInterface
+    {
+        return $this->selector;
+    }
+
+    public function getIdentifier(): string
+    {
+        return $this->identifier;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        return sprintf('%s[%s:%s]', $this->getNodeName(), $this->selector, $this->identifier);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/SelectorNode.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/SelectorNode.php
new file mode 100644 (file)
index 0000000..a76aa5b
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a "<selector>(::|:)<pseudoElement>" node.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class SelectorNode extends AbstractNode
+{
+    private $tree;
+    private $pseudoElement;
+
+    public function __construct(NodeInterface $tree, string $pseudoElement = null)
+    {
+        $this->tree = $tree;
+        $this->pseudoElement = $pseudoElement ? strtolower($pseudoElement) : null;
+    }
+
+    public function getTree(): NodeInterface
+    {
+        return $this->tree;
+    }
+
+    public function getPseudoElement(): ?string
+    {
+        return $this->pseudoElement;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSpecificity(): Specificity
+    {
+        return $this->tree->getSpecificity()->plus(new Specificity(0, 0, $this->pseudoElement ? 1 : 0));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __toString(): string
+    {
+        return sprintf('%s[%s%s]', $this->getNodeName(), $this->tree, $this->pseudoElement ? '::'.$this->pseudoElement : '');
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/Specificity.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/Specificity.php
new file mode 100644 (file)
index 0000000..9b35cb4
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Node;
+
+/**
+ * Represents a node specificity.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @see http://www.w3.org/TR/selectors/#specificity
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class Specificity
+{
+    const A_FACTOR = 100;
+    const B_FACTOR = 10;
+    const C_FACTOR = 1;
+
+    private $a;
+    private $b;
+    private $c;
+
+    public function __construct(int $a, int $b, int $c)
+    {
+        $this->a = $a;
+        $this->b = $b;
+        $this->c = $c;
+    }
+
+    public function plus(self $specificity): self
+    {
+        return new self($this->a + $specificity->a, $this->b + $specificity->b, $this->c + $specificity->c);
+    }
+
+    public function getValue(): int
+    {
+        return $this->a * self::A_FACTOR + $this->b * self::B_FACTOR + $this->c * self::C_FACTOR;
+    }
+
+    /**
+     * Returns -1 if the object specificity is lower than the argument,
+     * 0 if they are equal, and 1 if the argument is lower.
+     *
+     * @return int
+     */
+    public function compareTo(self $specificity)
+    {
+        if ($this->a !== $specificity->a) {
+            return $this->a > $specificity->a ? 1 : -1;
+        }
+
+        if ($this->b !== $specificity->b) {
+            return $this->b > $specificity->b ? 1 : -1;
+        }
+
+        if ($this->c !== $specificity->c) {
+            return $this->c > $specificity->c ? 1 : -1;
+        }
+
+        return 0;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/CommentHandler.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/CommentHandler.php
new file mode 100644 (file)
index 0000000..93f3188
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector comment handler.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class CommentHandler implements HandlerInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function handle(Reader $reader, TokenStream $stream): bool
+    {
+        if ('/*' !== $reader->getSubstring(2)) {
+            return false;
+        }
+
+        $offset = $reader->getOffset('*/');
+
+        if (false === $offset) {
+            $reader->moveToEnd();
+        } else {
+            $reader->moveForward($offset + 2);
+        }
+
+        return true;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HandlerInterface.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HandlerInterface.php
new file mode 100644 (file)
index 0000000..d7ac4d3
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector handler interface.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+interface HandlerInterface
+{
+    /**
+     * @return bool
+     */
+    public function handle(Reader $reader, TokenStream $stream): bool;
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HashHandler.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HashHandler.php
new file mode 100644 (file)
index 0000000..7ae9b43
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector comment handler.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class HashHandler implements HandlerInterface
+{
+    private $patterns;
+    private $escaping;
+
+    public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
+    {
+        $this->patterns = $patterns;
+        $this->escaping = $escaping;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function handle(Reader $reader, TokenStream $stream): bool
+    {
+        $match = $reader->findPattern($this->patterns->getHashPattern());
+
+        if (!$match) {
+            return false;
+        }
+
+        $value = $this->escaping->escapeUnicode($match[1]);
+        $stream->push(new Token(Token::TYPE_HASH, $value, $reader->getPosition()));
+        $reader->moveForward(\strlen($match[0]));
+
+        return true;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/IdentifierHandler.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/IdentifierHandler.php
new file mode 100644 (file)
index 0000000..7b2a14e
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector comment handler.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class IdentifierHandler implements HandlerInterface
+{
+    private $patterns;
+    private $escaping;
+
+    public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
+    {
+        $this->patterns = $patterns;
+        $this->escaping = $escaping;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function handle(Reader $reader, TokenStream $stream): bool
+    {
+        $match = $reader->findPattern($this->patterns->getIdentifierPattern());
+
+        if (!$match) {
+            return false;
+        }
+
+        $value = $this->escaping->escapeUnicode($match[0]);
+        $stream->push(new Token(Token::TYPE_IDENTIFIER, $value, $reader->getPosition()));
+        $reader->moveForward(\strlen($match[0]));
+
+        return true;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/NumberHandler.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/NumberHandler.php
new file mode 100644 (file)
index 0000000..8291a68
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector comment handler.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class NumberHandler implements HandlerInterface
+{
+    private $patterns;
+
+    public function __construct(TokenizerPatterns $patterns)
+    {
+        $this->patterns = $patterns;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function handle(Reader $reader, TokenStream $stream): bool
+    {
+        $match = $reader->findPattern($this->patterns->getNumberPattern());
+
+        if (!$match) {
+            return false;
+        }
+
+        $stream->push(new Token(Token::TYPE_NUMBER, $match[0], $reader->getPosition()));
+        $reader->moveForward(\strlen($match[0]));
+
+        return true;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/StringHandler.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/StringHandler.php
new file mode 100644 (file)
index 0000000..eea1aa9
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Handler;
+
+use Symfony\Component\CssSelector\Exception\InternalErrorException;
+use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector comment handler.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class StringHandler implements HandlerInterface
+{
+    private $patterns;
+    private $escaping;
+
+    public function __construct(TokenizerPatterns $patterns, TokenizerEscaping $escaping)
+    {
+        $this->patterns = $patterns;
+        $this->escaping = $escaping;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function handle(Reader $reader, TokenStream $stream): bool
+    {
+        $quote = $reader->getSubstring(1);
+
+        if (!\in_array($quote, array("'", '"'))) {
+            return false;
+        }
+
+        $reader->moveForward(1);
+        $match = $reader->findPattern($this->patterns->getQuotedStringPattern($quote));
+
+        if (!$match) {
+            throw new InternalErrorException(sprintf('Should have found at least an empty match at %s.', $reader->getPosition()));
+        }
+
+        // check unclosed strings
+        if (\strlen($match[0]) === $reader->getRemainingLength()) {
+            throw SyntaxErrorException::unclosedString($reader->getPosition() - 1);
+        }
+
+        // check quotes pairs validity
+        if ($quote !== $reader->getSubstring(1, \strlen($match[0]))) {
+            throw SyntaxErrorException::unclosedString($reader->getPosition() - 1);
+        }
+
+        $string = $this->escaping->escapeUnicodeAndNewLine($match[0]);
+        $stream->push(new Token(Token::TYPE_STRING, $string, $reader->getPosition()));
+        $reader->moveForward(\strlen($match[0]) + 1);
+
+        return true;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/WhitespaceHandler.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/WhitespaceHandler.php
new file mode 100644 (file)
index 0000000..21345e3
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector whitespace handler.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class WhitespaceHandler implements HandlerInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function handle(Reader $reader, TokenStream $stream): bool
+    {
+        $match = $reader->findPattern('~^[ \t\r\n\f]+~');
+
+        if (false === $match) {
+            return false;
+        }
+
+        $stream->push(new Token(Token::TYPE_WHITESPACE, $match[0], $reader->getPosition()));
+        $reader->moveForward(\strlen($match[0]));
+
+        return true;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Parser.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Parser.php
new file mode 100644 (file)
index 0000000..d42bdca
--- /dev/null
@@ -0,0 +1,353 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser;
+
+use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
+use Symfony\Component\CssSelector\Node;
+use Symfony\Component\CssSelector\Parser\Tokenizer\Tokenizer;
+
+/**
+ * CSS selector parser.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class Parser implements ParserInterface
+{
+    private $tokenizer;
+
+    public function __construct(Tokenizer $tokenizer = null)
+    {
+        $this->tokenizer = $tokenizer ?: new Tokenizer();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function parse(string $source): array
+    {
+        $reader = new Reader($source);
+        $stream = $this->tokenizer->tokenize($reader);
+
+        return $this->parseSelectorList($stream);
+    }
+
+    /**
+     * Parses the arguments for ":nth-child()" and friends.
+     *
+     * @param Token[] $tokens
+     *
+     * @throws SyntaxErrorException
+     */
+    public static function parseSeries(array $tokens): array
+    {
+        foreach ($tokens as $token) {
+            if ($token->isString()) {
+                throw SyntaxErrorException::stringAsFunctionArgument();
+            }
+        }
+
+        $joined = trim(implode('', array_map(function (Token $token) {
+            return $token->getValue();
+        }, $tokens)));
+
+        $int = function ($string) {
+            if (!is_numeric($string)) {
+                throw SyntaxErrorException::stringAsFunctionArgument();
+            }
+
+            return (int) $string;
+        };
+
+        switch (true) {
+            case 'odd' === $joined:
+                return array(2, 1);
+            case 'even' === $joined:
+                return array(2, 0);
+            case 'n' === $joined:
+                return array(1, 0);
+            case false === strpos($joined, 'n'):
+                return array(0, $int($joined));
+        }
+
+        $split = explode('n', $joined);
+        $first = isset($split[0]) ? $split[0] : null;
+
+        return array(
+            $first ? ('-' === $first || '+' === $first ? $int($first.'1') : $int($first)) : 1,
+            isset($split[1]) && $split[1] ? $int($split[1]) : 0,
+        );
+    }
+
+    private function parseSelectorList(TokenStream $stream): array
+    {
+        $stream->skipWhitespace();
+        $selectors = array();
+
+        while (true) {
+            $selectors[] = $this->parserSelectorNode($stream);
+
+            if ($stream->getPeek()->isDelimiter(array(','))) {
+                $stream->getNext();
+                $stream->skipWhitespace();
+            } else {
+                break;
+            }
+        }
+
+        return $selectors;
+    }
+
+    private function parserSelectorNode(TokenStream $stream): Node\SelectorNode
+    {
+        list($result, $pseudoElement) = $this->parseSimpleSelector($stream);
+
+        while (true) {
+            $stream->skipWhitespace();
+            $peek = $stream->getPeek();
+
+            if ($peek->isFileEnd() || $peek->isDelimiter(array(','))) {
+                break;
+            }
+
+            if (null !== $pseudoElement) {
+                throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
+            }
+
+            if ($peek->isDelimiter(array('+', '>', '~'))) {
+                $combinator = $stream->getNext()->getValue();
+                $stream->skipWhitespace();
+            } else {
+                $combinator = ' ';
+            }
+
+            list($nextSelector, $pseudoElement) = $this->parseSimpleSelector($stream);
+            $result = new Node\CombinedSelectorNode($result, $combinator, $nextSelector);
+        }
+
+        return new Node\SelectorNode($result, $pseudoElement);
+    }
+
+    /**
+     * Parses next simple node (hash, class, pseudo, negation).
+     *
+     * @throws SyntaxErrorException
+     */
+    private function parseSimpleSelector(TokenStream $stream, bool $insideNegation = false): array
+    {
+        $stream->skipWhitespace();
+
+        $selectorStart = \count($stream->getUsed());
+        $result = $this->parseElementNode($stream);
+        $pseudoElement = null;
+
+        while (true) {
+            $peek = $stream->getPeek();
+            if ($peek->isWhitespace()
+                || $peek->isFileEnd()
+                || $peek->isDelimiter(array(',', '+', '>', '~'))
+                || ($insideNegation && $peek->isDelimiter(array(')')))
+            ) {
+                break;
+            }
+
+            if (null !== $pseudoElement) {
+                throw SyntaxErrorException::pseudoElementFound($pseudoElement, 'not at the end of a selector');
+            }
+
+            if ($peek->isHash()) {
+                $result = new Node\HashNode($result, $stream->getNext()->getValue());
+            } elseif ($peek->isDelimiter(array('.'))) {
+                $stream->getNext();
+                $result = new Node\ClassNode($result, $stream->getNextIdentifier());
+            } elseif ($peek->isDelimiter(array('['))) {
+                $stream->getNext();
+                $result = $this->parseAttributeNode($result, $stream);
+            } elseif ($peek->isDelimiter(array(':'))) {
+                $stream->getNext();
+
+                if ($stream->getPeek()->isDelimiter(array(':'))) {
+                    $stream->getNext();
+                    $pseudoElement = $stream->getNextIdentifier();
+
+                    continue;
+                }
+
+                $identifier = $stream->getNextIdentifier();
+                if (\in_array(strtolower($identifier), array('first-line', 'first-letter', 'before', 'after'))) {
+                    // Special case: CSS 2.1 pseudo-elements can have a single ':'.
+                    // Any new pseudo-element must have two.
+                    $pseudoElement = $identifier;
+
+                    continue;
+                }
+
+                if (!$stream->getPeek()->isDelimiter(array('('))) {
+                    $result = new Node\PseudoNode($result, $identifier);
+
+                    continue;
+                }
+
+                $stream->getNext();
+                $stream->skipWhitespace();
+
+                if ('not' === strtolower($identifier)) {
+                    if ($insideNegation) {
+                        throw SyntaxErrorException::nestedNot();
+                    }
+
+                    list($argument, $argumentPseudoElement) = $this->parseSimpleSelector($stream, true);
+                    $next = $stream->getNext();
+
+                    if (null !== $argumentPseudoElement) {
+                        throw SyntaxErrorException::pseudoElementFound($argumentPseudoElement, 'inside ::not()');
+                    }
+
+                    if (!$next->isDelimiter(array(')'))) {
+                        throw SyntaxErrorException::unexpectedToken('")"', $next);
+                    }
+
+                    $result = new Node\NegationNode($result, $argument);
+                } else {
+                    $arguments = array();
+                    $next = null;
+
+                    while (true) {
+                        $stream->skipWhitespace();
+                        $next = $stream->getNext();
+
+                        if ($next->isIdentifier()
+                            || $next->isString()
+                            || $next->isNumber()
+                            || $next->isDelimiter(array('+', '-'))
+                        ) {
+                            $arguments[] = $next;
+                        } elseif ($next->isDelimiter(array(')'))) {
+                            break;
+                        } else {
+                            throw SyntaxErrorException::unexpectedToken('an argument', $next);
+                        }
+                    }
+
+                    if (empty($arguments)) {
+                        throw SyntaxErrorException::unexpectedToken('at least one argument', $next);
+                    }
+
+                    $result = new Node\FunctionNode($result, $identifier, $arguments);
+                }
+            } else {
+                throw SyntaxErrorException::unexpectedToken('selector', $peek);
+            }
+        }
+
+        if (\count($stream->getUsed()) === $selectorStart) {
+            throw SyntaxErrorException::unexpectedToken('selector', $stream->getPeek());
+        }
+
+        return array($result, $pseudoElement);
+    }
+
+    private function parseElementNode(TokenStream $stream): Node\ElementNode
+    {
+        $peek = $stream->getPeek();
+
+        if ($peek->isIdentifier() || $peek->isDelimiter(array('*'))) {
+            if ($peek->isIdentifier()) {
+                $namespace = $stream->getNext()->getValue();
+            } else {
+                $stream->getNext();
+                $namespace = null;
+            }
+
+            if ($stream->getPeek()->isDelimiter(array('|'))) {
+                $stream->getNext();
+                $element = $stream->getNextIdentifierOrStar();
+            } else {
+                $element = $namespace;
+                $namespace = null;
+            }
+        } else {
+            $element = $namespace = null;
+        }
+
+        return new Node\ElementNode($namespace, $element);
+    }
+
+    private function parseAttributeNode(Node\NodeInterface $selector, TokenStream $stream): Node\AttributeNode
+    {
+        $stream->skipWhitespace();
+        $attribute = $stream->getNextIdentifierOrStar();
+
+        if (null === $attribute && !$stream->getPeek()->isDelimiter(array('|'))) {
+            throw SyntaxErrorException::unexpectedToken('"|"', $stream->getPeek());
+        }
+
+        if ($stream->getPeek()->isDelimiter(array('|'))) {
+            $stream->getNext();
+
+            if ($stream->getPeek()->isDelimiter(array('='))) {
+                $namespace = null;
+                $stream->getNext();
+                $operator = '|=';
+            } else {
+                $namespace = $attribute;
+                $attribute = $stream->getNextIdentifier();
+                $operator = null;
+            }
+        } else {
+            $namespace = $operator = null;
+        }
+
+        if (null === $operator) {
+            $stream->skipWhitespace();
+            $next = $stream->getNext();
+
+            if ($next->isDelimiter(array(']'))) {
+                return new Node\AttributeNode($selector, $namespace, $attribute, 'exists', null);
+            } elseif ($next->isDelimiter(array('='))) {
+                $operator = '=';
+            } elseif ($next->isDelimiter(array('^', '$', '*', '~', '|', '!'))
+                && $stream->getPeek()->isDelimiter(array('='))
+            ) {
+                $operator = $next->getValue().'=';
+                $stream->getNext();
+            } else {
+                throw SyntaxErrorException::unexpectedToken('operator', $next);
+            }
+        }
+
+        $stream->skipWhitespace();
+        $value = $stream->getNext();
+
+        if ($value->isNumber()) {
+            // if the value is a number, it's casted into a string
+            $value = new Token(Token::TYPE_STRING, (string) $value->getValue(), $value->getPosition());
+        }
+
+        if (!($value->isIdentifier() || $value->isString())) {
+            throw SyntaxErrorException::unexpectedToken('string or identifier', $value);
+        }
+
+        $stream->skipWhitespace();
+        $next = $stream->getNext();
+
+        if (!$next->isDelimiter(array(']'))) {
+            throw SyntaxErrorException::unexpectedToken('"]"', $next);
+        }
+
+        return new Node\AttributeNode($selector, $namespace, $attribute, $operator, $value->getValue());
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/ParserInterface.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/ParserInterface.php
new file mode 100644 (file)
index 0000000..51c3d93
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser;
+
+use Symfony\Component\CssSelector\Node\SelectorNode;
+
+/**
+ * CSS selector parser interface.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+interface ParserInterface
+{
+    /**
+     * Parses given selector source into an array of tokens.
+     *
+     * @return SelectorNode[]
+     */
+    public function parse(string $source): array;
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Reader.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Reader.php
new file mode 100644 (file)
index 0000000..4b43eff
--- /dev/null
@@ -0,0 +1,86 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser;
+
+/**
+ * CSS selector reader.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class Reader
+{
+    private $source;
+    private $length;
+    private $position = 0;
+
+    public function __construct(string $source)
+    {
+        $this->source = $source;
+        $this->length = \strlen($source);
+    }
+
+    public function isEOF(): bool
+    {
+        return $this->position >= $this->length;
+    }
+
+    public function getPosition(): int
+    {
+        return $this->position;
+    }
+
+    public function getRemainingLength(): int
+    {
+        return $this->length - $this->position;
+    }
+
+    public function getSubstring(int $length, int $offset = 0): string
+    {
+        return substr($this->source, $this->position + $offset, $length);
+    }
+
+    public function getOffset(string $string)
+    {
+        $position = strpos($this->source, $string, $this->position);
+
+        return false === $position ? false : $position - $this->position;
+    }
+
+    /**
+     * @return array|false
+     */
+    public function findPattern(string $pattern)
+    {
+        $source = substr($this->source, $this->position);
+
+        if (preg_match($pattern, $source, $matches)) {
+            return $matches;
+        }
+
+        return false;
+    }
+
+    public function moveForward(int $length)
+    {
+        $this->position += $length;
+    }
+
+    public function moveToEnd()
+    {
+        $this->position = $this->length;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ClassParser.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ClassParser.php
new file mode 100644 (file)
index 0000000..ce7b20e
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Shortcut;
+
+use Symfony\Component\CssSelector\Node\ClassNode;
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\ParserInterface;
+
+/**
+ * CSS selector class parser shortcut.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class ClassParser implements ParserInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function parse(string $source): array
+    {
+        // Matches an optional namespace, optional element, and required class
+        // $source = 'test|input.ab6bd_field';
+        // $matches = array (size=4)
+        //     0 => string 'test|input.ab6bd_field' (length=22)
+        //     1 => string 'test' (length=4)
+        //     2 => string 'input' (length=5)
+        //     3 => string 'ab6bd_field' (length=11)
+        if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+\.([\w-]++)$/i', trim($source), $matches)) {
+            return array(
+                new SelectorNode(new ClassNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])),
+            );
+        }
+
+        return array();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ElementParser.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ElementParser.php
new file mode 100644 (file)
index 0000000..3b93f00
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Shortcut;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\ParserInterface;
+
+/**
+ * CSS selector element parser shortcut.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class ElementParser implements ParserInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function parse(string $source): array
+    {
+        // Matches an optional namespace, required element or `*`
+        // $source = 'testns|testel';
+        // $matches = array (size=3)
+        //     0 => string 'testns|testel' (length=13)
+        //     1 => string 'testns' (length=6)
+        //     2 => string 'testel' (length=6)
+        if (preg_match('/^(?:([a-z]++)\|)?([\w-]++|\*)$/i', trim($source), $matches)) {
+            return array(new SelectorNode(new ElementNode($matches[1] ?: null, $matches[2])));
+        }
+
+        return array();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php
new file mode 100644 (file)
index 0000000..e8a89cc
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Shortcut;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\ParserInterface;
+
+/**
+ * CSS selector class parser shortcut.
+ *
+ * This shortcut ensure compatibility with previous version.
+ * - The parser fails to parse an empty string.
+ * - In the previous version, an empty string matches each tags.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class EmptyStringParser implements ParserInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function parse(string $source): array
+    {
+        // Matches an empty string
+        if ('' == $source) {
+            return array(new SelectorNode(new ElementNode(null, '*')));
+        }
+
+        return array();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/HashParser.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/HashParser.php
new file mode 100644 (file)
index 0000000..e94ce0a
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Shortcut;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\HashNode;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\ParserInterface;
+
+/**
+ * CSS selector hash parser shortcut.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class HashParser implements ParserInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function parse(string $source): array
+    {
+        // Matches an optional namespace, optional element, and required id
+        // $source = 'test|input#ab6bd_field';
+        // $matches = array (size=4)
+        //     0 => string 'test|input#ab6bd_field' (length=22)
+        //     1 => string 'test' (length=4)
+        //     2 => string 'input' (length=5)
+        //     3 => string 'ab6bd_field' (length=11)
+        if (preg_match('/^(?:([a-z]++)\|)?+([\w-]++|\*)?+#([\w-]++)$/i', trim($source), $matches)) {
+            return array(
+                new SelectorNode(new HashNode(new ElementNode($matches[1] ?: null, $matches[2] ?: null), $matches[3])),
+            );
+        }
+
+        return array();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Token.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Token.php
new file mode 100644 (file)
index 0000000..91a98f7
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser;
+
+/**
+ * CSS selector token.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class Token
+{
+    const TYPE_FILE_END = 'eof';
+    const TYPE_DELIMITER = 'delimiter';
+    const TYPE_WHITESPACE = 'whitespace';
+    const TYPE_IDENTIFIER = 'identifier';
+    const TYPE_HASH = 'hash';
+    const TYPE_NUMBER = 'number';
+    const TYPE_STRING = 'string';
+
+    private $type;
+    private $value;
+    private $position;
+
+    public function __construct(?string $type, ?string $value, ?int $position)
+    {
+        $this->type = $type;
+        $this->value = $value;
+        $this->position = $position;
+    }
+
+    public function getType(): ?int
+    {
+        return $this->type;
+    }
+
+    public function getValue(): ?string
+    {
+        return $this->value;
+    }
+
+    public function getPosition(): ?int
+    {
+        return $this->position;
+    }
+
+    public function isFileEnd(): bool
+    {
+        return self::TYPE_FILE_END === $this->type;
+    }
+
+    public function isDelimiter(array $values = array()): bool
+    {
+        if (self::TYPE_DELIMITER !== $this->type) {
+            return false;
+        }
+
+        if (empty($values)) {
+            return true;
+        }
+
+        return \in_array($this->value, $values);
+    }
+
+    public function isWhitespace(): bool
+    {
+        return self::TYPE_WHITESPACE === $this->type;
+    }
+
+    public function isIdentifier(): bool
+    {
+        return self::TYPE_IDENTIFIER === $this->type;
+    }
+
+    public function isHash(): bool
+    {
+        return self::TYPE_HASH === $this->type;
+    }
+
+    public function isNumber(): bool
+    {
+        return self::TYPE_NUMBER === $this->type;
+    }
+
+    public function isString(): bool
+    {
+        return self::TYPE_STRING === $this->type;
+    }
+
+    public function __toString(): string
+    {
+        if ($this->value) {
+            return sprintf('<%s "%s" at %s>', $this->type, $this->value, $this->position);
+        }
+
+        return sprintf('<%s at %s>', $this->type, $this->position);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/TokenStream.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/TokenStream.php
new file mode 100644 (file)
index 0000000..d2aee54
--- /dev/null
@@ -0,0 +1,175 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser;
+
+use Symfony\Component\CssSelector\Exception\InternalErrorException;
+use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
+
+/**
+ * CSS selector token stream.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class TokenStream
+{
+    /**
+     * @var Token[]
+     */
+    private $tokens = array();
+
+    /**
+     * @var Token[]
+     */
+    private $used = array();
+
+    /**
+     * @var int
+     */
+    private $cursor = 0;
+
+    /**
+     * @var Token|null
+     */
+    private $peeked;
+
+    /**
+     * @var bool
+     */
+    private $peeking = false;
+
+    /**
+     * Pushes a token.
+     *
+     * @return $this
+     */
+    public function push(Token $token)
+    {
+        $this->tokens[] = $token;
+
+        return $this;
+    }
+
+    /**
+     * Freezes stream.
+     *
+     * @return $this
+     */
+    public function freeze()
+    {
+        return $this;
+    }
+
+    /**
+     * Returns next token.
+     *
+     * @return Token
+     *
+     * @throws InternalErrorException If there is no more token
+     */
+    public function getNext()
+    {
+        if ($this->peeking) {
+            $this->peeking = false;
+            $this->used[] = $this->peeked;
+
+            return $this->peeked;
+        }
+
+        if (!isset($this->tokens[$this->cursor])) {
+            throw new InternalErrorException('Unexpected token stream end.');
+        }
+
+        return $this->tokens[$this->cursor++];
+    }
+
+    /**
+     * Returns peeked token.
+     *
+     * @return Token
+     */
+    public function getPeek()
+    {
+        if (!$this->peeking) {
+            $this->peeked = $this->getNext();
+            $this->peeking = true;
+        }
+
+        return $this->peeked;
+    }
+
+    /**
+     * Returns used tokens.
+     *
+     * @return Token[]
+     */
+    public function getUsed()
+    {
+        return $this->used;
+    }
+
+    /**
+     * Returns nex identifier token.
+     *
+     * @return string The identifier token value
+     *
+     * @throws SyntaxErrorException If next token is not an identifier
+     */
+    public function getNextIdentifier()
+    {
+        $next = $this->getNext();
+
+        if (!$next->isIdentifier()) {
+            throw SyntaxErrorException::unexpectedToken('identifier', $next);
+        }
+
+        return $next->getValue();
+    }
+
+    /**
+     * Returns nex identifier or star delimiter token.
+     *
+     * @return string|null The identifier token value or null if star found
+     *
+     * @throws SyntaxErrorException If next token is not an identifier or a star delimiter
+     */
+    public function getNextIdentifierOrStar()
+    {
+        $next = $this->getNext();
+
+        if ($next->isIdentifier()) {
+            return $next->getValue();
+        }
+
+        if ($next->isDelimiter(array('*'))) {
+            return;
+        }
+
+        throw SyntaxErrorException::unexpectedToken('identifier or "*"', $next);
+    }
+
+    /**
+     * Skips next whitespace if any.
+     */
+    public function skipWhitespace()
+    {
+        $peek = $this->getPeek();
+
+        if ($peek->isWhitespace()) {
+            $this->getNext();
+        }
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/Tokenizer.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/Tokenizer.php
new file mode 100644 (file)
index 0000000..e32b4d2
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Tokenizer;
+
+use Symfony\Component\CssSelector\Parser\Handler;
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * CSS selector tokenizer.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class Tokenizer
+{
+    /**
+     * @var Handler\HandlerInterface[]
+     */
+    private $handlers;
+
+    public function __construct()
+    {
+        $patterns = new TokenizerPatterns();
+        $escaping = new TokenizerEscaping($patterns);
+
+        $this->handlers = array(
+            new Handler\WhitespaceHandler(),
+            new Handler\IdentifierHandler($patterns, $escaping),
+            new Handler\HashHandler($patterns, $escaping),
+            new Handler\StringHandler($patterns, $escaping),
+            new Handler\NumberHandler($patterns),
+            new Handler\CommentHandler(),
+        );
+    }
+
+    /**
+     * Tokenize selector source code.
+     *
+     * @return TokenStream
+     */
+    public function tokenize(Reader $reader)
+    {
+        $stream = new TokenStream();
+
+        while (!$reader->isEOF()) {
+            foreach ($this->handlers as $handler) {
+                if ($handler->handle($reader, $stream)) {
+                    continue 2;
+                }
+            }
+
+            $stream->push(new Token(Token::TYPE_DELIMITER, $reader->getSubstring(1), $reader->getPosition()));
+            $reader->moveForward(1);
+        }
+
+        return $stream
+            ->push(new Token(Token::TYPE_FILE_END, null, $reader->getPosition()))
+            ->freeze();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php
new file mode 100644 (file)
index 0000000..200272f
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Tokenizer;
+
+/**
+ * CSS selector tokenizer escaping applier.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class TokenizerEscaping
+{
+    private $patterns;
+
+    public function __construct(TokenizerPatterns $patterns)
+    {
+        $this->patterns = $patterns;
+    }
+
+    public function escapeUnicode(string $value): string
+    {
+        $value = $this->replaceUnicodeSequences($value);
+
+        return preg_replace($this->patterns->getSimpleEscapePattern(), '$1', $value);
+    }
+
+    public function escapeUnicodeAndNewLine(string $value): string
+    {
+        $value = preg_replace($this->patterns->getNewLineEscapePattern(), '', $value);
+
+        return $this->escapeUnicode($value);
+    }
+
+    private function replaceUnicodeSequences(string $value): string
+    {
+        return preg_replace_callback($this->patterns->getUnicodeEscapePattern(), function ($match) {
+            $c = hexdec($match[1]);
+
+            if (0x80 > $c %= 0x200000) {
+                return \chr($c);
+            }
+            if (0x800 > $c) {
+                return \chr(0xC0 | $c >> 6).\chr(0x80 | $c & 0x3F);
+            }
+            if (0x10000 > $c) {
+                return \chr(0xE0 | $c >> 12).\chr(0x80 | $c >> 6 & 0x3F).\chr(0x80 | $c & 0x3F);
+            }
+        }, $value);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php
new file mode 100644 (file)
index 0000000..5f16ac4
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Parser\Tokenizer;
+
+/**
+ * CSS selector tokenizer patterns builder.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class TokenizerPatterns
+{
+    private $unicodeEscapePattern;
+    private $simpleEscapePattern;
+    private $newLineEscapePattern;
+    private $escapePattern;
+    private $stringEscapePattern;
+    private $nonAsciiPattern;
+    private $nmCharPattern;
+    private $nmStartPattern;
+    private $identifierPattern;
+    private $hashPattern;
+    private $numberPattern;
+    private $quotedStringPattern;
+
+    public function __construct()
+    {
+        $this->unicodeEscapePattern = '\\\\([0-9a-f]{1,6})(?:\r\n|[ \n\r\t\f])?';
+        $this->simpleEscapePattern = '\\\\(.)';
+        $this->newLineEscapePattern = '\\\\(?:\n|\r\n|\r|\f)';
+        $this->escapePattern = $this->unicodeEscapePattern.'|\\\\[^\n\r\f0-9a-f]';
+        $this->stringEscapePattern = $this->newLineEscapePattern.'|'.$this->escapePattern;
+        $this->nonAsciiPattern = '[^\x00-\x7F]';
+        $this->nmCharPattern = '[_a-z0-9-]|'.$this->escapePattern.'|'.$this->nonAsciiPattern;
+        $this->nmStartPattern = '[_a-z]|'.$this->escapePattern.'|'.$this->nonAsciiPattern;
+        $this->identifierPattern = '-?(?:'.$this->nmStartPattern.')(?:'.$this->nmCharPattern.')*';
+        $this->hashPattern = '#((?:'.$this->nmCharPattern.')+)';
+        $this->numberPattern = '[+-]?(?:[0-9]*\.[0-9]+|[0-9]+)';
+        $this->quotedStringPattern = '([^\n\r\f%s]|'.$this->stringEscapePattern.')*';
+    }
+
+    public function getNewLineEscapePattern(): string
+    {
+        return '~^'.$this->newLineEscapePattern.'~';
+    }
+
+    public function getSimpleEscapePattern(): string
+    {
+        return '~^'.$this->simpleEscapePattern.'~';
+    }
+
+    public function getUnicodeEscapePattern(): string
+    {
+        return '~^'.$this->unicodeEscapePattern.'~i';
+    }
+
+    public function getIdentifierPattern(): string
+    {
+        return '~^'.$this->identifierPattern.'~i';
+    }
+
+    public function getHashPattern(): string
+    {
+        return '~^'.$this->hashPattern.'~i';
+    }
+
+    public function getNumberPattern(): string
+    {
+        return '~^'.$this->numberPattern.'~';
+    }
+
+    public function getQuotedStringPattern(string $quote): string
+    {
+        return '~^'.sprintf($this->quotedStringPattern, $quote).'~i';
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/README.md b/wcfsetup/install/files/lib/system/api/symfony/css-selector/README.md
new file mode 100644 (file)
index 0000000..7c4c411
--- /dev/null
@@ -0,0 +1,20 @@
+CssSelector Component
+=====================
+
+The CssSelector component converts CSS selectors to XPath expressions.
+
+Resources
+---------
+
+  * [Documentation](https://symfony.com/doc/current/components/css_selector.html)
+  * [Contributing](https://symfony.com/doc/current/contributing/index.html)
+  * [Report issues](https://github.com/symfony/symfony/issues) and
+    [send Pull Requests](https://github.com/symfony/symfony/pulls)
+    in the [main Symfony repository](https://github.com/symfony/symfony)
+
+Credits
+-------
+
+This component is a port of the Python cssselect library
+[v0.7.1](https://github.com/SimonSapin/cssselect/releases/tag/v0.7.1),
+which is distributed under the BSD license.
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/CssSelectorConverterTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/CssSelectorConverterTest.php
new file mode 100644 (file)
index 0000000..a3eea7a
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\CssSelectorConverter;
+
+class CssSelectorConverterTest extends TestCase
+{
+    public function testCssToXPath()
+    {
+        $converter = new CssSelectorConverter();
+
+        $this->assertEquals('descendant-or-self::*', $converter->toXPath(''));
+        $this->assertEquals('descendant-or-self::h1', $converter->toXPath('h1'));
+        $this->assertEquals("descendant-or-self::h1[@id = 'foo']", $converter->toXPath('h1#foo'));
+        $this->assertEquals("descendant-or-self::h1[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]", $converter->toXPath('h1.foo'));
+        $this->assertEquals('descendant-or-self::foo:h1', $converter->toXPath('foo|h1'));
+        $this->assertEquals('descendant-or-self::h1', $converter->toXPath('H1'));
+    }
+
+    public function testCssToXPathXml()
+    {
+        $converter = new CssSelectorConverter(false);
+
+        $this->assertEquals('descendant-or-self::H1', $converter->toXPath('H1'));
+    }
+
+    /**
+     * @expectedException \Symfony\Component\CssSelector\Exception\ParseException
+     * @expectedExceptionMessage Expected identifier, but <eof at 3> found.
+     */
+    public function testParseExceptions()
+    {
+        $converter = new CssSelectorConverter();
+        $converter->toXPath('h1:');
+    }
+
+    /** @dataProvider getCssToXPathWithoutPrefixTestData */
+    public function testCssToXPathWithoutPrefix($css, $xpath)
+    {
+        $converter = new CssSelectorConverter();
+
+        $this->assertEquals($xpath, $converter->toXPath($css, ''), '->parse() parses an input string and returns a node');
+    }
+
+    public function getCssToXPathWithoutPrefixTestData()
+    {
+        return array(
+            array('h1', 'h1'),
+            array('foo|h1', 'foo:h1'),
+            array('h1, h2, h3', 'h1 | h2 | h3'),
+            array('h1:nth-child(3n+1)', "*/*[(name() = 'h1') and (position() - 1 >= 0 and (position() - 1) mod 3 = 0)]"),
+            array('h1 > p', 'h1/p'),
+            array('h1#foo', "h1[@id = 'foo']"),
+            array('h1.foo', "h1[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"),
+            array('h1[class*="foo bar"]', "h1[@class and contains(@class, 'foo bar')]"),
+            array('h1[foo|class*="foo bar"]', "h1[@foo:class and contains(@foo:class, 'foo bar')]"),
+            array('h1[class]', 'h1[@class]'),
+            array('h1 .foo', "h1/descendant-or-self::*/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"),
+            array('h1 #foo', "h1/descendant-or-self::*/*[@id = 'foo']"),
+            array('h1 [class*=foo]', "h1/descendant-or-self::*/*[@class and contains(@class, 'foo')]"),
+            array('div>.foo', "div/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"),
+            array('div > .foo', "div/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' foo ')]"),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AbstractNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AbstractNodeTest.php
new file mode 100644 (file)
index 0000000..5955513
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Node\NodeInterface;
+
+abstract class AbstractNodeTest extends TestCase
+{
+    /** @dataProvider getToStringConversionTestData */
+    public function testToStringConversion(NodeInterface $node, $representation)
+    {
+        $this->assertEquals($representation, (string) $node);
+    }
+
+    /** @dataProvider getSpecificityValueTestData */
+    public function testSpecificityValue(NodeInterface $node, $value)
+    {
+        $this->assertEquals($value, $node->getSpecificity()->getValue());
+    }
+
+    abstract public function getToStringConversionTestData();
+
+    abstract public function getSpecificityValueTestData();
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AttributeNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AttributeNodeTest.php
new file mode 100644 (file)
index 0000000..1fd090f
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\AttributeNode;
+use Symfony\Component\CssSelector\Node\ElementNode;
+
+class AttributeNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new AttributeNode(new ElementNode(), null, 'attribute', 'exists', null), 'Attribute[Element[*][attribute]]'),
+            array(new AttributeNode(new ElementNode(), null, 'attribute', '$=', 'value'), "Attribute[Element[*][attribute $= 'value']]"),
+            array(new AttributeNode(new ElementNode(), 'namespace', 'attribute', '$=', 'value'), "Attribute[Element[*][namespace|attribute $= 'value']]"),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new AttributeNode(new ElementNode(), null, 'attribute', 'exists', null), 10),
+            array(new AttributeNode(new ElementNode(null, 'element'), null, 'attribute', 'exists', null), 11),
+            array(new AttributeNode(new ElementNode(), null, 'attribute', '$=', 'value'), 10),
+            array(new AttributeNode(new ElementNode(), 'namespace', 'attribute', '$=', 'value'), 10),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ClassNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ClassNodeTest.php
new file mode 100644 (file)
index 0000000..e0ab45a
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\ClassNode;
+use Symfony\Component\CssSelector\Node\ElementNode;
+
+class ClassNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new ClassNode(new ElementNode(), 'class'), 'Class[Element[*].class]'),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new ClassNode(new ElementNode(), 'class'), 10),
+            array(new ClassNode(new ElementNode(null, 'element'), 'class'), 11),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/CombinedSelectorNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/CombinedSelectorNodeTest.php
new file mode 100644 (file)
index 0000000..9547298
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\CombinedSelectorNode;
+use Symfony\Component\CssSelector\Node\ElementNode;
+
+class CombinedSelectorNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new CombinedSelectorNode(new ElementNode(), '>', new ElementNode()), 'CombinedSelector[Element[*] > Element[*]]'),
+            array(new CombinedSelectorNode(new ElementNode(), ' ', new ElementNode()), 'CombinedSelector[Element[*] <followed> Element[*]]'),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new CombinedSelectorNode(new ElementNode(), '>', new ElementNode()), 0),
+            array(new CombinedSelectorNode(new ElementNode(null, 'element'), '>', new ElementNode()), 1),
+            array(new CombinedSelectorNode(new ElementNode(null, 'element'), '>', new ElementNode(null, 'element')), 2),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ElementNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ElementNodeTest.php
new file mode 100644 (file)
index 0000000..6d24789
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+
+class ElementNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new ElementNode(), 'Element[*]'),
+            array(new ElementNode(null, 'element'), 'Element[element]'),
+            array(new ElementNode('namespace', 'element'), 'Element[namespace|element]'),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new ElementNode(), 0),
+            array(new ElementNode(null, 'element'), 1),
+            array(new ElementNode('namespace', 'element'), 1),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/FunctionNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/FunctionNodeTest.php
new file mode 100644 (file)
index 0000000..ee3ce51
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\FunctionNode;
+use Symfony\Component\CssSelector\Parser\Token;
+
+class FunctionNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new FunctionNode(new ElementNode(), 'function'), 'Function[Element[*]:function()]'),
+            array(new FunctionNode(new ElementNode(), 'function', array(
+                new Token(Token::TYPE_IDENTIFIER, 'value', 0),
+            )), "Function[Element[*]:function(['value'])]"),
+            array(new FunctionNode(new ElementNode(), 'function', array(
+                new Token(Token::TYPE_STRING, 'value1', 0),
+                new Token(Token::TYPE_NUMBER, 'value2', 0),
+            )), "Function[Element[*]:function(['value1', 'value2'])]"),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new FunctionNode(new ElementNode(), 'function'), 10),
+            array(new FunctionNode(new ElementNode(), 'function', array(
+                new Token(Token::TYPE_IDENTIFIER, 'value', 0),
+            )), 10),
+            array(new FunctionNode(new ElementNode(), 'function', array(
+                new Token(Token::TYPE_STRING, 'value1', 0),
+                new Token(Token::TYPE_NUMBER, 'value2', 0),
+            )), 10),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/HashNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/HashNodeTest.php
new file mode 100644 (file)
index 0000000..3bc74da
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\HashNode;
+
+class HashNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new HashNode(new ElementNode(), 'id'), 'Hash[Element[*]#id]'),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new HashNode(new ElementNode(), 'id'), 100),
+            array(new HashNode(new ElementNode(null, 'id'), 'class'), 101),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/NegationNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/NegationNodeTest.php
new file mode 100644 (file)
index 0000000..ed4d248
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\ClassNode;
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\NegationNode;
+
+class NegationNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new NegationNode(new ElementNode(), new ClassNode(new ElementNode(), 'class')), 'Negation[Element[*]:not(Class[Element[*].class])]'),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new NegationNode(new ElementNode(), new ClassNode(new ElementNode(), 'class')), 10),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/PseudoNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/PseudoNodeTest.php
new file mode 100644 (file)
index 0000000..bc57813
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\PseudoNode;
+
+class PseudoNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new PseudoNode(new ElementNode(), 'pseudo'), 'Pseudo[Element[*]:pseudo]'),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new PseudoNode(new ElementNode(), 'pseudo'), 10),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SelectorNodeTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SelectorNodeTest.php
new file mode 100644 (file)
index 0000000..5badf71
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use Symfony\Component\CssSelector\Node\ElementNode;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+
+class SelectorNodeTest extends AbstractNodeTest
+{
+    public function getToStringConversionTestData()
+    {
+        return array(
+            array(new SelectorNode(new ElementNode()), 'Selector[Element[*]]'),
+            array(new SelectorNode(new ElementNode(), 'pseudo'), 'Selector[Element[*]::pseudo]'),
+        );
+    }
+
+    public function getSpecificityValueTestData()
+    {
+        return array(
+            array(new SelectorNode(new ElementNode()), 0),
+            array(new SelectorNode(new ElementNode(), 'pseudo'), 1),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SpecificityTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SpecificityTest.php
new file mode 100644 (file)
index 0000000..b58eb89
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Node;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Node\Specificity;
+
+class SpecificityTest extends TestCase
+{
+    /** @dataProvider getValueTestData */
+    public function testValue(Specificity $specificity, $value)
+    {
+        $this->assertEquals($value, $specificity->getValue());
+    }
+
+    /** @dataProvider getValueTestData */
+    public function testPlusValue(Specificity $specificity, $value)
+    {
+        $this->assertEquals($value + 123, $specificity->plus(new Specificity(1, 2, 3))->getValue());
+    }
+
+    public function getValueTestData()
+    {
+        return array(
+            array(new Specificity(0, 0, 0), 0),
+            array(new Specificity(0, 0, 2), 2),
+            array(new Specificity(0, 3, 0), 30),
+            array(new Specificity(4, 0, 0), 400),
+            array(new Specificity(4, 3, 2), 432),
+        );
+    }
+
+    /** @dataProvider getCompareTestData */
+    public function testCompareTo(Specificity $a, Specificity $b, $result)
+    {
+        $this->assertEquals($result, $a->compareTo($b));
+    }
+
+    public function getCompareTestData()
+    {
+        return array(
+            array(new Specificity(0, 0, 0), new Specificity(0, 0, 0), 0),
+            array(new Specificity(0, 0, 1), new Specificity(0, 0, 1), 0),
+            array(new Specificity(0, 0, 2), new Specificity(0, 0, 1), 1),
+            array(new Specificity(0, 0, 2), new Specificity(0, 0, 3), -1),
+            array(new Specificity(0, 4, 0), new Specificity(0, 4, 0), 0),
+            array(new Specificity(0, 6, 0), new Specificity(0, 5, 11), 1),
+            array(new Specificity(0, 7, 0), new Specificity(0, 8, 0), -1),
+            array(new Specificity(9, 0, 0), new Specificity(9, 0, 0), 0),
+            array(new Specificity(11, 0, 0), new Specificity(10, 11, 0), 1),
+            array(new Specificity(12, 11, 0), new Specificity(13, 0, 0), -1),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/AbstractHandlerTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/AbstractHandlerTest.php
new file mode 100644 (file)
index 0000000..f5c9dc8
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Handler;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+/**
+ * @author Jean-François Simon <contact@jfsimon.fr>
+ */
+abstract class AbstractHandlerTest extends TestCase
+{
+    /** @dataProvider getHandleValueTestData */
+    public function testHandleValue($value, Token $expectedToken, $remainingContent)
+    {
+        $reader = new Reader($value);
+        $stream = new TokenStream();
+
+        $this->assertTrue($this->generateHandler()->handle($reader, $stream));
+        $this->assertEquals($expectedToken, $stream->getNext());
+        $this->assertRemainingContent($reader, $remainingContent);
+    }
+
+    /** @dataProvider getDontHandleValueTestData */
+    public function testDontHandleValue($value)
+    {
+        $reader = new Reader($value);
+        $stream = new TokenStream();
+
+        $this->assertFalse($this->generateHandler()->handle($reader, $stream));
+        $this->assertStreamEmpty($stream);
+        $this->assertRemainingContent($reader, $value);
+    }
+
+    abstract public function getHandleValueTestData();
+
+    abstract public function getDontHandleValueTestData();
+
+    abstract protected function generateHandler();
+
+    protected function assertStreamEmpty(TokenStream $stream)
+    {
+        $property = new \ReflectionProperty($stream, 'tokens');
+        $property->setAccessible(true);
+
+        $this->assertEquals(array(), $property->getValue($stream));
+    }
+
+    protected function assertRemainingContent(Reader $reader, $remainingContent)
+    {
+        if ('' === $remainingContent) {
+            $this->assertEquals(0, $reader->getRemainingLength());
+            $this->assertTrue($reader->isEOF());
+        } else {
+            $this->assertEquals(\strlen($remainingContent), $reader->getRemainingLength());
+            $this->assertEquals(0, $reader->getOffset($remainingContent));
+        }
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/CommentHandlerTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/CommentHandlerTest.php
new file mode 100644 (file)
index 0000000..3961bf7
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Handler\CommentHandler;
+use Symfony\Component\CssSelector\Parser\Reader;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+class CommentHandlerTest extends AbstractHandlerTest
+{
+    /** @dataProvider getHandleValueTestData */
+    public function testHandleValue($value, Token $unusedArgument, $remainingContent)
+    {
+        $reader = new Reader($value);
+        $stream = new TokenStream();
+
+        $this->assertTrue($this->generateHandler()->handle($reader, $stream));
+        // comments are ignored (not pushed as token in stream)
+        $this->assertStreamEmpty($stream);
+        $this->assertRemainingContent($reader, $remainingContent);
+    }
+
+    public function getHandleValueTestData()
+    {
+        return array(
+            // 2nd argument only exists for inherited method compatibility
+            array('/* comment */', new Token(null, null, null), ''),
+            array('/* comment */foo', new Token(null, null, null), 'foo'),
+        );
+    }
+
+    public function getDontHandleValueTestData()
+    {
+        return array(
+            array('>'),
+            array('+'),
+            array(' '),
+        );
+    }
+
+    protected function generateHandler()
+    {
+        return new CommentHandler();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/HashHandlerTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/HashHandlerTest.php
new file mode 100644 (file)
index 0000000..5730120
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Handler\HashHandler;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+
+class HashHandlerTest extends AbstractHandlerTest
+{
+    public function getHandleValueTestData()
+    {
+        return array(
+            array('#id', new Token(Token::TYPE_HASH, 'id', 0), ''),
+            array('#123', new Token(Token::TYPE_HASH, '123', 0), ''),
+
+            array('#id.class', new Token(Token::TYPE_HASH, 'id', 0), '.class'),
+            array('#id element', new Token(Token::TYPE_HASH, 'id', 0), ' element'),
+        );
+    }
+
+    public function getDontHandleValueTestData()
+    {
+        return array(
+            array('id'),
+            array('123'),
+            array('<'),
+            array('<'),
+            array('#'),
+        );
+    }
+
+    protected function generateHandler()
+    {
+        $patterns = new TokenizerPatterns();
+
+        return new HashHandler($patterns, new TokenizerEscaping($patterns));
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/IdentifierHandlerTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/IdentifierHandlerTest.php
new file mode 100644 (file)
index 0000000..f56430c
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Handler\IdentifierHandler;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+
+class IdentifierHandlerTest extends AbstractHandlerTest
+{
+    public function getHandleValueTestData()
+    {
+        return array(
+            array('foo', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), ''),
+            array('foo|bar', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '|bar'),
+            array('foo.class', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '.class'),
+            array('foo[attr]', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), '[attr]'),
+            array('foo bar', new Token(Token::TYPE_IDENTIFIER, 'foo', 0), ' bar'),
+        );
+    }
+
+    public function getDontHandleValueTestData()
+    {
+        return array(
+            array('>'),
+            array('+'),
+            array(' '),
+            array('*|foo'),
+            array('/* comment */'),
+        );
+    }
+
+    protected function generateHandler()
+    {
+        $patterns = new TokenizerPatterns();
+
+        return new IdentifierHandler($patterns, new TokenizerEscaping($patterns));
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/NumberHandlerTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/NumberHandlerTest.php
new file mode 100644 (file)
index 0000000..675fd05
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Handler\NumberHandler;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+
+class NumberHandlerTest extends AbstractHandlerTest
+{
+    public function getHandleValueTestData()
+    {
+        return array(
+            array('12', new Token(Token::TYPE_NUMBER, '12', 0), ''),
+            array('12.34', new Token(Token::TYPE_NUMBER, '12.34', 0), ''),
+            array('+12.34', new Token(Token::TYPE_NUMBER, '+12.34', 0), ''),
+            array('-12.34', new Token(Token::TYPE_NUMBER, '-12.34', 0), ''),
+
+            array('12 arg', new Token(Token::TYPE_NUMBER, '12', 0), ' arg'),
+            array('12]', new Token(Token::TYPE_NUMBER, '12', 0), ']'),
+        );
+    }
+
+    public function getDontHandleValueTestData()
+    {
+        return array(
+            array('hello'),
+            array('>'),
+            array('+'),
+            array(' '),
+            array('/* comment */'),
+        );
+    }
+
+    protected function generateHandler()
+    {
+        $patterns = new TokenizerPatterns();
+
+        return new NumberHandler($patterns);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/StringHandlerTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/StringHandlerTest.php
new file mode 100644 (file)
index 0000000..8ea5d4d
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Handler\StringHandler;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping;
+use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerPatterns;
+
+class StringHandlerTest extends AbstractHandlerTest
+{
+    public function getHandleValueTestData()
+    {
+        return array(
+            array('"hello"', new Token(Token::TYPE_STRING, 'hello', 1), ''),
+            array('"1"', new Token(Token::TYPE_STRING, '1', 1), ''),
+            array('" "', new Token(Token::TYPE_STRING, ' ', 1), ''),
+            array('""', new Token(Token::TYPE_STRING, '', 1), ''),
+            array("'hello'", new Token(Token::TYPE_STRING, 'hello', 1), ''),
+
+            array("'foo'bar", new Token(Token::TYPE_STRING, 'foo', 1), 'bar'),
+        );
+    }
+
+    public function getDontHandleValueTestData()
+    {
+        return array(
+            array('hello'),
+            array('>'),
+            array('1'),
+            array(' '),
+        );
+    }
+
+    protected function generateHandler()
+    {
+        $patterns = new TokenizerPatterns();
+
+        return new StringHandler($patterns, new TokenizerEscaping($patterns));
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/WhitespaceHandlerTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/WhitespaceHandlerTest.php
new file mode 100644 (file)
index 0000000..f5f9e71
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Handler;
+
+use Symfony\Component\CssSelector\Parser\Handler\WhitespaceHandler;
+use Symfony\Component\CssSelector\Parser\Token;
+
+class WhitespaceHandlerTest extends AbstractHandlerTest
+{
+    public function getHandleValueTestData()
+    {
+        return array(
+            array(' ', new Token(Token::TYPE_WHITESPACE, ' ', 0), ''),
+            array("\n", new Token(Token::TYPE_WHITESPACE, "\n", 0), ''),
+            array("\t", new Token(Token::TYPE_WHITESPACE, "\t", 0), ''),
+
+            array(' foo', new Token(Token::TYPE_WHITESPACE, ' ', 0), 'foo'),
+            array(' .foo', new Token(Token::TYPE_WHITESPACE, ' ', 0), '.foo'),
+        );
+    }
+
+    public function getDontHandleValueTestData()
+    {
+        return array(
+            array('>'),
+            array('1'),
+            array('a'),
+        );
+    }
+
+    protected function generateHandler()
+    {
+        return new WhitespaceHandler();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ParserTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ParserTest.php
new file mode 100644 (file)
index 0000000..53b35a9
--- /dev/null
@@ -0,0 +1,250 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
+use Symfony\Component\CssSelector\Node\FunctionNode;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\Parser;
+use Symfony\Component\CssSelector\Parser\Token;
+
+class ParserTest extends TestCase
+{
+    /** @dataProvider getParserTestData */
+    public function testParser($source, $representation)
+    {
+        $parser = new Parser();
+
+        $this->assertEquals($representation, array_map(function (SelectorNode $node) {
+            return (string) $node->getTree();
+        }, $parser->parse($source)));
+    }
+
+    /** @dataProvider getParserExceptionTestData */
+    public function testParserException($source, $message)
+    {
+        $parser = new Parser();
+
+        try {
+            $parser->parse($source);
+            $this->fail('Parser should throw a SyntaxErrorException.');
+        } catch (SyntaxErrorException $e) {
+            $this->assertEquals($message, $e->getMessage());
+        }
+    }
+
+    /** @dataProvider getPseudoElementsTestData */
+    public function testPseudoElements($source, $element, $pseudo)
+    {
+        $parser = new Parser();
+        $selectors = $parser->parse($source);
+        $this->assertCount(1, $selectors);
+
+        /** @var SelectorNode $selector */
+        $selector = $selectors[0];
+        $this->assertEquals($element, (string) $selector->getTree());
+        $this->assertEquals($pseudo, (string) $selector->getPseudoElement());
+    }
+
+    /** @dataProvider getSpecificityTestData */
+    public function testSpecificity($source, $value)
+    {
+        $parser = new Parser();
+        $selectors = $parser->parse($source);
+        $this->assertCount(1, $selectors);
+
+        /** @var SelectorNode $selector */
+        $selector = $selectors[0];
+        $this->assertEquals($value, $selector->getSpecificity()->getValue());
+    }
+
+    /** @dataProvider getParseSeriesTestData */
+    public function testParseSeries($series, $a, $b)
+    {
+        $parser = new Parser();
+        $selectors = $parser->parse(sprintf(':nth-child(%s)', $series));
+        $this->assertCount(1, $selectors);
+
+        /** @var FunctionNode $function */
+        $function = $selectors[0]->getTree();
+        $this->assertEquals(array($a, $b), Parser::parseSeries($function->getArguments()));
+    }
+
+    /** @dataProvider getParseSeriesExceptionTestData */
+    public function testParseSeriesException($series)
+    {
+        $parser = new Parser();
+        $selectors = $parser->parse(sprintf(':nth-child(%s)', $series));
+        $this->assertCount(1, $selectors);
+
+        /** @var FunctionNode $function */
+        $function = $selectors[0]->getTree();
+        $this->{method_exists($this, $_ = 'expectException') ? $_ : 'setExpectedException'}('Symfony\Component\CssSelector\Exception\SyntaxErrorException');
+        Parser::parseSeries($function->getArguments());
+    }
+
+    public function getParserTestData()
+    {
+        return array(
+            array('*', array('Element[*]')),
+            array('*|*', array('Element[*]')),
+            array('*|foo', array('Element[foo]')),
+            array('foo|*', array('Element[foo|*]')),
+            array('foo|bar', array('Element[foo|bar]')),
+            array('#foo#bar', array('Hash[Hash[Element[*]#foo]#bar]')),
+            array('div>.foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')),
+            array('div> .foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')),
+            array('div >.foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')),
+            array('div > .foo', array('CombinedSelector[Element[div] > Class[Element[*].foo]]')),
+            array("div \n>  \t \t .foo", array('CombinedSelector[Element[div] > Class[Element[*].foo]]')),
+            array('td.foo,.bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')),
+            array('td.foo, .bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')),
+            array("td.foo\t\r\n\f ,\t\r\n\f .bar", array('Class[Element[td].foo]', 'Class[Element[*].bar]')),
+            array('td.foo,.bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')),
+            array('td.foo, .bar', array('Class[Element[td].foo]', 'Class[Element[*].bar]')),
+            array("td.foo\t\r\n\f ,\t\r\n\f .bar", array('Class[Element[td].foo]', 'Class[Element[*].bar]')),
+            array('div, td.foo, div.bar span', array('Element[div]', 'Class[Element[td].foo]', 'CombinedSelector[Class[Element[div].bar] <followed> Element[span]]')),
+            array('div > p', array('CombinedSelector[Element[div] > Element[p]]')),
+            array('td:first', array('Pseudo[Element[td]:first]')),
+            array('td :first', array('CombinedSelector[Element[td] <followed> Pseudo[Element[*]:first]]')),
+            array('a[name]', array('Attribute[Element[a][name]]')),
+            array("a[ name\t]", array('Attribute[Element[a][name]]')),
+            array('a [name]', array('CombinedSelector[Element[a] <followed> Attribute[Element[*][name]]]')),
+            array('a[rel="include"]', array("Attribute[Element[a][rel = 'include']]")),
+            array('a[rel = include]', array("Attribute[Element[a][rel = 'include']]")),
+            array("a[hreflang |= 'en']", array("Attribute[Element[a][hreflang |= 'en']]")),
+            array('a[hreflang|=en]', array("Attribute[Element[a][hreflang |= 'en']]")),
+            array('div:nth-child(10)', array("Function[Element[div]:nth-child(['10'])]")),
+            array(':nth-child(2n+2)', array("Function[Element[*]:nth-child(['2', 'n', '+2'])]")),
+            array('div:nth-of-type(10)', array("Function[Element[div]:nth-of-type(['10'])]")),
+            array('div div:nth-of-type(10) .aclass', array("CombinedSelector[CombinedSelector[Element[div] <followed> Function[Element[div]:nth-of-type(['10'])]] <followed> Class[Element[*].aclass]]")),
+            array('label:only', array('Pseudo[Element[label]:only]')),
+            array('a:lang(fr)', array("Function[Element[a]:lang(['fr'])]")),
+            array('div:contains("foo")', array("Function[Element[div]:contains(['foo'])]")),
+            array('div#foobar', array('Hash[Element[div]#foobar]')),
+            array('div:not(div.foo)', array('Negation[Element[div]:not(Class[Element[div].foo])]')),
+            array('td ~ th', array('CombinedSelector[Element[td] ~ Element[th]]')),
+            array('.foo[data-bar][data-baz=0]', array("Attribute[Attribute[Class[Element[*].foo][data-bar]][data-baz = '0']]")),
+        );
+    }
+
+    public function getParserExceptionTestData()
+    {
+        return array(
+            array('attributes(href)/html/body/a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '(', 10))->getMessage()),
+            array('attributes(href)', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '(', 10))->getMessage()),
+            array('html/body/a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '/', 4))->getMessage()),
+            array(' ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 1))->getMessage()),
+            array('div, ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 5))->getMessage()),
+            array(' , div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, ',', 1))->getMessage()),
+            array('p, , div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, ',', 3))->getMessage()),
+            array('div > ', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_FILE_END, '', 6))->getMessage()),
+            array('  > div', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '>', 2))->getMessage()),
+            array('foo|#bar', SyntaxErrorException::unexpectedToken('identifier or "*"', new Token(Token::TYPE_HASH, 'bar', 4))->getMessage()),
+            array('#.foo', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '#', 0))->getMessage()),
+            array('.#foo', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_HASH, 'foo', 1))->getMessage()),
+            array(':#foo', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_HASH, 'foo', 1))->getMessage()),
+            array('[*]', SyntaxErrorException::unexpectedToken('"|"', new Token(Token::TYPE_DELIMITER, ']', 2))->getMessage()),
+            array('[foo|]', SyntaxErrorException::unexpectedToken('identifier', new Token(Token::TYPE_DELIMITER, ']', 5))->getMessage()),
+            array('[#]', SyntaxErrorException::unexpectedToken('identifier or "*"', new Token(Token::TYPE_DELIMITER, '#', 1))->getMessage()),
+            array('[foo=#]', SyntaxErrorException::unexpectedToken('string or identifier', new Token(Token::TYPE_DELIMITER, '#', 5))->getMessage()),
+            array(':nth-child()', SyntaxErrorException::unexpectedToken('at least one argument', new Token(Token::TYPE_DELIMITER, ')', 11))->getMessage()),
+            array('[href]a', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_IDENTIFIER, 'a', 6))->getMessage()),
+            array('[rel:stylesheet]', SyntaxErrorException::unexpectedToken('operator', new Token(Token::TYPE_DELIMITER, ':', 4))->getMessage()),
+            array('[rel=stylesheet', SyntaxErrorException::unexpectedToken('"]"', new Token(Token::TYPE_FILE_END, '', 15))->getMessage()),
+            array(':lang(fr', SyntaxErrorException::unexpectedToken('an argument', new Token(Token::TYPE_FILE_END, '', 8))->getMessage()),
+            array(':contains("foo', SyntaxErrorException::unclosedString(10)->getMessage()),
+            array('foo!', SyntaxErrorException::unexpectedToken('selector', new Token(Token::TYPE_DELIMITER, '!', 3))->getMessage()),
+        );
+    }
+
+    public function getPseudoElementsTestData()
+    {
+        return array(
+            array('foo', 'Element[foo]', ''),
+            array('*', 'Element[*]', ''),
+            array(':empty', 'Pseudo[Element[*]:empty]', ''),
+            array(':BEfore', 'Element[*]', 'before'),
+            array(':aftER', 'Element[*]', 'after'),
+            array(':First-Line', 'Element[*]', 'first-line'),
+            array(':First-Letter', 'Element[*]', 'first-letter'),
+            array('::befoRE', 'Element[*]', 'before'),
+            array('::AFter', 'Element[*]', 'after'),
+            array('::firsT-linE', 'Element[*]', 'first-line'),
+            array('::firsT-letteR', 'Element[*]', 'first-letter'),
+            array('::Selection', 'Element[*]', 'selection'),
+            array('foo:after', 'Element[foo]', 'after'),
+            array('foo::selection', 'Element[foo]', 'selection'),
+            array('lorem#ipsum ~ a#b.c[href]:empty::selection', 'CombinedSelector[Hash[Element[lorem]#ipsum] ~ Pseudo[Attribute[Class[Hash[Element[a]#b].c][href]]:empty]]', 'selection'),
+            array('video::-webkit-media-controls', 'Element[video]', '-webkit-media-controls'),
+        );
+    }
+
+    public function getSpecificityTestData()
+    {
+        return array(
+            array('*', 0),
+            array(' foo', 1),
+            array(':empty ', 10),
+            array(':before', 1),
+            array('*:before', 1),
+            array(':nth-child(2)', 10),
+            array('.bar', 10),
+            array('[baz]', 10),
+            array('[baz="4"]', 10),
+            array('[baz^="4"]', 10),
+            array('#lipsum', 100),
+            array(':not(*)', 0),
+            array(':not(foo)', 1),
+            array(':not(.foo)', 10),
+            array(':not([foo])', 10),
+            array(':not(:empty)', 10),
+            array(':not(#foo)', 100),
+            array('foo:empty', 11),
+            array('foo:before', 2),
+            array('foo::before', 2),
+            array('foo:empty::before', 12),
+            array('#lorem + foo#ipsum:first-child > bar:first-line', 213),
+        );
+    }
+
+    public function getParseSeriesTestData()
+    {
+        return array(
+            array('1n+3', 1, 3),
+            array('1n +3', 1, 3),
+            array('1n + 3', 1, 3),
+            array('1n+ 3', 1, 3),
+            array('1n-3', 1, -3),
+            array('1n -3', 1, -3),
+            array('1n - 3', 1, -3),
+            array('1n- 3', 1, -3),
+            array('n-5', 1, -5),
+            array('odd', 2, 1),
+            array('even', 2, 0),
+            array('3n', 3, 0),
+            array('n', 1, 0),
+            array('+n', 1, 0),
+            array('-n', -1, 0),
+            array('5', 0, 5),
+        );
+    }
+
+    public function getParseSeriesExceptionTestData()
+    {
+        return array(
+            array('foo'),
+            array('n+'),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ReaderTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ReaderTest.php
new file mode 100644 (file)
index 0000000..21eb608
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Parser\Reader;
+
+class ReaderTest extends TestCase
+{
+    public function testIsEOF()
+    {
+        $reader = new Reader('');
+        $this->assertTrue($reader->isEOF());
+
+        $reader = new Reader('hello');
+        $this->assertFalse($reader->isEOF());
+
+        $this->assignPosition($reader, 2);
+        $this->assertFalse($reader->isEOF());
+
+        $this->assignPosition($reader, 5);
+        $this->assertTrue($reader->isEOF());
+    }
+
+    public function testGetRemainingLength()
+    {
+        $reader = new Reader('hello');
+        $this->assertEquals(5, $reader->getRemainingLength());
+
+        $this->assignPosition($reader, 2);
+        $this->assertEquals(3, $reader->getRemainingLength());
+
+        $this->assignPosition($reader, 5);
+        $this->assertEquals(0, $reader->getRemainingLength());
+    }
+
+    public function testGetSubstring()
+    {
+        $reader = new Reader('hello');
+        $this->assertEquals('he', $reader->getSubstring(2));
+        $this->assertEquals('el', $reader->getSubstring(2, 1));
+
+        $this->assignPosition($reader, 2);
+        $this->assertEquals('ll', $reader->getSubstring(2));
+        $this->assertEquals('lo', $reader->getSubstring(2, 1));
+    }
+
+    public function testGetOffset()
+    {
+        $reader = new Reader('hello');
+        $this->assertEquals(2, $reader->getOffset('ll'));
+        $this->assertFalse($reader->getOffset('w'));
+
+        $this->assignPosition($reader, 2);
+        $this->assertEquals(0, $reader->getOffset('ll'));
+        $this->assertFalse($reader->getOffset('he'));
+    }
+
+    public function testFindPattern()
+    {
+        $reader = new Reader('hello');
+
+        $this->assertFalse($reader->findPattern('/world/'));
+        $this->assertEquals(array('hello', 'h'), $reader->findPattern('/^([a-z]).*/'));
+
+        $this->assignPosition($reader, 2);
+        $this->assertFalse($reader->findPattern('/^h.*/'));
+        $this->assertEquals(array('llo'), $reader->findPattern('/^llo$/'));
+    }
+
+    public function testMoveForward()
+    {
+        $reader = new Reader('hello');
+        $this->assertEquals(0, $reader->getPosition());
+
+        $reader->moveForward(2);
+        $this->assertEquals(2, $reader->getPosition());
+    }
+
+    public function testToEnd()
+    {
+        $reader = new Reader('hello');
+        $reader->moveToEnd();
+        $this->assertTrue($reader->isEOF());
+    }
+
+    private function assignPosition(Reader $reader, $value)
+    {
+        $position = new \ReflectionProperty($reader, 'position');
+        $position->setAccessible(true);
+        $position->setValue($reader, $value);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ClassParserTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ClassParserTest.php
new file mode 100644 (file)
index 0000000..7e92f5b
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser;
+
+/**
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+class ClassParserTest extends TestCase
+{
+    /** @dataProvider getParseTestData */
+    public function testParse($source, $representation)
+    {
+        $parser = new ClassParser();
+        $selectors = $parser->parse($source);
+        $this->assertCount(1, $selectors);
+
+        /** @var SelectorNode $selector */
+        $selector = $selectors[0];
+        $this->assertEquals($representation, (string) $selector->getTree());
+    }
+
+    public function getParseTestData()
+    {
+        return array(
+            array('.testclass', 'Class[Element[*].testclass]'),
+            array('testel.testclass', 'Class[Element[testel].testclass]'),
+            array('testns|.testclass', 'Class[Element[testns|*].testclass]'),
+            array('testns|*.testclass', 'Class[Element[testns|*].testclass]'),
+            array('testns|testel.testclass', 'Class[Element[testns|testel].testclass]'),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ElementParserTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ElementParserTest.php
new file mode 100644 (file)
index 0000000..05a730f
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser;
+
+/**
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+class ElementParserTest extends TestCase
+{
+    /** @dataProvider getParseTestData */
+    public function testParse($source, $representation)
+    {
+        $parser = new ElementParser();
+        $selectors = $parser->parse($source);
+        $this->assertCount(1, $selectors);
+
+        /** @var SelectorNode $selector */
+        $selector = $selectors[0];
+        $this->assertEquals($representation, (string) $selector->getTree());
+    }
+
+    public function getParseTestData()
+    {
+        return array(
+            array('*', 'Element[*]'),
+            array('testel', 'Element[testel]'),
+            array('testns|*', 'Element[testns|*]'),
+            array('testns|testel', 'Element[testns|testel]'),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/EmptyStringParserTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/EmptyStringParserTest.php
new file mode 100644 (file)
index 0000000..1cf742c
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser;
+
+/**
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+class EmptyStringParserTest extends TestCase
+{
+    public function testParse()
+    {
+        $parser = new EmptyStringParser();
+        $selectors = $parser->parse('');
+        $this->assertCount(1, $selectors);
+
+        /** @var SelectorNode $selector */
+        $selector = $selectors[0];
+        $this->assertEquals('Element[*]', (string) $selector->getTree());
+
+        $selectors = $parser->parse('this will produce an empty array');
+        $this->assertCount(0, $selectors);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/HashParserTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/HashParserTest.php
new file mode 100644 (file)
index 0000000..82f555d
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser\Shortcut;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\Shortcut\HashParser;
+
+/**
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ */
+class HashParserTest extends TestCase
+{
+    /** @dataProvider getParseTestData */
+    public function testParse($source, $representation)
+    {
+        $parser = new HashParser();
+        $selectors = $parser->parse($source);
+        $this->assertCount(1, $selectors);
+
+        /** @var SelectorNode $selector */
+        $selector = $selectors[0];
+        $this->assertEquals($representation, (string) $selector->getTree());
+    }
+
+    public function getParseTestData()
+    {
+        return array(
+            array('#testid', 'Hash[Element[*]#testid]'),
+            array('testel#testid', 'Hash[Element[testel]#testid]'),
+            array('testns|#testid', 'Hash[Element[testns|*]#testid]'),
+            array('testns|*#testid', 'Hash[Element[testns|*]#testid]'),
+            array('testns|testel#testid', 'Hash[Element[testns|testel]#testid]'),
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/TokenStreamTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/TokenStreamTest.php
new file mode 100644 (file)
index 0000000..44c751a
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\Parser;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\Parser\Token;
+use Symfony\Component\CssSelector\Parser\TokenStream;
+
+class TokenStreamTest extends TestCase
+{
+    public function testGetNext()
+    {
+        $stream = new TokenStream();
+        $stream->push($t1 = new Token(Token::TYPE_IDENTIFIER, 'h1', 0));
+        $stream->push($t2 = new Token(Token::TYPE_DELIMITER, '.', 2));
+        $stream->push($t3 = new Token(Token::TYPE_IDENTIFIER, 'title', 3));
+
+        $this->assertSame($t1, $stream->getNext());
+        $this->assertSame($t2, $stream->getNext());
+        $this->assertSame($t3, $stream->getNext());
+    }
+
+    public function testGetPeek()
+    {
+        $stream = new TokenStream();
+        $stream->push($t1 = new Token(Token::TYPE_IDENTIFIER, 'h1', 0));
+        $stream->push($t2 = new Token(Token::TYPE_DELIMITER, '.', 2));
+        $stream->push($t3 = new Token(Token::TYPE_IDENTIFIER, 'title', 3));
+
+        $this->assertSame($t1, $stream->getPeek());
+        $this->assertSame($t1, $stream->getNext());
+        $this->assertSame($t2, $stream->getPeek());
+        $this->assertSame($t2, $stream->getPeek());
+        $this->assertSame($t2, $stream->getNext());
+    }
+
+    public function testGetNextIdentifier()
+    {
+        $stream = new TokenStream();
+        $stream->push(new Token(Token::TYPE_IDENTIFIER, 'h1', 0));
+
+        $this->assertEquals('h1', $stream->getNextIdentifier());
+    }
+
+    public function testFailToGetNextIdentifier()
+    {
+        $this->{method_exists($this, $_ = 'expectException') ? $_ : 'setExpectedException'}('Symfony\Component\CssSelector\Exception\SyntaxErrorException');
+
+        $stream = new TokenStream();
+        $stream->push(new Token(Token::TYPE_DELIMITER, '.', 2));
+        $stream->getNextIdentifier();
+    }
+
+    public function testGetNextIdentifierOrStar()
+    {
+        $stream = new TokenStream();
+
+        $stream->push(new Token(Token::TYPE_IDENTIFIER, 'h1', 0));
+        $this->assertEquals('h1', $stream->getNextIdentifierOrStar());
+
+        $stream->push(new Token(Token::TYPE_DELIMITER, '*', 0));
+        $this->assertNull($stream->getNextIdentifierOrStar());
+    }
+
+    public function testFailToGetNextIdentifierOrStar()
+    {
+        $this->{method_exists($this, $_ = 'expectException') ? $_ : 'setExpectedException'}('Symfony\Component\CssSelector\Exception\SyntaxErrorException');
+
+        $stream = new TokenStream();
+        $stream->push(new Token(Token::TYPE_DELIMITER, '.', 2));
+        $stream->getNextIdentifierOrStar();
+    }
+
+    public function testSkipWhitespace()
+    {
+        $stream = new TokenStream();
+        $stream->push($t1 = new Token(Token::TYPE_IDENTIFIER, 'h1', 0));
+        $stream->push($t2 = new Token(Token::TYPE_WHITESPACE, ' ', 2));
+        $stream->push($t3 = new Token(Token::TYPE_IDENTIFIER, 'h1', 3));
+
+        $stream->skipWhitespace();
+        $this->assertSame($t1, $stream->getNext());
+
+        $stream->skipWhitespace();
+        $this->assertSame($t3, $stream->getNext());
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/ids.html b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/ids.html
new file mode 100644 (file)
index 0000000..5799fad
--- /dev/null
@@ -0,0 +1,48 @@
+<html id="html"><head>
+  <link id="link-href" href="foo" />
+  <link id="link-nohref" />
+</head><body>
+<div id="outer-div">
+ <a id="name-anchor" name="foo"></a>
+ <a id="tag-anchor" rel="tag" href="http://localhost/foo">link</a>
+ <a id="nofollow-anchor" rel="nofollow" href="https://example.org">
+    link</a>
+ <ol id="first-ol" class="a b c">
+   <li id="first-li">content</li>
+   <li id="second-li" lang="En-us">
+     <div id="li-div">
+     </div>
+   </li>
+   <li id="third-li" class="ab c"></li>
+   <li id="fourth-li" class="ab
+c"></li>
+   <li id="fifth-li"></li>
+   <li id="sixth-li"></li>
+   <li id="seventh-li">  </li>
+ </ol>
+ <p id="paragraph">
+   <b id="p-b">hi</b> <em id="p-em">there</em>
+   <b id="p-b2">guy</b>
+   <input type="checkbox" id="checkbox-unchecked" />
+   <input type="checkbox" id="checkbox-disabled" disabled="" />
+   <input type="text" id="text-checked" checked="checked" />
+   <input type="hidden" />
+   <input type="hidden" disabled="disabled" />
+   <input type="checkbox" id="checkbox-checked" checked="checked" />
+   <input type="checkbox" id="checkbox-disabled-checked"
+          disabled="disabled" checked="checked" />
+   <fieldset id="fieldset" disabled="disabled">
+     <input type="checkbox" id="checkbox-fieldset-disabled" />
+     <input type="hidden" />
+   </fieldset>
+ </p>
+ <ol id="second-ol">
+ </ol>
+ <map name="dummymap">
+   <area shape="circle" coords="200,250,25" href="foo.html" id="area-href" />
+   <area shape="default" id="area-nohref" />
+ </map>
+</div>
+<div id="foobar-div" foobar="ab bc
+cde"><span id="foobar-span"></span></div>
+</body></html>
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/lang.xml b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/lang.xml
new file mode 100644 (file)
index 0000000..14f8dbe
--- /dev/null
@@ -0,0 +1,11 @@
+<test>
+  <a id="first" xml:lang="en">a</a>
+  <b id="second" xml:lang="en-US">b</b>
+  <c id="third" xml:lang="en-Nz">c</c>
+  <d id="fourth" xml:lang="En-us">d</d>
+  <e id="fifth" xml:lang="fr">e</e>
+  <f id="sixth" xml:lang="ru">f</f>
+  <g id="seventh" xml:lang="de">
+    <h id="eighth" xml:lang="zh"/>
+  </g>
+</test>
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/shakespear.html b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/shakespear.html
new file mode 100644 (file)
index 0000000..15d1ad3
--- /dev/null
@@ -0,0 +1,308 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+       "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en" debug="true">
+<head>
+       <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+</head>
+<body>
+       <div id="test">
+       <div class="dialog">
+       <h2>As You Like It</h2>
+       <div id="playwright">
+         by William Shakespeare
+       </div>
+       <div class="dialog scene thirdClass" id="scene1">
+         <h3>ACT I, SCENE III. A room in the palace.</h3>
+         <div class="dialog">
+         <div class="direction">Enter CELIA and ROSALIND</div>
+         </div>
+         <div id="speech1" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.1">Why, cousin! why, Rosalind! Cupid have mercy! not a word?</div>
+         </div>
+         <div id="speech2" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.2">Not one to throw at a dog.</div>
+         </div>
+         <div id="speech3" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.3">No, thy words are too precious to be cast away upon</div>
+         <div id="scene1.3.4">curs; throw some of them at me; come, lame me with reasons.</div>
+         </div>
+         <div id="speech4" class="character">ROSALIND</div>
+         <div id="speech5" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.8">But is all this for your father?</div>
+         </div>
+         <div class="dialog">
+         <div id="scene1.3.5">Then there were two cousins laid up; when the one</div>
+         <div id="scene1.3.6">should be lamed with reasons and the other mad</div>
+         <div id="scene1.3.7">without any.</div>
+         </div>
+         <div id="speech6" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.9">No, some of it is for my child's father. O, how</div>
+         <div id="scene1.3.10">full of briers is this working-day world!</div>
+         </div>
+         <div id="speech7" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.11">They are but burs, cousin, thrown upon thee in</div>
+         <div id="scene1.3.12">holiday foolery: if we walk not in the trodden</div>
+         <div id="scene1.3.13">paths our very petticoats will catch them.</div>
+         </div>
+         <div id="speech8" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.14">I could shake them off my coat: these burs are in my heart.</div>
+         </div>
+         <div id="speech9" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.15">Hem them away.</div>
+         </div>
+         <div id="speech10" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.16">I would try, if I could cry 'hem' and have him.</div>
+         </div>
+         <div id="speech11" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.17">Come, come, wrestle with thy affections.</div>
+         </div>
+         <div id="speech12" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.18">O, they take the part of a better wrestler than myself!</div>
+         </div>
+         <div id="speech13" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.19">O, a good wish upon you! you will try in time, in</div>
+         <div id="scene1.3.20">despite of a fall. But, turning these jests out of</div>
+         <div id="scene1.3.21">service, let us talk in good earnest: is it</div>
+         <div id="scene1.3.22">possible, on such a sudden, you should fall into so</div>
+         <div id="scene1.3.23">strong a liking with old Sir Rowland's youngest son?</div>
+         </div>
+         <div id="speech14" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.24">The duke my father loved his father dearly.</div>
+         </div>
+         <div id="speech15" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.25">Doth it therefore ensue that you should love his son</div>
+         <div id="scene1.3.26">dearly? By this kind of chase, I should hate him,</div>
+         <div id="scene1.3.27">for my father hated his father dearly; yet I hate</div>
+         <div id="scene1.3.28">not Orlando.</div>
+         </div>
+         <div id="speech16" class="character">ROSALIND</div>
+         <div title="wtf" class="dialog">
+         <div id="scene1.3.29">No, faith, hate him not, for my sake.</div>
+         </div>
+         <div id="speech17" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.30">Why should I not? doth he not deserve well?</div>
+         </div>
+         <div id="speech18" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.31">Let me love him for that, and do you love him</div>
+         <div id="scene1.3.32">because I do. Look, here comes the duke.</div>
+         </div>
+         <div id="speech19" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.33">With his eyes full of anger.</div>
+         <div class="direction">Enter DUKE FREDERICK, with Lords</div>
+         </div>
+         <div id="speech20" class="character">DUKE FREDERICK</div>
+         <div class="dialog">
+         <div id="scene1.3.34">Mistress, dispatch you with your safest haste</div>
+         <div id="scene1.3.35">And get you from our court.</div>
+         </div>
+         <div id="speech21" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.36">Me, uncle?</div>
+         </div>
+         <div id="speech22" class="character">DUKE FREDERICK</div>
+         <div class="dialog">
+         <div id="scene1.3.37">You, cousin</div>
+         <div id="scene1.3.38">Within these ten days if that thou be'st found</div>
+         <div id="scene1.3.39">So near our public court as twenty miles,</div>
+         <div id="scene1.3.40">Thou diest for it.</div>
+         </div>
+         <div id="speech23" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.41">                  I do beseech your grace,</div>
+         <div id="scene1.3.42">Let me the knowledge of my fault bear with me:</div>
+         <div id="scene1.3.43">If with myself I hold intelligence</div>
+         <div id="scene1.3.44">Or have acquaintance with mine own desires,</div>
+         <div id="scene1.3.45">If that I do not dream or be not frantic,--</div>
+         <div id="scene1.3.46">As I do trust I am not--then, dear uncle,</div>
+         <div id="scene1.3.47">Never so much as in a thought unborn</div>
+         <div id="scene1.3.48">Did I offend your highness.</div>
+         </div>
+         <div id="speech24" class="character">DUKE FREDERICK</div>
+         <div class="dialog">
+         <div id="scene1.3.49">Thus do all traitors:</div>
+         <div id="scene1.3.50">If their purgation did consist in words,</div>
+         <div id="scene1.3.51">They are as innocent as grace itself:</div>
+         <div id="scene1.3.52">Let it suffice thee that I trust thee not.</div>
+         </div>
+         <div id="speech25" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.53">Yet your mistrust cannot make me a traitor:</div>
+         <div id="scene1.3.54">Tell me whereon the likelihood depends.</div>
+         </div>
+         <div id="speech26" class="character">DUKE FREDERICK</div>
+         <div class="dialog">
+         <div id="scene1.3.55">Thou art thy father's daughter; there's enough.</div>
+         </div>
+         <div id="speech27" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.56">So was I when your highness took his dukedom;</div>
+         <div id="scene1.3.57">So was I when your highness banish'd him:</div>
+         <div id="scene1.3.58">Treason is not inherited, my lord;</div>
+         <div id="scene1.3.59">Or, if we did derive it from our friends,</div>
+         <div id="scene1.3.60">What's that to me? my father was no traitor:</div>
+         <div id="scene1.3.61">Then, good my liege, mistake me not so much</div>
+         <div id="scene1.3.62">To think my poverty is treacherous.</div>
+         </div>
+         <div id="speech28" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.63">Dear sovereign, hear me speak.</div>
+         </div>
+         <div id="speech29" class="character">DUKE FREDERICK</div>
+         <div class="dialog">
+         <div id="scene1.3.64">Ay, Celia; we stay'd her for your sake,</div>
+         <div id="scene1.3.65">Else had she with her father ranged along.</div>
+         </div>
+         <div id="speech30" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.66">I did not then entreat to have her stay;</div>
+         <div id="scene1.3.67">It was your pleasure and your own remorse:</div>
+         <div id="scene1.3.68">I was too young that time to value her;</div>
+         <div id="scene1.3.69">But now I know her: if she be a traitor,</div>
+         <div id="scene1.3.70">Why so am I; we still have slept together,</div>
+         <div id="scene1.3.71">Rose at an instant, learn'd, play'd, eat together,</div>
+         <div id="scene1.3.72">And wheresoever we went, like Juno's swans,</div>
+         <div id="scene1.3.73">Still we went coupled and inseparable.</div>
+         </div>
+         <div id="speech31" class="character">DUKE FREDERICK</div>
+         <div class="dialog">
+         <div id="scene1.3.74">She is too subtle for thee; and her smoothness,</div>
+         <div id="scene1.3.75">Her very silence and her patience</div>
+         <div id="scene1.3.76">Speak to the people, and they pity her.</div>
+         <div id="scene1.3.77">Thou art a fool: she robs thee of thy name;</div>
+         <div id="scene1.3.78">And thou wilt show more bright and seem more virtuous</div>
+         <div id="scene1.3.79">When she is gone. Then open not thy lips:</div>
+         <div id="scene1.3.80">Firm and irrevocable is my doom</div>
+         <div id="scene1.3.81">Which I have pass'd upon her; she is banish'd.</div>
+         </div>
+         <div id="speech32" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.82">Pronounce that sentence then on me, my liege:</div>
+         <div id="scene1.3.83">I cannot live out of her company.</div>
+         </div>
+         <div id="speech33" class="character">DUKE FREDERICK</div>
+         <div class="dialog">
+         <div id="scene1.3.84">You are a fool. You, niece, provide yourself:</div>
+         <div id="scene1.3.85">If you outstay the time, upon mine honour,</div>
+         <div id="scene1.3.86">And in the greatness of my word, you die.</div>
+         <div class="direction">Exeunt DUKE FREDERICK and Lords</div>
+         </div>
+         <div id="speech34" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.87">O my poor Rosalind, whither wilt thou go?</div>
+         <div id="scene1.3.88">Wilt thou change fathers? I will give thee mine.</div>
+         <div id="scene1.3.89">I charge thee, be not thou more grieved than I am.</div>
+         </div>
+         <div id="speech35" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.90">I have more cause.</div>
+         </div>
+         <div id="speech36" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.91">                  Thou hast not, cousin;</div>
+         <div id="scene1.3.92">Prithee be cheerful: know'st thou not, the duke</div>
+         <div id="scene1.3.93">Hath banish'd me, his daughter?</div>
+         </div>
+         <div id="speech37" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.94">That he hath not.</div>
+         </div>
+         <div id="speech38" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.95">No, hath not? Rosalind lacks then the love</div>
+         <div id="scene1.3.96">Which teacheth thee that thou and I am one:</div>
+         <div id="scene1.3.97">Shall we be sunder'd? shall we part, sweet girl?</div>
+         <div id="scene1.3.98">No: let my father seek another heir.</div>
+         <div id="scene1.3.99">Therefore devise with me how we may fly,</div>
+         <div id="scene1.3.100">Whither to go and what to bear with us;</div>
+         <div id="scene1.3.101">And do not seek to take your change upon you,</div>
+         <div id="scene1.3.102">To bear your griefs yourself and leave me out;</div>
+         <div id="scene1.3.103">For, by this heaven, now at our sorrows pale,</div>
+         <div id="scene1.3.104">Say what thou canst, I'll go along with thee.</div>
+         </div>
+         <div id="speech39" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.105">Why, whither shall we go?</div>
+         </div>
+         <div id="speech40" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.106">To seek my uncle in the forest of Arden.</div>
+         </div>
+         <div id="speech41" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.107">Alas, what danger will it be to us,</div>
+         <div id="scene1.3.108">Maids as we are, to travel forth so far!</div>
+         <div id="scene1.3.109">Beauty provoketh thieves sooner than gold.</div>
+         </div>
+         <div id="speech42" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.110">I'll put myself in poor and mean attire</div>
+         <div id="scene1.3.111">And with a kind of umber smirch my face;</div>
+         <div id="scene1.3.112">The like do you: so shall we pass along</div>
+         <div id="scene1.3.113">And never stir assailants.</div>
+         </div>
+         <div id="speech43" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.114">Were it not better,</div>
+         <div id="scene1.3.115">Because that I am more than common tall,</div>
+         <div id="scene1.3.116">That I did suit me all points like a man?</div>
+         <div id="scene1.3.117">A gallant curtle-axe upon my thigh,</div>
+         <div id="scene1.3.118">A boar-spear in my hand; and--in my heart</div>
+         <div id="scene1.3.119">Lie there what hidden woman's fear there will--</div>
+         <div id="scene1.3.120">We'll have a swashing and a martial outside,</div>
+         <div id="scene1.3.121">As many other mannish cowards have</div>
+         <div id="scene1.3.122">That do outface it with their semblances.</div>
+         </div>
+         <div id="speech44" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.123">What shall I call thee when thou art a man?</div>
+         </div>
+         <div id="speech45" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.124">I'll have no worse a name than Jove's own page;</div>
+         <div id="scene1.3.125">And therefore look you call me Ganymede.</div>
+         <div id="scene1.3.126">But what will you be call'd?</div>
+         </div>
+         <div id="speech46" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.127">Something that hath a reference to my state</div>
+         <div id="scene1.3.128">No longer Celia, but Aliena.</div>
+         </div>
+         <div id="speech47" class="character">ROSALIND</div>
+         <div class="dialog">
+         <div id="scene1.3.129">But, cousin, what if we assay'd to steal</div>
+         <div id="scene1.3.130">The clownish fool out of your father's court?</div>
+         <div id="scene1.3.131">Would he not be a comfort to our travel?</div>
+         </div>
+         <div id="speech48" class="character">CELIA</div>
+         <div class="dialog">
+         <div id="scene1.3.132">He'll go along o'er the wide world with me;</div>
+         <div id="scene1.3.133">Leave me alone to woo him. Let's away,</div>
+         <div id="scene1.3.134">And get our jewels and our wealth together,</div>
+         <div id="scene1.3.135">Devise the fittest time and safest way</div>
+         <div id="scene1.3.136">To hide us from pursuit that will be made</div>
+         <div id="scene1.3.137">After my flight. Now go we in content</div>
+         <div id="scene1.3.138">To liberty and not to banishment.</div>
+         <div class="direction">Exeunt</div>
+         </div>
+       </div>
+       </div>
+</div>
+</body>
+</html>
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/TranslatorTest.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/TranslatorTest.php
new file mode 100644 (file)
index 0000000..6104582
--- /dev/null
@@ -0,0 +1,327 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\Tests\XPath;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\CssSelector\XPath\Extension\HtmlExtension;
+use Symfony\Component\CssSelector\XPath\Translator;
+
+class TranslatorTest extends TestCase
+{
+    /** @dataProvider getXpathLiteralTestData */
+    public function testXpathLiteral($value, $literal)
+    {
+        $this->assertEquals($literal, Translator::getXpathLiteral($value));
+    }
+
+    /** @dataProvider getCssToXPathTestData */
+    public function testCssToXPath($css, $xpath)
+    {
+        $translator = new Translator();
+        $translator->registerExtension(new HtmlExtension($translator));
+        $this->assertEquals($xpath, $translator->cssToXPath($css, ''));
+    }
+
+    /** @dataProvider getXmlLangTestData */
+    public function testXmlLang($css, array $elementsId)
+    {
+        $translator = new Translator();
+        $document = new \SimpleXMLElement(file_get_contents(__DIR__.'/Fixtures/lang.xml'));
+        $elements = $document->xpath($translator->cssToXPath($css));
+        $this->assertCount(\count($elementsId), $elements);
+        foreach ($elements as $element) {
+            $this->assertTrue(\in_array($element->attributes()->id, $elementsId));
+        }
+    }
+
+    /** @dataProvider getHtmlIdsTestData */
+    public function testHtmlIds($css, array $elementsId)
+    {
+        $translator = new Translator();
+        $translator->registerExtension(new HtmlExtension($translator));
+        $document = new \DOMDocument();
+        $document->strictErrorChecking = false;
+        $internalErrors = libxml_use_internal_errors(true);
+        $document->loadHTMLFile(__DIR__.'/Fixtures/ids.html');
+        $document = simplexml_import_dom($document);
+        $elements = $document->xpath($translator->cssToXPath($css));
+        $this->assertCount(\count($elementsId), $elementsId);
+        foreach ($elements as $element) {
+            if (null !== $element->attributes()->id) {
+                $this->assertTrue(\in_array($element->attributes()->id, $elementsId));
+            }
+        }
+        libxml_clear_errors();
+        libxml_use_internal_errors($internalErrors);
+    }
+
+    /** @dataProvider getHtmlShakespearTestData */
+    public function testHtmlShakespear($css, $count)
+    {
+        $translator = new Translator();
+        $translator->registerExtension(new HtmlExtension($translator));
+        $document = new \DOMDocument();
+        $document->strictErrorChecking = false;
+        $document->loadHTMLFile(__DIR__.'/Fixtures/shakespear.html');
+        $document = simplexml_import_dom($document);
+        $bodies = $document->xpath('//body');
+        $elements = $bodies[0]->xpath($translator->cssToXPath($css));
+        $this->assertCount($count, $elements);
+    }
+
+    public function getXpathLiteralTestData()
+    {
+        return array(
+            array('foo', "'foo'"),
+            array("foo's bar", '"foo\'s bar"'),
+            array("foo's \"middle\" bar", 'concat(\'foo\', "\'", \'s "middle" bar\')'),
+            array("foo's 'middle' \"bar\"", 'concat(\'foo\', "\'", \'s \', "\'", \'middle\', "\'", \' "bar"\')'),
+        );
+    }
+
+    public function getCssToXPathTestData()
+    {
+        return array(
+            array('*', '*'),
+            array('e', 'e'),
+            array('*|e', 'e'),
+            array('e|f', 'e:f'),
+            array('e[foo]', 'e[@foo]'),
+            array('e[foo|bar]', 'e[@foo:bar]'),
+            array('e[foo="bar"]', "e[@foo = 'bar']"),
+            array('e[foo~="bar"]', "e[@foo and contains(concat(' ', normalize-space(@foo), ' '), ' bar ')]"),
+            array('e[foo^="bar"]', "e[@foo and starts-with(@foo, 'bar')]"),
+            array('e[foo$="bar"]', "e[@foo and substring(@foo, string-length(@foo)-2) = 'bar']"),
+            array('e[foo*="bar"]', "e[@foo and contains(@foo, 'bar')]"),
+            array('e[foo!="bar"]', "e[not(@foo) or @foo != 'bar']"),
+            array('e[foo!="bar"][foo!="baz"]', "e[(not(@foo) or @foo != 'bar') and (not(@foo) or @foo != 'baz')]"),
+            array('e[hreflang|="en"]', "e[@hreflang and (@hreflang = 'en' or starts-with(@hreflang, 'en-'))]"),
+            array('e:nth-child(1)', "*/*[(name() = 'e') and (position() = 1)]"),
+            array('e:nth-last-child(1)', "*/*[(name() = 'e') and (position() = last() - 0)]"),
+            array('e:nth-last-child(2n+2)', "*/*[(name() = 'e') and (last() - position() - 1 >= 0 and (last() - position() - 1) mod 2 = 0)]"),
+            array('e:nth-of-type(1)', '*/e[position() = 1]'),
+            array('e:nth-last-of-type(1)', '*/e[position() = last() - 0]'),
+            array('div e:nth-last-of-type(1) .aclass', "div/descendant-or-self::*/e[position() = last() - 0]/descendant-or-self::*/*[@class and contains(concat(' ', normalize-space(@class), ' '), ' aclass ')]"),
+            array('e:first-child', "*/*[(name() = 'e') and (position() = 1)]"),
+            array('e:last-child', "*/*[(name() = 'e') and (position() = last())]"),
+            array('e:first-of-type', '*/e[position() = 1]'),
+            array('e:last-of-type', '*/e[position() = last()]'),
+            array('e:only-child', "*/*[(name() = 'e') and (last() = 1)]"),
+            array('e:only-of-type', 'e[last() = 1]'),
+            array('e:empty', 'e[not(*) and not(string-length())]'),
+            array('e:EmPTY', 'e[not(*) and not(string-length())]'),
+            array('e:root', 'e[not(parent::*)]'),
+            array('e:hover', 'e[0]'),
+            array('e:contains("foo")', "e[contains(string(.), 'foo')]"),
+            array('e:ConTains(foo)', "e[contains(string(.), 'foo')]"),
+            array('e.warning', "e[@class and contains(concat(' ', normalize-space(@class), ' '), ' warning ')]"),
+            array('e#myid', "e[@id = 'myid']"),
+            array('e:not(:nth-child(odd))', 'e[not(position() - 1 >= 0 and (position() - 1) mod 2 = 0)]'),
+            array('e:nOT(*)', 'e[0]'),
+            array('e f', 'e/descendant-or-self::*/f'),
+            array('e > f', 'e/f'),
+            array('e + f', "e/following-sibling::*[(name() = 'f') and (position() = 1)]"),
+            array('e ~ f', 'e/following-sibling::f'),
+            array('div#container p', "div[@id = 'container']/descendant-or-self::*/p"),
+        );
+    }
+
+    public function getXmlLangTestData()
+    {
+        return array(
+            array(':lang("EN")', array('first', 'second', 'third', 'fourth')),
+            array(':lang("en-us")', array('second', 'fourth')),
+            array(':lang(en-nz)', array('third')),
+            array(':lang(fr)', array('fifth')),
+            array(':lang(ru)', array('sixth')),
+            array(":lang('ZH')", array('eighth')),
+            array(':lang(de) :lang(zh)', array('eighth')),
+            array(':lang(en), :lang(zh)', array('first', 'second', 'third', 'fourth', 'eighth')),
+            array(':lang(es)', array()),
+        );
+    }
+
+    public function getHtmlIdsTestData()
+    {
+        return array(
+            array('div', array('outer-div', 'li-div', 'foobar-div')),
+            array('DIV', array('outer-div', 'li-div', 'foobar-div')),  // case-insensitive in HTML
+            array('div div', array('li-div')),
+            array('div, div div', array('outer-div', 'li-div', 'foobar-div')),
+            array('a[name]', array('name-anchor')),
+            array('a[NAme]', array('name-anchor')), // case-insensitive in HTML:
+            array('a[rel]', array('tag-anchor', 'nofollow-anchor')),
+            array('a[rel="tag"]', array('tag-anchor')),
+            array('a[href*="localhost"]', array('tag-anchor')),
+            array('a[href*=""]', array()),
+            array('a[href^="http"]', array('tag-anchor', 'nofollow-anchor')),
+            array('a[href^="http:"]', array('tag-anchor')),
+            array('a[href^=""]', array()),
+            array('a[href$="org"]', array('nofollow-anchor')),
+            array('a[href$=""]', array()),
+            array('div[foobar~="bc"]', array('foobar-div')),
+            array('div[foobar~="cde"]', array('foobar-div')),
+            array('[foobar~="ab bc"]', array('foobar-div')),
+            array('[foobar~=""]', array()),
+            array('[foobar~=" \t"]', array()),
+            array('div[foobar~="cd"]', array()),
+            array('*[lang|="En"]', array('second-li')),
+            array('[lang|="En-us"]', array('second-li')),
+            // Attribute values are case sensitive
+            array('*[lang|="en"]', array()),
+            array('[lang|="en-US"]', array()),
+            array('*[lang|="e"]', array()),
+            // ... :lang() is not.
+            array(':lang("EN")', array('second-li', 'li-div')),
+            array('*:lang(en-US)', array('second-li', 'li-div')),
+            array(':lang("e")', array()),
+            array('li:nth-child(3)', array('third-li')),
+            array('li:nth-child(10)', array()),
+            array('li:nth-child(2n)', array('second-li', 'fourth-li', 'sixth-li')),
+            array('li:nth-child(even)', array('second-li', 'fourth-li', 'sixth-li')),
+            array('li:nth-child(2n+0)', array('second-li', 'fourth-li', 'sixth-li')),
+            array('li:nth-child(+2n+1)', array('first-li', 'third-li', 'fifth-li', 'seventh-li')),
+            array('li:nth-child(odd)', array('first-li', 'third-li', 'fifth-li', 'seventh-li')),
+            array('li:nth-child(2n+4)', array('fourth-li', 'sixth-li')),
+            array('li:nth-child(3n+1)', array('first-li', 'fourth-li', 'seventh-li')),
+            array('li:nth-child(n)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-child(n-1)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-child(n+1)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-child(n+3)', array('third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-child(-n)', array()),
+            array('li:nth-child(-n-1)', array()),
+            array('li:nth-child(-n+1)', array('first-li')),
+            array('li:nth-child(-n+3)', array('first-li', 'second-li', 'third-li')),
+            array('li:nth-last-child(0)', array()),
+            array('li:nth-last-child(2n)', array('second-li', 'fourth-li', 'sixth-li')),
+            array('li:nth-last-child(even)', array('second-li', 'fourth-li', 'sixth-li')),
+            array('li:nth-last-child(2n+2)', array('second-li', 'fourth-li', 'sixth-li')),
+            array('li:nth-last-child(n)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-last-child(n-1)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-last-child(n-3)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-last-child(n+1)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li', 'sixth-li', 'seventh-li')),
+            array('li:nth-last-child(n+3)', array('first-li', 'second-li', 'third-li', 'fourth-li', 'fifth-li')),
+            array('li:nth-last-child(-n)', array()),
+            array('li:nth-last-child(-n-1)', array()),
+            array('li:nth-last-child(-n+1)', array('seventh-li')),
+            array('li:nth-last-child(-n+3)', array('fifth-li', 'sixth-li', 'seventh-li')),
+            array('ol:first-of-type', array('first-ol')),
+            array('ol:nth-child(1)', array('first-ol')),
+            array('ol:nth-of-type(2)', array('second-ol')),
+            array('ol:nth-last-of-type(1)', array('second-ol')),
+            array('span:only-child', array('foobar-span')),
+            array('li div:only-child', array('li-div')),
+            array('div *:only-child', array('li-div', 'foobar-span')),
+            array('p:only-of-type', array('paragraph')),
+            array('a:empty', array('name-anchor')),
+            array('a:EMpty', array('name-anchor')),
+            array('li:empty', array('third-li', 'fourth-li', 'fifth-li', 'sixth-li')),
+            array(':root', array('html')),
+            array('html:root', array('html')),
+            array('li:root', array()),
+            array('* :root', array()),
+            array('*:contains("link")', array('html', 'outer-div', 'tag-anchor', 'nofollow-anchor')),
+            array(':CONtains("link")', array('html', 'outer-div', 'tag-anchor', 'nofollow-anchor')),
+            array('*:contains("LInk")', array()),  // case sensitive
+            array('*:contains("e")', array('html', 'nil', 'outer-div', 'first-ol', 'first-li', 'paragraph', 'p-em')),
+            array('*:contains("E")', array()),  // case-sensitive
+            array('.a', array('first-ol')),
+            array('.b', array('first-ol')),
+            array('*.a', array('first-ol')),
+            array('ol.a', array('first-ol')),
+            array('.c', array('first-ol', 'third-li', 'fourth-li')),
+            array('*.c', array('first-ol', 'third-li', 'fourth-li')),
+            array('ol *.c', array('third-li', 'fourth-li')),
+            array('ol li.c', array('third-li', 'fourth-li')),
+            array('li ~ li.c', array('third-li', 'fourth-li')),
+            array('ol > li.c', array('third-li', 'fourth-li')),
+            array('#first-li', array('first-li')),
+            array('li#first-li', array('first-li')),
+            array('*#first-li', array('first-li')),
+            array('li div', array('li-div')),
+            array('li > div', array('li-div')),
+            array('div div', array('li-div')),
+            array('div > div', array()),
+            array('div>.c', array('first-ol')),
+            array('div > .c', array('first-ol')),
+            array('div + div', array('foobar-div')),
+            array('a ~ a', array('tag-anchor', 'nofollow-anchor')),
+            array('a[rel="tag"] ~ a', array('nofollow-anchor')),
+            array('ol#first-ol li:last-child', array('seventh-li')),
+            array('ol#first-ol *:last-child', array('li-div', 'seventh-li')),
+            array('#outer-div:first-child', array('outer-div')),
+            array('#outer-div :first-child', array('name-anchor', 'first-li', 'li-div', 'p-b', 'checkbox-fieldset-disabled', 'area-href')),
+            array('a[href]', array('tag-anchor', 'nofollow-anchor')),
+            array(':not(*)', array()),
+            array('a:not([href])', array('name-anchor')),
+            array('ol :Not(li[class])', array('first-li', 'second-li', 'li-div', 'fifth-li', 'sixth-li', 'seventh-li')),
+            // HTML-specific
+            array(':link', array('link-href', 'tag-anchor', 'nofollow-anchor', 'area-href')),
+            array(':visited', array()),
+            array(':enabled', array('link-href', 'tag-anchor', 'nofollow-anchor', 'checkbox-unchecked', 'text-checked', 'checkbox-checked', 'area-href')),
+            array(':disabled', array('checkbox-disabled', 'checkbox-disabled-checked', 'fieldset', 'checkbox-fieldset-disabled')),
+            array(':checked', array('checkbox-checked', 'checkbox-disabled-checked')),
+        );
+    }
+
+    public function getHtmlShakespearTestData()
+    {
+        return array(
+            array('*', 246),
+            array('div:contains(CELIA)', 26),
+            array('div:only-child', 22), // ?
+            array('div:nth-child(even)', 106),
+            array('div:nth-child(2n)', 106),
+            array('div:nth-child(odd)', 137),
+            array('div:nth-child(2n+1)', 137),
+            array('div:nth-child(n)', 243),
+            array('div:last-child', 53),
+            array('div:first-child', 51),
+            array('div > div', 242),
+            array('div + div', 190),
+            array('div ~ div', 190),
+            array('body', 1),
+            array('body div', 243),
+            array('div', 243),
+            array('div div', 242),
+            array('div div div', 241),
+            array('div, div, div', 243),
+            array('div, a, span', 243),
+            array('.dialog', 51),
+            array('div.dialog', 51),
+            array('div .dialog', 51),
+            array('div.character, div.dialog', 99),
+            array('div.direction.dialog', 0),
+            array('div.dialog.direction', 0),
+            array('div.dialog.scene', 1),
+            array('div.scene.scene', 1),
+            array('div.scene .scene', 0),
+            array('div.direction .dialog ', 0),
+            array('div .dialog .direction', 4),
+            array('div.dialog .dialog .direction', 4),
+            array('#speech5', 1),
+            array('div#speech5', 1),
+            array('div #speech5', 1),
+            array('div.scene div.dialog', 49),
+            array('div#scene1 div.dialog div', 142),
+            array('#scene1 #speech1', 1),
+            array('div[class]', 103),
+            array('div[class=dialog]', 50),
+            array('div[class^=dia]', 51),
+            array('div[class$=log]', 50),
+            array('div[class*=sce]', 1),
+            array('div[class|=dialog]', 50), // ? Seems right
+            array('div[class!=madeup]', 243), // ? Seems right
+            array('div[class~=dialog]', 51), // ? Seems right
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AbstractExtension.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AbstractExtension.php
new file mode 100644 (file)
index 0000000..026ac06
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+/**
+ * XPath expression translator abstract extension.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+abstract class AbstractExtension implements ExtensionInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function getNodeTranslators()
+    {
+        return array();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getCombinationTranslators()
+    {
+        return array();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getFunctionTranslators()
+    {
+        return array();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getPseudoClassTranslators()
+    {
+        return array();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getAttributeMatchingTranslators()
+    {
+        return array();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php
new file mode 100644 (file)
index 0000000..8a4d884
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+use Symfony\Component\CssSelector\XPath\Translator;
+use Symfony\Component\CssSelector\XPath\XPathExpr;
+
+/**
+ * XPath expression translator attribute extension.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class AttributeMatchingExtension extends AbstractExtension
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function getAttributeMatchingTranslators()
+    {
+        return array(
+            'exists' => array($this, 'translateExists'),
+            '=' => array($this, 'translateEquals'),
+            '~=' => array($this, 'translateIncludes'),
+            '|=' => array($this, 'translateDashMatch'),
+            '^=' => array($this, 'translatePrefixMatch'),
+            '$=' => array($this, 'translateSuffixMatch'),
+            '*=' => array($this, 'translateSubstringMatch'),
+            '!=' => array($this, 'translateDifferent'),
+        );
+    }
+
+    public function translateExists(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition($attribute);
+    }
+
+    public function translateEquals(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition(sprintf('%s = %s', $attribute, Translator::getXpathLiteral($value)));
+    }
+
+    public function translateIncludes(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition($value ? sprintf(
+            '%1$s and contains(concat(\' \', normalize-space(%1$s), \' \'), %2$s)',
+            $attribute,
+            Translator::getXpathLiteral(' '.$value.' ')
+        ) : '0');
+    }
+
+    public function translateDashMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition(sprintf(
+            '%1$s and (%1$s = %2$s or starts-with(%1$s, %3$s))',
+            $attribute,
+            Translator::getXpathLiteral($value),
+            Translator::getXpathLiteral($value.'-')
+        ));
+    }
+
+    public function translatePrefixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition($value ? sprintf(
+            '%1$s and starts-with(%1$s, %2$s)',
+            $attribute,
+            Translator::getXpathLiteral($value)
+        ) : '0');
+    }
+
+    public function translateSuffixMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition($value ? sprintf(
+            '%1$s and substring(%1$s, string-length(%1$s)-%2$s) = %3$s',
+            $attribute,
+            \strlen($value) - 1,
+            Translator::getXpathLiteral($value)
+        ) : '0');
+    }
+
+    public function translateSubstringMatch(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition($value ? sprintf(
+            '%1$s and contains(%1$s, %2$s)',
+            $attribute,
+            Translator::getXpathLiteral($value)
+        ) : '0');
+    }
+
+    public function translateDifferent(XPathExpr $xpath, string $attribute, ?string $value): XPathExpr
+    {
+        return $xpath->addCondition(sprintf(
+            $value ? 'not(%1$s) or %1$s != %2$s' : '%s != %s',
+            $attribute,
+            Translator::getXpathLiteral($value)
+        ));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return 'attribute-matching';
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/CombinationExtension.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/CombinationExtension.php
new file mode 100644 (file)
index 0000000..85181fd
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+use Symfony\Component\CssSelector\XPath\XPathExpr;
+
+/**
+ * XPath expression translator combination extension.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class CombinationExtension extends AbstractExtension
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function getCombinationTranslators(): array
+    {
+        return array(
+            ' ' => array($this, 'translateDescendant'),
+            '>' => array($this, 'translateChild'),
+            '+' => array($this, 'translateDirectAdjacent'),
+            '~' => array($this, 'translateIndirectAdjacent'),
+        );
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateDescendant(XPathExpr $xpath, XPathExpr $combinedXpath): XPathExpr
+    {
+        return $xpath->join('/descendant-or-self::*/', $combinedXpath);
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateChild(XPathExpr $xpath, XPathExpr $combinedXpath)
+    {
+        return $xpath->join('/', $combinedXpath);
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateDirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath)
+    {
+        return $xpath
+            ->join('/following-sibling::', $combinedXpath)
+            ->addNameTest()
+            ->addCondition('position() = 1');
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateIndirectAdjacent(XPathExpr $xpath, XPathExpr $combinedXpath)
+    {
+        return $xpath->join('/following-sibling::', $combinedXpath);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return 'combination';
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/ExtensionInterface.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/ExtensionInterface.php
new file mode 100644 (file)
index 0000000..3607022
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+/**
+ * XPath expression translator extension interface.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+interface ExtensionInterface
+{
+    /**
+     * Returns node translators.
+     *
+     * These callables will receive the node as first argument and the translator as second argument.
+     *
+     * @return callable[]
+     */
+    public function getNodeTranslators();
+
+    /**
+     * Returns combination translators.
+     *
+     * @return callable[]
+     */
+    public function getCombinationTranslators();
+
+    /**
+     * Returns function translators.
+     *
+     * @return callable[]
+     */
+    public function getFunctionTranslators();
+
+    /**
+     * Returns pseudo-class translators.
+     *
+     * @return callable[]
+     */
+    public function getPseudoClassTranslators();
+
+    /**
+     * Returns attribute operation translators.
+     *
+     * @return callable[]
+     */
+    public function getAttributeMatchingTranslators();
+
+    /**
+     * Returns extension name.
+     *
+     * @return string
+     */
+    public function getName();
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/FunctionExtension.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/FunctionExtension.php
new file mode 100644 (file)
index 0000000..ebe508f
--- /dev/null
@@ -0,0 +1,171 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
+use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
+use Symfony\Component\CssSelector\Node\FunctionNode;
+use Symfony\Component\CssSelector\Parser\Parser;
+use Symfony\Component\CssSelector\XPath\Translator;
+use Symfony\Component\CssSelector\XPath\XPathExpr;
+
+/**
+ * XPath expression translator function extension.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class FunctionExtension extends AbstractExtension
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function getFunctionTranslators()
+    {
+        return array(
+            'nth-child' => array($this, 'translateNthChild'),
+            'nth-last-child' => array($this, 'translateNthLastChild'),
+            'nth-of-type' => array($this, 'translateNthOfType'),
+            'nth-last-of-type' => array($this, 'translateNthLastOfType'),
+            'contains' => array($this, 'translateContains'),
+            'lang' => array($this, 'translateLang'),
+        );
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function translateNthChild(XPathExpr $xpath, FunctionNode $function, bool $last = false, bool $addNameTest = true): XPathExpr
+    {
+        try {
+            list($a, $b) = Parser::parseSeries($function->getArguments());
+        } catch (SyntaxErrorException $e) {
+            throw new ExpressionErrorException(sprintf('Invalid series: %s', implode(', ', $function->getArguments())), 0, $e);
+        }
+
+        $xpath->addStarPrefix();
+        if ($addNameTest) {
+            $xpath->addNameTest();
+        }
+
+        if (0 === $a) {
+            return $xpath->addCondition('position() = '.($last ? 'last() - '.($b - 1) : $b));
+        }
+
+        if ($a < 0) {
+            if ($b < 1) {
+                return $xpath->addCondition('false()');
+            }
+
+            $sign = '<=';
+        } else {
+            $sign = '>=';
+        }
+
+        $expr = 'position()';
+
+        if ($last) {
+            $expr = 'last() - '.$expr;
+            --$b;
+        }
+
+        if (0 !== $b) {
+            $expr .= ' - '.$b;
+        }
+
+        $conditions = array(sprintf('%s %s 0', $expr, $sign));
+
+        if (1 !== $a && -1 !== $a) {
+            $conditions[] = sprintf('(%s) mod %d = 0', $expr, $a);
+        }
+
+        return $xpath->addCondition(implode(' and ', $conditions));
+
+        // todo: handle an+b, odd, even
+        // an+b means every-a, plus b, e.g., 2n+1 means odd
+        // 0n+b means b
+        // n+0 means a=1, i.e., all elements
+        // an means every a elements, i.e., 2n means even
+        // -n means -1n
+        // -1n+6 means elements 6 and previous
+    }
+
+    public function translateNthLastChild(XPathExpr $xpath, FunctionNode $function): XPathExpr
+    {
+        return $this->translateNthChild($xpath, $function, true);
+    }
+
+    public function translateNthOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr
+    {
+        return $this->translateNthChild($xpath, $function, false, false);
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function translateNthLastOfType(XPathExpr $xpath, FunctionNode $function): XPathExpr
+    {
+        if ('*' === $xpath->getElement()) {
+            throw new ExpressionErrorException('"*:nth-of-type()" is not implemented.');
+        }
+
+        return $this->translateNthChild($xpath, $function, true, false);
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function translateContains(XPathExpr $xpath, FunctionNode $function): XPathExpr
+    {
+        $arguments = $function->getArguments();
+        foreach ($arguments as $token) {
+            if (!($token->isString() || $token->isIdentifier())) {
+                throw new ExpressionErrorException('Expected a single string or identifier for :contains(), got '.implode(', ', $arguments));
+            }
+        }
+
+        return $xpath->addCondition(sprintf(
+            'contains(string(.), %s)',
+            Translator::getXpathLiteral($arguments[0]->getValue())
+        ));
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function translateLang(XPathExpr $xpath, FunctionNode $function): XPathExpr
+    {
+        $arguments = $function->getArguments();
+        foreach ($arguments as $token) {
+            if (!($token->isString() || $token->isIdentifier())) {
+                throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments));
+            }
+        }
+
+        return $xpath->addCondition(sprintf(
+            'lang(%s)',
+            Translator::getXpathLiteral($arguments[0]->getValue())
+        ));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return 'function';
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/HtmlExtension.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/HtmlExtension.php
new file mode 100644 (file)
index 0000000..cd8e0d5
--- /dev/null
@@ -0,0 +1,213 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
+use Symfony\Component\CssSelector\Node\FunctionNode;
+use Symfony\Component\CssSelector\XPath\Translator;
+use Symfony\Component\CssSelector\XPath\XPathExpr;
+
+/**
+ * XPath expression translator HTML extension.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class HtmlExtension extends AbstractExtension
+{
+    public function __construct(Translator $translator)
+    {
+        $translator
+            ->getExtension('node')
+            ->setFlag(NodeExtension::ELEMENT_NAME_IN_LOWER_CASE, true)
+            ->setFlag(NodeExtension::ATTRIBUTE_NAME_IN_LOWER_CASE, true);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getPseudoClassTranslators()
+    {
+        return array(
+            'checked' => array($this, 'translateChecked'),
+            'link' => array($this, 'translateLink'),
+            'disabled' => array($this, 'translateDisabled'),
+            'enabled' => array($this, 'translateEnabled'),
+            'selected' => array($this, 'translateSelected'),
+            'invalid' => array($this, 'translateInvalid'),
+            'hover' => array($this, 'translateHover'),
+            'visited' => array($this, 'translateVisited'),
+        );
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getFunctionTranslators()
+    {
+        return array(
+            'lang' => array($this, 'translateLang'),
+        );
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateChecked(XPathExpr $xpath)
+    {
+        return $xpath->addCondition(
+            '(@checked '
+            ."and (name(.) = 'input' or name(.) = 'command')"
+            ."and (@type = 'checkbox' or @type = 'radio'))"
+        );
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateLink(XPathExpr $xpath)
+    {
+        return $xpath->addCondition("@href and (name(.) = 'a' or name(.) = 'link' or name(.) = 'area')");
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateDisabled(XPathExpr $xpath)
+    {
+        return $xpath->addCondition(
+            '('
+                .'@disabled and'
+                .'('
+                    ."(name(.) = 'input' and @type != 'hidden')"
+                    ." or name(.) = 'button'"
+                    ." or name(.) = 'select'"
+                    ." or name(.) = 'textarea'"
+                    ." or name(.) = 'command'"
+                    ." or name(.) = 'fieldset'"
+                    ." or name(.) = 'optgroup'"
+                    ." or name(.) = 'option'"
+                .')'
+            .') or ('
+                ."(name(.) = 'input' and @type != 'hidden')"
+                ." or name(.) = 'button'"
+                ." or name(.) = 'select'"
+                ." or name(.) = 'textarea'"
+            .')'
+            .' and ancestor::fieldset[@disabled]'
+        );
+        // todo: in the second half, add "and is not a descendant of that fieldset element's first legend element child, if any."
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateEnabled(XPathExpr $xpath)
+    {
+        return $xpath->addCondition(
+            '('
+                .'@href and ('
+                    ."name(.) = 'a'"
+                    ." or name(.) = 'link'"
+                    ." or name(.) = 'area'"
+                .')'
+            .') or ('
+                .'('
+                    ."name(.) = 'command'"
+                    ." or name(.) = 'fieldset'"
+                    ." or name(.) = 'optgroup'"
+                .')'
+                .' and not(@disabled)'
+            .') or ('
+                .'('
+                    ."(name(.) = 'input' and @type != 'hidden')"
+                    ." or name(.) = 'button'"
+                    ." or name(.) = 'select'"
+                    ." or name(.) = 'textarea'"
+                    ." or name(.) = 'keygen'"
+                .')'
+                .' and not (@disabled or ancestor::fieldset[@disabled])'
+            .') or ('
+                ."name(.) = 'option' and not("
+                    .'@disabled or ancestor::optgroup[@disabled]'
+                .')'
+            .')'
+        );
+    }
+
+    /**
+     * @return XPathExpr
+     *
+     * @throws ExpressionErrorException
+     */
+    public function translateLang(XPathExpr $xpath, FunctionNode $function)
+    {
+        $arguments = $function->getArguments();
+        foreach ($arguments as $token) {
+            if (!($token->isString() || $token->isIdentifier())) {
+                throw new ExpressionErrorException('Expected a single string or identifier for :lang(), got '.implode(', ', $arguments));
+            }
+        }
+
+        return $xpath->addCondition(sprintf(
+            'ancestor-or-self::*[@lang][1][starts-with(concat('
+            ."translate(@%s, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '-')"
+            .', %s)]',
+            'lang',
+            Translator::getXpathLiteral(strtolower($arguments[0]->getValue()).'-')
+        ));
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateSelected(XPathExpr $xpath)
+    {
+        return $xpath->addCondition("(@selected and name(.) = 'option')");
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateInvalid(XPathExpr $xpath)
+    {
+        return $xpath->addCondition('0');
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateHover(XPathExpr $xpath)
+    {
+        return $xpath->addCondition('0');
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateVisited(XPathExpr $xpath)
+    {
+        return $xpath->addCondition('0');
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return 'html';
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/NodeExtension.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/NodeExtension.php
new file mode 100644 (file)
index 0000000..61442b6
--- /dev/null
@@ -0,0 +1,197 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+use Symfony\Component\CssSelector\Node;
+use Symfony\Component\CssSelector\XPath\Translator;
+use Symfony\Component\CssSelector\XPath\XPathExpr;
+
+/**
+ * XPath expression translator node extension.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class NodeExtension extends AbstractExtension
+{
+    const ELEMENT_NAME_IN_LOWER_CASE = 1;
+    const ATTRIBUTE_NAME_IN_LOWER_CASE = 2;
+    const ATTRIBUTE_VALUE_IN_LOWER_CASE = 4;
+
+    private $flags;
+
+    public function __construct(int $flags = 0)
+    {
+        $this->flags = $flags;
+    }
+
+    /**
+     * @return $this
+     */
+    public function setFlag(int $flag, bool $on)
+    {
+        if ($on && !$this->hasFlag($flag)) {
+            $this->flags += $flag;
+        }
+
+        if (!$on && $this->hasFlag($flag)) {
+            $this->flags -= $flag;
+        }
+
+        return $this;
+    }
+
+    public function hasFlag(int $flag): bool
+    {
+        return (bool) ($this->flags & $flag);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getNodeTranslators()
+    {
+        return array(
+            'Selector' => array($this, 'translateSelector'),
+            'CombinedSelector' => array($this, 'translateCombinedSelector'),
+            'Negation' => array($this, 'translateNegation'),
+            'Function' => array($this, 'translateFunction'),
+            'Pseudo' => array($this, 'translatePseudo'),
+            'Attribute' => array($this, 'translateAttribute'),
+            'Class' => array($this, 'translateClass'),
+            'Hash' => array($this, 'translateHash'),
+            'Element' => array($this, 'translateElement'),
+        );
+    }
+
+    public function translateSelector(Node\SelectorNode $node, Translator $translator): XPathExpr
+    {
+        return $translator->nodeToXPath($node->getTree());
+    }
+
+    public function translateCombinedSelector(Node\CombinedSelectorNode $node, Translator $translator): XPathExpr
+    {
+        return $translator->addCombination($node->getCombinator(), $node->getSelector(), $node->getSubSelector());
+    }
+
+    public function translateNegation(Node\NegationNode $node, Translator $translator): XPathExpr
+    {
+        $xpath = $translator->nodeToXPath($node->getSelector());
+        $subXpath = $translator->nodeToXPath($node->getSubSelector());
+        $subXpath->addNameTest();
+
+        if ($subXpath->getCondition()) {
+            return $xpath->addCondition(sprintf('not(%s)', $subXpath->getCondition()));
+        }
+
+        return $xpath->addCondition('0');
+    }
+
+    public function translateFunction(Node\FunctionNode $node, Translator $translator): XPathExpr
+    {
+        $xpath = $translator->nodeToXPath($node->getSelector());
+
+        return $translator->addFunction($xpath, $node);
+    }
+
+    public function translatePseudo(Node\PseudoNode $node, Translator $translator): XPathExpr
+    {
+        $xpath = $translator->nodeToXPath($node->getSelector());
+
+        return $translator->addPseudoClass($xpath, $node->getIdentifier());
+    }
+
+    public function translateAttribute(Node\AttributeNode $node, Translator $translator): XPathExpr
+    {
+        $name = $node->getAttribute();
+        $safe = $this->isSafeName($name);
+
+        if ($this->hasFlag(self::ATTRIBUTE_NAME_IN_LOWER_CASE)) {
+            $name = strtolower($name);
+        }
+
+        if ($node->getNamespace()) {
+            $name = sprintf('%s:%s', $node->getNamespace(), $name);
+            $safe = $safe && $this->isSafeName($node->getNamespace());
+        }
+
+        $attribute = $safe ? '@'.$name : sprintf('attribute::*[name() = %s]', Translator::getXpathLiteral($name));
+        $value = $node->getValue();
+        $xpath = $translator->nodeToXPath($node->getSelector());
+
+        if ($this->hasFlag(self::ATTRIBUTE_VALUE_IN_LOWER_CASE)) {
+            $value = strtolower($value);
+        }
+
+        return $translator->addAttributeMatching($xpath, $node->getOperator(), $attribute, $value);
+    }
+
+    public function translateClass(Node\ClassNode $node, Translator $translator): XPathExpr
+    {
+        $xpath = $translator->nodeToXPath($node->getSelector());
+
+        return $translator->addAttributeMatching($xpath, '~=', '@class', $node->getName());
+    }
+
+    public function translateHash(Node\HashNode $node, Translator $translator): XPathExpr
+    {
+        $xpath = $translator->nodeToXPath($node->getSelector());
+
+        return $translator->addAttributeMatching($xpath, '=', '@id', $node->getId());
+    }
+
+    public function translateElement(Node\ElementNode $node): XPathExpr
+    {
+        $element = $node->getElement();
+
+        if ($this->hasFlag(self::ELEMENT_NAME_IN_LOWER_CASE)) {
+            $element = strtolower($element);
+        }
+
+        if ($element) {
+            $safe = $this->isSafeName($element);
+        } else {
+            $element = '*';
+            $safe = true;
+        }
+
+        if ($node->getNamespace()) {
+            $element = sprintf('%s:%s', $node->getNamespace(), $element);
+            $safe = $safe && $this->isSafeName($node->getNamespace());
+        }
+
+        $xpath = new XPathExpr('', $element);
+
+        if (!$safe) {
+            $xpath->addNameTest();
+        }
+
+        return $xpath;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return 'node';
+    }
+
+    private function isSafeName(string $name): bool
+    {
+        return 0 < preg_match('~^[a-zA-Z_][a-zA-Z0-9_.-]*$~', $name);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/PseudoClassExtension.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/PseudoClassExtension.php
new file mode 100644 (file)
index 0000000..378dfb7
--- /dev/null
@@ -0,0 +1,148 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath\Extension;
+
+use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
+use Symfony\Component\CssSelector\XPath\XPathExpr;
+
+/**
+ * XPath expression translator pseudo-class extension.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class PseudoClassExtension extends AbstractExtension
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function getPseudoClassTranslators()
+    {
+        return array(
+            'root' => array($this, 'translateRoot'),
+            'first-child' => array($this, 'translateFirstChild'),
+            'last-child' => array($this, 'translateLastChild'),
+            'first-of-type' => array($this, 'translateFirstOfType'),
+            'last-of-type' => array($this, 'translateLastOfType'),
+            'only-child' => array($this, 'translateOnlyChild'),
+            'only-of-type' => array($this, 'translateOnlyOfType'),
+            'empty' => array($this, 'translateEmpty'),
+        );
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateRoot(XPathExpr $xpath)
+    {
+        return $xpath->addCondition('not(parent::*)');
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateFirstChild(XPathExpr $xpath)
+    {
+        return $xpath
+            ->addStarPrefix()
+            ->addNameTest()
+            ->addCondition('position() = 1');
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateLastChild(XPathExpr $xpath)
+    {
+        return $xpath
+            ->addStarPrefix()
+            ->addNameTest()
+            ->addCondition('position() = last()');
+    }
+
+    /**
+     * @return XPathExpr
+     *
+     * @throws ExpressionErrorException
+     */
+    public function translateFirstOfType(XPathExpr $xpath)
+    {
+        if ('*' === $xpath->getElement()) {
+            throw new ExpressionErrorException('"*:first-of-type" is not implemented.');
+        }
+
+        return $xpath
+            ->addStarPrefix()
+            ->addCondition('position() = 1');
+    }
+
+    /**
+     * @return XPathExpr
+     *
+     * @throws ExpressionErrorException
+     */
+    public function translateLastOfType(XPathExpr $xpath)
+    {
+        if ('*' === $xpath->getElement()) {
+            throw new ExpressionErrorException('"*:last-of-type" is not implemented.');
+        }
+
+        return $xpath
+            ->addStarPrefix()
+            ->addCondition('position() = last()');
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateOnlyChild(XPathExpr $xpath)
+    {
+        return $xpath
+            ->addStarPrefix()
+            ->addNameTest()
+            ->addCondition('last() = 1');
+    }
+
+    /**
+     * @return XPathExpr
+     *
+     * @throws ExpressionErrorException
+     */
+    public function translateOnlyOfType(XPathExpr $xpath)
+    {
+        if ('*' === $xpath->getElement()) {
+            throw new ExpressionErrorException('"*:only-of-type" is not implemented.');
+        }
+
+        return $xpath->addCondition('last() = 1');
+    }
+
+    /**
+     * @return XPathExpr
+     */
+    public function translateEmpty(XPathExpr $xpath)
+    {
+        return $xpath->addCondition('not(*) and not(string-length())');
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return 'pseudo-class';
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Translator.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Translator.php
new file mode 100644 (file)
index 0000000..97e4dfb
--- /dev/null
@@ -0,0 +1,224 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath;
+
+use Symfony\Component\CssSelector\Exception\ExpressionErrorException;
+use Symfony\Component\CssSelector\Node\FunctionNode;
+use Symfony\Component\CssSelector\Node\NodeInterface;
+use Symfony\Component\CssSelector\Node\SelectorNode;
+use Symfony\Component\CssSelector\Parser\Parser;
+use Symfony\Component\CssSelector\Parser\ParserInterface;
+
+/**
+ * XPath expression translator interface.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class Translator implements TranslatorInterface
+{
+    private $mainParser;
+
+    /**
+     * @var ParserInterface[]
+     */
+    private $shortcutParsers = array();
+
+    /**
+     * @var Extension\ExtensionInterface[]
+     */
+    private $extensions = array();
+
+    private $nodeTranslators = array();
+    private $combinationTranslators = array();
+    private $functionTranslators = array();
+    private $pseudoClassTranslators = array();
+    private $attributeMatchingTranslators = array();
+
+    public function __construct(ParserInterface $parser = null)
+    {
+        $this->mainParser = $parser ?: new Parser();
+
+        $this
+            ->registerExtension(new Extension\NodeExtension())
+            ->registerExtension(new Extension\CombinationExtension())
+            ->registerExtension(new Extension\FunctionExtension())
+            ->registerExtension(new Extension\PseudoClassExtension())
+            ->registerExtension(new Extension\AttributeMatchingExtension())
+        ;
+    }
+
+    public static function getXpathLiteral(string $element): string
+    {
+        if (false === strpos($element, "'")) {
+            return "'".$element."'";
+        }
+
+        if (false === strpos($element, '"')) {
+            return '"'.$element.'"';
+        }
+
+        $string = $element;
+        $parts = array();
+        while (true) {
+            if (false !== $pos = strpos($string, "'")) {
+                $parts[] = sprintf("'%s'", substr($string, 0, $pos));
+                $parts[] = "\"'\"";
+                $string = substr($string, $pos + 1);
+            } else {
+                $parts[] = "'$string'";
+                break;
+            }
+        }
+
+        return sprintf('concat(%s)', implode(', ', $parts));
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string
+    {
+        $selectors = $this->parseSelectors($cssExpr);
+
+        /** @var SelectorNode $selector */
+        foreach ($selectors as $index => $selector) {
+            if (null !== $selector->getPseudoElement()) {
+                throw new ExpressionErrorException('Pseudo-elements are not supported.');
+            }
+
+            $selectors[$index] = $this->selectorToXPath($selector, $prefix);
+        }
+
+        return implode(' | ', $selectors);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string
+    {
+        return ($prefix ?: '').$this->nodeToXPath($selector);
+    }
+
+    public function registerExtension(Extension\ExtensionInterface $extension): self
+    {
+        $this->extensions[$extension->getName()] = $extension;
+
+        $this->nodeTranslators = array_merge($this->nodeTranslators, $extension->getNodeTranslators());
+        $this->combinationTranslators = array_merge($this->combinationTranslators, $extension->getCombinationTranslators());
+        $this->functionTranslators = array_merge($this->functionTranslators, $extension->getFunctionTranslators());
+        $this->pseudoClassTranslators = array_merge($this->pseudoClassTranslators, $extension->getPseudoClassTranslators());
+        $this->attributeMatchingTranslators = array_merge($this->attributeMatchingTranslators, $extension->getAttributeMatchingTranslators());
+
+        return $this;
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function getExtension(string $name): Extension\ExtensionInterface
+    {
+        if (!isset($this->extensions[$name])) {
+            throw new ExpressionErrorException(sprintf('Extension "%s" not registered.', $name));
+        }
+
+        return $this->extensions[$name];
+    }
+
+    public function registerParserShortcut(ParserInterface $shortcut): self
+    {
+        $this->shortcutParsers[] = $shortcut;
+
+        return $this;
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function nodeToXPath(NodeInterface $node): XPathExpr
+    {
+        if (!isset($this->nodeTranslators[$node->getNodeName()])) {
+            throw new ExpressionErrorException(sprintf('Node "%s" not supported.', $node->getNodeName()));
+        }
+
+        return \call_user_func($this->nodeTranslators[$node->getNodeName()], $node, $this);
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function addCombination(string $combiner, NodeInterface $xpath, NodeInterface $combinedXpath): XPathExpr
+    {
+        if (!isset($this->combinationTranslators[$combiner])) {
+            throw new ExpressionErrorException(sprintf('Combiner "%s" not supported.', $combiner));
+        }
+
+        return \call_user_func($this->combinationTranslators[$combiner], $this->nodeToXPath($xpath), $this->nodeToXPath($combinedXpath));
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function addFunction(XPathExpr $xpath, FunctionNode $function): XPathExpr
+    {
+        if (!isset($this->functionTranslators[$function->getName()])) {
+            throw new ExpressionErrorException(sprintf('Function "%s" not supported.', $function->getName()));
+        }
+
+        return \call_user_func($this->functionTranslators[$function->getName()], $xpath, $function);
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function addPseudoClass(XPathExpr $xpath, string $pseudoClass): XPathExpr
+    {
+        if (!isset($this->pseudoClassTranslators[$pseudoClass])) {
+            throw new ExpressionErrorException(sprintf('Pseudo-class "%s" not supported.', $pseudoClass));
+        }
+
+        return \call_user_func($this->pseudoClassTranslators[$pseudoClass], $xpath);
+    }
+
+    /**
+     * @throws ExpressionErrorException
+     */
+    public function addAttributeMatching(XPathExpr $xpath, string $operator, string $attribute, $value): XPathExpr
+    {
+        if (!isset($this->attributeMatchingTranslators[$operator])) {
+            throw new ExpressionErrorException(sprintf('Attribute matcher operator "%s" not supported.', $operator));
+        }
+
+        return \call_user_func($this->attributeMatchingTranslators[$operator], $xpath, $attribute, $value);
+    }
+
+    /**
+     * @return SelectorNode[]
+     */
+    private function parseSelectors(string $css)
+    {
+        foreach ($this->shortcutParsers as $shortcut) {
+            $tokens = $shortcut->parse($css);
+
+            if (!empty($tokens)) {
+                return $tokens;
+            }
+        }
+
+        return $this->mainParser->parse($css);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/TranslatorInterface.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/TranslatorInterface.php
new file mode 100644 (file)
index 0000000..c19eefb
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath;
+
+use Symfony\Component\CssSelector\Node\SelectorNode;
+
+/**
+ * XPath expression translator interface.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+interface TranslatorInterface
+{
+    /**
+     * Translates a CSS selector to an XPath expression.
+     */
+    public function cssToXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string;
+
+    /**
+     * Translates a parsed selector node to an XPath expression.
+     */
+    public function selectorToXPath(SelectorNode $selector, string $prefix = 'descendant-or-self::'): string;
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/XPathExpr.php b/wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/XPathExpr.php
new file mode 100644 (file)
index 0000000..638cbd0
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\CssSelector\XPath;
+
+/**
+ * XPath expression translator interface.
+ *
+ * This component is a port of the Python cssselect library,
+ * which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
+ *
+ * @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
+ *
+ * @internal
+ */
+class XPathExpr
+{
+    private $path;
+    private $element;
+    private $condition;
+
+    public function __construct(string $path = '', string $element = '*', string $condition = '', bool $starPrefix = false)
+    {
+        $this->path = $path;
+        $this->element = $element;
+        $this->condition = $condition;
+
+        if ($starPrefix) {
+            $this->addStarPrefix();
+        }
+    }
+
+    public function getElement(): string
+    {
+        return $this->element;
+    }
+
+    public function addCondition(string $condition): self
+    {
+        $this->condition = $this->condition ? sprintf('(%s) and (%s)', $this->condition, $condition) : $condition;
+
+        return $this;
+    }
+
+    public function getCondition(): string
+    {
+        return $this->condition;
+    }
+
+    public function addNameTest(): self
+    {
+        if ('*' !== $this->element) {
+            $this->addCondition('name() = '.Translator::getXpathLiteral($this->element));
+            $this->element = '*';
+        }
+
+        return $this;
+    }
+
+    public function addStarPrefix(): self
+    {
+        $this->path .= '*/';
+
+        return $this;
+    }
+
+    /**
+     * Joins another XPathExpr with a combiner.
+     *
+     * @return $this
+     */
+    public function join(string $combiner, self $expr): self
+    {
+        $path = $this->__toString().$combiner;
+
+        if ('*/' !== $expr->path) {
+            $path .= $expr->path;
+        }
+
+        $this->path = $path;
+        $this->element = $expr->element;
+        $this->condition = $expr->condition;
+
+        return $this;
+    }
+
+    public function __toString(): string
+    {
+        $path = $this->path.$this->element;
+        $condition = null === $this->condition || '' === $this->condition ? '' : '['.$this->condition.']';
+
+        return $path.$condition;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/composer.json b/wcfsetup/install/files/lib/system/api/symfony/css-selector/composer.json
new file mode 100644 (file)
index 0000000..ebe7d0d
--- /dev/null
@@ -0,0 +1,37 @@
+{
+    "name": "symfony/css-selector",
+    "type": "library",
+    "description": "Symfony CssSelector Component",
+    "keywords": [],
+    "homepage": "https://symfony.com",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Fabien Potencier",
+            "email": "fabien@symfony.com"
+        },
+        {
+            "name": "Jean-François Simon",
+            "email": "jeanfrancois.simon@sensiolabs.com"
+        },
+        {
+            "name": "Symfony Community",
+            "homepage": "https://symfony.com/contributors"
+        }
+    ],
+    "require": {
+        "php": "^7.1.3"
+    },
+    "autoload": {
+        "psr-4": { "Symfony\\Component\\CssSelector\\": "" },
+        "exclude-from-classmap": [
+            "/Tests/"
+        ]
+    },
+    "minimum-stability": "dev",
+    "extra": {
+        "branch-alias": {
+            "dev-master": "4.2-dev"
+        }
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/api/symfony/css-selector/phpunit.xml.dist b/wcfsetup/install/files/lib/system/api/symfony/css-selector/phpunit.xml.dist
new file mode 100644 (file)
index 0000000..a8e537e
--- /dev/null
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
+         backupGlobals="false"
+         colors="true"
+         bootstrap="vendor/autoload.php"
+         failOnRisky="true"
+         failOnWarning="true"
+>
+    <php>
+        <ini name="error_reporting" value="-1" />
+    </php>
+
+    <testsuites>
+        <testsuite name="Symfony CssSelector Component Test Suite">
+            <directory>./Tests/</directory>
+        </testsuite>
+    </testsuites>
+
+    <filter>
+        <whitelist>
+            <directory>./</directory>
+            <exclude>
+                <directory>./Resources</directory>
+                <directory>./Tests</directory>
+                <directory>./vendor</directory>
+            </exclude>
+        </whitelist>
+    </filter>
+</phpunit>