Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract font-face rules and inject into head <styles> element #870

Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## x.y.z

### Added
- Extract and inject `@font-face` rules into head
([#870](https://github.com/MyIntervals/emogrifier/pull/870))
- Test tag omission in conformant supplied HTML
([#868](https://github.com/MyIntervals/emogrifier/pull/868))
- Check for missing return type hint annotations in the code sniffs
Expand Down
55 changes: 47 additions & 8 deletions src/CssInliner.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,13 @@ public function inlineCss(string $css = ''): self
$cssWithoutComments = $this->removeCssComments($combinedCss);
list($cssWithoutCommentsCharsetOrImport, $cssImportRules)
= $this->extractImportAndCharsetRules($cssWithoutComments);
list($cssWithoutCommentsCharsetImportOrFontFace, $cssFontFaces)
= $this->extractFontFaceRules($cssWithoutCommentsCharsetOrImport);

$uninlinableCss = $cssImportRules . $cssFontFaces;

$excludedNodes = $this->getNodesToExclude();
$cssRules = $this->parseCssRules($cssWithoutCommentsCharsetOrImport);
$cssRules = $this->parseCssRules($cssWithoutCommentsCharsetImportOrFontFace);
$cssSelectorConverter = $this->getCssSelectorConverter();
foreach ($cssRules['inlinable'] as $cssRule) {
try {
Expand Down Expand Up @@ -202,7 +206,7 @@ public function inlineCss(string $css = ''): self
$this->removeImportantAnnotationFromAllInlineStyles();

$this->determineMatchingUninlinableCssRules($cssRules['uninlinable']);
$this->copyUninlinableCssToStyleNode($cssImportRules);
$this->copyUninlinableCssToStyleNode($uninlinableCss);

return $this;
}
Expand Down Expand Up @@ -525,6 +529,41 @@ private function extractImportAndCharsetRules(string $css): array
return [$possiblyModifiedCss, $importRules];
}

/**
* 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.
*
* @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.
*/
private function extractFontFaceRules(string $css): array
{
$possiblyModifiedCss = $css;
$fontFaces = '';

while (
\preg_match(
'/(@font-face[^}]++}\\s*+)/i',
$possiblyModifiedCss,
$matches
)
) {
list($fullMatch, $atRuleAndFollowingWhitespace) = $matches;

if (\stripos($fullMatch, 'font-family') !== false && \stripos($fullMatch, 'src') !== false) {
$fontFaces .= $atRuleAndFollowingWhitespace;
}

$possiblyModifiedCss = \str_replace($fullMatch, '', $possiblyModifiedCss);
}

return [$possiblyModifiedCss, $fontFaces];
}

/**
* Find the nodes that are not to be emogrified.
*
Expand Down Expand Up @@ -1070,16 +1109,16 @@ private function replaceUnmatchableNotComponent(array $matches): string
* Applies `$this->matchingUninlinableCssRules` to `$this->domDocument` by placing them as CSS in a `<style>`
* element.
*
* @param string $cssImportRules This may contain any `@import` rules that should precede the CSS placed in the
* `<style>` element. If there are no unlinlinable CSS rules to copy there, a `<style>` element will be
* created containing just `$cssImportRules`. `$cssImportRules` may be an empty string; if it is, and there
* are no unlinlinable CSS rules, an empty `<style>` element will not be created.
* @param string $uninlinableCss This may contain any `@import` or `@font-face` rules that should precede the CSS
* 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 $cssImportRules)
private function copyUninlinableCssToStyleNode(string $uninlinableCss)
{
$css = $cssImportRules;
$css = $uninlinableCss;

// avoid including unneeded class dependency if there are no rules
if ($this->matchingUninlinableCssRules !== []) {
Expand Down
115 changes: 115 additions & 0 deletions tests/Unit/CssInlinerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3206,6 +3206,121 @@ public function inlineCssNotCopiesInlinableRuleAfterImportRuleToStyleElement()
self::assertNotContainsCss($cssAfter, $subject->render());
}

/**
* @return string[][]
*/
public function provideValidFontFaceRules(): array
{
return [
'single @font-face' => [
'before' => '',
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => '',
],
'uppercase @FONT-FACE' => [
'before' => '',
'@font-face' => '@FONT-FACE { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => '',
],
'mixed case @FoNt-FaCe' => [
'before' => '',
'@font-face' => '@FoNt-FaCe { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => '',
],
'2 @font-faces' => [
'before' => '',
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }'
. "\n" . '@font-face { font-family: "Bar Sans"; src: url("/bar-sans.woff2") format("woff2"); }',
'after' => '',
],
'2 @font-faces, minified' => [
'before' => '',
'@font-face' => '@font-face{font-family:"Foo Sans";src:url(/foo-sans.woff2) format("woff2")}'
. '@font-face{font-family:"Bar Sans";src:url(/bar-sans.woff2) format("woff2")}',
'after' => '',
],
'@font-face followed by matching inlinable rule' => [
'before' => '',
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => "\n" . 'p { color: green; }',
],
'@font-face followed by matching uninlinable rule' => [
'before' => '',
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => "\n" . 'p:hover { color: green; }',
],
'@font-face followed by matching @media rule' => [
'before' => '',
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => "\n" . '@media (max-width: 640px) { p { color: green; } }',
],
'@font-face preceded by matching inlinable rule' => [
'before' => "p { color: green; }\n",
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => '',
],
'@font-face preceded by matching uninlinable rule' => [
'before' => "p:hover { color: green; }\n",
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => '',
],
'@font-face preceded by matching @media rule' => [
'before' => "@media (max-width: 640px) { p { color: green; } }\n",
'@font-face' => '@font-face { font-family: "Foo Sans"; src: url("/foo-sans.woff2") format("woff2"); }',
'after' => '',
],
];
}

/**
* @test
*
* @param string $cssBefore
* @param string $cssFontFaces
* @param string $cssAfter
*
* @dataProvider provideValidFontFaceRules
*/
public function inlineCssPreservesValidFontFaceRules(string $cssBefore, string $cssFontFaces, string $cssAfter)
{
$subject = $this->buildDebugSubject('<html><p>foo</p></html>');

$subject->inlineCss($cssBefore . $cssFontFaces . $cssAfter);

self::assertContains($cssFontFaces, $subject->render());
}

/**
* @return string[][]
*/
public function provideInvalidFontFaceRules(): array
{
return [
'@font-face without font-family descriptor' => [
'@font-face { src: url("/foo-sans.woff2") format("woff2"); }',
],
'@font-face without src descriptor' => [
'@font-face { font-family: "Foo Sans"; }',
],
];
}

/**
* @test
*
* @param string $css
*
* @dataProvider provideInvalidFontFaceRules
*/
public function inlineCssRemovesInvalidFontFaceRules(string $css)
{
$subject = $this->buildDebugSubject('<html></html>');

$subject->inlineCss($css);

self::assertNotContains('@font-face', $subject->render());
}

/**
* @test
*/
Expand Down