├── src ├── HtmlProcessor │ ├── HtmlNormalizer.php │ ├── HtmlPruner.php │ ├── CssToAttributeConverter.php │ └── AbstractHtmlProcessor.php ├── Utilities │ ├── ArrayIntersector.php │ └── CssConcatenator.php ├── Css │ ├── StyleRule.php │ └── CssDocument.php └── CssInliner.php ├── phpunit.xml ├── LICENSE ├── composer.json ├── CHANGELOG.md └── README.md /src/HtmlProcessor/HtmlNormalizer.php: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | 20 | src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2008-2018 Pelago 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Utilities/ArrayIntersector.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private $invertedArray; 28 | 29 | /** 30 | * Constructs the object with the array that will be reused for many intersection computations. 31 | * 32 | * @param array $array 33 | */ 34 | public function __construct(array $array) 35 | { 36 | $this->invertedArray = \array_flip($array); 37 | } 38 | 39 | /** 40 | * Computes the intersection of `$array` and the array with which this object was constructed. 41 | * 42 | * @param array $array 43 | * 44 | * @return array 45 | * Returns an array containing all of the values in `$array` whose values exist in the array 46 | * with which this object was constructed. Note that keys are preserved, order is maintained, but 47 | * duplicates are removed. 48 | */ 49 | public function intersectWith(array $array): array 50 | { 51 | $invertedArray = \array_flip($array); 52 | 53 | $invertedIntersection = \array_intersect_key($invertedArray, $this->invertedArray); 54 | 55 | return \array_flip($invertedIntersection); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Css/StyleRule.php: -------------------------------------------------------------------------------- 1 | declarationBlock = $declarationBlock; 34 | $this->containingAtRule = \trim($containingAtRule); 35 | } 36 | 37 | /** 38 | * @return array the selectors, e.g. `["h1", "p"]` 39 | */ 40 | public function getSelectors(): array 41 | { 42 | /** @var array $selectors */ 43 | $selectors = $this->declarationBlock->getSelectors(); 44 | return \array_map( 45 | static function (Selector $selector): string { 46 | return (string)$selector; 47 | }, 48 | $selectors 49 | ); 50 | } 51 | 52 | /** 53 | * @return string the CSS declarations, separated and followed by a semicolon, e.g., `color: red; height: 4px;` 54 | */ 55 | public function getDeclarationAsText(): string 56 | { 57 | return \implode(' ', $this->declarationBlock->getRules()); 58 | } 59 | 60 | /** 61 | * Checks whether the declaration block has at least one declaration. 62 | */ 63 | public function hasAtLeastOneDeclaration(): bool 64 | { 65 | return $this->declarationBlock->getRules() !== []; 66 | } 67 | 68 | /** 69 | * @returns string e.g. `@media screen and (max-width: 480px)`, or an empty string 70 | */ 71 | public function getContainingAtRule(): string 72 | { 73 | return $this->containingAtRule; 74 | } 75 | 76 | /** 77 | * Checks whether the containing at-rule is non-empty and has any non-whitespace characters. 78 | */ 79 | public function hasContainingAtRule(): bool 80 | { 81 | return $this->getContainingAtRule() !== ''; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pelago/emogrifier", 3 | "description": "Converts CSS styles into inline style attributes in your HTML code", 4 | "keywords": [ 5 | "email", 6 | "css", 7 | "pre-processing" 8 | ], 9 | "homepage": "https://www.myintervals.com/emogrifier.php", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Oliver Klee", 14 | "email": "github@oliverklee.de" 15 | }, 16 | { 17 | "name": "Zoli Szabó", 18 | "email": "zoli.szabo+github@gmail.com" 19 | }, 20 | { 21 | "name": "John Reeve", 22 | "email": "jreeve@pelagodesign.com" 23 | }, 24 | { 25 | "name": "Jake Hotson", 26 | "email": "jake@qzdesign.co.uk" 27 | }, 28 | { 29 | "name": "Cameron Brooks" 30 | }, 31 | { 32 | "name": "Jaime Prado" 33 | } 34 | ], 35 | "require": { 36 | "php": "~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0", 37 | "ext-dom": "*", 38 | "ext-libxml": "*", 39 | "sabberworm/php-css-parser": "^8.3.1", 40 | "symfony/css-selector": "^3.4.32 || ^4.4 || ^5.2 || ^6.0" 41 | }, 42 | "require-dev": { 43 | "php-parallel-lint/php-parallel-lint": "^1.3.0", 44 | "phpunit/phpunit": "^8.5.16", 45 | "rawr/cross-data-providers": "^2.3.0" 46 | }, 47 | "config": { 48 | "preferred-install": { 49 | "*": "dist" 50 | }, 51 | "sort-packages": true 52 | }, 53 | "extra": { 54 | "branch-alias": { 55 | "dev-main": "6.0.x-dev" 56 | } 57 | }, 58 | "autoload": { 59 | "psr-4": { 60 | "Pelago\\Emogrifier\\": "src/" 61 | } 62 | }, 63 | "autoload-dev": { 64 | "psr-4": { 65 | "Pelago\\Emogrifier\\Tests\\": "tests/" 66 | } 67 | }, 68 | "prefer-stable": true, 69 | "scripts": { 70 | "ci": [ 71 | "@ci:static", 72 | "@ci:dynamic" 73 | ], 74 | "ci:composer:normalize": "@php \"./.phive/composer-normalize\" --dry-run", 75 | "ci:dynamic": [ 76 | "@ci:tests" 77 | ], 78 | "ci:php:fixer": "@php \"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots config/ src/ tests/", 79 | "ci:php:lint": "\"./vendor/bin/parallel-lint\" config src tests", 80 | "ci:php:md": "@php \"./.phive/phpmd\" src text config/phpmd.xml", 81 | "ci:php:psalm": "@php \"./.phive/psalm\" --show-info=false", 82 | "ci:php:sniff": "@php \"./.phive/phpcs\" config src tests", 83 | "ci:static": [ 84 | "@ci:composer:normalize", 85 | "@ci:php:lint", 86 | "@ci:php:sniff", 87 | "@ci:php:fixer", 88 | "@ci:php:md", 89 | "@ci:php:psalm" 90 | ], 91 | "ci:tests": [ 92 | "@ci:tests:unit" 93 | ], 94 | "ci:tests:sof": "@php \"./vendor/bin/phpunit\" --stop-on-failure", 95 | "ci:tests:unit": "@php \"./vendor/bin/phpunit\"", 96 | "composer:normalize": "@php \"./.phive/composer-normalize\"", 97 | "php:fix": "@php \"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix config/ src/ tests/", 98 | "php:version": "@php -v | grep -Po 'PHP\\s++\\K(?:\\d++\\.)*+\\d++(?:-\\w++)?+'", 99 | "psalm:baseline": "@php \"./.phive/psalm\" --set-baseline=psalm.baseline.xml", 100 | "psalm:cc": "@php \"./.phive/psalm\" --clear-cache" 101 | }, 102 | "scripts-descriptions": { 103 | "ci": "Runs all dynamic and static code checks.", 104 | "ci:composer:normalize": "Checks the formatting and structure of the composer.json.", 105 | "ci:dynamic": "Runs all dynamic tests (i.e., currently, the unit tests).", 106 | "ci:php:fixer": "Checks the code style with PHP CS Fixer.", 107 | "ci:php:lint": "Lints the PHP files for syntax errors.", 108 | "ci:php:md": "Checks the code complexity with PHPMD.", 109 | "ci:php:psalm": "Checks the types with Psalm.", 110 | "ci:php:sniff": "Checks the code style with PHP_CodeSniffer.", 111 | "ci:static": "Runs all static code analysis checks for the code and the composer.json.", 112 | "ci:tests": "Runs all dynamic tests (i.e., currently, the unit tests).", 113 | "ci:tests:sof": "Runs the unit tests and stops at the first failure.", 114 | "ci:tests:unit": "Runs all unit tests.", 115 | "composer:normalize": "Reformats and sorts the composer.json file.", 116 | "php:fix": "Reformats the code with php-cs-fixer.", 117 | "php:version": "Outputs the installed PHP version.", 118 | "psalm:baseline": "Updates the Psalm baseline file to match the code.", 119 | "psalm:cc": "Clears the Psalm cache." 120 | }, 121 | "support": { 122 | "issues": "https://github.com/MyIntervals/emogrifier/issues", 123 | "source": "https://github.com/MyIntervals/emogrifier" 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/HtmlProcessor/HtmlPruner.php: -------------------------------------------------------------------------------- 1 | getXPath()->query(self::DISPLAY_NONE_MATCHER); 35 | if ($elementsWithStyleDisplayNone->length === 0) { 36 | return $this; 37 | } 38 | 39 | foreach ($elementsWithStyleDisplayNone as $element) { 40 | $parentNode = $element->parentNode; 41 | if ($parentNode !== null) { 42 | $parentNode->removeChild($element); 43 | } 44 | } 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Removes classes that are no longer required (e.g. because there are no longer any CSS rules that reference them) 51 | * from `class` attributes. 52 | * 53 | * Note that this does not inspect the CSS, but expects to be provided with a list of classes that are still in use. 54 | * 55 | * This method also has the (presumably beneficial) side-effect of minifying (removing superfluous whitespace from) 56 | * `class` attributes. 57 | * 58 | * @param array $classesToKeep names of classes that should not be removed 59 | * 60 | * @return self fluent interface 61 | */ 62 | public function removeRedundantClasses(array $classesToKeep = []): self 63 | { 64 | $elementsWithClassAttribute = $this->getXPath()->query('//*[@class]'); 65 | 66 | if ($classesToKeep !== []) { 67 | $this->removeClassesFromElements($elementsWithClassAttribute, $classesToKeep); 68 | } else { 69 | // Avoid unnecessary processing if there are no classes to keep. 70 | $this->removeClassAttributeFromElements($elementsWithClassAttribute); 71 | } 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * Removes classes from the `class` attribute of each element in `$elements`, except any in `$classesToKeep`, 78 | * removing the `class` attribute itself if the resultant list is empty. 79 | * 80 | * @param \DOMNodeList $elements 81 | * @param array $classesToKeep 82 | */ 83 | private function removeClassesFromElements(\DOMNodeList $elements, array $classesToKeep): void 84 | { 85 | $classesToKeepIntersector = new ArrayIntersector($classesToKeep); 86 | 87 | /** @var \DOMElement $element */ 88 | foreach ($elements as $element) { 89 | $elementClasses = \preg_split('/\\s++/', \trim($element->getAttribute('class'))); 90 | $elementClassesToKeep = $classesToKeepIntersector->intersectWith($elementClasses); 91 | if ($elementClassesToKeep !== []) { 92 | $element->setAttribute('class', \implode(' ', $elementClassesToKeep)); 93 | } else { 94 | $element->removeAttribute('class'); 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * Removes the `class` attribute from each element in `$elements`. 101 | * 102 | * @param \DOMNodeList $elements 103 | */ 104 | private function removeClassAttributeFromElements(\DOMNodeList $elements): void 105 | { 106 | /** @var \DOMElement $element */ 107 | foreach ($elements as $element) { 108 | $element->removeAttribute('class'); 109 | } 110 | } 111 | 112 | /** 113 | * After CSS has been inlined, there will likely be some classes in `class` attributes that are no longer referenced 114 | * by any remaining (uninlinable) CSS. This method removes such classes. 115 | * 116 | * Note that it does not inspect the remaining CSS, but uses information readily available from the `CssInliner` 117 | * instance about the CSS rules that could not be inlined. 118 | * 119 | * @param CssInliner $cssInliner object instance that performed the CSS inlining 120 | * 121 | * @return self fluent interface 122 | * 123 | * @throws \BadMethodCallException if `inlineCss` has not first been called on `$cssInliner` 124 | */ 125 | public function removeRedundantClassesAfterCssInlined(CssInliner $cssInliner): self 126 | { 127 | $classesToKeepAsKeys = []; 128 | foreach ($cssInliner->getMatchingUninlinableSelectors() as $selector) { 129 | \preg_match_all('/\\.(-?+[_a-zA-Z][\\w\\-]*+)/', $selector, $matches); 130 | $classesToKeepAsKeys += \array_fill_keys($matches[1], true); 131 | } 132 | 133 | $this->removeRedundantClasses(\array_keys($classesToKeepAsKeys)); 134 | 135 | return $this; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Css/CssDocument.php: -------------------------------------------------------------------------------- 1 | parse(); 46 | $this->sabberwormCssDocument = $sabberwormCssDocument; 47 | } 48 | 49 | /** 50 | * Collates the media query, selectors and declarations for individual rules from the parsed CSS, in order. 51 | * 52 | * @param array $allowedMediaTypes 53 | * 54 | * @return array 55 | */ 56 | public function getStyleRulesData(array $allowedMediaTypes): array 57 | { 58 | $ruleMatches = []; 59 | /** @var CssRenderable $rule */ 60 | foreach ($this->sabberwormCssDocument->getContents() as $rule) { 61 | if ($rule instanceof CssAtRuleBlockList) { 62 | $containingAtRule = $this->getFilteredAtIdentifierAndRule($rule, $allowedMediaTypes); 63 | if (\is_string($containingAtRule)) { 64 | /** @var CssRenderable $nestedRule */ 65 | foreach ($rule->getContents() as $nestedRule) { 66 | if ($nestedRule instanceof CssDeclarationBlock) { 67 | $ruleMatches[] = new StyleRule($nestedRule, $containingAtRule); 68 | } 69 | } 70 | } 71 | } elseif ($rule instanceof CssDeclarationBlock) { 72 | $ruleMatches[] = new StyleRule($rule); 73 | } 74 | } 75 | 76 | return $ruleMatches; 77 | } 78 | 79 | /** 80 | * Renders at-rules from the parsed CSS that are valid and not conditional group rules (i.e. not rules such as 81 | * `@media` which contain style rules whose data is returned by {@see getStyleRulesData}). Also does not render 82 | * `@charset` rules; these are discarded (only UTF-8 is supported). 83 | * 84 | * @return string 85 | */ 86 | public function renderNonConditionalAtRules(): string 87 | { 88 | $this->isImportRuleAllowed = true; 89 | /** @var array $cssContents */ 90 | $cssContents = $this->sabberwormCssDocument->getContents(); 91 | $atRules = \array_filter($cssContents, [$this, 'isValidAtRuleToRender']); 92 | 93 | if ($atRules === []) { 94 | return ''; 95 | } 96 | 97 | $atRulesDocument = new SabberwormCssDocument(); 98 | $atRulesDocument->setContents($atRules); 99 | 100 | /** @var string $renderedRules */ 101 | $renderedRules = $atRulesDocument->render(); 102 | return $renderedRules; 103 | } 104 | 105 | /** 106 | * @param CssAtRuleBlockList $rule 107 | * @param array $allowedMediaTypes 108 | * 109 | * @return ?string 110 | * If the nested at-rule is supported, it's opening declaration (e.g. "@media (max-width: 768px)") is 111 | * returned; otherwise the return value is null. 112 | */ 113 | private function getFilteredAtIdentifierAndRule(CssAtRuleBlockList $rule, array $allowedMediaTypes): ?string 114 | { 115 | $result = null; 116 | 117 | if ($rule->atRuleName() === 'media') { 118 | /** @var string $mediaQueryList */ 119 | $mediaQueryList = $rule->atRuleArgs(); 120 | [$mediaType] = \explode('(', $mediaQueryList, 2); 121 | if (\trim($mediaType) !== '') { 122 | $escapedAllowedMediaTypes = \array_map( 123 | static function (string $allowedMediaType): string { 124 | return \preg_quote($allowedMediaType, '/'); 125 | }, 126 | $allowedMediaTypes 127 | ); 128 | $mediaTypesMatcher = \implode('|', $escapedAllowedMediaTypes); 129 | $isAllowed = \preg_match('/^\\s*+(?:only\\s++)?+(?:' . $mediaTypesMatcher . ')/i', $mediaType) > 0; 130 | } else { 131 | $isAllowed = true; 132 | } 133 | 134 | if ($isAllowed) { 135 | $result = '@media ' . $mediaQueryList; 136 | } 137 | } 138 | 139 | return $result; 140 | } 141 | 142 | /** 143 | * Tests if a CSS rule is an at-rule that should be passed though and copied to a `