"require": {
"ezyang/htmlpurifier": "4.13.*",
"erusev/parsedown": "1.7.*",
- "pelago/emogrifier": "4.0.*",
+ "pelago/emogrifier": "5.0.*",
"chrisjean/php-ico": "1.0.*",
"true/punycode": "~2.0",
"pear/net_idna2": "^0.2.0",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "88f8ac80c8e871d978df5d19cf119c8e",
+ "content-hash": "0930361ee03b18bc90818e4c0c6afddd",
"packages": [
{
"name": "chrisjean/php-ico",
},
{
"name": "pelago/emogrifier",
- "version": "v4.0.0",
+ "version": "v5.0.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/emogrifier.git",
- "reference": "f6fd679303c6e6861b5ff29af221f684729d8fd9"
+ "reference": "b43b650880d189b0ada61d95d0729c7424b1752d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/f6fd679303c6e6861b5ff29af221f684729d8fd9",
- "reference": "f6fd679303c6e6861b5ff29af221f684729d8fd9",
+ "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/b43b650880d189b0ada61d95d0729c7424b1752d",
+ "reference": "b43b650880d189b0ada61d95d0729c7424b1752d",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
- "php": "~7.0 || ~7.1 || ~7.2 || ~7.3 || ~7.4",
- "symfony/css-selector": "^3.4.32 || ^4.3.5 || ^5.0"
+ "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0",
+ "symfony/css-selector": "^3.4.32 || ^4.4 || ^5.1"
},
"require-dev": {
- "grogy/php-parallel-lint": "^1.1.0",
- "phpunit/phpunit": "^6.5.14",
- "psalm/plugin-phpunit": "^0.5.8",
- "slevomat/coding-standard": "^4.0.0",
- "squizlabs/php_codesniffer": "^3.5.1",
- "vimeo/psalm": "^3.2.12"
+ "php-parallel-lint/php-parallel-lint": "^1.2.0",
+ "slevomat/coding-standard": "^6.4.1",
+ "squizlabs/php_codesniffer": "^3.5.8"
},
"type": "library",
"extra": {
"issues": "https://github.com/MyIntervals/emogrifier/issues",
"source": "https://github.com/MyIntervals/emogrifier"
},
- "time": "2020-06-12T12:55:03+00:00"
+ "time": "2020-11-23T18:37:25+00:00"
},
{
"name": "psr/http-client",
),
'pelago/emogrifier' =>
array (
- 'pretty_version' => 'v4.0.0',
- 'version' => '4.0.0.0',
+ 'pretty_version' => 'v5.0.0',
+ 'version' => '5.0.0.0',
'aliases' =>
array (
),
- 'reference' => 'f6fd679303c6e6861b5ff29af221f684729d8fd9',
+ 'reference' => 'b43b650880d189b0ada61d95d0729c7424b1752d',
),
'psr/http-client' =>
array (
},
{
"name": "pelago/emogrifier",
- "version": "v4.0.0",
- "version_normalized": "4.0.0.0",
+ "version": "v5.0.0",
+ "version_normalized": "5.0.0.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/emogrifier.git",
- "reference": "f6fd679303c6e6861b5ff29af221f684729d8fd9"
+ "reference": "b43b650880d189b0ada61d95d0729c7424b1752d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/f6fd679303c6e6861b5ff29af221f684729d8fd9",
- "reference": "f6fd679303c6e6861b5ff29af221f684729d8fd9",
+ "url": "https://api.github.com/repos/MyIntervals/emogrifier/zipball/b43b650880d189b0ada61d95d0729c7424b1752d",
+ "reference": "b43b650880d189b0ada61d95d0729c7424b1752d",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
- "php": "~7.0 || ~7.1 || ~7.2 || ~7.3 || ~7.4",
- "symfony/css-selector": "^3.4.32 || ^4.3.5 || ^5.0"
+ "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0",
+ "symfony/css-selector": "^3.4.32 || ^4.4 || ^5.1"
},
"require-dev": {
- "grogy/php-parallel-lint": "^1.1.0",
- "phpunit/phpunit": "^6.5.14",
- "psalm/plugin-phpunit": "^0.5.8",
- "slevomat/coding-standard": "^4.0.0",
- "squizlabs/php_codesniffer": "^3.5.1",
- "vimeo/psalm": "^3.2.12"
- },
- "time": "2020-06-12T12:55:03+00:00",
+ "php-parallel-lint/php-parallel-lint": "^1.2.0",
+ "slevomat/coding-standard": "^6.4.1",
+ "squizlabs/php_codesniffer": "^3.5.8"
+ },
+ "time": "2020-11-23T18:37:25+00:00",
"type": "library",
"extra": {
"branch-alias": {
),
'pelago/emogrifier' =>
array (
- 'pretty_version' => 'v4.0.0',
- 'version' => '4.0.0.0',
+ 'pretty_version' => 'v5.0.0',
+ 'version' => '5.0.0.0',
'aliases' =>
array (
),
- 'reference' => 'f6fd679303c6e6861b5ff29af221f684729d8fd9',
+ 'reference' => 'b43b650880d189b0ada61d95d0729c7424b1752d',
),
'psr/http-client' =>
array (
### Changed
+### Deprecated
+
+### Removed
+
+### Fixed
+
+## 5.0.0
+
+### Added
+- Add an `.editorconfig` file
+ ([#940](https://github.com/MyIntervals/emogrifier/pull/940))
+- Support PHP 8.0
+ ([#926](https://github.com/MyIntervals/emogrifier/pull/926))
+- Run the CI build once a week
+ ([#933](https://github.com/MyIntervals/emogrifier/pull/933))
+- Move more development tools to PHIVE
+ ([#894](https://github.com/MyIntervals/emogrifier/pull/894),
+ [#907](https://github.com/MyIntervals/emogrifier/pull/907))
+
+### Changed
+- Automatically add a backslash for global functions
+ ([#909](https://github.com/MyIntervals/emogrifier/pull/909))
+- Update the development tools
+ ([#898](https://github.com/MyIntervals/emogrifier/pull/898),
+ [#895](https://github.com/MyIntervals/emogrifier/pull/895))
+- Upgrade to PHPUnit 7.5
+ ([#888](https://github.com/MyIntervals/emogrifier/pull/888))
+- Enforce constant visibility
+ ([#892](https://github.com/MyIntervals/emogrifier/pull/892))
+- Rename the PHPCS configuration file
+ ([#891](https://github.com/MyIntervals/emogrifier/pull/891),
+ [#896](https://github.com/MyIntervals/emogrifier/pull/896))
+- Make use of PHP 7.1 language features
+ ([#883](https://github.com/MyIntervals/emogrifier/pull/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](https://github.com/MyIntervals/emogrifier/pull/936))
+- Stop checking `tests/` with Psalm
+ ([#885](https://github.com/MyIntervals/emogrifier/pull/885))
+- Drop support for PHP 7.0
+ ([#880](https://github.com/MyIntervals/emogrifier/pull/880))
### Fixed
+- Fix a nonsensical code example in the README
+ ([#920](https://github.com/MyIntervals/emogrifier/issues/920),
+ [#935](https://github.com/MyIntervals/emogrifier/pull/935))
+- Remove `!important` from `style` attributes also when uppercase, mixed case or
+ having whitespace after `!`
+ ([#911](https://github.com/MyIntervals/emogrifier/pull/911))
+- Copy rules using `:...of-type` without a type to the `<style>` element
+ ([#904](https://github.com/MyIntervals/emogrifier/pull/904))
+- Support combinator followed by dynamic pseudo-class in minified CSS
+ ([#903](https://github.com/MyIntervals/emogrifier/pull/903))
+- Preserve all uninlinable (or otherwise unprocessed) at-rules
+ ([#899](https://github.com/MyIntervals/emogrifier/pull/899))
+- Allow Windows CLI to run development tools installed through PHIVE
+ ([#900](https://github.com/MyIntervals/emogrifier/pull/900))
+- Switch to a maintained package for parallel PHP linting
+ ([#884](https://github.com/MyIntervals/emogrifier/pull/884))
+- Add `.0` version suffixes to PHP version requirements
+ ([#881](https://github.com/MyIntervals/emogrifier/pull/881))
## 4.0.0
([#866](https://github.com/MyIntervals/emogrifier/pull/866))
- Upgrade to V2 of the PHP setup GitHub action
([#861](https://github.com/MyIntervals/emogrifier/pull/861))
-- Move the development tools to Phive
+- Move the development tools to PHIVE
([#850](https://github.com/MyIntervals/emogrifier/pull/850),
[#851](https://github.com/MyIntervals/emogrifier/pull/851))
- Switch the parallel linting to a maintained fork
```php
$domDocument = CssInliner::fromHtml($html)->inlineCss($css)->getDomDocument();
-HtmlPruner::fromDomDocument($domDocument)->removeElementsWithDisplayNone(),
+HtmlPruner::fromDomDocument($domDocument)->removeElementsWithDisplayNone();
$html = CssToAttributeConverter::fromDomDocument($domDocument)
->convertCssToVisualAttributes()->render();
```
* [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` which will
- currently be treated as `*:not(*)`)
+ (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 – without a type, it will be treated as `:not(*)`)
+ (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 – without a type, it will be treated as `:not(*)`)
+ (with a type)
* [nth-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-of-type)
- (with a type – without a type, it will be applied as if `:nth-child`)
+ (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 – without a type, it will be applied as if `:only-child`
- or `:not(*)`, depending on version constraints for `symfony/css-selector`)
+ (with a type)
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):
+ * 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 (declarations discarded)
+ without a type
* [last-of-type](https://developer.mozilla.org/en-US/docs/Web/CSS/:last-of-type)
- without a type (declarations discarded)
+ without a type
* [nth-last-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-of-type)
- without a type (declarations discarded)
+ without a type
* [nth-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-of-type)
- without a type (will behave as `:nth-child()`)
+ without a type
* [only-of-type()](https://developer.mozilla.org/en-US/docs/Web/CSS/:only-of-type)
- without a type (will behave as `:only-child()` or `:not(*)`)
- * any 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)
- * [optional](https://developer.mozilla.org/en-US/docs/Web/CSS/:optional)
- * [required](https://developer.mozilla.org/en-US/docs/Web/CSS/:required)
+ 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:
"source": "https://github.com/MyIntervals/emogrifier"
},
"require": {
- "php": "~7.0 || ~7.1 || ~7.2 || ~7.3 || ~7.4",
+ "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0",
"ext-dom": "*",
"ext-libxml": "*",
- "symfony/css-selector": "^3.4.32 || ^4.3.5 || ^5.0"
+ "symfony/css-selector": "^3.4.32 || ^4.4 || ^5.1"
},
"require-dev": {
- "grogy/php-parallel-lint": "^1.1.0",
- "phpunit/phpunit": "^6.5.14",
- "psalm/plugin-phpunit": "^0.5.8",
- "slevomat/coding-standard": "^4.0.0",
- "squizlabs/php_codesniffer": "^3.5.1",
- "vimeo/psalm": "^3.2.12"
+ "php-parallel-lint/php-parallel-lint": "^1.2.0",
+ "slevomat/coding-standard": "^6.4.1",
+ "squizlabs/php_codesniffer": "^3.5.8"
},
"autoload": {
"psr-4": {
},
"scripts": {
"php:version": "php -v | grep -Po 'PHP\\s++\\K(?:\\d++\\.)*+\\d++(?:-\\w++)?+'",
- "php:fix": "\"./tools/php-cs-fixer\" --config=config/php-cs-fixer.php fix config/ src/ tests/",
+ "php:fix": "\"./tools/php-cs-fixer.phar\" --config=config/php-cs-fixer.php fix config/ src/ tests/",
"ci:php:lint": "\"vendor/bin/parallel-lint\" config src tests",
"ci:php:sniff": "\"vendor/bin/phpcs\" config src tests",
- "ci:php:fixer": "\"./tools/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff-format=udiff config/ src/ tests/",
- "ci:php:md": "\"./tools/phpmd\" src text config/phpmd.xml",
- "ci:php:psalm": "\"vendor/bin/psalm\" --show-info=false",
- "ci:tests:unit": "\"vendor/bin/phpunit\" tests/",
- "ci:tests:sof": "\"vendor/bin/phpunit\" tests/ --stop-on-failure",
+ "ci:php:fixer": "\"./tools/php-cs-fixer.phar\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff-format=udiff config/ src/ tests/",
+ "ci:php:md": "\"./tools/phpmd.phar\" src text config/phpmd.xml",
+ "ci:php:psalm": "\"./tools/psalm.phar\" --show-info=false",
+ "ci:tests:unit": "\"./tools/phpunit.phar\"",
+ "ci:tests:sof": "\"./tools/phpunit.phar\" --stop-on-failure",
"ci:tests": [
"@ci:tests:unit"
],
"ci": [
"@ci:static",
"@ci:dynamic"
- ]
+ ],
+ "phive:update:phpunit": "echo y | \"./tools/phive.phar\" --no-progress update phpunit"
},
"extra": {
"branch-alias": {
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/7.5/phpunit.xsd"
+ bootstrap="vendor/autoload.php"
+ colors="true"
+ verbose="true">
+ <testsuites>
+ <testsuite name="default">
+ <directory suffix="Test.php">tests</directory>
+ </testsuite>
+ </testsuites>
+</phpunit>
/**
* @var int
*/
- const CACHE_KEY_CSS = 0;
+ private const CACHE_KEY_CSS = 0;
/**
* @var int
*/
- const CACHE_KEY_SELECTOR = 1;
+ private const CACHE_KEY_SELECTOR = 1;
/**
* @var int
*/
- const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 2;
+ private const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 2;
/**
* @var int
*/
- const CACHE_KEY_COMBINED_STYLES = 3;
+ private const CACHE_KEY_COMBINED_STYLES = 3;
+
+ /**
+ * This regular expression pattern will match any uninlinable at-rule with nested statements, along with any
+ * whitespace immediately following. Currently, any at-rule apart from `@media` is considered uninlinable. The
+ * first capturing group matches the at sign and identifier (e.g. `@font-face`). The second capturing group matches
+ * the nested statements along with their enclosing curly brackets (i.e. `{...}`), and via `(?2)` will match deeper
+ * nested blocks recursively.
+ *
+ * @var string
+ */
+ private const UNINLINABLE_AT_RULE_MATCHER
+ = '/(@(?!media\\b)[\\w\\-]++)[^\\{]*+(\\{[^\\{\\}]*+(?:(?2)[^\\{\\}]*+)*+\\})\\s*+/i';
/**
* Regular expression component matching a static pseudo class in a selector, without the preceding ":",
*
* @var string
*/
- const PSEUDO_CLASS_MATCHER = 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\\([[:ascii:]]*\\)';
+ private const PSEUDO_CLASS_MATCHER
+ = 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\\([[:ascii:]]*\\)';
+
+ /**
+ * This regular expression componenet matches an `...of-type` pseudo class name, without the preceding ":". These
+ * pseudo-classes can currently online be inlined if they have an associated type in the selector expression.
+ *
+ * @var string
+ */
+ private const OF_TYPE_PSEUDO_CLASS_MATCHER = '(?:first|last|nth(?:-last)?+|only)-of-type';
+
+ /**
+ * regular expression component to match a selector combinator
+ *
+ * @var string
+ */
+ private const COMBINATOR_MATCHER = '(?:\\s++|\\s*+[>+~]\\s*+)(?=[[:alpha:]_\\-.#*:\\[])';
/**
* @var bool[]
*
* @return self fluent interface
*
- * @throws ParseException
+ * @throws ParseException in debug mode, if an invalid selector is encountered
+ * @throws \RuntimeException in debug mode, if an internal PCRE error occurs
*/
public function inlineCss(string $css = ''): self
{
}
$cssWithoutComments = $this->removeCssComments($combinedCss);
- list($cssWithoutCommentsCharsetOrImport, $cssImportRules)
+ [$cssWithoutCommentsCharsetOrImport, $cssImportRules]
= $this->extractImportAndCharsetRules($cssWithoutComments);
- list($cssWithoutCommentsCharsetImportOrFontFace, $cssFontFaces)
- = $this->extractFontFaceRules($cssWithoutCommentsCharsetOrImport);
+ [$cssWithoutCommentsOrUninlinableAtRules, $cssAtRules]
+ = $this->extractUninlinableCssAtRules($cssWithoutCommentsCharsetOrImport);
- $uninlinableCss = $cssImportRules . $cssFontFaces;
+ $uninlinableCss = $cssImportRules . $cssAtRules;
$excludedNodes = $this->getNodesToExclude();
- $cssRules = $this->parseCssRules($cssWithoutCommentsCharsetImportOrFontFace);
+ $cssRules = $this->parseCssRules($cssWithoutCommentsOrUninlinableAtRules);
$cssSelectorConverter = $this->getCssSelectorConverter();
foreach ($cssRules['inlinable'] as $cssRule) {
try {
/**
* Clears all caches.
- *
- * @return void
*/
- private function clearAllCaches()
+ private function clearAllCaches(): void
{
$this->caches = [
self::CACHE_KEY_CSS => [],
/**
* Purges the visited nodes.
- *
- * @return void
*/
- private function purgeVisitedNodes()
+ private function purgeVisitedNodes(): void
{
$this->visitedNodes = [];
$this->styleAttributesForNodes = [];
* This changes 'DISPLAY: none' to 'display: none'.
* We wouldn't have to do this if DOMXPath supported XPath 2.0.
* Also stores a reference of nodes with existing inline styles so we don't overwrite them.
- *
- * @return void
*/
- private function normalizeStyleAttributesOfAllNodes()
+ private function normalizeStyleAttributesOfAllNodes(): void
{
/** @var \DOMElement $node */
foreach ($this->getAllNodesWithStyleAttribute() as $node) {
* Normalizes the value of the "style" attribute and saves it.
*
* @param \DOMElement $node
- *
- * @return void
*/
- private function normalizeStyleAttributes(\DOMElement $node)
+ private function normalizeStyleAttributes(\DOMElement $node): void
{
$normalizedOriginalStyle = \preg_replace_callback(
'/-?+[_a-zA-Z][\\w\\-]*+(?=:)/S',
}
$css = '';
- /** @var \DOMNode $styleNode */
foreach ($styleNodes as $styleNode) {
$css .= "\n\n" . $styleNode->nodeValue;
- $styleNode->parentNode->removeChild($styleNode);
+ $parentNode = $styleNode->parentNode;
+ if ($parentNode instanceof \DOMNode) {
+ $parentNode->removeChild($styleNode);
+ }
}
return $css;
* @param string $css CSS with comments removed
*
* @return string[] The first element is the CSS with the valid `@import` and `@charset` rules removed. The second
- * element contains a concatenation of the valid `@import` rules, each followed by whatever whitespace followed it
- * in the original CSS (so that either unminified or minified formatting is preserved); if there were no `@import`
- * rules, it will be an empty string. The (valid) `@charset` rules are discarded.
+ * element contains a concatenation of the valid `@import` rules, each followed by whatever whitespace
+ * followed it in the original CSS (so that either unminified or minified formatting is preserved); if there
+ * were no `@import` rules, it will be an empty string. The (valid) `@charset` rules are discarded.
*/
private function extractImportAndCharsetRules(string $css): array
{
$matches
)
) {
- list($fullMatch, $atRuleAndFollowingWhitespace, $atRuleName) = $matches;
+ [$fullMatch, $atRuleAndFollowingWhitespace, $atRuleName] = $matches;
if (\strtolower($atRuleName) === 'import') {
$importRules .= $atRuleAndFollowingWhitespace;
}
/**
- * Extracts `@font-face` rules from the supplied CSS. Note that `@font-face` rules can be placed anywhere in your
- * CSS and are not case sensitive.
+ * Extracts uninlinable at-rules with nested statements (i.e. a block enclosed in curly brackets) from the supplied
+ * CSS. Currently, any such at-rule apart from `@media` is considered uninlinable. These rules can be placed
+ * anywhere in the CSS and are not case sensitive. `@font-face` rules will be checked for validity, though other
+ * at-rules will be assumed to be valid.
*
* @param string $css CSS with comments, import and charset removed
*
- * @return string[] The first element is the CSS with the valid `@font-face` rules removed. The second
- * element contains a concatenation of the valid `@font-face` rules, each followed by whatever whitespace followed
- * it in the original CSS (so that either unminified or minified formatting is preserved); if there were no
- * `@font-face` rules, it will be an empty string.
+ * @return string[] The first element is the CSS with the at-rules removed. The second element contains a
+ * concatenation of the valid at-rules, each followed by whatever whitespace followed it in the
+ * original CSS (so that either unminified or minified formatting is preserved); if there were no
+ * at-rules, it will be an empty string.
*/
- private function extractFontFaceRules(string $css): array
+ private function extractUninlinableCssAtRules(string $css): array
{
$possiblyModifiedCss = $css;
- $fontFaces = '';
+ $atRules = '';
while (
\preg_match(
- '/(@font-face[^}]++}\\s*+)/i',
+ self::UNINLINABLE_AT_RULE_MATCHER,
$possiblyModifiedCss,
$matches
)
) {
- list($fullMatch, $atRuleAndFollowingWhitespace) = $matches;
+ [$fullMatch, $atRuleName] = $matches;
- if (\stripos($fullMatch, 'font-family') !== false && \stripos($fullMatch, 'src') !== false) {
- $fontFaces .= $atRuleAndFollowingWhitespace;
+ if ($this->isValidAtRule($atRuleName, $fullMatch)) {
+ $atRules .= $fullMatch;
}
$possiblyModifiedCss = \str_replace($fullMatch, '', $possiblyModifiedCss);
}
- return [$possiblyModifiedCss, $fontFaces];
+ return [$possiblyModifiedCss, $atRules];
+ }
+
+ /**
+ * Tests if an at-rule is valid. Currently only `@font-face` rules are checked for validity; others are assumed to
+ * be valid.
+ *
+ * @param string $atIdentifier name of the at-rule with the preceding at sign
+ * @param string $rule full content of the rule, including the at-identifier
+ *
+ * @return bool
+ */
+ private function isValidAtRule(string $atIdentifier, string $rule): bool
+ {
+ if (\strcasecmp($atIdentifier, '@font-face') === 0) {
+ return \stripos($rule, 'font-family') !== false && \stripos($rule, 'src') !== false;
+ }
+
+ return true;
}
/**
'inlinable' => [],
'uninlinable' => [],
];
- /** @var string[][] $matches */
- /** @var string[] $cssRule */
foreach ($matches as $key => $cssRule) {
$cssDeclaration = \trim($cssRule['declarations']);
if ($cssDeclaration === '') {
// don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
// only allow structural pseudo-classes
$hasPseudoElement = \strpos($selector, '::') !== false;
- $hasUnsupportedPseudoClass = (bool)\preg_match(
- '/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i',
- $selector
- );
- $hasUnmatchablePseudo = $hasPseudoElement || $hasUnsupportedPseudoClass;
+ $hasUnmatchablePseudo = $hasPseudoElement || $this->hasUnsupportedPseudoClass($selector);
$parsedCssRule = [
'media' => $cssRule['media'],
return $cssRules;
}
+ /**
+ * Tests if a selector contains a pseudo-class which would mean it cannot be converted to an XPath expression for
+ * inlining CSS declarations.
+ *
+ * Any pseudo class that does not match {@see PSEUDO_CLASS_MATCHER} cannot be converted. Additionally, `...of-type`
+ * pseudo-classes cannot be converted if they are not associated with a type selector.
+ *
+ * @param string $selector
+ *
+ * @return bool
+ */
+ private function hasUnsupportedPseudoClass(string $selector): bool
+ {
+ if (\preg_match('/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i', $selector)) {
+ return true;
+ }
+
+ if (!\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selector)) {
+ return false;
+ }
+
+ foreach (\preg_split('/' . self::COMBINATOR_MATCHER . '/', $selector) as $selectorPart) {
+ if ($this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Tests if part of a selector contains an `...of-type` pseudo-class such that it cannot be converted to an XPath
+ * expression.
+ *
+ * @param string $selectorPart part of a selector which has been split up at combinators
+ *
+ * @return bool `true` if the selector part does not have a type but does have an `...of-type` pseudo-class
+ */
+ private function selectorPartHasUnsupportedOfTypePseudoClass(string $selectorPart): bool
+ {
+ if (\preg_match('/^[\\w\\-]/', $selectorPart)) {
+ return false;
+ }
+
+ return (bool)\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorPart);
+ }
+
/**
* @param string[] $a
* @param string[] $b
}
$number = 0;
$selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number);
- $precedence += ($value * $number);
+ $precedence += ($value * (int)$number);
}
$this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
*
* @param string $css CSS with comments removed
*
- * @return string[][] Array of string sub-arrays with the keys
+ * @return array<array-key, array<string, string>> Array of string sub-arrays with the keys
* "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
* or an empty string if not from an `@media` rule),
* "selectors" (the CSS selector(s), e.g., "*" or "h1, h2"),
// process each part for selectors and definitions
\preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mi', $cssPart['css'], $matches, PREG_SET_ORDER);
- /** @var string[][] $matches */
+ /** @var string[] $cssRule */
foreach ($matches as $cssRule) {
$ruleMatches[] = [
'media' => $cssPart['media'],
*
* @param \DOMElement $node
* @param string[][] $cssRule
- *
- * @return void
*/
- private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule)
+ private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule): void
{
/** @var string $declarationsBlock */
$declarationsBlock = $cssRule['declarationsBlock'];
*/
private function attributeValueIsImportant(string $attributeValue): bool
{
- return \strtolower(\substr(\trim($attributeValue), -10)) === '!important';
+ return (bool)\preg_match('/!\\s*+important$/i', $attributeValue);
}
/**
* Merges styles from styles attributes and style nodes and applies them to the attribute nodes
- *
- * @return void
*/
- private function fillStyleAttributesWithMergedStyles()
+ private function fillStyleAttributesWithMergedStyles(): void
{
foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
$node = $this->visitedNodes[$nodePath];
* Searches for all nodes with a style attribute and removes the "!important" annotations out of
* the inline style declarations, eventually by rearranging declarations.
*
- * @return void
+ * @throws \RuntimeException
*/
- private function removeImportantAnnotationFromAllInlineStyles()
+ private function removeImportantAnnotationFromAllInlineStyles(): void
{
foreach ($this->getAllNodesWithStyleAttribute() as $node) {
$this->removeImportantAnnotationFromNodeInlineStyle($node);
*
* @param \DOMElement $node
*
- * @return void
+ * @throws \RuntimeException
*/
- private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node)
+ private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node): void
{
$inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
$regularStyleDeclarations = [];
$importantStyleDeclarations = [];
foreach ($inlineStyleDeclarations as $property => $value) {
if ($this->attributeValueIsImportant($value)) {
- $importantStyleDeclarations[$property] = \trim(\str_replace('!important', '', $value));
+ $importantStyleDeclarations[$property] = $this->pregReplace('/\\s*+!\\s*+important$/i', '', $value);
} else {
$regularStyleDeclarations[$property] = $value;
}
* `$this->matchingUninlinableCssRules`.
*
* @param string[][] $cssRules the "uninlinable" array of CSS rules returned by `parseCssRules`
- *
- * @return void
*/
- private function determineMatchingUninlinableCssRules(array $cssRules)
+ private function determineMatchingUninlinableCssRules(array $cssRules): void
{
$this->matchingUninlinableCssRules = \array_filter($cssRules, [$this, 'existsMatchForSelectorInCssRule']);
}
// 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(
- '/(\\s?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i',
+ '/([\\s>+~]?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i',
[$this, 'replaceUnmatchableNotComponent'],
' ' . $selector
));
- $pseudoComponentMatcher = ':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+';
- return \preg_replace(
- ['/(\\s|^)' . $pseudoComponentMatcher . '/i', '/' . $pseudoComponentMatcher . '/i'],
- ['$1*', ''],
+ $selectorWithoutUnmatchablePseudoComponents = $this->removeSelectorComponents(
+ ':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+',
$selectorWithoutNots
);
+
+ if (
+ !\preg_match(
+ '/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i',
+ $selectorWithoutUnmatchablePseudoComponents
+ )
+ ) {
+ return $selectorWithoutUnmatchablePseudoComponents;
+ }
+ return \implode('', \array_map(
+ [$this, 'removeUnsupportedOfTypePseudoClasses'],
+ \preg_split(
+ '/(' . self::COMBINATOR_MATCHER . ')/',
+ $selectorWithoutUnmatchablePseudoComponents,
+ -1,
+ PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
+ )
+ ));
}
/**
* @param string[] $matches array of elements matched by the regular expression
*
* @return string the full match if there were no unmatchable pseudo components within; otherwise, any preceding
- * whitespace followed by "*", or an empty string if there was no preceding whitespace
+ * combinator followed by "*", or an empty string if there was no preceding combinator
*/
private function replaceUnmatchableNotComponent(array $matches): string
{
- list($notComponentWithAnyPrecedingWhitespace, $anyPrecedingWhitespace, $notArgumentInBrackets) = $matches;
+ [$notComponentWithAnyPrecedingCombinator, $anyPrecedingCombinator, $notArgumentInBrackets] = $matches;
+
+ if ($this->hasUnsupportedPseudoClass($notArgumentInBrackets)) {
+ return $anyPrecedingCombinator !== '' ? $anyPrecedingCombinator . '*' : '';
+ }
+ return $notComponentWithAnyPrecedingCombinator;
+ }
- $hasUnmatchablePseudo = \preg_match(
- '/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-:]/i',
- $notArgumentInBrackets
+ /**
+ * Removes components from a CSS selector, replacing them with "*" if necessary.
+ *
+ * @param string $matcher regular expression part to match the components to remove
+ * @param string $selector
+ *
+ * @return string selector which will match the relevant DOM elements if the removed components are assumed to apply
+ * (or in the case of pseudo-elements will match their originating element)
+ */
+ private function removeSelectorComponents(string $matcher, string $selector): string
+ {
+ return \preg_replace(
+ ['/([\\s>+~]|^)' . $matcher . '/i', '/' . $matcher . '/i'],
+ ['$1*', ''],
+ $selector
);
+ }
- if ($hasUnmatchablePseudo) {
- return $anyPrecedingWhitespace !== '' ? $anyPrecedingWhitespace . '*' : '';
+ /**
+ * Removes any `...-of-type` pseudo-classes from part of a CSS selector, if it does not have a type, replacing them
+ * with "*" if necessary.
+ *
+ * @param string $selectorPart part of a selector which has been split up at combinators
+ *
+ * @return string selector part which will match the relevant DOM elements if the pseudo-classes are assumed to
+ * apply
+ */
+ private function removeUnsupportedOfTypePseudoClasses(string $selectorPart): string
+ {
+ if (!$this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
+ return $selectorPart;
}
- return $notComponentWithAnyPrecedingWhitespace;
+
+ return $this->removeSelectorComponents(
+ ':(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')(?:\\([^\\)]*+\\))?+',
+ $selectorPart
+ );
}
/**
* placed in the `<style>` element. If there are no unlinlinable CSS rules to copy there, a `<style>`
* element will be created containing just `$uninlinableCss`. `$uninlinableCss` may be an empty string;
* if it is, and there are no unlinlinable CSS rules, an empty `<style>` element will not be created.
- *
- * @return void
*/
- private function copyUninlinableCssToStyleNode(string $uninlinableCss)
+ private function copyUninlinableCssToStyleNode(string $uninlinableCss): void
{
$css = $uninlinableCss;
* @see https://github.com/MyIntervals/emogrifier/issues/103
*
* @param string $css
- *
- * @return void
*/
- protected function addStyleElementToDocument(string $css)
+ protected function addStyleElementToDocument(string $css): void
{
$styleElement = $this->domDocument->createElement('style', $css);
$styleAttribute = $this->domDocument->createAttribute('type');
{
return $this->domDocument->getElementsByTagName('head')->item(0);
}
+
+ /**
+ * 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 does not currently allow `$subject` (and return value) to be an array, because a means of telling
+ * Psalm that a method returns the same type a particular parameter has not been found (though it knows this for
+ * `preg_replace`); nor does it currently support the optional parameters.
+ *
+ * @param string|string[] $pattern
+ * @param string|string[] $replacement
+ * @param string $subject
+ *
+ * @return string
+ *
+ * @throws \RuntimeException
+ */
+ private function pregReplace($pattern, $replacement, string $subject): string
+ {
+ $result = \preg_replace($pattern, $replacement, $subject);
+
+ if ($result === null) {
+ $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 = \is_array($pcreConstants) ? \array_flip(\array_filter(
+ $pcreConstants,
+ 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);
+ }
}
/**
* @var string
*/
- const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
+ protected const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
/**
* @var string
*/
- const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
+ protected const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
/**
* @var string Regular expression part to match tag names that PHP's DOMDocument implementation is not aware are
*
* @see https://bugs.php.net/bug.php?id=73175
*/
- const PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER = '(?:command|embed|keygen|source|track|wbr)';
+ protected const PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER = '(?:command|embed|keygen|source|track|wbr)';
/**
* @var \DOMDocument|null
* Sets the HTML to process.
*
* @param string $html the HTML to process, must be UTF-8-encoded
- *
- * @return void
*/
- private function setHtml(string $html)
+ private function setHtml(string $html): void
{
$this->createUnifiedDomDocument($html);
}
/**
* @param \DOMDocument $domDocument
- *
- * @return void
*/
- private function setDomDocument(\DOMDocument $domDocument)
+ private function setDomDocument(\DOMDocument $domDocument): void
{
$this->domDocument = $domDocument;
$this->xPath = new \DOMXPath($this->domDocument);
* The DOM document will always have a BODY element and a document type.
*
* @param string $html
- *
- * @return void
*/
- private function createUnifiedDomDocument(string $html)
+ private function createUnifiedDomDocument(string $html): void
{
$this->createRawDomDocument($html);
$this->ensureExistenceOfBodyElement();
* Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
*
* @param string $html
- *
- * @return void
*/
- private function createRawDomDocument(string $html)
+ private function createRawDomDocument(string $html): void
{
$domDocument = new \DOMDocument();
$domDocument->strictErrorChecking = false;
/**
* Checks that $this->domDocument has a BODY element and adds it if it is missing.
*
- * @return void
- *
* @throws \UnexpectedValueException
*/
- private function ensureExistenceOfBodyElement()
+ private function ensureExistenceOfBodyElement(): void
{
if ($this->getDomDocument()->getElementsByTagName('body')->item(0) !== null) {
return;
*
* @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
* @param \DOMElement $node node to apply styles to
- *
- * @return void
*/
- private function mapCssToHtmlAttributes(array $styles, \DOMElement $node)
+ private function mapCssToHtmlAttributes(array $styles, \DOMElement $node): void
{
foreach ($styles as $property => $value) {
// Strip !important indicator
* @param string $property the name of the CSS property to map
* @param string $value the value of the style rule to map
* @param \DOMElement $node node to apply styles to
- *
- * @return void
*/
- private function mapCssToHtmlAttribute(string $property, string $value, \DOMElement $node)
+ private function mapCssToHtmlAttribute(string $property, string $value, \DOMElement $node): void
{
if (!$this->mapSimpleCssProperty($property, $value, $node)) {
$this->mapComplexCssProperty($property, $value, $node);
* @param string $property the name of the CSS property to map
* @param string $value the value of the style rule to map
* @param \DOMElement $node node to apply styles to
- *
- * @return void
*/
- private function mapComplexCssProperty(string $property, string $value, \DOMElement $node)
+ private function mapComplexCssProperty(string $property, string $value, \DOMElement $node): void
{
switch ($property) {
case 'background':
/**
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
- *
- * @return void
*/
- private function mapBackgroundProperty(\DOMElement $node, string $value)
+ private function mapBackgroundProperty(\DOMElement $node, string $value): void
{
// parse out the color, if any
$styles = \explode(' ', $value, 2);
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
* @param string $property the name of the CSS property to map
- *
- * @return void
*/
- private function mapWidthOrHeightProperty(\DOMElement $node, string $value, string $property)
+ private function mapWidthOrHeightProperty(\DOMElement $node, string $value, string $property): void
{
// only parse values in px and %, but not values like "auto"
if (!\preg_match('/^(\\d+)(\\.(\\d+))?(px|%)$/', $value)) {
/**
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
- *
- * @return void
*/
- private function mapMarginProperty(\DOMElement $node, string $value)
+ private function mapMarginProperty(\DOMElement $node, string $value): void
{
if (!$this->isTableOrImageNode($node)) {
return;
/**
* @param \DOMElement $node node to apply styles to
* @param string $value the value of the style rule to map
- *
- * @return void
*/
- private function mapBorderProperty(\DOMElement $node, string $value)
+ private function mapBorderProperty(\DOMElement $node, string $value): void
{
if (!$this->isTableOrImageNode($node)) {
return;
* Parses a shorthand CSS value and splits it into individual values
*
* @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
- * For example: padding: 0 auto;
- * '0 auto' is split into top: 0, left: auto, bottom: 0,
- * right: auto.
+ * For example: padding: 0 auto; '0 auto' is split into top: 0, left: auto, bottom: 0, right: auto.
*
* @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
*/
*
* @var string
*/
- const DISPLAY_NONE_MATCHER
+ private const DISPLAY_NONE_MATCHER
= '//*[@style and contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")'
. ' and not(@class and contains(concat(" ", normalize-space(@class), " "), " -emogrifier-keep "))]';
return $this;
}
- /** @var \DOMNode $element */
foreach ($elementsWithStyleDisplayNone as $element) {
$parentNode = $element->parentNode;
if ($parentNode !== null) {
*
* @param \DOMNodeList $elements
* @param string[] $classesToKeep
- *
- * @return void
*/
- private function removeClassesFromElements(\DOMNodeList $elements, array $classesToKeep)
+ private function removeClassesFromElements(\DOMNodeList $elements, array $classesToKeep): void
{
$classesToKeepIntersector = new ArrayIntersector($classesToKeep);
* Removes the `class` attribute from each element in `$elements`.
*
* @param \DOMNodeList $elements
- *
- * @return void
*/
- private function removeClassAttributeFromElements(\DOMNodeList $elements)
+ private function removeClassAttributeFromElements(\DOMNodeList $elements): void
{
/** @var \DOMElement $element */
foreach ($elements as $element) {
* @param string[] $selectors Array of selectors for the rule, e.g. ["ul", "ol", "p:first-child"].
* @param string $declarationsBlock The property declarations, e.g. "margin-top: 0.5em; padding: 0".
* @param string $media The media query for the rule, e.g. "@media screen and (max-width:639px)",
- * or an empty string if none.
+ * or an empty string if none.
*/
- public function append(array $selectors, string $declarationsBlock, string $media = '')
+ public function append(array $selectors, string $declarationsBlock, string $media = ''): void
{
$selectorsAsKeys = \array_flip($selectors);
/**
* @param string $media The media query for rules to be appended, e.g. "@media screen and (max-width:639px)",
- * or an empty string if none.
+ * or an empty string if none.
*
* @return \stdClass Object with properties as described for elements of `$mediaRules`.
*/
* Tests if two sets of selectors are equivalent (i.e. the same selectors, possibly in a different order).
*
* @param mixed[] $selectorsAsKeys1 Array in which the selectors are the keys, and the values are of no
- * significance.
+ * significance.
* @param mixed[] $selectorsAsKeys2 Another such array.
*
* @return bool
/**
* @param \stdClass $ruleBlock Object with properties as described for elements of the `ruleBlocks` property of
- * elements of `$mediaRules`.
+ * elements of `$mediaRules`.
*
* @return string CSS for the rule block.
*/