├── 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 `