From 5ef47e854d57370a701a5da1172b465da447b2e7 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Tim=20D=C3=BCsterhus?= Date: Mon, 10 Dec 2018 13:05:34 +0100 Subject: [PATCH] Update to pelago/emogrifier 2.1.* --- .../files/lib/system/api/composer.json | 2 +- .../files/lib/system/api/composer.lock | 84 +- .../lib/system/api/composer/autoload_psr4.php | 3 +- .../system/api/composer/autoload_static.php | 7 +- .../lib/system/api/composer/installed.json | 86 +- .../pelago/emogrifier/.github/CONTRIBUTING.md | 37 +- .../system/api/pelago/emogrifier/.gitignore | 1 + .../system/api/pelago/emogrifier/.travis.yml | 65 +- .../system/api/pelago/emogrifier/CHANGELOG.md | 148 +- .../lib/system/api/pelago/emogrifier/LICENSE | 28 +- .../system/api/pelago/emogrifier/README.md | 79 +- .../api/pelago/emogrifier/composer.json | 62 +- .../pelago/emogrifier/config/php-cs-fixer.php | 93 + .../api/pelago/emogrifier/config/phpmd.xml | 49 + .../Emogrifier/ruleset.xml => phpcs.xml.dist} | 35 +- .../{Classes => src}/Emogrifier.php | 1020 +++--- .../src/Emogrifier/CssConcatenator.php | 154 + .../emogrifier/src/Emogrifier/CssInliner.php | 1346 ++++++++ .../HtmlProcessor/AbstractHtmlProcessor.php | 221 ++ .../HtmlProcessor/CssToAttributeConverter.php | 320 ++ .../HtmlProcessor/HtmlNormalizer.php | 18 + .../tests/Support/Traits/AssertCss.php | 100 + .../emogrifier/tests/Unit/CssInlinerTest.php | 2930 +++++++++++++++++ .../Unit/Emogrifier/CssConcatenatorTest.php | 317 ++ .../AbstractHtmlProcessorTest.php | 385 +++ .../CssToAttributeConverterTest.php | 166 + .../Fixtures/TestingHtmlProcessor.php | 14 + .../HtmlProcessor/HtmlNormalizerTest.php | 22 + .../{Tests => tests}/Unit/EmogrifierTest.php | 1406 ++++++-- .../Unit/Support/Traits/AssertCssTest.php | 333 ++ .../api/symfony/css-selector/.gitignore | 3 + .../api/symfony/css-selector/CHANGELOG.md | 13 + .../css-selector/CssSelectorConverter.php | 65 + .../Exception/ExceptionInterface.php | 24 + .../Exception/ExpressionErrorException.php | 24 + .../Exception/InternalErrorException.php | 24 + .../css-selector/Exception/ParseException.php | 24 + .../Exception/SyntaxErrorException.php | 73 + .../system/api/symfony/css-selector/LICENSE | 19 + .../css-selector/Node/AbstractNode.php | 42 + .../css-selector/Node/AttributeNode.php | 85 + .../symfony/css-selector/Node/ClassNode.php | 60 + .../Node/CombinedSelectorNode.php | 69 + .../symfony/css-selector/Node/ElementNode.php | 72 + .../css-selector/Node/FunctionNode.php | 81 + .../symfony/css-selector/Node/HashNode.php | 60 + .../css-selector/Node/NegationNode.php | 66 + .../css-selector/Node/NodeInterface.php | 31 + .../symfony/css-selector/Node/PseudoNode.php | 60 + .../css-selector/Node/SelectorNode.php | 60 + .../symfony/css-selector/Node/Specificity.php | 75 + .../Parser/Handler/CommentHandler.php | 48 + .../Parser/Handler/HandlerInterface.php | 33 + .../Parser/Handler/HashHandler.php | 58 + .../Parser/Handler/IdentifierHandler.php | 58 + .../Parser/Handler/NumberHandler.php | 54 + .../Parser/Handler/StringHandler.php | 77 + .../Parser/Handler/WhitespaceHandler.php | 46 + .../symfony/css-selector/Parser/Parser.php | 353 ++ .../css-selector/Parser/ParserInterface.php | 34 + .../symfony/css-selector/Parser/Reader.php | 86 + .../Parser/Shortcut/ClassParser.php | 51 + .../Parser/Shortcut/ElementParser.php | 47 + .../Parser/Shortcut/EmptyStringParser.php | 46 + .../Parser/Shortcut/HashParser.php | 51 + .../api/symfony/css-selector/Parser/Token.php | 111 + .../css-selector/Parser/TokenStream.php | 175 + .../Parser/Tokenizer/Tokenizer.php | 75 + .../Parser/Tokenizer/TokenizerEscaping.php | 63 + .../Parser/Tokenizer/TokenizerPatterns.php | 89 + .../system/api/symfony/css-selector/README.md | 20 + .../Tests/CssSelectorConverterTest.php | 76 + .../Tests/Node/AbstractNodeTest.php | 34 + .../Tests/Node/AttributeNodeTest.php | 37 + .../css-selector/Tests/Node/ClassNodeTest.php | 33 + .../Tests/Node/CombinedSelectorNodeTest.php | 35 + .../Tests/Node/ElementNodeTest.php | 35 + .../Tests/Node/FunctionNodeTest.php | 47 + .../css-selector/Tests/Node/HashNodeTest.php | 33 + .../Tests/Node/NegationNodeTest.php | 33 + .../Tests/Node/PseudoNodeTest.php | 32 + .../Tests/Node/SelectorNodeTest.php | 34 + .../Tests/Node/SpecificityTest.php | 63 + .../Parser/Handler/AbstractHandlerTest.php | 70 + .../Parser/Handler/CommentHandlerTest.php | 55 + .../Tests/Parser/Handler/HashHandlerTest.php | 49 + .../Parser/Handler/IdentifierHandlerTest.php | 49 + .../Parser/Handler/NumberHandlerTest.php | 50 + .../Parser/Handler/StringHandlerTest.php | 50 + .../Parser/Handler/WhitespaceHandlerTest.php | 44 + .../css-selector/Tests/Parser/ParserTest.php | 250 ++ .../css-selector/Tests/Parser/ReaderTest.php | 102 + .../Tests/Parser/Shortcut/ClassParserTest.php | 45 + .../Parser/Shortcut/ElementParserTest.php | 44 + .../Parser/Shortcut/EmptyStringParserTest.php | 36 + .../Tests/Parser/Shortcut/HashParserTest.php | 45 + .../Tests/Parser/TokenStreamTest.php | 96 + .../Tests/XPath/Fixtures/ids.html | 48 + .../Tests/XPath/Fixtures/lang.xml | 11 + .../Tests/XPath/Fixtures/shakespear.html | 308 ++ .../Tests/XPath/TranslatorTest.php | 327 ++ .../XPath/Extension/AbstractExtension.php | 65 + .../Extension/AttributeMatchingExtension.php | 119 + .../XPath/Extension/CombinationExtension.php | 83 + .../XPath/Extension/ExtensionInterface.php | 69 + .../XPath/Extension/FunctionExtension.php | 171 + .../XPath/Extension/HtmlExtension.php | 213 ++ .../XPath/Extension/NodeExtension.php | 197 ++ .../XPath/Extension/PseudoClassExtension.php | 148 + .../symfony/css-selector/XPath/Translator.php | 224 ++ .../XPath/TranslatorInterface.php | 37 + .../symfony/css-selector/XPath/XPathExpr.php | 102 + .../api/symfony/css-selector/composer.json | 37 + .../api/symfony/css-selector/phpunit.xml.dist | 31 + 114 files changed, 14912 insertions(+), 891 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/php-cs-fixer.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/phpmd.xml rename wcfsetup/install/files/lib/system/api/pelago/emogrifier/{Configuration/PhpCodeSniffer/Standards/Emogrifier/ruleset.xml => phpcs.xml.dist} (68%) rename wcfsetup/install/files/lib/system/api/pelago/emogrifier/{Classes => src}/Emogrifier.php (59%) create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssConcatenator.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssInliner.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/AbstractHtmlProcessor.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/CssToAttributeConverter.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/HtmlProcessor/HtmlNormalizer.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Support/Traits/AssertCss.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/CssInlinerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/CssConcatenatorTest.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/AbstractHtmlProcessorTest.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/CssToAttributeConverterTest.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/Fixtures/TestingHtmlProcessor.php create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Emogrifier/HtmlProcessor/HtmlNormalizerTest.php rename wcfsetup/install/files/lib/system/api/pelago/emogrifier/{Tests => tests}/Unit/EmogrifierTest.php (60%) create mode 100644 wcfsetup/install/files/lib/system/api/pelago/emogrifier/tests/Unit/Support/Traits/AssertCssTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/.gitignore create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/CHANGELOG.md create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/CssSelectorConverter.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExceptionInterface.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ExpressionErrorException.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/InternalErrorException.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/ParseException.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Exception/SyntaxErrorException.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/LICENSE create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/AbstractNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/AttributeNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ClassNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/CombinedSelectorNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/ElementNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/FunctionNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/HashNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NegationNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/NodeInterface.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/PseudoNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/SelectorNode.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Node/Specificity.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/CommentHandler.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HandlerInterface.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/HashHandler.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/IdentifierHandler.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/NumberHandler.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/StringHandler.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Handler/WhitespaceHandler.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Parser.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/ParserInterface.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Reader.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ClassParser.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/ElementParser.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/EmptyStringParser.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Shortcut/HashParser.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Token.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/TokenStream.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/Tokenizer.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerEscaping.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Parser/Tokenizer/TokenizerPatterns.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/README.md create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/CssSelectorConverterTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AbstractNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/AttributeNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ClassNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/CombinedSelectorNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/ElementNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/FunctionNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/HashNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/NegationNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/PseudoNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SelectorNodeTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Node/SpecificityTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/AbstractHandlerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/CommentHandlerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/HashHandlerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/IdentifierHandlerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/NumberHandlerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/StringHandlerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Handler/WhitespaceHandlerTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ParserTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/ReaderTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ClassParserTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/ElementParserTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/EmptyStringParserTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/Shortcut/HashParserTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/Parser/TokenStreamTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/ids.html create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/lang.xml create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/Fixtures/shakespear.html create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/Tests/XPath/TranslatorTest.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AbstractExtension.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/AttributeMatchingExtension.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/CombinationExtension.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/ExtensionInterface.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/FunctionExtension.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/HtmlExtension.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/NodeExtension.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Extension/PseudoClassExtension.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/Translator.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/TranslatorInterface.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/XPath/XPathExpr.php create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/composer.json create mode 100644 wcfsetup/install/files/lib/system/api/symfony/css-selector/phpunit.xml.dist diff --git a/wcfsetup/install/files/lib/system/api/composer.json b/wcfsetup/install/files/lib/system/api/composer.json index 46fb8ac624..65ecab83ea 100644 --- a/wcfsetup/install/files/lib/system/api/composer.json +++ b/wcfsetup/install/files/lib/system/api/composer.json @@ -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", diff --git a/wcfsetup/install/files/lib/system/api/composer.lock b/wcfsetup/install/files/lib/system/api/composer.lock index e479a1cc3a..89eaf185da 100644 --- a/wcfsetup/install/files/lib/system/api/composer.lock +++ b/wcfsetup/install/files/lib/system/api/composer.lock @@ -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", @@ -299,24 +299,29 @@ }, { "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": { @@ -326,7 +331,7 @@ }, "autoload": { "psr-4": { - "Pelago\\": "Classes/" + "Pelago\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -344,10 +349,6 @@ { "name": "Jaime Prado" }, - { - "name": "Roman Ožana", - "email": "ozana@omdesign.cz" - }, { "name": "Oliver Klee", "email": "github@oliverklee.de" @@ -355,6 +356,10 @@ { "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", @@ -364,7 +369,60 @@ "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", diff --git a/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php b/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php index e71c2cfc74..1cae62ff68 100644 --- a/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php +++ b/wcfsetup/install/files/lib/system/api/composer/autoload_psr4.php @@ -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'), ); diff --git a/wcfsetup/install/files/lib/system/api/composer/autoload_static.php b/wcfsetup/install/files/lib/system/api/composer/autoload_static.php index 52ee683b4e..acad207606 100644 --- a/wcfsetup/install/files/lib/system/api/composer/autoload_static.php +++ b/wcfsetup/install/files/lib/system/api/composer/autoload_static.php @@ -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 ( diff --git a/wcfsetup/install/files/lib/system/api/composer/installed.json b/wcfsetup/install/files/lib/system/api/composer/installed.json index 070b11b6df..f36b09bd4c 100644 --- a/wcfsetup/install/files/lib/system/api/composer/installed.json +++ b/wcfsetup/install/files/lib/system/api/composer/installed.json @@ -304,27 +304,32 @@ }, { "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": { @@ -334,7 +339,7 @@ "installation-source": "dist", "autoload": { "psr-4": { - "Pelago\\": "Classes/" + "Pelago\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -352,10 +357,6 @@ { "name": "Jaime Prado" }, - { - "name": "Roman Ožana", - "email": "ozana@omdesign.cz" - }, { "name": "Oliver Klee", "email": "github@oliverklee.de" @@ -363,6 +364,10 @@ { "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", @@ -373,6 +378,61 @@ "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", diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.github/CONTRIBUTING.md b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.github/CONTRIBUTING.md index 5347d662bc..6287d6549c 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.github/CONTRIBUTING.md +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.github/CONTRIBUTING.md @@ -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 diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore index 6be926125b..8bfbb851e4 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.gitignore @@ -20,5 +20,6 @@ .TemporaryItems .webprj nbproject +/.php_cs.cache /vendor/ composer.lock diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml index da143168ce..f3a6c22ea5 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/.travis.yml @@ -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; diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md index 255db41f79..a3866a4bbd 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/CHANGELOG.md @@ -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/LICENSE b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/LICENSE index 766106c6d7..577abe9a15 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/LICENSE +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/LICENSE @@ -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. diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md index b94b73751f..eee5b79c40 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/README.md @@ -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/composer.json b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/composer.json index 016370aee5..b2949f9bbd 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/composer.json +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/composer.json @@ -10,26 +10,26 @@ "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": { @@ -37,22 +37,56 @@ "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 index 0000000000..4e379ee7af --- /dev/null +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/php-cs-fixer.php @@ -0,0 +1,93 @@ +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 index 0000000000..569409b48b --- /dev/null +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/config/phpmd.xml @@ -0,0 +1,49 @@ + + + + PHPMD rules for Emogrifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/phpcs.xml.dist similarity index 68% rename from wcfsetup/install/files/lib/system/api/pelago/emogrifier/Configuration/PhpCodeSniffer/Standards/Emogrifier/ruleset.xml rename to wcfsetup/install/files/lib/system/api/pelago/emogrifier/phpcs.xml.dist index ff86aaa136..d87f19c9e5 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Configuration/PhpCodeSniffer/Standards/Emogrifier/ruleset.xml +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/phpcs.xml.dist @@ -1,15 +1,18 @@ - - This is the coding standard used for the Emogrifier code. - This standard has been tested with to work with PHP_CodeSniffer >= 2.3.0. + + + This standard requires PHP_CodeSniffer >= 3.2.0. + + + @@ -35,6 +38,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -56,12 +79,12 @@ + - - + @@ -81,6 +104,7 @@ + @@ -92,7 +116,6 @@ - diff --git a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Classes/Emogrifier.php b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier.php similarity index 59% rename from wcfsetup/install/files/lib/system/api/pelago/emogrifier/Classes/Emogrifier.php rename to wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier.php index 01c800a036..08f5eb6af4 100644 --- a/wcfsetup/install/files/lib/system/api/pelago/emogrifier/Classes/Emogrifier.php +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier.php @@ -7,8 +7,6 @@ namespace Pelago; * * For more information, please see the README.md file. * - * @version 2.0.0 - * * @author Cameron Brooks * @author Jaime Prado * @author Oliver Klee @@ -67,6 +65,16 @@ class Emogrifier */ 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 */ @@ -78,9 +86,9 @@ class Emogrifier const DEFAULT_DOCUMENT_TYPE = ''; /** - * @var string + * @var \DOMDocument */ - private $html = ''; + protected $domDocument = null; /** * @var string @@ -154,7 +162,23 @@ class Emogrifier * * @var bool */ - private $shouldKeepInvisibleNodes = true; + 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 + '(?:\\.|\\[|(? 100, + // elements (not attribute values or `:not`), pseudo-elements: worth 1 + '(?:(? 1, + ]; /** * @var string[] @@ -165,21 +189,21 @@ class Emogrifier // 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-_\\/]+)[\'"]?\\]/' + '/([\\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\\/]+)[\'"]?\\]/' + '/([\\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")]', + '/([\\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")]', + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w\\-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]', // adjacent sibling - '/\\s+\\+\\s+/' => '/following-sibling::*[1]/self::', + '/\\s*\\+\\s*/' => '/following-sibling::*[1]/self::', // child '/\\s*>\\s*/' => '/', - // descendant - '/\\s+(?=.*[^\\]]{1}$)/' => '//', + // 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 @@ -188,7 +212,7 @@ class Emogrifier // 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\\/]+)[\'"]?\\]/' + '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w\\-_\\s\\/]+)[\'"]?\\]/' => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]', ]; @@ -236,35 +260,46 @@ class Emogrifier private $debug = false; /** - * The constructor. - * - * @param string $html the HTML to emogrify, must be UTF-8-encoded + * @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($html = '', $css = '') + public function __construct($unprocessedHtml = '', $css = '') { - $this->setHtml($html); + if ($unprocessedHtml !== '') { + $this->setHtml($unprocessedHtml); + } $this->setCss($css); } /** - * The destructor. + * 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 __destruct() + public function setHtml($html) { - $this->purgeVisitedNodes(); + 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); } /** - * Sets the HTML to emogrify. - * - * @param string $html the HTML to emogrify, must be UTF-8-encoded + * Provides access to the internal DOMDocument representation of the HTML in its current state. * - * @return void + * @return \DOMDocument */ - public function setHtml($html) + public function getDomDocument() { - $this->html = $html; + return $this->domDocument; } /** @@ -280,7 +315,53 @@ class Emogrifier } /** - * Applies $this->css to $this->html and returns the HTML with the 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(['', ''], '', $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. @@ -291,11 +372,15 @@ class Emogrifier */ public function emogrify() { - return $this->createAndProcessXmlDocument()->saveHTML(); + $this->assertExistenceOfHtml(); + + $this->process(); + + return $this->render(); } /** - * Applies $this->css to $this->html and returns only the HTML content + * Applies $this->css to the given HTML and returns only the HTML content * within the tag. * * This method places the CSS inline. @@ -306,50 +391,96 @@ class Emogrifier */ public function emogrifyBodyContent() { - $xmlDocument = $this->createAndProcessXmlDocument(); - $bodyNodeHtml = $xmlDocument->saveHTML($this->getBodyElement($xmlDocument)); + $this->assertExistenceOfHtml(); - return str_replace(['', ''], '', $bodyNodeHtml); + $this->process(); + + return $this->renderBodyContent(); } /** - * Creates an XML document from $this->html and emogrifies ist. + * Checks that some HTML has been set, and throws an exception otherwise. * - * @return \DOMDocument + * @return void * * @throws \BadMethodCallException */ - private function createAndProcessXmlDocument() + private function assertExistenceOfHtml() { - if ($this->html === '') { + 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(); + } - $xmlDocument = $this->createRawXmlDocument(); - $this->ensureExistenceOfBodyElement($xmlDocument); - $this->process($xmlDocument); + /** + * 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(); - return $xmlDocument; + $this->domDocument = $domDocument; } /** - * Applies $this->css to $xmlDocument. + * 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. * - * This method places the CSS inline. + * @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. * - * @param \DOMDocument $xmlDocument + * This method places the CSS inline. * * @return void * * @throws \InvalidArgumentException */ - protected function process(\DOMDocument $xmlDocument) + protected function process() { - $xPath = new \DOMXPath($xmlDocument); $this->clearAllCaches(); $this->purgeVisitedNodes(); - set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING); + $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 @@ -359,16 +490,15 @@ class Emogrifier $allCss .= $this->getCssFromAllStyleNodes($xPath); } - $cssParts = $this->splitCssAndMediaQuery($allCss); $excludedNodes = $this->getNodesToExclude($xPath); - $cssRules = $this->parseCssRules($cssParts['css']); - foreach ($cssRules as $cssRule) { + $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 an exception when errors are caught. + // \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) { @@ -379,41 +509,40 @@ class Emogrifier /** @var \DOMElement $node */ foreach ($nodesMatchingCssSelectors as $node) { - if (in_array($node, $excludedNodes, true)) { + 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) - ); + $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->shouldKeepInvisibleNodes) { + if ($this->shouldRemoveInvisibleNodes) { $this->removeInvisibleNodes($xPath); } - - $this->removeImportantAnnotationFromAllInlineStyles($xPath); - - $this->copyCssWithMediaToStyleNode($xmlDocument, $xPath, $cssParts['media']); - - restore_error_handler(); } /** @@ -467,12 +596,12 @@ class Emogrifier $importantStyleDeclarations = []; foreach ($inlineStyleDeclarations as $property => $value) { if ($this->attributeValueIsImportant($value)) { - $importantStyleDeclarations[$property] = trim(str_replace('!important', '', $value)); + $importantStyleDeclarations[$property] = \trim(\str_replace('!important', '', $value)); } else { $regularStyleDeclarations[$property] = $value; } } - $inlineStyleDeclarationsInNewOrder = array_merge( + $inlineStyleDeclarationsInNewOrder = \array_merge( $regularStyleDeclarations, $importantStyleDeclarations ); @@ -509,7 +638,7 @@ class Emogrifier { foreach ($styles as $property => $value) { // Strip !important indicator - $value = trim(str_replace('!important', '', $value)); + $value = \trim(\str_replace('!important', '', $value)); $this->mapCssToHtmlAttribute($property, $value, $node); } } @@ -539,7 +668,7 @@ class Emogrifier * @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 + * @return bool true if the property can be mapped using the simple mapping table */ private function mapSimpleCssProperty($property, $value, \DOMElement $node) { @@ -548,8 +677,8 @@ class Emogrifier } $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); + $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; } @@ -570,50 +699,114 @@ class Emogrifier */ 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); - } + $this->mapBackgroundProperty($node, $value); 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); - } + $this->mapWidthOrHeightProperty($node, $value, $property); break; case 'margin': - if ($isTableOrImage) { - $margins = $this->parseCssShorthandValue($value); - if ($margins['left'] === 'auto' && $margins['right'] === 'auto') { - $node->setAttribute('align', 'center'); - } - } + $this->mapMarginProperty($node, $value); break; case 'border': - if ($isTableOrImage) { - if ($value === 'none' || $value === '0') { - $node->setAttribute('border', '0'); - } - } + $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 * @@ -626,13 +819,13 @@ class Emogrifier */ private function parseCssShorthandValue($value) { - $values = preg_split('/\\s+/', $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']; + $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; } @@ -642,57 +835,100 @@ class Emogrifier * * @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), + * @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[self::CACHE_KEY_CSS][$cssKey])) { - // process the CSS file for selectors and definitions - preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mi', $css, $matches, PREG_SET_ORDER); + $cssKey = \md5($css); + if (!isset($this->caches[static::CACHE_KEY_CSS][$cssKey])) { + $matches = $this->getCssRuleMatches($css); - $cssRules = []; + $cssRules = [ + 'inlineable' => [], + 'uninlineable' => [], + ]; /** @var string[][] $matches */ /** @var string[] $cssRule */ foreach ($matches as $key => $cssRule) { - $cssDeclaration = trim($cssRule[2]); + $cssDeclaration = \trim($cssRule['declarations']); if ($cssDeclaration === '') { continue; } - $selectors = explode(',', $cssRule[1]); + $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; - $hasAnyPseudoClass = (bool)preg_match('/:[a-zA-Z]/', $selector); - $hasSupportedPseudoClass = (bool)preg_match( - '/:(\\S+\\-(child|type\\()|not\\([[:ascii:]]*\\))/i', + $hasPseudoElement = \strpos($selector, '::') !== false; + $hasUnsupportedPseudoClass = (bool)\preg_match( + '/:(?!' . static::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i', $selector ); - if ($hasPseudoElement || ($hasAnyPseudoClass && !$hasSupportedPseudoClass)) { - continue; - } + $hasUnmatchablePseudo = $hasPseudoElement || $hasUnsupportedPseudoClass; - $cssRules[] = [ - 'selector' => trim($selector), + $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, [$this, 'sortBySelectorPrecedence']); + \usort($cssRules['inlineable'], [$this, 'sortBySelectorPrecedence']); - $this->caches[self::CACHE_KEY_CSS][$cssKey] = $cssRules; + $this->caches[static::CACHE_KEY_CSS][$cssKey] = $cssRules; } - return $this->caches[self::CACHE_KEY_CSS][$cssKey]; + 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; } /** @@ -718,17 +954,21 @@ class Emogrifier /** * Disables the removal of elements with `display: none` properties. * + * @deprecated will be removed in Emogrifier 3.0 + * * @return void */ public function disableInvisibleNodeRemoval() { - $this->shouldKeepInvisibleNodes = false; + $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() @@ -743,37 +983,13 @@ class Emogrifier */ 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, + $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 => [], ]; - if (!in_array($key, $allowedCacheKeys, true)) { - throw new \InvalidArgumentException('Invalid cache key: ' . $key, 1391822035); - } - - $this->caches[$key] = []; } /** @@ -813,7 +1029,7 @@ class Emogrifier */ public function removeUnprocessableHtmlTag($tagName) { - $key = array_search($tagName, $this->unprocessableHtmlTags, true); + $key = \array_search($tagName, $this->unprocessableHtmlTags, true); if ($key !== false) { unset($this->unprocessableHtmlTags[$key]); } @@ -897,7 +1113,7 @@ class Emogrifier // 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'])) { + if ($node->parentNode && \is_callable([$node->parentNode, 'removeChild'])) { $node->parentNode->removeChild($node); } } @@ -937,10 +1153,10 @@ class Emogrifier */ private function normalizeStyleAttributes(\DOMElement $node) { - $normalizedOriginalStyle = preg_replace_callback( + $normalizedOriginalStyle = \preg_replace_callback( '/[A-z\\-]+(?=\\:)/S', function (array $m) { - return strtolower($m[0]); + return \strtolower($m[0]); }, $node->getAttribute('style') ); @@ -989,12 +1205,12 @@ class Emogrifier */ 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]; + $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; @@ -1004,17 +1220,21 @@ class Emogrifier if ($this->attributeValueIsImportant($attributeValue) && !$this->attributeValueIsImportant($newAttributeValue) ) { - $combinedStyles[$attributeName] = $attributeValue; + unset($newStyles[$attributeName]); + } else { + unset($oldStyles[$attributeName]); } } + $combinedStyles = \array_merge($oldStyles, $newStyles); + $style = ''; foreach ($combinedStyles as $attributeName => $attributeValue) { - $style .= strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; '; + $style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; '; } - $trimmedStyle = rtrim($style); + $trimmedStyle = \rtrim($style); - $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle; + $this->caches[static::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle; return $trimmedStyle; } @@ -1040,61 +1260,90 @@ class Emogrifier */ private function attributeValueIsImportant($attributeValue) { - return strtolower(substr(trim($attributeValue), -10)) === '!important'; + 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 $css to $xmlDocument, limited to the media queries that actually apply to the document. + * Applies $cssRules to $this->domDocument, limited to the rules that actually apply to the document. * - * @param \DOMDocument $xmlDocument the document to match against * @param \DOMXPath $xPath - * @param string $css a string of CSS + * @param string[][] $cssRules The "uninlineable" array of CSS rules returned by `parseCssRules` * * @return void */ - private function copyCssWithMediaToStyleNode(\DOMDocument $xmlDocument, \DOMXPath $xPath, $css) + private function copyUninlineableCssToStyleNode(\DOMXPath $xPath, array $cssRules) { - if ($css === '') { + $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; } - $mediaQueriesRelevantForDocument = []; + // support use without autoload + if (!\class_exists('Pelago\\Emogrifier\\CssConcatenator')) { + require_once __DIR__ . '/Emogrifier/CssConcatenator.php'; + } - foreach ($this->extractMediaQueriesFromCss($css) as $mediaQuery) { - foreach ($this->parseCssRules($mediaQuery['css']) as $selector) { - if ($this->existsMatchForCssSelector($xPath, $selector['selector'])) { - $mediaQueriesRelevantForDocument[] = $mediaQuery['query']; - break; - } - } + $cssConcatenator = new Emogrifier\CssConcatenator(); + foreach ($cssRulesRelevantForDocument as $cssRule) { + $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']); } - $this->addStyleElementToDocument($xmlDocument, implode($mediaQueriesRelevantForDocument)); + $this->addStyleElementToDocument($cssConcatenator->getCss()); } /** - * Extracts the media queries from $css while skipping empty media queries. + * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary. * - * @param string $css + * @param string $selector * - * @return string[][] numeric array with string sub-arrays with the keys "css" and "query" + * @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 extractMediaQueriesFromCss($css) + private function removeUnmatchablePseudoComponents($selector) { - 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; + $pseudoComponentMatcher = ':(?!' . static::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+'; + return \preg_replace( + ['/(\\s|^)' . $pseudoComponentMatcher . '/i', '/' . $pseudoComponentMatcher . '/i'], + ['$1*', ''], + $selector + ); } /** @@ -1149,73 +1398,49 @@ class Emogrifier } /** - * Adds a style element with $css to $document. + * 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 \DOMDocument $document * @param string $css * * @return void */ - protected function addStyleElementToDocument(\DOMDocument $document, $css) + protected function addStyleElementToDocument($css) { - $styleElement = $document->createElement('style', $css); - $styleAttribute = $document->createAttribute('type'); + $styleElement = $this->domDocument->createElement('style', $css); + $styleAttribute = $this->domDocument->createAttribute('type'); $styleAttribute->value = 'text/css'; $styleElement->appendChild($styleAttribute); - $bodyElement = $this->getBodyElement($document); - $bodyElement->appendChild($styleElement); + $headElement = $this->getHeadElement(); + $headElement->appendChild($styleElement); } /** - * Checks that $document has a BODY element and adds it if it is missing. + * Checks that $this->domDocument has a BODY element and adds it if it is missing. * - * @param \DOMDocument $document + * @return void */ - private function ensureExistenceOfBodyElement(\DOMDocument $document) + private function ensureExistenceOfBodyElement() { - if ($document->getElementsByTagName('body')->item(0) !== null) { + if ($this->domDocument->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; + $htmlElement = $this->domDocument->getElementsByTagName('html')->item(0); + $htmlElement->appendChild($this->domDocument->createElement('body')); } /** - * Splits input CSS code to an array where: + * Splits input CSS code into an array of parts for different media querues, in order. + * Each part is an array where: * - * - key "css" will be contains clean CSS code - * - key "media" will be contains all valuable media queries + * - 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: * @@ -1225,98 +1450,83 @@ class Emogrifier * * will be parsed into the following array: * - * "css" => "h1 { color:red; }" - * "media" => "@media { h1 {}}" + * 0 => [ + * "css" => "h1 { color:red; }", + * "media" => "" + * ], + * 1 => [ + * "css" => " h1 {}", + * "media" => "@media " + * ] * * @param string $css * - * @return string[] + * @return string[][] */ private function splitCssAndMediaQuery($css) { - $cssWithoutComments = preg_replace('/\\/\\*.*\\*\\//sU', '', $css); + $cssWithoutComments = \preg_replace('/\\/\\*.*\\*\\//sU', '', $css); $mediaTypesExpression = ''; if (!empty($this->allowedMediaTypes)) { - $mediaTypesExpression = '|' . implode('|', array_keys($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 + $mediaRuleBodyMatcher = '[^{]*+{(?:[^{}]*+{.*})?\\s*+}\\s*+'; + + $cssSplitForAllowedMediaTypes = \preg_split( + '#(@media\\s++(?:only\\s++)?+(?:(?=[{\\(])' . $mediaTypesExpression . ')' . $mediaRuleBodyMatcher + . ')#misU', + $cssWithoutComments, + -1, + PREG_SPLIT_DELIM_CAPTURE ); - // filter the CSS - $search = [ - 'import directives' => '/^\\s*@import\\s[^;]+;/misU', - 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU', + // 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', ]; - $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); + $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 the unprocessable tags from $html (if this feature is enabled). - * - * @param string $html + * Removes empty unprocessable tags from the DOM document. * - * @return string the reworked HTML with the unprocessable tags removed + * @return void */ - private function removeUnprocessableTags($html) + private function removeUnprocessableTags() { - if (empty($this->unprocessableHtmlTags)) { - return $html; + 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); + } + } } - - $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags); - - return preg_replace( - '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i', - '', - $html - ); } /** @@ -1328,43 +1538,45 @@ class Emogrifier */ private function ensureDocumentType($html) { - $hasDocumentType = stripos($html, '/i', '' . self::CONTENT_TYPE_META_TAG, $html); + $reworkedHtml = \preg_replace('//i', '' . static::CONTENT_TYPE_META_TAG, $html); } elseif ($hasHtmlTag) { - $reworkedHtml = preg_replace( + $reworkedHtml = \preg_replace( '//i', - '' . self::CONTENT_TYPE_META_TAG . '', + '' . static::CONTENT_TYPE_META_TAG . '', $html ); } else { - $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html; + $reworkedHtml = static::CONTENT_TYPE_META_TAG . $html; } return $reworkedHtml; @@ -1395,26 +1607,21 @@ class Emogrifier */ private function getCssSelectorPrecedence($selector) { - $selectorKey = md5($selector); - if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) { + $selectorKey = \md5($selector); + if (!isset($this->caches[static::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) === '') { + foreach ($this->selectorPrecedenceMatchers as $matcher => $value) { + if (\trim($selector) === '') { break; } $number = 0; - $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number); + $selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number); $precedence += ($value * $number); - $value /= 10; } - $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence; + $this->caches[static::CACHE_KEY_SELECTOR][$selectorKey] = $precedence; } - return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey]; + return $this->caches[static::CACHE_KEY_SELECTOR][$selectorKey]; } /** @@ -1429,20 +1636,20 @@ class Emogrifier private function translateCssToXpath($cssSelector) { $paddedSelector = ' ' . $cssSelector . ' '; - $lowercasePaddedSelector = preg_replace_callback( + $lowercasePaddedSelector = \preg_replace_callback( '/\\s+\\w+\\s+/', function (array $matches) { - return strtolower($matches[0]); + 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]; + $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( + $hasNotSelector = (bool)\preg_match( '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/', $trimmedLowercaseSelector, $matches @@ -1451,14 +1658,13 @@ class Emogrifier $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector); } else { /** @var string[] $matches */ - $partBeforeNot = $matches[1]; - $notContents = $matches[2]; + list(, $partBeforeNot, $notContents) = $matches; $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) . '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]'; } - $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $xPath; + $this->caches[static::CACHE_KEY_SELECTOR][$xPathKey] = $xPath; - return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey]; + return $this->caches[static::CACHE_KEY_SELECTOR][$xPathKey]; } /** @@ -1504,26 +1710,26 @@ class Emogrifier $trimmedLowercaseSelector, callable $matchClassAttributesCallback ) { - $roughXpath = preg_replace(array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector); - $xPathWithIdAttributeMatchers = preg_replace_callback( - self::ID_ATTRIBUTE_MATCHER, + $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( - self::CLASS_ATTRIBUTE_MATCHER, + $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( + $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', + $finalXpath = \preg_replace_callback( + '/([^\\/]+):nth-of-type\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i', [$this, 'translateNthOfType'], $xPathWithIdAttributeAndClassMatchers ); @@ -1559,9 +1765,9 @@ class Emogrifier private function matchClassAttributesInline(array $match) { return 'contains(concat(" ",@class," "),concat(" ","' . - implode( + \implode( '"," "))][contains(concat(" ",@class," "),concat(" ","', - explode('.', substr($match[2], 1)) + \explode('.', \substr($match[2], 1)) ) . '"," "))'; } @@ -1574,25 +1780,25 @@ class Emogrifier { $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], + 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]/self::%3$s', - $parseResult[self::MULTIPLIER], - $parseResult[self::INDEX], + $xPathExpression = \sprintf( + '*[position() mod %1$u = %2$u]/static::%3$s', + $parseResult[static::MULTIPLIER], + $parseResult[static::INDEX], $match[1] ); } } else { - $xPathExpression = sprintf('*[%1$u]/self::%2$s', $parseResult[self::INDEX], $match[1]); + $xPathExpression = \sprintf('*[%1$u]/static::%2$s', $parseResult[static::INDEX], $match[1]); } return $xPathExpression; @@ -1607,25 +1813,25 @@ class Emogrifier { $parseResult = $this->parseNth($match); - if (isset($parseResult[self::MULTIPLIER])) { - if ($parseResult[self::MULTIPLIER] < 0) { - $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]); - $xPathExpression = sprintf( + 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[self::MULTIPLIER], - $parseResult[self::INDEX] + $parseResult[static::MULTIPLIER], + $parseResult[static::INDEX] ); } else { - $xPathExpression = sprintf( + $xPathExpression = \sprintf( '%1$s[position() mod %2$u = %3$u]', $match[1], - $parseResult[self::MULTIPLIER], - $parseResult[self::INDEX] + $parseResult[static::MULTIPLIER], + $parseResult[static::INDEX] ); } } else { - $xPathExpression = sprintf('%1$s[%2$u]', $match[1], $parseResult[self::INDEX]); + $xPathExpression = \sprintf('%1$s[%2$u]', $match[1], $parseResult[static::INDEX]); } return $xPathExpression; @@ -1638,40 +1844,40 @@ class Emogrifier */ private function parseNth(array $match) { - if (in_array(strtolower($match[2]), ['even', 'odd'], true)) { + 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]; + $index = \strtolower($match[2]) === 'even' ? 0 : 1; + return [static::MULTIPLIER => 2, static::INDEX => $index]; } - if (stripos($match[2], 'n') === false) { + if (\stripos($match[2], 'n') === false) { // if there is a multiplier - $index = (int)str_replace(' ', '', $match[2]); - return [self::INDEX => $index]; + $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]); + $multipleTerm = \str_replace($match[3], '', $match[2]); + $index = (int)\str_replace(' ', '', $match[3]); } else { $multipleTerm = $match[2]; $index = 0; } - $multiplier = str_ireplace('n', '', $multipleTerm); + $multiplier = \str_ireplace('n', '', $multipleTerm); if ($multiplier === '') { $multiplier = 1; } elseif ($multiplier === '0') { - return [self::INDEX => $index]; + return [static::INDEX => $index]; } else { $multiplier = (int)$multiplier; } while ($index < 0) { - $index += abs($multiplier); + $index += \abs($multiplier); } - return [self::MULTIPLIER => $multiplier, self::INDEX => $index]; + return [static::MULTIPLIER => $multiplier, static::INDEX => $index]; } /** @@ -1695,24 +1901,24 @@ class Emogrifier */ 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]; + 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); + $declarations = \preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock); foreach ($declarations as $declaration) { $matches = []; - if (!preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches)) { + if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) { continue; } - $propertyName = strtolower($matches[1]); + $propertyName = \strtolower($matches[1]); $propertyValue = $matches[2]; $properties[$propertyName] = $propertyValue; } - $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties; + $this->caches[static::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties; return $properties; } @@ -1729,7 +1935,7 @@ class Emogrifier private function getNodesToExclude(\DOMXPath $xPath) { $excludedNodes = []; - foreach (array_keys($this->excludedSelectors) as $selectorToExclude) { + foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) { try { $matchingNodes = $xPath->query($this->translateCssToXpath($selectorToExclude)); } catch (\InvalidArgumentException $e) { @@ -1748,7 +1954,7 @@ class Emogrifier /** * Handles invalid xPath expression warnings, generated during the process() method, - * during querying \DOMDocument and trigger \InvalidArgumentException with invalid selector + * during querying \DOMDocument and trigger an \InvalidArgumentException with an invalid selector * or \RuntimeException, depending on the source of the warning. * * @param int $type @@ -1762,7 +1968,7 @@ class Emogrifier * @throws \InvalidArgumentException * @throws \RuntimeException */ - public function handleXpathQueryWarnings( // @codingStandardsIgnoreLine + public function handleXpathQueryWarnings(// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter $type, $message, $file, @@ -1783,7 +1989,7 @@ class Emogrifier if ($selector !== '') { throw new \InvalidArgumentException( - sprintf('%1$s in selector >> %2$s << in %3$s on line %4$u', $message, $selector, $file, $line), + \sprintf('%1$s in selector >> %2$s << in %3$s on line %4$u', $message, $selector, $file, $line), 1509279985 ); } @@ -1791,7 +1997,7 @@ class Emogrifier // 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), + \sprintf('%1$s in %2$s on line %3$u', $message, $file, $line), 1509280067 ); } 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 index 0000000000..3ebd33b95d --- /dev/null +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssConcatenator.php @@ -0,0 +1,154 @@ +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 + */ +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 index 0000000000..847767ea41 --- /dev/null +++ b/wcfsetup/install/files/lib/system/api/pelago/emogrifier/src/Emogrifier/CssInliner.php @@ -0,0 +1,1346 @@ + + * @author Roman Ožana + * @author Sander Kruger + * @author Zoli Szabó + */ +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 = ''; + + /** + * @var string + */ + const DEFAULT_DOCUMENT_TYPE = ''; + + /** + * @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

target

' + ); + + $result = $subject->emogrify(); + + static::assertContains('

target

', $result); + } + + /** + * @test + */ + public function emogrifyRemovesStyleNodes() + { + $subject = $this->buildDebugSubject(''); + + $result = $subject->emogrify(); + + static::assertNotContains('' + ); + $subject->setDebug(true); + + $subject->emogrify(); + } + + /** + * @test + */ + public function emogrifyNotInDebugModeIgnoresInvalidCssSelectors() + { + $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 = ' ' . + '

'; + $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('

foo

'); + $subject->setCss($unneededCss); + + $result = $subject->emogrify(); + + static::assertNotContains($markerNotExpectedInHtml, $result); + } + + /** + * @test + * + * @param string $unneededCss + * + * @dataProvider unneededCssThingsDataProvider + */ + public function emogrifyMatchesRuleAfterUnneededCssThing($unneededCss) + { + $subject = $this->buildDebugSubject(''); + $subject->setCss($unneededCss . ' body { color: green; }'); + + $result = $subject->emogrify(); + + static::assertContains('', $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('

foo

'); + $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('

foo

'); + $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(''); + $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(''); + $subject->setCss($css); + $subject->addAllowedMediaType('braille'); + + $result = $subject->emogrify(); + + static::assertContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyKeepsExistingHeadElementContent() + { + $subject = $this->buildDebugSubject(''); + $subject->setCss('@media all { html { some-property: value; } }'); + + $result = $subject->emogrify(); + + static::assertContains('', $result); + } + + /** + * @test + */ + public function emogrifyKeepsExistingStyleElementWithMedia() + { + $html = $this->html5DocumentType . ''; + $subject = $this->buildDebugSubject($html); + $subject->setCss('@media all { html { some-property: value; } }'); + + $result = $subject->emogrify(); + + static::assertContains(''; + $html = '' . $style . ''; + $subject = $this->buildDebugSubject($html); + + $result = $subject->emogrify(); + + static::assertRegExp('/.*/s', $result); + } + + /** + * @test + */ + public function emogrifyKeepsExistingStyleElementWithMediaOutOfBody() + { + $style = ''; + $html = '' . $style . ''; + $subject = $this->buildDebugSubject($html); + + $result = $subject->emogrify(); + + static::assertNotRegExp('/.*', $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('

'); + $subject->setCss($css); + + $result = $subject->emogrify(); + + static::assertContains('', $result); + } + + /** + * @test + * + * @param string $css + * + * @dataProvider validMediaPreserveDataProvider + */ + public function emogrifyForHtmlWithValidMediaQueryContainsInnerCss($css) + { + $subject = $this->buildDebugSubject('

'); + + $result = $subject->emogrify(); + + static::assertContainsCss('', $result); + } + + /** + * @test + * + * @param string $css + * + * @dataProvider validMediaPreserveDataProvider + */ + public function emogrifyWithValidMediaQueryNotContainsInlineCss($css) + { + $subject = $this->buildDebugSubject('

'); + $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('

'); + $subject->setCss($css); + + $result = $subject->emogrify(); + + static::assertNotContainsCss($css, $result); + } + + /** + * @test + * + * @param string $css + * + * @dataProvider invalidMediaPreserveDataProvider + */ + public function emogrifyWithInvalidMediaQueryNotContainsInlineCss($css) + { + $subject = $this->buildDebugSubject('

'); + $subject->setCss($css); + + $result = $subject->emogrify(); + + static::assertNotContains('style=', $result); + } + + /** + * @test + * + * @param string $css + * + * @dataProvider invalidMediaPreserveDataProvider + */ + public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInnerCss($css) + { + $subject = $this->buildDebugSubject('

'); + + $result = $subject->emogrify(); + + static::assertNotContainsCss($css, $result); + } + + /** + * @test + * + * @param string $css + * + * @dataProvider invalidMediaPreserveDataProvider + */ + public function emogrifyFromHtmlWithInvalidMediaQueryNotContainsInlineCss($css) + { + $subject = $this->buildDebugSubject('

'); + + $result = $subject->emogrify(); + + static::assertNotContains('style=', $result); + } + + /** + * @test + */ + public function emogrifyIgnoresEmptyMediaQuery() + { + $subject = $this->buildDebugSubject('

'); + $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('

'); + $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('

'); + $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('

'); + $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('

foo

'); + $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('

foo

'); + $subject->setCss($css); + + $result = $subject->emogrify(); + + self::assertNotContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyKeepsRuleInMediaQueryWithPseudoComponentInMatchingSelector() + { + $subject = $this->buildDebugSubject('foo'); + $css = '@media screen { a:hover { color: green; } }'; + $subject->setCss($css); + + $result = $subject->emogrify(); + + self::assertContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyNotKeepsRuleInMediaQueryWithPseudoComponentInNonMatchingSelector() + { + $subject = $this->buildDebugSubject('foo'); + $css = '@media screen { b:hover { color: green; } }'; + $subject->setCss($css); + + $result = $subject->emogrify(); + + self::assertNotContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyKeepsRuleWithPseudoComponentInMultipleMatchingSelectorsFromSingleRule() + { + $subject = $this->buildDebugSubject('

foo

bar'); + $css = 'p:hover, a:hover { color: green; }'; + $subject->setCss($css); + + $result = $subject->emogrify(); + + static::assertContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyKeepsOnlyMatchingSelectorsWithPseudoComponentFromSingleRule() + { + $subject = $this->buildDebugSubject('foo'); + $subject->setCss('p:hover, a:hover { color: green; }'); + + $result = $subject->emogrify(); + + static::assertContainsCss('', $result); + } + + /** + * @test + */ + public function emogrifyAppliesCssToMatchingElementsAndKeepsRuleWithPseudoComponentFromSingleRule() + { + $subject = $this->buildDebugSubject('

foo

bar'); + $subject->setCss('p, a:hover { color: green; }'); + + $result = $subject->emogrify(); + + static::assertContains('

', $result); + static::assertContainsCss('', $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('

'); + $subject->setCss( + '@media ' . $emptyRuleMediaType . ' {} h1 { color: green; } @media ' . $mediaType + . ' { h1 { color: red; } }' + ); + + $result = $subject->emogrify(); + + static::assertContains('

', $result); + } + + /** + * @test + * + * @param string $emptyRuleMediaType + * @param string $mediaType + * + * @dataProvider mediaTypesDataProvider + */ + public function emogrifyAppliesCssBetweenEmptyMediaRuleAndMediaRuleWithCssAfter($emptyRuleMediaType, $mediaType) + { + $subject = $this->buildDebugSubject('

'); + $subject->setCss( + '@media ' . $emptyRuleMediaType . ' {} h1 { color: green; } @media ' . $mediaType + . ' { h1 { color: red; } } h1 { font-size: 24px; }' + ); + + $result = $subject->emogrify(); + + static::assertContains('

', $result); + } + + /** + * @test + */ + public function emogrifyAppliesCssFromStyleNodes() + { + $styleAttributeValue = 'color: #ccc;'; + $subject = $this->buildDebugSubject( + '' + ); + + $result = $subject->emogrify(); + + static::assertContains('', $result); + } + + /** + * @test + */ + public function emogrifyWhenDisabledNotAppliesCssFromStyleBlocks() + { + $styleAttributeValue = 'color: #ccc;'; + $subject = $this->buildDebugSubject( + '' + ); + $subject->disableStyleBlocksParsing(); + + $result = $subject->emogrify(); + + static::assertNotContains('style=', $result); + } + + /** + * @test + */ + public function emogrifyWhenStyleBlocksParsingDisabledKeepInlineStyles() + { + $styleAttributeValue = 'text-align: center;'; + $subject = $this->buildDebugSubject( + '' . + '

paragraph

' + ); + $subject->disableStyleBlocksParsing(); + + $result = $subject->emogrify(); + + static::assertContains('

', $result); + } + + /** + * @test + */ + public function emogrifyWhenDisabledNotAppliesCssFromInlineStyles() + { + $subject = $this->buildDebugSubject(''); + $subject->disableInlineStyleAttributesParsing(); + + $result = $subject->emogrify(); + + static::assertNotContains('buildDebugSubject( + '' . + '

paragraph

' + ); + $subject->disableInlineStyleAttributesParsing(); + + $result = $subject->emogrify(); + + static::assertContains('

', $result); + } + + /** + * Emogrify was handling case differently for passed-in CSS vs. CSS parsed from style blocks. + * + * @test + */ + public function emogrifyAppliesCssWithMixedCaseAttributesInStyleBlock() + { + $subject = $this->buildDebugSubject( + '' . + '

some content

' + ); + + $result = $subject->emogrify(); + + static::assertContains('

', $result); + } + + /** + * Style block CSS overrides values. + * + * @test + */ + public function emogrifyMergesCssWithMixedCaseAttribute() + { + $subject = $this->buildDebugSubject( + '' . + '

some content

' + ); + $subject->setCss('p { margin: 0; padding-TOP: 0; PADDING-bottom: 1PX;}'); + + $result = $subject->emogrify(); + + static::assertContains( + '

', + $result + ); + } + + /** + * @test + */ + public function emogrifyMergesCssWithMixedUnits() + { + $subject = $this->buildDebugSubject( + '' . + '

some content

' + ); + $subject->setCss('p { margin: 1px; padding-bottom:0;}'); + + $result = $subject->emogrify(); + + static::assertContains('

', $result); + } + + /** + * @test + */ + public function emogrifyByDefaultRemovesElementsWithDisplayNoneFromExternalCss() + { + $subject = $this->buildDebugSubject('

'); + $subject->setCss('div.foo { display: none; }'); + + $result = $subject->emogrify(); + + static::assertNotContains('
', $result); + } + + /** + * @test + */ + public function emogrifyByDefaultRemovesElementsWithDisplayNoneInStyleAttribute() + { + $subject = $this->buildDebugSubject( + '' . + '' + ); + + $result = $subject->emogrify(); + + static::assertNotContains('buildDebugSubject('
'); + $subject->setCss('div.foo { display: none; }'); + + $subject->disableInvisibleNodeRemoval(); + $result = $subject->emogrify(); + + static::assertContains('