N°8834 - Add compatibility with PHP 8.4 (#819)

* N°8834 - Add compatibility with PHP 8.4

* Rollback of scssphp/scssphp version upgrade due to compilation error
This commit is contained in:
Lenaick
2026-02-26 10:36:32 +01:00
committed by GitHub
parent d4821b7edc
commit fc967c06ce
961 changed files with 12298 additions and 7130 deletions

View File

@@ -3,6 +3,9 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).
Please also have a look at our
[API and deprecation policy](docs/API-and-deprecation-policy.md).
## x.y.z
### Added
@@ -15,84 +18,123 @@ This project adheres to [Semantic Versioning](https://semver.org/).
### Fixed
## 7.3.0: Add support for PHP 8.4 and CSS custom properties
### Added
- Add support for PHP 8.4 (#1278)
- Support CSS custom properties (variables) (#1336)
- Support `:root` pseudo-class (#1306)
- Add CSS selectors exclusion feature (#1236)
### Changed
- Require `sabberworm/php-css-parser:^8.7.0` (#1355)
### Fixed
- Preserve case of CSS custom property (variable) names (#1332)
### Documentation
- Add an API and deprecation policy (#1323)
## 7.2.0: Add support for Symfony 7
### Added
- Add support for Symfony 7 (#1243)
## 7.1.0: Add support for PHP 8.3
### Added
- Add support for PHP 8.3 (#1218)
### Changed
- Disable HTML formatting by default (#1214)
## 7.0.0
### Added
- Add support for PHP 8.2 (#1155)
### Changed
- Throw exception with invalid CSS in debug mode (#1142)
- Only support up to 69 atomic expressions in a selector (#1113)
- Require `sabberworm/php-css-parser:^8.4.0` (#1134)
- Upgrade to PHPUnit 9 (#1112)
### Deprecated
- Support for PHP 7.3 will be removed in Emogrifier 8.0.
### Removed
- Drop support for Symfony 3.x and 5.3 (#1120, #1162)
- Drop support for PHP 7.2 (#1111)
### Fixed
- Bump the minimum Symfony 4.4 version to avoid PHP deprecation warnings (#1187)
## 6.0.0
### Added
- Test with Symfony 6-dev (#1109)
- Add support for PHP 8.1 (#1103)
- Add a dedicated class for caching (#1097)
- Allow installation together with Symfony 6 (#1065)
- Support more file types in the `.editorconfig` (#1035)
- Set `align` attribute of `<th>` elements with `CssToAttributeConverter` (#1008)
- Set `align` attribute of `<th>` elements with `CssToAttributeConverter`
(#1008)
### Changed
- Use `sabberworm/php-css-parser` to parse the CSS (#1015)
- Also check the unit test code with Psalm (#1003)
### Deprecated
- Support for PHP 7.2 will be removed in Emogrifier 7.0.
### Removed
- Remove a redundant CSS data cache (#1018)
- Drop support for Symfony 5.1 and 5.2 (#972, #1104)
- Drop support for PHP 7.1 (#967)
### Fixed
- Allow `@import` after ignored invalid `@charset` (@1081)
- Allow line feeds within `<html>` tag (#987)
## 5.0.1
### Changed
- Switch the default branch from `master` to `main` (#951)
### Fixed
- Ignore `http-equiv` `Content-Type` in `<body>` (#961)
- Allow "Content-Type" in content (#959)
## 5.0.0
### Added
- Add an `.editorconfig` file (#940)
- Support PHP 8.0 (#926)
- Run the CI build once a week (#933)
- Move more development tools to PHIVE (#894, #907)
### Changed
- Automatically add a backslash for global functions (#909)
- Update the development tools (#898, #895)
- Upgrade to PHPUnit 7.5 (#888)
@@ -101,14 +143,17 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Make use of PHP 7.1 language features (#883)
### Deprecated
- Support for PHP 7.1 will be removed in Emogrifier 6.0.
### Removed
- Drop support for Symfony 4.3 and 5.0 (#936)
- Stop checking `tests/` with Psalm (#885)
- Drop support for PHP 7.0 (#880)
### Fixed
- Fix a nonsensical code example in the README (#920, #935)
- Remove `!important` from `style` attributes also when uppercase, mixed case or
having whitespace after `!` (#911)
@@ -122,35 +167,43 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## 4.0.0
### Added
- Extract and inject `@font-face` rules into head (#870)
- Test tag omission in conformant supplied HTML (#868)
- Check for missing return type hint annotations in the code sniffs (#860)
- Support `:only-of-type` (with a type) (#849, #856)
- Configuration setting methods now all return `$this` to allow chaining (#824, #854)
- Configuration setting methods now all return `$this` to allow chaining
(#824, #854)
- Disable php-cs-fixer Yoda conditions (#791, #794)
- Check the code with psalm (#537, #779)
- Composer script to run tests with `--stop-on-failure` (#782)
- Test universal selector with combinators (#776)
### Changed
- Normalize DOCTYPE declaration according to polyglot markup recommendation (#866)
- Normalize DOCTYPE declaration according to polyglot markup recommendation
(#866)
- Upgrade to V2 of the PHP setup GitHub action (#861)
- Move the development tools to PHIVE (#850, #851)
- Switch the parallel linting to a maintained fork (#842)
- Move continuous integration from Travis CI to GitHub actions (#832, #834, #838, #839, #840, #841, #843, #846, #849)
- Move continuous integration from Travis CI to GitHub actions
(#832, #834, #838, #839, #840, #841, #843, #846, #849)
- Clean up the folder structure and autoloading configuration (#529, #785)
- Use `self` as the return type for `fromHtml` (#784)
- Make use of PHP 7.0 language features (#777)
### Deprecated
- Support for PHP 7.0 will be removed in Emogrifier 5.0.
### Removed
- Drop support for Symfony versions that have reached their end of life (#847)
- Drop the `Emogrifier` class (#774)
- Drop support for PHP 5.6 (#773)
### Fixed
- Allow `:last-of-type` etc. without type, without causing exception (#875)
- Make sure to use the Composer-installed development tools (#862, #865)
- Add missing `<head>` element when there's a `<header>` element (#844, #853)
@@ -161,22 +214,28 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## 3.1.0
### Added
- Add support for PHP 7.4 (#821, #829)
### Changed
- Upgrade to Symfony 5.0 (#820)
## 3.0.0
### Added
- Test and document excluding entire subtree with `addExcludedSelector()` (#347, #768)
- Test and document excluding entire subtree with `addExcludedSelector()`
(#347, #768)
- Test that rules with `:optional` or `:required` are copied to the `<style>`
element (#748, #765)
- Test that rules with `:only-of-type` are copied to the `<style>` element (#748, #760)
- Test that rules with `:only-of-type` are copied to the `<style>` element
(#748, #760)
- Support `:last-of-type` (#748, #758)
- Support `:first-of-type` (#748, #757)
- Support `:empty` (#748, #756)
- Test that rules with `:any-link` are copied to the `<style>` element (#748, #755)
- Test that rules with `:any-link` are copied to the `<style>` element
(#748, #755)
- Support and test `:only-child` (#747, #754)
- Support and test `:nth-last-of-type` (#747, #751)
- Support and test `:nth-last-child` (#747, #750)
@@ -195,6 +254,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Add tests for `:nth-child` and `:nth-of-type` (#71, #698)
### Changed
- Relax the dependency on `symfony/css-selector` (#762)
- Rename `HtmlPruner::removeInvisibleNodes` to
`HtmlPruner::removeElementsWithDisplayNone` (#717, #718)
@@ -204,14 +264,17 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Update the development dependencies (#691)
### Deprecated
- Support for PHP 5.6 will be removed in Emogrifier 4.0.
- Deprecate the `Emogrifier` class (#701)
### Removed
- Drop `enableCssToHtmlMapping` and `disableInvisibleNodeRemoval` (#692)
- Drop support for PHP 5.5 (#690)
### Fixed
- Fix PhpStorm code inspection warnings (#729, #770)
- Uppercase type combined with class or ID in selector (#590, #769)
- Dynamic pseudo-class combined with static one (rules copied to `<style>`
@@ -226,22 +289,27 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## 2.2.0
### Added
- Add a `HtmlPruner` class (#679)
- Add `AbstractHtmlProcessor::fromDomDocument` (#676)
- Add `AbstractHtmlProcessor::fromHtml` (#675)
### Changed
- Make the closures static (#674)
- Keep `<wbr>` elements by default with `CssInliner` (#665)
- Make the `CssInliner` inherit `AbstractHtmlProcessor` (#660)
- Separate `CssInliner::inlineCss` and the rendering (#654)
### Removed
- Drop the removal of unprocessable tags from `CssInliner` (#685)
- Drop the removal of invisible nodes from `CssInliner` (#684)
### Fixed
- Remove opening `<body>` tag from `body` content when element has attribute(s) (#677, #683)
- Remove opening `<body>` tag from `body` content when element has attribute(s)
(#677, #683)
- Keep development files out of the Composer packages (#678)
- Call all static methods statically in `CssConcatenator` (#670)
- Support all HTML5 self-closing tags, including `<embed>`, `<source>`,
@@ -252,17 +320,20 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## 2.1.1
### Changed
- Add a test that a missing document type gets added (#641)
### Fixed
- Keep the `style` element the `head` (#642)
## 2.1.0
### Added
- PHP 7.3 support (#638)
- Allow PHP 7.3 in `composer.json`
- Test in Travis for PHP 7.3
- Allow PHP 7.3 in `composer.json`
- Test in Travis for PHP 7.3
- Add a `renderBodyContent()` method (#633)
- Add a `getDomDocument()` method (#630)
- Add a Composer script for PHP CS Fixer (#607)
@@ -276,17 +347,20 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Validate the composer.json on Travis (#476)
### Changed
- Mark the work-in-progress classes as `@internal` (#640)
- Remove the unprocessable tags from the DOM, not from the raw HTML (#627)
- Reject empty HTML in `setHtml()` (#622)
- Stop passing the DOM document around (#618)
- Improve performance by using explicit namespaces for PHP functions (#573, #576)
- Improve performance by using explicit namespaces for PHP functions
(#573, #576)
- Add type hint checking to the code sniffs (#566)
- Check the code with PHPMD (#561)
- Add the cyclomatic complexity to the checked code sniffs (#558)
- Use the Symfony CSS selector component (#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)
@@ -300,16 +374,19 @@ This project adheres to [Semantic Versioning](https://semver.org/).
version 3.0 and removed for version 4.0.
### Removed
- Drop the `@version` PHPDoc annotations (#637)
- Drop the destructors (#619)
### Fixed
- Add required XML PHP extension to `composer.json` (#614)
- Add required DOM PHP extension to `composer.json` (#595)
- Escape hyphens in regular expressions (#588)
- Fix Travis for PHP 5.x (#589)
- Allow CSS between empty `@media` rule and another `@media` rule (#534)
- Allow additional whitespace in media-query-list of disallowed `@media` rules (#532)
- Allow additional whitespace in media-query-list of disallowed `@media` rules (
#532)
- Allow multiple minified `@import` rules in the CSS without error (note:
`@import`s are currently ignored, #527)
- Style property ordering when multiple mixed individual and shorthand
@@ -325,28 +402,34 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## 2.0.0
### Added
- Support for CSS :not() selector (#431)
- Automatically remove !important annotations from final inline style declarations (#420)
- Automatically remove !important annotations from final inline style
declarations (#420)
- Automatically move `<style>` block from `<head>` to `<body>` (#396)
- PHP 7.2 support (#398)
- Allow PHP 7.2 in `composer.json`, cleaner PHP version constraint
- Test in Travis for PHP 7.2
- Allow PHP 7.2 in `composer.json`, cleaner PHP version constraint
- Test in Travis for PHP 7.2
- Debug mode. Throw debug exceptions only if debug is active. (#392)
### Changed
- Test with latest and oldest dependencies on Travis (#463)
- Always enable the debug mode in the tests (#448)
- Optimize the string operations (#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)
- Drop support for HHVM (#386)
### Fixed
- Handle invalid/unrecognized selectors in media query blocks (#442)
- Throw (the correct) exception for invalid excluded selectors (#437)
- emogrifyBody must not encode umlaut entities (#414)
@@ -359,19 +442,23 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## 1.2.0 (2017-03-02)
### Added
- Handling invalid xPath expression warnings (#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)
- Ignore "auto" width and height (#365)
## 1.1.0 (2016-09-18)
### Added
- Add support for PHP 7.1 (#342)
- Support the attr|=value selector (#337)
- Support the attr*=value selector (#330)
@@ -381,13 +468,17 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Add CSS to HTML attribute mapper (#288)
### Changed
- Remove composer dependency from PHP mbstring extension (Actual code dependency were removed a lot of time ago) (#295)
- Remove composer dependency from PHP mbstring extension (Actual code dependency
were removed a lot of time ago) (#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)
- Ignore value with words more than one in the attribute selector (#327)
- Ignore spaces around the > in the direct child selector (#322)
@@ -399,6 +490,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## 1.0.0 (2015-10-15)
### Added
- Add branch alias (#231)
- Remove media queries which do not impact the document (#217)
- Allow elements to be excluded from emogrification (#215)
@@ -415,19 +507,23 @@ This project adheres to [Semantic Versioning](https://semver.org/).
and nth-of-type)
### Changed
- Make HTML5 the default document type (#245)
- Make copyCssWithMediaToStyleNode private (#218)
- Stop encoding umlauts and dollar signs (#170)
- Convert the classes to namespaces (#41)
### Deprecated
- Support for PHP 5.4 will be removed in Emogrifier 2.0.
### Removed
- Drop support for PHP 5.3 (#114)
- Support for character sets other than UTF-8 was removed.
### Fixed
- Fix failing tests on Windows due to line endings (#263)
- Parsing CSS declaration blocks (#261)
- Fix first-child and last-child selectors (#257)

View File

@@ -1,9 +1,10 @@
# Emogrifier
[![Build Status](https://github.com/MyIntervals/emogrifier/workflows/CI/badge.svg?branch=main)](https://github.com/MyIntervals/emogrifier/actions/)
[![Build Status](https://github.com/MyIntervals/emogrifier/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/MyIntervals/emogrifier/actions/)
[![Latest Stable Version](https://poser.pugx.org/pelago/emogrifier/v/stable.svg)](https://packagist.org/packages/pelago/emogrifier)
[![Total Downloads](https://poser.pugx.org/pelago/emogrifier/downloads.svg)](https://packagist.org/packages/pelago/emogrifier)
[![License](https://poser.pugx.org/pelago/emogrifier/license.svg)](https://packagist.org/packages/pelago/emogrifier)
[![Coverage Status](https://coveralls.io/repos/github/MyIntervals/emogrifier/badge.svg?branch=main)](https://coveralls.io/github/MyIntervals/emogrifier?branch=main)
_n. e•mog•ri•fi•er [\ē-'mä-grƏ-,fī-Ər\] - a utility for changing completely the
nature or appearance of HTML email, esp. in a particularly fantastic or bizarre
@@ -31,6 +32,7 @@ into inline style attributes in your HTML code.
- [Usage](#usage)
- [Supported CSS selectors](#supported-css-selectors)
- [Caveats](#caveats)
- [Contributing](#contributing)
- [Steps to release a new version](#steps-to-release-a-new-version)
- [Maintainers](#maintainers)
@@ -157,6 +159,58 @@ $visualHtml = CssToAttributeConverter::fromDomDocument($domDocument)
->convertCssToVisualAttributes()->render();
```
### Evaluating CSS custom properties (variables)
The `CssVariableEvaluator` class can be used to apply the values of CSS
variables defined in inline style attributes to inline style properties which
use them.
For example, the following CSS defines and uses a custom property:
```css
:root {
--text-color: green;
}
p {
color: var(--text-color);
}
```
After `CssInliner` has inlined that CSS on the (contrived) HTML
`<html><body><p></p></body></html>`, it will look like this:
```html
<html style="--text-color: green;">
<body>
<p style="color: var(--text-color);">
<p>
</body>
</html>
```
The `CssVariableEvaluator` method `evaluateVariables` will apply the value of
`--text-color` so that the paragraph `style` attribute becomes `color: green;`.
It can be used like this:
```php
use Pelago\Emogrifier\HtmlProcessor\CssVariableEvaluator;
$evaluatedHtml = CssVariableEvaluator::fromHtml($html)
->evaluateVariables()->render();
```
You can also have the ` CssVariableEvaluator ` work on a `DOMDocument`:
```php
$evaluatedHtml = CssVariableEvaluator::fromDomDocument($domDocument)
->evaluateVariables()->render();
```
### Removing redundant content and attributes from the HTML
The `HtmlPruner` class can reduce the size of the HTML by removing elements with
@@ -174,11 +228,11 @@ $prunedHtml = HtmlPruner::fromHtml($html)->removeElementsWithDisplayNone()
->removeRedundantClasses($classesToKeep)->render();
```
The `removeRedundantClasses` method accepts a whitelist of names of classes that
should be retained. If this is a post-processing step after inlining CSS, you
can alternatively use `removeRedundantClassesAfterCssInlined`, passing it the
`CssInliner` instance that has inlined the CSS (and having the `HtmlPruner` work
on the `DOMDocument`). This will use information from the `CssInliner` to
The `removeRedundantClasses` method accepts an allowlist of names of classes
that should be retained. If this is a post-processing step after inlining CSS,
you can alternatively use `removeRedundantClassesAfterCssInlined`, passing it
the `CssInliner` instance that has inlined the CSS (and having the `HtmlPruner`
work on the `DOMDocument`). This will use information from the `CssInliner` to
determine which classes are still required (namely, those used in uninlinable
rules that have been copied to a `<style>` element):
@@ -189,16 +243,16 @@ $prunedHtml = HtmlPruner::fromDomDocument($cssInliner->getDomDocument())
```
The `removeElementsWithDisplayNone` method will not remove any elements which
have the class `-emogrifier-keep`. So if, for example, there are elements which
have the class `-emogrifier-keep`. So if, for example, there are elements which
by default have `display: none` but are revealed by an `@media` rule, or which
are intended as a preheader, you can add that class to those elements. The
are intended as a preheader, you can add that class to those elements. The
paragraph in this HTML snippet will not be removed even though it has
`display: none` (which has presumably been applied by `CssInliner::inlineCss()`
from a CSS rule `.preheader { display: none; }`):
```html
<p class="preheader -emogrifier-keep" style="display: none;">
Hello World!
Hello World!
</p>
```
@@ -229,15 +283,32 @@ calling the `inlineCss` method:
* `->removeAllowedMediaType(string $mediaName)` - You can use this
method to remove media types that Emogrifier keeps.
* `->addExcludedSelector(string $selector)` - Keeps elements from
being affected by CSS inlining. Note that only elements matching the supplied
being affected by CSS inlining. Note that only elements matching the supplied
selector(s) will be excluded from CSS inlining, not necessarily their
descendants. If you wish to exclude an entire subtree, you should provide
descendants. If you wish to exclude an entire subtree, you should provide
selector(s) which will match all elements in the subtree, for example by using
the universal selector:
```php
$cssInliner->addExcludedSelector('.message-preview');
$cssInliner->addExcludedSelector('.message-preview *');
```
* `->addExcludedCssSelector(string $selector)` - Contrary to
`addExcludedSelector`, which excludes HTML nodes, this method excludes CSS
selectors from being inlined. This is for example useful if you don't want
your CSS reset rules to be inlined on each HTML node (e.g.
`* { margin: 0; padding: 0; font-size: 100% }`).
Note that these selectors must precisely match the selectors you wish to
exclude.
Meaning that excluding `.example` will not exclude `p .example`.
```php
$cssInliner->addExcludedCssSelector('*');
$cssInliner->addExcludedCssSelector('form');
```
* `->removeExcludedCssSelector(string $selector)` - Removes previously added
excluded selectors, if any.
```php
$cssInliner->removeExcludedCssSelector('form');
```
### Migrating from the dropped `Emogrifier` class to the `CssInliner` class
@@ -286,11 +357,11 @@ $html = CssToAttributeConverter::fromDomDocument($domDocument)
Emogrifier currently supports the following
[CSS selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors):
* [type](https://developer.mozilla.org/en-US/docs/Web/CSS/Type_selectors)
* [class](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors)
* [ID](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors)
* [universal](https://developer.mozilla.org/en-US/docs/Web/CSS/Universal_selectors)
* [attribute](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors):
* [type](https://developer.mozilla.org/en-US/docs/Web/CSS/Type_selectors)
* [class](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors)
* [ID](https://developer.mozilla.org/en-US/docs/Web/CSS/ID_selectors)
* [universal](https://developer.mozilla.org/en-US/docs/Web/CSS/Universal_selectors)
* [attribute](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors):
* presence
* exact value match
* value with `~` (one word within a whitespace-separated list of words)
@@ -298,68 +369,71 @@ Emogrifier currently supports the following
* value with `^` (prefix match)
* value with `$` (suffix match)
* value with `*` (substring match)
* [adjacent](https://developer.mozilla.org/en-US/docs/Web/CSS/Adjacent_sibling_selectors)
* [general sibling](https://developer.mozilla.org/en-US/docs/Web/CSS/General_sibling_combinator)
* [child](https://developer.mozilla.org/en-US/docs/Web/CSS/Child_selectors)
* [descendant](https://developer.mozilla.org/en-US/docs/Web/CSS/Descendant_selectors)
* [pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes):
* [empty](https://developer.mozilla.org/en-US/docs/Web/CSS/:empty)
* [first-child](https://developer.mozilla.org/en-US/docs/Web/CSS/:first-child)
* [first-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:first-of-type)
(with a type, e.g. `p:first-of-type` but not `*:first-of-type`)
* [last-child](https://developer.mozilla.org/en-US/docs/Web/CSS/:last-child)
* [last-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:last-of-type)
(with a type)
* [not()](https://developer.mozilla.org/en-US/docs/Web/CSS/:not)
* [nth-child()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child)
* [nth-last-child()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-child)
* [nth-last-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-of-type)
(with a type)
* [nth-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-of-type)
(with a type)
* [only-child](https://developer.mozilla.org/en-US/docs/Web/CSS/:only-child)
* [only-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:only-of-type)
(with a type)
* [adjacent](https://developer.mozilla.org/en-US/docs/Web/CSS/Adjacent_sibling_selectors)
* [general sibling](https://developer.mozilla.org/en-US/docs/Web/CSS/General_sibling_combinator)
* [child](https://developer.mozilla.org/en-US/docs/Web/CSS/Child_selectors)
* [descendant](https://developer.mozilla.org/en-US/docs/Web/CSS/Descendant_selectors)
* [pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes):
* [empty](https://developer.mozilla.org/en-US/docs/Web/CSS/:empty)
* [first-child](https://developer.mozilla.org/en-US/docs/Web/CSS/:first-child)
* [first-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:first-of-type)
(with a type, e.g. `p:first-of-type` but not `*:first-of-type`)
* [last-child](https://developer.mozilla.org/en-US/docs/Web/CSS/:last-child)
* [last-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:last-of-type)
(with a type)
* [not()](https://developer.mozilla.org/en-US/docs/Web/CSS/:not)
* [nth-child()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child)
* [nth-last-child()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-child)
* [nth-last-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-of-type)
(with a type)
* [nth-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-of-type)
(with a type)
* [only-child](https://developer.mozilla.org/en-US/docs/Web/CSS/:only-child)
* [only-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:only-of-type)
(with a type)
* [root](https://developer.mozilla.org/en-US/docs/Web/CSS/:root)
The following selectors are not implemented yet:
* [case-insensitive attribute value](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors#case-insensitive)
* static [pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes)
not listed above as supported rules involving them will nonetheless be
preserved and copied to a `<style>` element in the HTML including (but not
necessarily limited to) the following:
* [any-link](https://developer.mozilla.org/en-US/docs/Web/CSS/:any-link)
* [first-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:first-of-type)
without a type
* [last-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:last-of-type)
without a type
* [nth-last-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-of-type)
without a type
* [nth-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-of-type)
without a type
* [only-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:only-of-type)
without a type
* [optional](https://developer.mozilla.org/en-US/docs/Web/CSS/:optional)
* [required](https://developer.mozilla.org/en-US/docs/Web/CSS/:required)
* [case-insensitive attribute value](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors#case-insensitive)
* static
[pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes)
not listed above as supported rules involving them will nonetheless be
preserved and copied to a `<style>` element in the HTML including (but not
necessarily limited to) the following:
* [any-link](https://developer.mozilla.org/en-US/docs/Web/CSS/:any-link)
* [first-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:first-of-type)
without a type
* [last-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:last-of-type)
without a type
* [nth-last-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-of-type)
without a type
* [nth-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-of-type)
without a type
* [only-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:only-of-type)
without a type
* [optional](https://developer.mozilla.org/en-US/docs/Web/CSS/:optional)
* [required](https://developer.mozilla.org/en-US/docs/Web/CSS/:required)
Rules involving the following selectors cannot be applied as inline styles.
They will, however, be preserved and copied to a `<style>` element in the HTML:
* dynamic [pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes)
(such as `:hover`)
* [pseudo-elements](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements)
(such as `::after`)
* dynamic
[pseudo-classes](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes)
(such as `:hover`)
* [pseudo-elements](https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements)
(such as `::after`)
## Caveats
* Emogrifier requires the HTML and the CSS to be UTF-8. Encodings like
ISO8859-1 or ISO8859-15 are not supported.
* Emogrifier preserves all valuable `@media` rules. Media queries can be very
useful in responsive email design. See
* Emogrifier preserves all applicable `@media` rules. Media queries can be very
useful in responsive email design. See
[media query support](https://litmus.com/help/email-clients/media-query-support/).
However, in order for them to be effective, you may need to add `!important`
to some of the declarations within them so that they will override CSS styles
that have been inlined. For example, with the following CSS, the `font-size`
that have been inlined. For example, with the following CSS, the `font-size`
declaration in the `@media` rule would not override the font size for `p`
elements from the preceding rule after that has been inlined as
`<p style="font-size: 16px;">` in the HTML, without the `!important` directive
@@ -374,11 +448,15 @@ They will, however, be preserved and copied to a `<style>` element in the HTML:
}
}
```
Any CSS custom properties (variables) defined in `@media` rules cannot be
applied to CSS property values that have been inlined and evaluated. However,
`@media` rules using custom properties (with `var()`) would still be able to
obtain their values (from the inlined definitions or `@media` rules) in email
clients that support custom properties.
* Emogrifier cannot inline CSS rules involving selectors with pseudo-elements
(such as `::after`) or dynamic pseudo-classes (such as `:hover`) it is
impossible. However, such rules will be preserved and copied to a `<style>`
element, as for `@media` rules. The same caveat about the possible need for
the `!important` directive also applies with pseudo-classes.
impossible. However, such rules will be preserved and copied to a `<style>`
element, as for `@media` rules, with the same caveats applying.
* Emogrifier will grab existing inline style attributes _and_ will
grab `<style>` blocks from your HTML, but it will not grab CSS files
referenced in `<link>` elements or `@import` rules (though it will leave them
@@ -402,20 +480,32 @@ They will, however, be preserved and copied to a `<style>` element in the HTML:
self-closing tags will lose their slash. To keep your HTML valid, it is
recommended to use HTML5 instead of one of the XHTML variants.
## API and deprecation policy
Please have a look at our
[API and deprecation policy](docs/API-and-deprecation-policy.md).
## Contributing
Contributions in the form of bug reports, feature requests, or pull requests are
more than welcome. :pray: Please have a look at our
[contribution guidelines](CONTRIBUTING.md) to learn more about how to
contribute to Emogrifier.
## Steps to release a new version
1. In the [composer.json](composer.json), update the `branch-alias` entry to
point to the release _after_ the upcoming release.
2. In the [CHANGELOG.md](CHANGELOG.md), create a new section with subheadings
1. In the [CHANGELOG.md](CHANGELOG.md), create a new section with subheadings
for changes _after_ the upcoming release, set the version number for the
upcoming release, and remove any empty sections.
3. Create a pull request "Prepare release of version x.y.z" with those
changes.
4. Have the pull request reviewed and merged.
5. Tag the new release.
6. In the [Releases tab](https://github.com/MyIntervals/emogrifier/releases),
1. Update the target milestone in the Dependabot configuration.
1. Create a pull request "Prepare release of version x.y.z" with those changes.
1. Have the pull request reviewed and merged.
1. Tag the new release.
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.
7. Post about the new release on social media.
1. Post about the new release on social media.
## Maintainers

View File

@@ -22,7 +22,7 @@
},
{
"name": "Jake Hotson",
"email": "jake@qzdesign.co.uk"
"email": "jake.github@qzdesign.co.uk"
},
{
"name": "Cameron Brooks"
@@ -37,15 +37,19 @@
"source": "https://github.com/MyIntervals/emogrifier"
},
"require": {
"php": "~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0",
"php": "~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
"ext-dom": "*",
"ext-libxml": "*",
"sabberworm/php-css-parser": "^8.4.0",
"sabberworm/php-css-parser": "^8.7.0",
"symfony/css-selector": "^4.4.23 || ^5.4.0 || ^6.0.0 || ^7.0.0"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.3.2",
"phpunit/phpunit": "9.6.11",
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.7",
"phpstan/phpstan-phpunit": "1.4.0",
"phpstan/phpstan-strict-rules": "1.6.1",
"phpunit/phpunit": "9.6.21",
"rawr/cross-data-providers": "2.4.0"
},
"prefer-stable": true,
@@ -60,6 +64,9 @@
}
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
},
"preferred-install": {
"*": "dist"
},
@@ -80,28 +87,26 @@
"@ci:tests"
],
"ci:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots config/ src/ tests/",
"ci:php:lint": "\"./vendor/bin/parallel-lint\" config src tests",
"ci:php:lint": "parallel-lint config src tests",
"ci:php:md": "\"./.phive/phpmd\" src text config/phpmd.xml",
"ci:php:psalm": "\"./.phive/psalm\" --show-info=false",
"ci:php:sniff": "\"./.phive/phpcs\" config src tests",
"ci:php:stan": "phpstan --no-progress --error-format=github",
"ci:static": [
"@ci:composer:normalize",
"@ci:php:lint",
"@ci:php:sniff",
"@ci:php:fixer",
"@ci:php:md",
"@ci:php:psalm"
"@ci:php:stan"
],
"ci:tests": [
"@ci:tests:unit"
],
"ci:tests:sof": "@php \"./vendor/bin/phpunit\" --stop-on-failure --do-not-cache-result",
"ci:tests:unit": "@php \"./vendor/bin/phpunit\" --do-not-cache-result",
"ci:tests:coverage": "phpunit --do-not-cache-result --coverage-clover=coverage.xml",
"ci:tests:sof": "phpunit --stop-on-failure --do-not-cache-result",
"ci:tests:unit": "phpunit --do-not-cache-result",
"composer:normalize": "\"./.phive/composer-normalize\" --no-check-lock",
"php:fix": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix config/ src/ tests/",
"php:version": "@php -v | grep -Po 'PHP\\s++\\K(?:\\d++\\.)*+\\d++(?:-\\w++)?+'",
"psalm:baseline": "\"./.phive/psalm\" --set-baseline=psalm.baseline.xml",
"psalm:cc": "\"./.phive/psalm\" --clear-cache"
"phpstan:baseline": "phpstan --generate-baseline --allow-empty-baseline"
},
"scripts-descriptions": {
"ci": "Runs all dynamic and static code checks.",
@@ -110,16 +115,15 @@
"ci:php:fixer": "Checks the code style with PHP CS Fixer.",
"ci:php:lint": "Lints the PHP files for syntax errors.",
"ci:php:md": "Checks the code complexity with PHPMD.",
"ci:php:psalm": "Checks the types with Psalm.",
"ci:php:sniff": "Checks the code style with PHP_CodeSniffer.",
"ci:php:stan": "Checks the PHP types using PHPStan.",
"ci:static": "Runs all static code analysis checks for the code and the composer.json.",
"ci:tests": "Runs all dynamic tests (i.e., currently, the unit tests).",
"ci:tests:coverage": "Runs the unit tests with code coverage.",
"ci:tests:sof": "Runs the unit tests and stops at the first failure.",
"ci:tests:unit": "Runs all unit tests.",
"composer:normalize": "Reformats and sorts the composer.json file.",
"php:fix": "Reformats the code with php-cs-fixer.",
"php:version": "Outputs the installed PHP version.",
"psalm:baseline": "Updates the Psalm baseline file to match the code.",
"psalm:cc": "Clears the Psalm cache."
"phpstan:baseline": "Updates the PHPStan baseline file to match the code."
}
}

View File

@@ -20,7 +20,7 @@ namespace Pelago\Emogrifier\Caching;
*
* @internal
*/
class SimpleStringCache
final class SimpleStringCache
{
/**
* @var array<string, string>

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pelago\Emogrifier\Css;
use Pelago\Emogrifier\Utilities\Preg;
use Sabberworm\CSS\CSSList\AtRuleBlockList as CssAtRuleBlockList;
use Sabberworm\CSS\CSSList\Document as SabberwormCssDocument;
use Sabberworm\CSS\Parser as CssParser;
@@ -21,7 +22,7 @@ use Sabberworm\CSS\Settings as ParserSettings;
*
* @internal
*/
class CssDocument
final class CssDocument
{
/**
* @var SabberwormCssDocument
@@ -61,7 +62,8 @@ class CssDocument
*/
private function hasNestedAtRule(string $css): bool
{
return \preg_match('/@(?:media|supports|(?:-webkit-|-moz-|-ms-|-o-)?+(keyframes|document))\\b/', $css) === 1;
return (new Preg())
->match('/@(?:media|supports|(?:-webkit-|-moz-|-ms-|-o-)?+(keyframes|document))\\b/', $css) !== 0;
}
/**
@@ -140,7 +142,8 @@ class CssDocument
$allowedMediaTypes
);
$mediaTypesMatcher = \implode('|', $escapedAllowedMediaTypes);
$isAllowed = \preg_match('/^\\s*+(?:only\\s++)?+(?:' . $mediaTypesMatcher . ')/i', $mediaType) > 0;
$isAllowed
= (new Preg())->match('/^\\s*+(?:only\\s++)?+(?:' . $mediaTypesMatcher . ')/i', $mediaType) !== 0;
} else {
$isAllowed = true;
}

View File

@@ -12,7 +12,7 @@ use Sabberworm\CSS\RuleSet\DeclarationBlock;
*
* @internal
*/
class StyleRule
final class StyleRule
{
/**
* @var DeclarationBlock
@@ -43,7 +43,7 @@ class StyleRule
$selectors = $this->declarationBlock->getSelectors();
return \array_map(
static function (Selector $selector): string {
return (string)$selector;
return (string) $selector;
},
$selectors
);

View File

@@ -7,13 +7,15 @@ namespace Pelago\Emogrifier;
use Pelago\Emogrifier\Css\CssDocument;
use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
use Pelago\Emogrifier\Utilities\CssConcatenator;
use Pelago\Emogrifier\Utilities\DeclarationBlockParser;
use Pelago\Emogrifier\Utilities\Preg;
use Symfony\Component\CssSelector\CssSelectorConverter;
use Symfony\Component\CssSelector\Exception\ParseException;
/**
* This class provides functions for converting CSS styles into inline style attributes in your HTML code.
*/
class CssInliner extends AbstractHtmlProcessor
final class CssInliner extends AbstractHtmlProcessor
{
/**
* @var int
@@ -23,12 +25,7 @@ class CssInliner extends AbstractHtmlProcessor
/**
* @var int
*/
private const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 1;
/**
* @var int
*/
private const CACHE_KEY_COMBINED_STYLES = 2;
private const CACHE_KEY_COMBINED_STYLES = 1;
/**
* Regular expression component matching a static pseudo class in a selector, without the preceding ":",
@@ -39,7 +36,7 @@ class CssInliner extends AbstractHtmlProcessor
* @var string
*/
private const PSEUDO_CLASS_MATCHER
= 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\\([[:ascii:]]*\\)';
= 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\\([[:ascii:]]*\\)|root';
/**
* This regular expression componenet matches an `...of-type` pseudo class name, without the preceding ":". These
@@ -56,11 +53,23 @@ class CssInliner extends AbstractHtmlProcessor
*/
private const COMBINATOR_MATCHER = '(?:\\s++|\\s*+[>+~]\\s*+)(?=[[:alpha:]_\\-.#*:\\[])';
/**
* options array key for `querySelectorAll`
*
* @var string
*/
private const QSA_ALWAYS_THROW_PARSE_EXCEPTION = 'alwaysThrowParseException';
/**
* @var array<string, bool>
*/
private $excludedSelectors = [];
/**
* @var array<non-empty-string, bool>
*/
private $excludedCssSelectors = [];
/**
* @var array<string, bool>
*/
@@ -69,13 +78,11 @@ class CssInliner extends AbstractHtmlProcessor
/**
* @var array{
* 0: array<string, int>,
* 1: array<string, array<string, string>>,
* 2: array<string, string>
* 1: array<string, string>
* }
*/
private $caches = [
self::CACHE_KEY_SELECTOR => [],
self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
self::CACHE_KEY_COMBINED_STYLES => [],
];
@@ -164,7 +171,11 @@ class CssInliner extends AbstractHtmlProcessor
* @return $this
*
* @throws ParseException in debug mode, if an invalid selector is encountered
* @throws \RuntimeException in debug mode, if an internal PCRE error occurs
* @throws \RuntimeException
* in debug mode, if an internal PCRE error occurs
* or `CssSelectorConverter::toXPath` returns an invalid XPath expression
* @throws \UnexpectedValueException
* if a selector query result includes a node which is not a `DOMElement`
*/
public function inlineCss(string $css = ''): self
{
@@ -183,23 +194,12 @@ class CssInliner extends AbstractHtmlProcessor
$excludedNodes = $this->getNodesToExclude();
$cssRules = $this->collateCssRules($parsedCss);
$cssSelectorConverter = $this->getCssSelectorConverter();
foreach ($cssRules['inlinable'] as $cssRule) {
try {
$nodesMatchingCssSelectors = $this->getXPath()
->query($cssSelectorConverter->toXPath($cssRule['selector']));
/** @var \DOMElement $node */
foreach ($nodesMatchingCssSelectors as $node) {
if (\in_array($node, $excludedNodes, true)) {
continue;
}
$this->copyInlinableCssToStyleAttribute($node, $cssRule);
}
} catch (ParseException $e) {
if ($this->debug) {
throw $e;
foreach ($this->querySelectorAll($cssRule['selector']) as $node) {
if (\in_array($node, $excludedNodes, true)) {
continue;
}
$this->copyInlinableCssToStyleAttribute($this->ensureNodeIsElement($node), $cssRule);
}
}
@@ -301,6 +301,36 @@ class CssInliner extends AbstractHtmlProcessor
return $this;
}
/**
* Adds a selector to exclude CSS selector from emogrification.
*
* @param non-empty-string $selector the selector to exclude, e.g., `.editor`
*
* @return $this
*/
public function addExcludedCssSelector(string $selector): self
{
$this->excludedCssSelectors[$selector] = true;
return $this;
}
/**
* No longer excludes the CSS selector from emogrification.
*
* @param non-empty-string $selector the selector to no longer exclude, e.g., `.editor`
*
* @return $this
*/
public function removeExcludedCssSelector(string $selector): self
{
if (isset($this->excludedCssSelectors[$selector])) {
unset($this->excludedCssSelectors[$selector]);
}
return $this;
}
/**
* Sets the debug mode.
*
@@ -357,7 +387,6 @@ class CssInliner extends AbstractHtmlProcessor
{
$this->caches = [
self::CACHE_KEY_SELECTOR => [],
self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
self::CACHE_KEY_COMBINED_STYLES => [],
];
}
@@ -417,11 +446,13 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function normalizeStyleAttributes(\DOMElement $node): void
{
$normalizedOriginalStyle = \preg_replace_callback(
'/-?+[_a-zA-Z][\\w\\-]*+(?=:)/S',
$declarationBlockParser = new DeclarationBlockParser();
$normalizedOriginalStyle = (new Preg())->throwExceptions($this->debug)->replaceCallback(
'/-{0,2}+[_a-zA-Z][\\w\\-]*+(?=:)/S',
/** @param array<array-key, string> $propertyNameMatches */
static function (array $propertyNameMatches): string {
return \strtolower($propertyNameMatches[0]);
static function (array $propertyNameMatches) use ($declarationBlockParser): string {
return $declarationBlockParser->normalizePropertyName($propertyNameMatches[0]);
},
$node->getAttribute('style')
);
@@ -429,55 +460,13 @@ class CssInliner extends AbstractHtmlProcessor
// In order to not overwrite existing style attributes in the HTML, we have to save the original HTML styles.
$nodePath = $node->getNodePath();
if (\is_string($nodePath) && !isset($this->styleAttributesForNodes[$nodePath])) {
$this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
$this->styleAttributesForNodes[$nodePath] = $declarationBlockParser->parse($normalizedOriginalStyle);
$this->visitedNodes[$nodePath] = $node;
}
$node->setAttribute('style', $normalizedOriginalStyle);
}
/**
* Parses a CSS declaration block into property name/value pairs.
*
* Example:
*
* The declaration block
*
* "color: #000; font-weight: bold;"
*
* will be parsed into the following array:
*
* "color" => "#000"
* "font-weight" => "bold"
*
* @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
*
* @return array<string, string>
* the CSS declarations with the property names as array keys and the property values as array values
*/
private function parseCssDeclarationsBlock(string $cssDeclarationsBlock): array
{
if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
}
$properties = [];
foreach (\preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock) as $declaration) {
/** @var list<string> $matches */
$matches = [];
if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
continue;
}
$propertyName = \strtolower($matches[1]);
$propertyValue = $matches[2];
$properties[$propertyName] = $propertyValue;
}
$this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
return $properties;
}
/**
* Returns CSS content.
*
@@ -509,34 +498,74 @@ class CssInliner extends AbstractHtmlProcessor
*
* @return list<\DOMElement>
*
* @throws ParseException
* @throws \UnexpectedValueException
* @throws ParseException in debug mode, if an invalid selector is encountered
* @throws \RuntimeException in debug mode, if `CssSelectorConverter::toXPath` returns an invalid XPath expression
* @throws \UnexpectedValueException if the selector query result includes a node which is not a `DOMElement`
*/
private function getNodesToExclude(): array
{
$excludedNodes = [];
foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
try {
$matchingNodes = $this->getXPath()
->query($this->getCssSelectorConverter()->toXPath($selectorToExclude));
foreach ($matchingNodes as $node) {
if (!$node instanceof \DOMElement) {
$path = $node->getNodePath() ?? '$node';
throw new \UnexpectedValueException($path . ' is not a DOMElement.', 1617975914);
}
$excludedNodes[] = $node;
}
} catch (ParseException $e) {
if ($this->debug) {
throw $e;
}
foreach ($this->querySelectorAll($selectorToExclude) as $node) {
$excludedNodes[] = $this->ensureNodeIsElement($node);
}
}
return $excludedNodes;
}
/**
* @param array{}|array{alwaysThrowParseException: bool} $options
* This is an array of option values to control behaviour:
* - `QSA_ALWAYS_THROW_PARSE_EXCEPTION` - `bool` - throw any `ParseException` regardless of debug setting.
*
* @return \DOMNodeList<\DOMNode> the HTML elements that match the provided CSS `$selectors`
*
* @throws ParseException
* in debug mode (or with `QSA_ALWAYS_THROW_PARSE_EXCEPTION` option), if an invalid selector is encountered
* @throws \RuntimeException in debug mode, if `CssSelectorConverter::toXPath` returns an invalid XPath expression
*/
private function querySelectorAll(string $selectors, array $options = []): \DOMNodeList
{
try {
$result = $this->getXPath()->query($this->getCssSelectorConverter()->toXPath($selectors));
if ($result === false) {
throw new \RuntimeException('query failed with selector \'' . $selectors . '\'', 1726533051);
}
return $result;
} catch (ParseException $exception) {
$alwaysThrowParseException = $options[self::QSA_ALWAYS_THROW_PARSE_EXCEPTION] ?? false;
if ($this->debug || $alwaysThrowParseException) {
throw $exception;
}
return new \DOMNodeList();
} catch (\RuntimeException $exception) {
if (
$this->debug
) {
throw $exception;
}
// `RuntimeException` indicates a bug in CssSelector so pass the message to the error handler.
\trigger_error($exception->getMessage());
return new \DOMNodeList();
}
}
/**
* @throws \UnexpectedValueException if `$node` is not a `DOMElement`
*/
private function ensureNodeIsElement(\DOMNode $node): \DOMElement
{
if (!$node instanceof \DOMElement) {
$path = $node->getNodePath() ?? '$node';
throw new \UnexpectedValueException($path . ' is not a DOMElement.', 1617975914);
}
return $node;
}
/**
* @return CssSelectorConverter
*/
@@ -577,6 +606,7 @@ class CssInliner extends AbstractHtmlProcessor
{
$matches = $parsedCss->getStyleRulesData(\array_keys($this->allowedMediaTypes));
$preg = (new Preg())->throwExceptions($this->debug);
$cssRules = [
'inlinable' => [],
'uninlinable' => [],
@@ -588,7 +618,21 @@ class CssInliner extends AbstractHtmlProcessor
$mediaQuery = $cssRule->getContainingAtRule();
$declarationsBlock = $cssRule->getDeclarationAsText();
foreach ($cssRule->getSelectors() as $selector) {
$selectors = $cssRule->getSelectors();
// Maybe exclude CSS selectors
if (\count($this->excludedCssSelectors) > 0) {
// Normalize spaces, line breaks & tabs
$selectorsNormalized = \array_map(static function (string $selector) use ($preg): string {
return $preg->replace('@\\s++@u', ' ', $selector);
}, $selectors);
$selectors = \array_filter($selectorsNormalized, function (string $selector): bool {
return !isset($this->excludedCssSelectors[$selector]);
});
}
foreach ($selectors as $selector) {
// don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
// only allow structural pseudo-classes
$hasPseudoElement = \strpos($selector, '::') !== false;
@@ -634,15 +678,17 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function hasUnsupportedPseudoClass(string $selector): bool
{
if (\preg_match('/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i', $selector)) {
$preg = (new Preg())->throwExceptions($this->debug);
if ($preg->match('/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i', $selector) !== 0) {
return true;
}
if (!\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selector)) {
if ($preg->match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selector) === 0) {
return false;
}
foreach (\preg_split('/' . self::COMBINATOR_MATCHER . '/', $selector) as $selectorPart) {
foreach ($preg->split('/' . self::COMBINATOR_MATCHER . '/', $selector) as $selectorPart) {
if ($this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
return true;
}
@@ -661,11 +707,13 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function selectorPartHasUnsupportedOfTypePseudoClass(string $selectorPart): bool
{
if (\preg_match('/^[\\w\\-]/', $selectorPart)) {
$preg = (new Preg())->throwExceptions($this->debug);
if ($preg->match('/^[\\w\\-]/', $selectorPart) !== 0) {
return false;
}
return (bool)\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorPart);
return $preg->match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorPart) !== 0;
}
/**
@@ -693,19 +741,20 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function getCssSelectorPrecedence(string $selector): int
{
$selectorKey = \md5($selector);
$selectorKey = $selector;
if (isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
}
$preg = (new Preg())->throwExceptions($this->debug);
$precedence = 0;
foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
if (\trim($selector) === '') {
break;
}
$number = 0;
$selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number);
$precedence += ($value * (int)$number);
$count = 0;
$selector = $preg->replace('/' . $matcher . '\\w+/', '', $selector, -1, $count);
$precedence += ($value * $count);
}
$this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
@@ -729,7 +778,8 @@ class CssInliner extends AbstractHtmlProcessor
private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule): void
{
$declarationsBlock = $cssRule['declarationsBlock'];
$newStyleDeclarations = $this->parseCssDeclarationsBlock($declarationsBlock);
$declarationBlockParser = new DeclarationBlockParser();
$newStyleDeclarations = $declarationBlockParser->parse($declarationsBlock);
if ($newStyleDeclarations === []) {
return;
}
@@ -737,7 +787,7 @@ class CssInliner extends AbstractHtmlProcessor
// 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'));
$oldStyleDeclarations = $declarationBlockParser->parse($node->getAttribute('style'));
} else {
$oldStyleDeclarations = [];
}
@@ -757,6 +807,8 @@ class CssInliner extends AbstractHtmlProcessor
* @param array<string, string> $newStyles
*
* @return string
*
* @throws \UnexpectedValueException if an empty property name is encountered (which should not happen)
*/
private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles): string
{
@@ -784,9 +836,16 @@ class CssInliner extends AbstractHtmlProcessor
$combinedStyles = \array_merge($oldStyles, $newStyles);
$declarationBlockParser = new DeclarationBlockParser();
$style = '';
foreach ($combinedStyles as $attributeName => $attributeValue) {
$style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; ';
$trimmedAttributeName = \trim($attributeName);
if ($trimmedAttributeName === '') {
throw new \UnexpectedValueException('An empty property name was encountered.', 1727046078);
}
$propertyName = $declarationBlockParser->normalizePropertyName($trimmedAttributeName);
$propertyValue = \trim($attributeValue);
$style .= $propertyName . ': ' . $propertyValue . '; ';
}
$trimmedStyle = \rtrim($style);
@@ -804,7 +863,7 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function attributeValueIsImportant(string $attributeValue): bool
{
return (bool)\preg_match('/!\\s*+important$/i', $attributeValue);
return (new Preg())->throwExceptions($this->debug)->match('/!\\s*+important$/i', $attributeValue) !== 0;
}
/**
@@ -812,9 +871,10 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function fillStyleAttributesWithMergedStyles(): void
{
$declarationBlockParser = new DeclarationBlockParser();
foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
$node = $this->visitedNodes[$nodePath];
$currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
$currentStyleAttributes = $declarationBlockParser->parse($node->getAttribute('style'));
$node->setAttribute(
'style',
$this->generateStyleStringFromDeclarationsArrays(
@@ -853,14 +913,16 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node): void
{
$inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
$style = $node->getAttribute('style');
$inlineStyleDeclarations = (new DeclarationBlockParser())->parse((bool) $style ? $style : '');
/** @var array<string, string> $regularStyleDeclarations */
$regularStyleDeclarations = [];
/** @var array<string, string> $importantStyleDeclarations */
$importantStyleDeclarations = [];
foreach ($inlineStyleDeclarations as $property => $value) {
if ($this->attributeValueIsImportant($value)) {
$importantStyleDeclarations[$property] = $this->pregReplace('/\\s*+!\\s*+important$/i', '', $value);
$importantStyleDeclarations[$property]
= (new Preg())->throwExceptions($this->debug)->replace('/\\s*+!\\s*+important$/i', '', $value);
} else {
$regularStyleDeclarations[$property] = $value;
}
@@ -944,12 +1006,14 @@ class CssInliner extends AbstractHtmlProcessor
*
* @return bool
*
* @throws ParseException
* @throws ParseException in debug mode, if an invalid selector is encountered
* @throws \RuntimeException in debug mode, if `CssSelectorConverter::toXPath` returns an invalid XPath expression
*/
private function existsMatchForCssSelector(string $cssSelector): bool
{
try {
$nodesMatchingSelector = $this->getXPath()->query($this->getCssSelectorConverter()->toXPath($cssSelector));
$nodesMatchingSelector
= $this->querySelectorAll($cssSelector, [self::QSA_ALWAYS_THROW_PARSE_EXCEPTION => true]);
} catch (ParseException $e) {
if ($this->debug) {
throw $e;
@@ -957,7 +1021,7 @@ class CssInliner extends AbstractHtmlProcessor
return true;
}
return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
return $nodesMatchingSelector->length !== 0;
}
/**
@@ -972,9 +1036,11 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function removeUnmatchablePseudoComponents(string $selector): string
{
$preg = (new Preg())->throwExceptions($this->debug);
// The regex allows nested brackets via `(?2)`.
// A space is temporarily prepended because the callback can't determine if the match was at the very start.
$selectorWithoutNots = \ltrim(\preg_replace_callback(
$selectorWithoutNots = \ltrim((new Preg())->throwExceptions($this->debug)->replaceCallback(
'/([\\s>+~]?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i',
/** @param array<array-key, string> $matches */
function (array $matches): string {
@@ -989,10 +1055,11 @@ class CssInliner extends AbstractHtmlProcessor
);
if (
!\preg_match(
$preg->match(
'/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i',
$selectorWithoutUnmatchablePseudoComponents
)
=== 0
) {
return $selectorWithoutUnmatchablePseudoComponents;
}
@@ -1000,7 +1067,7 @@ class CssInliner extends AbstractHtmlProcessor
function (string $selectorPart): string {
return $this->removeUnsupportedOfTypePseudoClasses($selectorPart);
},
\preg_split(
$preg->split(
'/(' . self::COMBINATOR_MATCHER . ')/',
$selectorWithoutUnmatchablePseudoComponents,
-1,
@@ -1041,7 +1108,7 @@ class CssInliner extends AbstractHtmlProcessor
*/
private function removeSelectorComponents(string $matcher, string $selector): string
{
return \preg_replace(
return (new Preg())->throwExceptions($this->debug)->replace(
['/([\\s>+~]|^)' . $matcher . '/i', '/' . $matcher . '/i'],
['$1*', ''],
$selector
@@ -1141,58 +1208,4 @@ class CssInliner extends AbstractHtmlProcessor
return $node;
}
/**
* Wraps `preg_replace`. If an error occurs (which is highly unlikely), either it is logged and the original
* `$subject` is returned, or in debug mode an exception is thrown.
*
* This method only supports strings, not arrays of strings.
*
* @param non-empty-string $pattern
* @param string $replacement
* @param string $subject
*
* @return string
*
* @throws \RuntimeException
*/
private function pregReplace(string $pattern, string $replacement, string $subject): string
{
$result = \preg_replace($pattern, $replacement, $subject);
if (!\is_string($result)) {
$this->logOrThrowPregLastError();
$result = $subject;
}
return $result;
}
/**
* Obtains the name of the error constant for `preg_last_error` (based on code posted at
* {@see https://www.php.net/manual/en/function.preg-last-error.php#124124}) and puts it into an error message
* which is either passed to `trigger_error` (in non-debug mode) or an exception which is thrown (in debug mode).
*
* @throws \RuntimeException
*/
private function logOrThrowPregLastError(): void
{
$pcreConstants = \get_defined_constants(true)['pcre'];
$pcreErrorConstantNames = \array_flip(\array_filter(
$pcreConstants,
static function (string $key): bool {
return \substr($key, -6) === '_ERROR';
},
ARRAY_FILTER_USE_KEY
));
$pregLastError = \preg_last_error();
$message = 'PCRE regex execution error `' . (string)($pcreErrorConstantNames[$pregLastError] ?? $pregLastError)
. '`';
if ($this->debug) {
throw new \RuntimeException($message, 1592870147);
}
\trigger_error($message);
}
}

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Pelago\Emogrifier\HtmlProcessor;
use Pelago\Emogrifier\Utilities\Preg;
/**
* Base class for HTML processor that e.g., can remove, add or modify nodes or attributes.
*
* The "vanilla" subclass is the HtmlNormalizer.
*
* @psalm-consistent-constructor
*/
abstract class AbstractHtmlProcessor
{
@@ -71,9 +71,7 @@ abstract class AbstractHtmlProcessor
*
* Please use `::fromHtml` or `::fromDomDocument` instead.
*/
private function __construct()
{
}
private function __construct() {}
/**
* Builds a new instance from the given HTML.
@@ -184,7 +182,7 @@ abstract class AbstractHtmlProcessor
$htmlWithPossibleErroneousClosingTags = $this->getDomDocument()->saveHTML($this->getBodyElement());
$bodyNodeHtml = $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
return \preg_replace('%</?+body(?:\\s[^>]*+)?+>%', '', $bodyNodeHtml);
return (new Preg())->replace('%</?+body(?:\\s[^>]*+)?+>%', '', $bodyNodeHtml);
}
/**
@@ -196,7 +194,24 @@ abstract class AbstractHtmlProcessor
*/
private function removeSelfClosingTagsClosingTags(string $html): string
{
return \preg_replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
return (new Preg())->replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
}
/**
* Returns the HTML element.
*
* This method assumes that there always is an HTML element, throwing an exception otherwise.
*
* @throws \UnexpectedValueException
*/
protected function getHtmlElement(): \DOMElement
{
$htmlElement = $this->getDomDocument()->getElementsByTagName('html')->item(0);
if (!$htmlElement instanceof \DOMElement) {
throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930853);
}
return $htmlElement;
}
/**
@@ -292,7 +307,7 @@ abstract class AbstractHtmlProcessor
private function normalizeDocumentType(string $html): string
{
// Limit to replacing the first occurrence: as an optimization; and in case an example exists as unescaped text.
return \preg_replace(
return (new Preg())->replace(
'/<!DOCTYPE\\s++html(?=[\\s>])/i',
'<!DOCTYPE html',
$html,
@@ -317,17 +332,17 @@ abstract class AbstractHtmlProcessor
// We are trying to insert the meta tag to the right spot in the DOM.
// If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
$hasHeadTag = \preg_match('/<head[\\s>]/i', $html);
$hasHeadTag = (new Preg())->match('/<head[\\s>]/i', $html) !== 0;
$hasHtmlTag = \stripos($html, '<html') !== false;
if ($hasHeadTag) {
$reworkedHtml = \preg_replace(
$reworkedHtml = (new Preg())->replace(
'/<head(?=[\\s>])([^>]*+)>/i',
'<head$1>' . self::CONTENT_TYPE_META_TAG,
$html
);
} elseif ($hasHtmlTag) {
$reworkedHtml = \preg_replace(
$reworkedHtml = (new Preg())->replace(
'/<html(.*?)>/is',
'<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
$html
@@ -350,7 +365,11 @@ abstract class AbstractHtmlProcessor
*/
private function hasContentTypeMetaTagInHead(string $html): bool
{
\preg_match('%^.*?(?=<meta(?=\\s)[^>]*\\shttp-equiv=(["\']?+)Content-Type\\g{-1}[\\s/>])%is', $html, $matches);
(new Preg())->match(
'%^.*?(?=<meta(?=\\s)[^>]*\\shttp-equiv=(["\']?+)Content-Type\\g{-1}[\\s/>])%is',
$html,
$matches
);
if (isset($matches[0])) {
$htmlBefore = $matches[0];
try {
@@ -380,9 +399,10 @@ abstract class AbstractHtmlProcessor
*/
private function hasEndOfHeadElement(string $html): bool
{
$headEndTagMatchCount
= \preg_match('%<(?!' . self::TAGNAME_ALLOWED_BEFORE_BODY_MATCHER . '[\\s/>])\\w|</head>%i', $html);
if (\is_int($headEndTagMatchCount) && $headEndTagMatchCount > 0) {
if (
(new Preg())->match('%<(?!' . self::TAGNAME_ALLOWED_BEFORE_BODY_MATCHER . '[\\s/>])\\w|</head>%i', $html)
!== 0
) {
// An exception to the implicit end of the `<head>` is any content within a `<template>` element, as well in
// comments. As an optimization, this is only checked for if a potential `<head>` end tag is found.
$htmlWithoutCommentsOrTemplates = $this->removeHtmlTemplateElements($this->removeHtmlComments($html));
@@ -407,12 +427,7 @@ abstract class AbstractHtmlProcessor
*/
private function removeHtmlComments(string $html): string
{
$result = \preg_replace(self::HTML_COMMENT_PATTERN, '', $html);
if (!\is_string($result)) {
throw new \RuntimeException('Internal PCRE error', 1616521475);
}
return $result;
return (new Preg())->throwExceptions(true)->replace(self::HTML_COMMENT_PATTERN, '', $html);
}
/**
@@ -427,12 +442,7 @@ abstract class AbstractHtmlProcessor
*/
private function removeHtmlTemplateElements(string $html): string
{
$result = \preg_replace(self::HTML_TEMPLATE_ELEMENT_PATTERN, '', $html);
if (!\is_string($result)) {
throw new \RuntimeException('Internal PCRE error', 1616519652);
}
return $result;
return (new Preg())->throwExceptions(true)->replace(self::HTML_TEMPLATE_ELEMENT_PATTERN, '', $html);
}
/**
@@ -445,7 +455,7 @@ abstract class AbstractHtmlProcessor
*/
private function ensurePhpUnrecognizedSelfClosingTagsAreXml(string $html): string
{
return \preg_replace(
return (new Preg())->replace(
'%<' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '\\b[^>]*+(?<!/)(?=>)%',
'$0/',
$html
@@ -463,10 +473,6 @@ abstract class AbstractHtmlProcessor
return;
}
$htmlElement = $this->getDomDocument()->getElementsByTagName('html')->item(0);
if (!$htmlElement instanceof \DOMElement) {
throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930853);
}
$htmlElement->appendChild($this->getDomDocument()->createElement('body'));
$this->getHtmlElement()->appendChild($this->getDomDocument()->createElement('body'));
}
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace Pelago\Emogrifier\HtmlProcessor;
use Pelago\Emogrifier\Utilities\DeclarationBlockParser;
use Pelago\Emogrifier\Utilities\Preg;
/**
* This HtmlProcessor can convert style HTML attributes to the corresponding other visual HTML attributes,
* e.g. it converts style="width: 100px" to width="100".
@@ -12,12 +15,12 @@ namespace Pelago\Emogrifier\HtmlProcessor;
*
* To trigger the conversion, call the convertCssToVisualAttributes method.
*/
class CssToAttributeConverter extends AbstractHtmlProcessor
final class CssToAttributeConverter extends AbstractHtmlProcessor
{
/**
* This multi-level array contains simple mappings of CSS properties to
* HTML attributes. If a mapping only applies to certain HTML nodes or
* only for certain values, the mapping is an object with a whitelist
* only for certain values, the mapping is an object with an allowlist
* of nodes and values.
*
* @var array<string, array{attribute: string, nodes?: array<int, string>, values?: array<int, string>}>
@@ -42,11 +45,6 @@ class CssToAttributeConverter extends AbstractHtmlProcessor
],
];
/**
* @var array<string, array<string, string>>
*/
private static $parsedCssCache = [];
/**
* Maps the CSS from the style nodes to visual HTML attributes.
*
@@ -54,9 +52,10 @@ class CssToAttributeConverter extends AbstractHtmlProcessor
*/
public function convertCssToVisualAttributes(): self
{
$declarationBlockParser = new DeclarationBlockParser();
/** @var \DOMElement $node */
foreach ($this->getAllNodesWithStyleAttribute() as $node) {
$inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
$inlineStyleDeclarations = $declarationBlockParser->parse($node->getAttribute('style'));
$this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
}
@@ -73,48 +72,6 @@ class CssToAttributeConverter extends AbstractHtmlProcessor
return $this->getXPath()->query('//*[@style]');
}
/**
* Parses a CSS declaration block into property name/value pairs.
*
* Example:
*
* The declaration block
*
* "color: #000; font-weight: bold;"
*
* will be parsed into the following array:
*
* "color" => "#000"
* "font-weight" => "bold"
*
* @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
*
* @return array<string, string>
* the CSS declarations with the property names as array keys and the property values as array values
*/
private function parseCssDeclarationsBlock(string $cssDeclarationsBlock): array
{
if (isset(self::$parsedCssCache[$cssDeclarationsBlock])) {
return self::$parsedCssCache[$cssDeclarationsBlock];
}
$properties = [];
foreach (\preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock) as $declaration) {
/** @var array<int, string> $matches */
$matches = [];
if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
continue;
}
$propertyName = \strtolower($matches[1]);
$propertyValue = $matches[2];
$properties[$propertyName] = $propertyValue;
}
self::$parsedCssCache[$cssDeclarationsBlock] = $properties;
return $properties;
}
/**
* Applies $styles to $node.
*
@@ -228,12 +185,14 @@ class CssToAttributeConverter extends AbstractHtmlProcessor
*/
private function mapWidthOrHeightProperty(\DOMElement $node, string $value, string $property): void
{
$preg = new Preg();
// only parse values in px and %, but not values like "auto"
if (!\preg_match('/^(\\d+)(\\.(\\d+))?(px|%)$/', $value)) {
if ($preg->match('/^(\\d+)(\\.(\\d+))?(px|%)$/', $value) === 0) {
return;
}
$number = \preg_replace('/[^0-9.%]/', '', $value);
$number = $preg->replace('/[^0-9.%]/', '', $value);
$node->setAttribute($property, $number);
}
@@ -289,8 +248,7 @@ class CssToAttributeConverter extends AbstractHtmlProcessor
*/
private function parseCssShorthandValue(string $value): array
{
/** @var array<int, string> $values */
$values = \preg_split('/\\s+/', $value);
$values = (new Preg())->split('/\\s+/', $value);
$css = [];
$css['top'] = $values[0];

View File

@@ -11,6 +11,4 @@ namespace Pelago\Emogrifier\HtmlProcessor;
* - add HEAD and BODY elements (if they are missing)
* - reformat the HTML
*/
class HtmlNormalizer extends AbstractHtmlProcessor
{
}
final class HtmlNormalizer extends AbstractHtmlProcessor {}

View File

@@ -6,11 +6,12 @@ namespace Pelago\Emogrifier\HtmlProcessor;
use Pelago\Emogrifier\CssInliner;
use Pelago\Emogrifier\Utilities\ArrayIntersector;
use Pelago\Emogrifier\Utilities\Preg;
/**
* This class can remove things from HTML.
*/
class HtmlPruner extends AbstractHtmlProcessor
final class HtmlPruner extends AbstractHtmlProcessor
{
/**
* We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
@@ -84,9 +85,10 @@ class HtmlPruner extends AbstractHtmlProcessor
{
$classesToKeepIntersector = new ArrayIntersector($classesToKeep);
$preg = new Preg();
/** @var \DOMElement $element */
foreach ($elements as $element) {
$elementClasses = \preg_split('/\\s++/', \trim($element->getAttribute('class')));
$elementClasses = $preg->split('/\\s++/', \trim($element->getAttribute('class')));
$elementClassesToKeep = $classesToKeepIntersector->intersectWith($elementClasses);
if ($elementClassesToKeep !== []) {
$element->setAttribute('class', \implode(' ', $elementClassesToKeep));
@@ -124,9 +126,11 @@ class HtmlPruner extends AbstractHtmlProcessor
*/
public function removeRedundantClassesAfterCssInlined(CssInliner $cssInliner): self
{
$preg = new Preg();
$classesToKeepAsKeys = [];
foreach ($cssInliner->getMatchingUninlinableSelectors() as $selector) {
\preg_match_all('/\\.(-?+[_a-zA-Z][\\w\\-]*+)/', $selector, $matches);
$preg->matchAll('/\\.(-?+[_a-zA-Z][\\w\\-]*+)/', $selector, $matches);
$classesToKeepAsKeys += \array_fill_keys($matches[1], true);
}

View File

@@ -17,7 +17,7 @@ namespace Pelago\Emogrifier\Utilities;
*
* @internal
*/
class ArrayIntersector
final class ArrayIntersector
{
/**
* the array with which the object was constructed, with all its keys exchanged with their associated values

View File

@@ -37,7 +37,7 @@ namespace Pelago\Emogrifier\Utilities;
*
* @internal
*/
class CssConcatenator
final class CssConcatenator
{
/**
* Array of media rules in order. Each element is an object with the following properties:
@@ -89,7 +89,7 @@ class CssConcatenator
$lastDeclarationsBlockWithoutSemicolon = \rtrim(\rtrim($lastRuleBlock->declarationsBlock), ';');
$lastRuleBlock->declarationsBlock = $lastDeclarationsBlockWithoutSemicolon . ';' . $declarationsBlock;
} else {
$mediaRule->ruleBlocks[] = (object)\compact('selectorsAsKeys', 'declarationsBlock');
$mediaRule->ruleBlocks[] = (object) \compact('selectorsAsKeys', 'declarationsBlock');
}
}
}
@@ -121,7 +121,7 @@ class CssConcatenator
return $lastMediaRule;
}
$newMediaRule = (object)[
$newMediaRule = (object) [
'media' => $media,
'ruleBlocks' => [],
];