diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md
index 86e478799..d2c0a85c1 100644
--- a/CHANGELOG-GRAVITYPDF.md
+++ b/CHANGELOG-GRAVITYPDF.md
@@ -168,6 +168,11 @@ Bugfixes
* The rule after an `@supports`, `@layer`, `@keyframes`, `@container` or other block at-rule was lost, and so were the rules inside `@supports` and `@layer`. The rule after `@charset`, `@namespace` or `@import` was lost too. `@supports` and `@layer` blocks are now unwrapped as `@media` blocks are, except `@supports not`, and other at-rules are removed whole. [#524] [#566]
* `@media` was matched by looking for the word `print`, so `@media not print` applied, `orientation` was ignored and `min-width` never matched. Media queries are now evaluated for the print medium against the page current when the stylesheet is read: media types with `not` and `only`; `and`, `or` and `not` conditions; and `width`, `height` and `orientation`, with `min-`/`max-` prefixes and range syntax such as `(width >= 600px)`. An A4 page is about 794px wide, so `@media (min-width: 768px)` rules now apply to it. [#552] [#575]
* `@import` ignored its media list, and did not load a URL that does not end in `.css`, such as a Google Fonts stylesheet. [#552] [#575]
+* A brace, semicolon or comment marker inside a quoted string, an unquoted `url()` or an escape broke the stylesheet around it. `q { quotes: "}" }` lost the rule after it, `font-family: 'a;color:red'` set the colour, a `/*` in one string and a `*/` in a later one removed every rule between them, and `@import url(…?family=Inter:wght@300;400)` lost the rule after it. A rule or at-rule nested in a block, as CSS nesting writes them, lost the rest of the block and the rule after it. Stylesheets are now split by a tokenizer that reads strings, `url()`, escapes and comments as CSS does, in either `cssMode`. A rule or at-rule nested in a block is left out, and the declarations after it apply. [#536] [#635]
+ * A string left open ends at the line break, as in CSS. A block or comment left open ends with its `';
+ $sub = '>' . str_replace([''], ' ', $style) . '';
$html = str_replace('>' . $style . '', $sub, $html);
}
diff --git a/src/Css/CssLoader.php b/src/Css/CssLoader.php
index 56d840987..d58dce9e4 100644
--- a/src/Css/CssLoader.php
+++ b/src/Css/CssLoader.php
@@ -32,12 +32,18 @@ class CssLoader
*/
private $mediaQueryProcessor;
+ /**
+ * @var StylesheetTokenizer
+ */
+ private $tokenizer;
+
public function __construct(Mpdf $mpdf, AssetFetcher $assetFetcher, Cache $cache, MediaQueryProcessor $mediaQueryProcessor)
{
$this->mpdf = $mpdf;
$this->assetFetcher = $assetFetcher;
$this->cache = $cache;
$this->mediaQueryProcessor = $mediaQueryProcessor;
+ $this->tokenizer = new StylesheetTokenizer();
}
/**
@@ -93,29 +99,40 @@ public function extractExternalStylesheetUrls($html)
}
preg_match_all('/(.*?)<\/style>/si', $html, $styles);
+ foreach ($styles[1] as $css) {
+ $cssUrls = array_merge($cssUrls, $this->extractImportUrls($css));
+ }
- return array_merge($cssUrls, $this->extractImportUrls(implode(' ', $styles[1])));
+ return $cssUrls;
}
/**
* The URLs the @import rules in $css load, less those whose media query list does not match
*
- * A layer() before the media query list is ignored. A supports() condition is taken to pass, as the rules in an
- * @supports block are unwrapped, so one that starts with not fails.
+ * Only an @import among the stylesheet's own rules is read, not one in a comment, a string or a block. A layer()
+ * before the media query list is ignored. A supports() condition is taken to pass, as the rules in an @supports
+ * block are unwrapped, so one that starts with not fails.
*
* @param string $css
* @return string[]
*/
private function extractImportUrls($css)
{
+ if (stripos($css, '@import') === false) {
+ return [];
+ }
+
$url = '(?|url\(\s*"([^"]*)"\s*\)|url\(\s*\'([^\']*)\'\s*\)|url\(\s*([^\s"\')]*)\s*\)|"([^"]*)"|\'([^\']*)\')';
$layer = '(?:\s*layer(?:\([^)]*\))?)?';
$supports = '(?:\s*supports\(\s*(not\b)?(?:[^()]|\([^()]*\))*\))?';
- preg_match_all('/@import\s*' . $url . $layer . $supports . '([^;{}]*)/i', $css, $imports, PREG_SET_ORDER);
+ $pattern = '/^' . $url . $layer . $supports . '(.*)$/is';
$urls = [];
- foreach ($imports as $import) {
- if ($import[1] !== '' && $import[2] === '' && $this->mediaQueryProcessor->matches($import[3])) {
+ foreach ($this->tokenizer->rules($this->tokenizer->removeComments($css)) as $rule) {
+ if ($rule[0] === 'import' && $rule[2] === null
+ && preg_match($pattern, $rule[1], $import)
+ && $import[1] !== '' && $import[2] === '' && $this->mediaQueryProcessor->matches($import[3])
+ ) {
$urls[] = $import[1];
}
}
@@ -135,8 +152,6 @@ private function extractImportUrls($css)
*/
public function processExternalCssImports($stylesheetCss, $path, &$externalCss, &$externalCssCount)
{
- $css = '';
-
$cssBasePath = preg_replace('/\/[^\/]*$/', '', $path) . '/';
foreach ($this->extractImportUrls($stylesheetCss) as $cxtembedded) {
// path is relative to original stylesheet!!
@@ -144,9 +159,7 @@ public function processExternalCssImports($stylesheetCss, $path, &$externalCss,
$externalCssCount++;
}
- $css .= ' ' . $this->resolveBackgroundUrls($stylesheetCss, $cssBasePath);
-
- return $css;
+ return $this->resolveBackgroundUrls($stylesheetCss, $cssBasePath);
}
/**
diff --git a/src/Css/CssParser.php b/src/Css/CssParser.php
index 724e40e35..d8d20f2a9 100644
--- a/src/Css/CssParser.php
+++ b/src/Css/CssParser.php
@@ -13,6 +13,11 @@
class CssParser
{
+ /**
+ * Whitespace, each character of which is read as a space in a selector or declaration
+ */
+ const WHITESPACE = '/\s/';
+
/**
* @var Mpdf
*/
@@ -29,9 +34,9 @@ class CssParser
private $mediaQueryProcessor;
/**
- * @var AtRuleProcessor
+ * @var StylesheetTokenizer
*/
- private $atRuleProcessor;
+ private $tokenizer;
/**
* @var CommentParser
@@ -138,7 +143,7 @@ public function __construct(
$this->normalizeProperties = new NormalizeProperties($mpdf, $sizeConverter, $colorConverter);
$this->mediaQueryProcessor = new MediaQueryProcessor($mpdf, $sizeConverter);
$this->cssLoader = new CssLoader($mpdf, $assetFetcher, $cache, $this->mediaQueryProcessor);
- $this->atRuleProcessor = new AtRuleProcessor($this->mediaQueryProcessor);
+ $this->tokenizer = new StylesheetTokenizer();
$this->commentParser = new CommentParser();
$this->inlineStyleParser = new InlineStyleParser($this->normalizeProperties);
$this->selectorParser = new SelectorParser($mpdf);
@@ -159,7 +164,7 @@ public function parse($html)
$this->compiledRules = [];
$ind = 0;
- $css = '';
+ $stylesheets = [];
$html = $this->mediaQueryProcessor->filterByMediaQuery($html, '/
+
+ ';
+
+ $this->assertSame(
+ ['https://fonts.example/css2?family=Inter:wght@300;400&display=swap', 'after.css', 'next.css'],
+ $this->cssLoader->extractExternalStylesheetUrls($html)
+ );
+ }
+
+ /**
+ * An @import in a loaded stylesheet is queued relative to that stylesheet when its media query list matches, in
+ * either form, and not when it is in a comment
+ */
+ public function testProcessExternalCssImportsSkipsACommentedImport()
+ {
+ $externalCss = [];
+ $count = 0;
+
+ $this->cssLoader->processExternalCssImports(
+ '/* @import "old.css"; */ @import "base.css"; p { color: red; }',
+ 'http://example.com/css/main.css',
+ $externalCss,
+ $count
+ );
+
+ $this->assertSame(['http://example.com/css/base.css'], $externalCss);
+ }
+
/**
* An @import in a loaded stylesheet is queued relative to that stylesheet when its media query list matches, in
* either form
diff --git a/tests/Mpdf/Css/CssParserTest.php b/tests/Mpdf/Css/CssParserTest.php
index 22593314b..6143406df 100644
--- a/tests/Mpdf/Css/CssParserTest.php
+++ b/tests/Mpdf/Css/CssParserTest.php
@@ -323,4 +323,266 @@ public function testCompilesEveryRuleUnderTheStandardCascade()
$this->assertSame(['P', 'CLASS>>A', '@PAGE'], array_keys($this->parser->getCss()));
$this->assertSame([], $this->parser->getCascadeCss());
}
+
+ /**
+ * Each at-rule is unwrapped, kept or left out whole, in either cssMode, and the rules around it are stored as
+ * they are written
+ *
+ * @dataProvider atRulesInEachMode
+ *
+ * @param string $mode
+ * @param string $css
+ * @param array[] $expected Each key stored, with the values of the properties to check
+ */
+ public function testAtRules($mode, $css, array $expected)
+ {
+ $this->mpdf->cssMode = $mode;
+ $this->parser->parse('');
+
+ $this->assertSame($expected, $this->storedValues($expected));
+ }
+
+ /**
+ * Each stylesheet in atRules() in each cssMode
+ *
+ * @return array[]
+ */
+ public function atRulesInEachMode()
+ {
+ return $this->inEachMode($this->atRules());
+ }
+
+ /**
+ * Stylesheets with an at-rule in them, and the keys each stores
+ *
+ * @return array[]
+ */
+ private function atRules()
+ {
+ $p = ['P' => ['COLOR' => 'red']];
+ $h1 = ['H1' => ['COLOR' => 'blue']];
+ $h1p = ['H1' => ['COLOR' => 'blue'], 'P' => ['COLOR' => 'red']];
+
+ return [
+ 'no at-rule' => ['p { color: red }', $p],
+
+ '@charset' => ['@charset "UTF-8"; p { color: red }', $p],
+ '@namespace' => ['@namespace svg url(http://www.w3.org/2000/svg); p { color: red }', $p],
+ '@import' => ['@import url("a.css") screen; @import "b.css"; p { color: red }', $p],
+ '@import with semicolons in its url()' => ['@import url(https://fonts.example/css2?family=Inter:wght@300;400;500&display=swap); p { color: red }', $p],
+ '@layer statement' => ['@layer base, theme; p { color: red }', $p],
+
+ '@keyframes' => ['@keyframes spin { from { opacity: 0 } to { opacity: 1 } } p { color: red }', $p],
+ 'prefixed @keyframes' => ['@-webkit-keyframes spin { 0% { opacity: 0 } } p { color: red }', $p],
+ '@container' => ['@container (min-width: 1px) { h1 { color: blue } } p { color: red }', $p],
+ '@font-feature-values' => ['@font-feature-values Font { @swash { fancy: 1 } } p { color: red }', $p],
+ '@font-face' => ['@font-face { font-family: x; src: url(x.ttf) } p { color: red }', $p],
+ 'an empty unknown block' => ['@unknown {} p { color: red }', $p],
+
+ '@supports' => ['@supports (display: grid) { h1 { color: blue } } p { color: red }', $h1p],
+ '@supports not' => ['@supports not (display: grid) { h1 { color: blue } } p { color: red }', $p],
+ '@supports with not inside it' => ['@supports (display: grid) and (not (display: inline-grid)) { h1 { color: blue } }', $h1],
+ '@layer block' => ['@layer base { h1 { color: blue } } p { color: red }', $h1p],
+ '@layer block with no name' => ['@layer { h1 { color: blue } }', $h1],
+
+ '@media for print' => ['@media print { h1 { color: blue } } p { color: red }', $h1p],
+ '@media for screen' => ['@media screen { h1 { color: blue } } p { color: red }', $p],
+ '@media in upper case' => ['@MEDIA print { h1 { color: blue } }', $h1],
+ 'an empty @media block' => ['@media print {} p { color: red }', $p],
+ '@media inside @supports' => ['@supports (display: grid) { @media print { h1 { color: blue } } @media screen { h2 { color: blue } } }', $h1],
+ '@supports inside @media' => ['@media print { @supports (display: grid) { h1 { color: blue } } h2 { color: blue } }', $h1 + ['H2' => ['COLOR' => 'blue']]],
+ '@media inside @media' => ['@media print { @media all { h1 { color: blue } } }', $h1],
+ '@keyframes inside @media' => ['@media print { @keyframes spin { from { opacity: 0 } } h1 { color: blue } }', $h1],
+ '@media left open' => ['p { color: red } @media print { h1 { color: blue }', ['P' => ['COLOR' => 'red'], 'H1' => ['COLOR' => 'blue']]],
+
+ '@page' => ['@page { margin-top: 10mm } p { color: red }', ['@PAGE' => ['MARGIN-TOP' => '10mm'], 'P' => ['COLOR' => 'red']]],
+ '@page with a pseudo page' => ['@page :first { margin-top: 10mm }', ['@PAGE>>PSEUDO>>FIRST' => ['MARGIN-TOP' => '10mm']]],
+ '@page with a pseudo page and no space' => ['@page:first { margin-top: 10mm }', ['@PAGE>>PSEUDO>>FIRST' => ['MARGIN-TOP' => '10mm']]],
+ '@page inside @media' => ['@media print { @page { margin-top: 10mm } }', ['@PAGE' => ['MARGIN-TOP' => '10mm']]],
+ '@page with margin boxes' => [
+ '@page { margin-top: 10mm; @top-center { content: "}" } margin-bottom: 20mm; @bottom-left { content: "y" } } p { color: red }',
+ ['@PAGE' => ['MARGIN-TOP' => '10mm', 'MARGIN-BOTTOM' => '20mm'], 'P' => ['COLOR' => 'red']],
+ ],
+
+ 'a brace in a string in a prelude' => ['@supports (content: "}") { h1 { color: blue } } p { color: red }', $h1p],
+ 'a brace in a string in a removed block' => ['@keyframes x { from { content: "}" } } p { color: red }', $p],
+ 'a brace in a string in an unwrapped block' => ['@layer { h1 { content: \'{\'; color: blue } } p { color: red }', $h1p],
+ 'a quote escaped in a string' => ['@keyframes x { from { content: "\"}" } } p { color: red }', $p],
+ 'a semicolon in a string in a statement' => ['@import "a;b.css"; p { color: red }', $p],
+ 'an escaped brace' => ['@keyframes x { from { content: \} } } p { color: red }', $p],
+ 'an at sign in a declaration' => ['h1 { color: blue; background: url(a@2x.png) } p { color: red }', $h1p],
+ 'a string left open at the end of a line' => ["@keyframes x { from { content: \"} } }\n} } p { color: red }", $p],
+ 'a statement left open' => ['p { color: red } @import "a.css"', $p],
+ ];
+ }
+
+ /**
+ * Braces, semicolons and comment markers inside strings, url() and escapes are part of the declaration or selector
+ * they are in, in either cssMode, and the rules around them are stored as they are written
+ *
+ * @dataProvider constructsInEachMode
+ *
+ * @param string $mode
+ * @param string $css
+ * @param array[] $expected Each key stored, with the values of the properties to check
+ */
+ public function testStringsUrlsAndEscapes($mode, $css, array $expected)
+ {
+ $this->mpdf->cssMode = $mode;
+ $this->parser->parse('');
+
+ $this->assertSame($expected, $this->storedValues($expected));
+ }
+
+ /**
+ * Each stylesheet in constructs() in each cssMode
+ *
+ * @return array[]
+ */
+ public function constructsInEachMode()
+ {
+ return $this->inEachMode($this->constructs());
+ }
+
+ /**
+ * Stylesheets with braces, semicolons or comment markers in strings, url() or escapes, and the keys each stores
+ *
+ * @return array[]
+ */
+ private function constructs()
+ {
+ $quotes = function ($value) {
+ return ['Q' => ['QUOTES' => $value], 'P' => ['COLOR' => 'red']];
+ };
+
+ return [
+ 'braces in a string' => ['q { quotes: "}" "{" } p { color: red }', $quotes('"}" "{"')],
+ 'an unbalanced brace in a string' => ['q { quotes: "}" } p { color: red }', $quotes('"}"')],
+ 'a semicolon in a string' => ['q { quotes: ";" ";" } p { color: red }', $quotes('";" ";"')],
+ 'a declaration in a string' => [
+ 'p { color: red; font-family: \'x;color:blue;y\', monospace }',
+ ['P' => ['COLOR' => 'red', 'FONT-FAMILY' => 'monospace']],
+ ],
+ 'an escaped quote in a string' => ['q { quotes: "\"}" "x" } p { color: red }', $quotes('"\"}" "x"')],
+ 'a comment opener in a string' => ['q { quotes: "/*" "x" } p { color: red } q { quotes: "*/" "x" }', $quotes('"*/" "x"')],
+ 'a comment in a string' => ['q { quotes: "/* x */" "y" } p { color: red }', $quotes('"/* x */" "y"')],
+ 'a comment with a quote in it' => ['/* it\'s } */ q { quotes: "x" "y" } p { color: red }', $quotes('"x" "y"')],
+ 'a string left open at a line break' => ["q { quotes: \"a}b\n; } p { color: red }", ['Q' => [], 'P' => ['COLOR' => 'red']]],
+
+ 'an escaped brace in a selector' => ['.a\{b { color: blue } p { color: red }', ['P' => ['COLOR' => 'red']]],
+ 'a brace in an attribute selector' => ['q[title="{"] { color: blue } p { color: red }', ['P' => ['COLOR' => 'red']]],
+
+ 'braces and a semicolon in an unquoted url()' => [
+ 'div { background-image: url(http://example.com/a};{b.png) } p { color: red }',
+ ['DIV' => ['BACKGROUND-IMAGE' => 'http://example.com/a%7D;%7Bb.png'], 'P' => ['COLOR' => 'red']],
+ ],
+ 'a semicolon in a quoted url()' => [
+ 'div { background-image: url("data:image/svg+xml;utf8,"); color: blue } p { color: red }',
+ ['DIV' => ['BACKGROUND-IMAGE' => 'data:image/svg+xml;utf8,', 'COLOR' => 'blue'], 'P' => ['COLOR' => 'red']],
+ ],
+ 'a percent sign and ZZ in a url()' => ['div { background-image: url(http://example.com/a%ZZb.png) }', ['DIV' => ['BACKGROUND-IMAGE' => 'http://example.com/a%ZZb.png']]],
+
+ 'a rule nested in a block' => [
+ 'q { color: blue; & b { color: green } margin-top: 1mm } p { color: red }',
+ ['Q' => ['COLOR' => 'blue', 'MARGIN-TOP' => '1mm'], 'P' => ['COLOR' => 'red']],
+ ],
+ 'an at-rule nested in a block' => [
+ 'q { color: blue; @media print { color: green } margin-top: 1mm } p { color: red }',
+ ['Q' => ['COLOR' => 'blue', 'MARGIN-TOP' => '1mm'], 'P' => ['COLOR' => 'red']],
+ ],
+ 'whitespace across lines' => ["p\n{ margin:\n1mm\t2mm; }", ['P' => ['MARGIN-TOP' => '1mm', 'MARGIN-RIGHT' => '2mm']]],
+ ];
+ }
+
+ /**
+ * In standard mode, a selector with escapes in it reaches the compiler whole, which reads each escape as the
+ * character it stands for
+ */
+ public function testASelectorWithEscapesReachesTheCompilerWhole()
+ {
+ $this->mpdf->cssMode = CssMode::STANDARD;
+ $this->parser->parse('');
+
+ $rules = $this->parser->getCompiledRules();
+ $this->assertCount(2, $rules);
+ $this->assertSame(['SM:HIDDEN'], $rules[0][0]['compounds'][0]['classes']);
+ $this->assertSame(['COLOR' => 'blue'], $rules[0][1]);
+ $this->assertSame(['A{B;C'], $rules[1][0]['compounds'][0]['classes']);
+ $this->assertSame(['COLOR' => 'green'], $rules[1][1]);
+ }
+
+ /**
+ * A block, comment or string left open in one stylesheet ends with it, and the next stylesheet is read from its
+ * start
+ *
+ * @dataProvider stylesheetsLeftOpen
+ *
+ * @param string $first
+ */
+ public function testEachStylesheetIsReadOnItsOwn($first)
+ {
+ $this->parser->parse('');
+
+ $this->assertSame(['COLOR' => 'red'], $this->parser->getCss()['P']);
+ }
+
+ /**
+ * Stylesheets that leave something open at their end
+ *
+ * @return array[]
+ */
+ public function stylesheetsLeftOpen()
+ {
+ return [
+ 'a block' => ['h1 { color: blue'],
+ 'a comment' => ['/* h1 { color: blue }'],
+ 'a string' => ['h1 { font-family: "a'],
+ ];
+ }
+
+ /**
+ * A byte order mark at the start of a stylesheet, as one read from a file may have, is not part of its first rule
+ */
+ public function testAByteOrderMarkIsNotPartOfTheFirstRule()
+ {
+ $this->parser->parse("");
+
+ $this->assertSame(['P' => ['COLOR' => 'red'], 'H1' => ['COLOR' => 'blue']], $this->parser->getCss());
+ }
+
+ /**
+ * Each case in each cssMode
+ *
+ * @param array[] $cases
+ *
+ * @return array[] Each case with the mode before its arguments, named for both
+ */
+ private function inEachMode(array $cases)
+ {
+ $data = [];
+ foreach ([CssMode::LEGACY, CssMode::STANDARD] as $mode) {
+ foreach ($cases as $name => $case) {
+ $data[$mode . ': ' . $name] = array_merge([$mode], $case);
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * The keys the last CSS parsed stored, each with the values of the properties named for it
+ *
+ * @param array[] $properties The properties to read for each key, keyed by their names
+ *
+ * @return array[] Each key stored, with those of the properties named for it that it has
+ */
+ private function storedValues(array $properties)
+ {
+ $stored = [];
+ foreach ($this->parser->getCss() as $key => $values) {
+ $stored[$key] = array_intersect_key($values, isset($properties[$key]) ? $properties[$key] : []);
+ }
+
+ return $stored;
+ }
}
diff --git a/tests/Mpdf/Css/InlineStyleParserTest.php b/tests/Mpdf/Css/InlineStyleParserTest.php
index a16e30479..7c72f6c0b 100644
--- a/tests/Mpdf/Css/InlineStyleParserTest.php
+++ b/tests/Mpdf/Css/InlineStyleParserTest.php
@@ -64,14 +64,44 @@ public function testParse_WithUrls()
$this->assertEquals('image.png', $result['BACKGROUND-IMAGE']);
}
- public function testProcessUrls_WithParenthesesAndSemicolons()
+ /**
+ * A style attribute splits into declarations as a stylesheet's block does
+ *
+ * @dataProvider declarationLists
+ *
+ * @param string $style
+ * @param array $expected
+ */
+ public function testParse_SplitsDeclarationsAsABlock($style, array $expected)
+ {
+ $this->assertSame($expected, $this->inlineStyleParser->parse($style));
+ }
+
+ /**
+ * Style attributes, and the properties each gives
+ *
+ * @return array[]
+ */
+ public function declarationLists()
+ {
+ return [
+ 'a semicolon in a string' => ["font-family: 'x;color:#f00;y', monospace; color: #00f", ['FONT-FAMILY' => 'monospace', 'COLOR' => '#00f']],
+ 'a semicolon in an unquoted url()' => ['background-image: url(a;b.png); color: #00f', ['BACKGROUND-IMAGE' => 'a;b.png', 'COLOR' => '#00f']],
+ 'a value across lines' => ["color:\n#00f", ['COLOR' => '#00f']],
+ 'a space before the colon' => ['color : #00f', ['COLOR' => '#00f']],
+ 'escaped quotes as HTML entities' => ['font-family: "a;b", monospace; color: #00f', ['FONT-FAMILY' => 'monospace', 'COLOR' => '#00f']],
+ ];
+ }
+
+ /**
+ * A semicolon in a url() is kept, and the declaration after it still applies
+ */
+ public function testParse_WithASemicolonInAUrl()
{
- $css = 'background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns=\'http://www.w3.org/2000/svg\'%3E%3C/svg%3E");';
+ $result = $this->inlineStyleParser->parse('background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns=\'http://www.w3.org/2000/svg\'%3E%3C/svg%3E"); color: #00f');
- $result = $this->inlineStyleParser->processUrlsInCss($css);
-
- $this->assertStringContainsString('svg+xml%ZZ', $result);
- $this->assertStringNotContainsString('svg+xml;', $result);
+ $this->assertSame('data:image/svg+xml;charset=utf-8,%3Csvg xmlns=\'http://www.w3.org/2000/svg\'%3E%3C/svg%3E', $result['BACKGROUND-IMAGE']);
+ $this->assertSame('#00f', $result['COLOR']);
}
/**
@@ -109,15 +139,15 @@ public function urlProvider()
'spaces and parentheses' => ['url("sub dir/im (1).png")', "url('sub dir/im %281%29.png')"],
'whitespace around a quoted url' => ['url( "a.png" )', "url('a.png')"],
'whitespace around an unquoted url' => ["url(\n a.png\t)", "url('a.png')"],
- 'semicolon and braces' => ["url('data:image/svg+xml;utf8,')", "url('data:image/svg+xml%ZZutf8,')"],
+ 'semicolon and braces' => ["url('data:image/svg+xml;utf8,')", "url('data:image/svg+xml;utf8,')"],
'escaped quote' => ['url("a\\"b.png")', "url('a\"b.png')"],
'escaped parenthesis, unquoted' => ['url(a\\(1\\).png)', "url('a%281%29.png')"],
'escaped space, unquoted' => ['url(a\\ b.png)', "url('a b.png')"],
'Windows path' => ['url("D:\\a\\mpdf\\data\\img (1).png")', "url('D:\\a\\mpdf\\data\\img %281%29.png')"],
'two urls' => ['url(a.png), url( "b c.png" )', "url('a.png'), url('b c.png')"],
'upper case' => ['URL(a.png)', "url('a.png')"],
- 'long quoted data URI' => ['url("data:image/png;base64,' . $long . '")', "url('data:image/png%ZZbase64," . $long . "')"],
- 'long unquoted data URI' => ['url( data:image/png;base64,' . $long . ' )', "url('data:image/png%ZZbase64," . $long . "')"],
+ 'long quoted data URI' => ['url("data:image/png;base64,' . $long . '")', "url('data:image/png;base64," . $long . "')"],
+ 'long unquoted data URI' => ['url( data:image/png;base64,' . $long . ' )', "url('data:image/png;base64," . $long . "')"],
];
}
diff --git a/tests/Mpdf/Css/StylesheetTokenizerTest.php b/tests/Mpdf/Css/StylesheetTokenizerTest.php
new file mode 100644
index 000000000..912dd883a
--- /dev/null
+++ b/tests/Mpdf/Css/StylesheetTokenizerTest.php
@@ -0,0 +1,205 @@
+tokenizer = new StylesheetTokenizer();
+ }
+
+ /**
+ * Release the tokenizer
+ */
+ protected function tear_down()
+ {
+ unset($this->tokenizer);
+
+ parent::tear_down();
+ }
+
+ /**
+ * Each comment, and each HTML comment marker, becomes a space, and those inside strings and url() stay
+ *
+ * @dataProvider comments
+ *
+ * @param string $css
+ * @param string $expected
+ */
+ public function testRemoveComments($css, $expected)
+ {
+ $this->assertSame($expected, $this->tokenizer->removeComments($css));
+ }
+
+ /**
+ * Stylesheets with comments, or with text that looks like one, and what is left of each
+ *
+ * @return array[]
+ */
+ public function comments()
+ {
+ return [
+ 'no comment' => ['p { color: red }', 'p { color: red }'],
+ 'a comment' => ['p { /* a */ color: red }', 'p { color: red }'],
+ 'two comments' => ['/* a */p/* b */{}', ' p {}'],
+ 'a comment across lines' => ["/* a\n b */p {}", ' p {}'],
+ 'a comment with quotes and braces in it' => ['/* it\'s "} { */ p {}', ' p {}'],
+ 'a comment left open' => ['p {} /* a { b', 'p {} '],
+ 'a comment with only an asterisk after the opener' => ['/*/ p {} */q {}', ' q {}'],
+ 'a comment with asterisks in it' => ['/** a **/p {}', ' p {}'],
+
+ 'a comment opener in a double-quoted string' => ['q { quotes: "/*" "x" } p {} q { quotes: "*/" }', 'q { quotes: "/*" "x" } p {} q { quotes: "*/" }'],
+ 'a comment in a single-quoted string' => ["q { quotes: '/* x */' }", "q { quotes: '/* x */' }"],
+ 'a comment opener after an escaped quote' => ['q { quotes: "\\"/*" } p {}', 'q { quotes: "\\"/*" } p {}'],
+ 'a comment in an unquoted url()' => ['p { background: url(http://a/*b*/c.png) }', 'p { background: url(http://a/*b*/c.png) }'],
+ 'a comment after url() in upper case' => ['p { background: URL(a.png) /* b */ }', 'p { background: URL(a.png) }'],
+ 'a comment in a function whose name ends in url' => ['p { x: myurl(a /* b */) }', 'p { x: myurl(a ) }'],
+ 'an escaped comment opener' => ['.a\\/* { color: red } */', '.a\\/* { color: red } */'],
+ 'a string left open at a line break' => ["q { quotes: \"a /* b\n/* c */ }", "q { quotes: \"a /* b\n }"],
+
+ 'HTML comment markers' => ['', ' p {} '],
+ 'HTML comment markers in a string' => ['q { quotes: "" }', 'q { quotes: "" }'],
+ ];
+ }
+
+ /**
+ * A stylesheet splits into its rules at the braces and semicolons outside strings, url() and escapes
+ *
+ * @dataProvider stylesheets
+ *
+ * @param string $css
+ * @param array[] $expected
+ */
+ public function testRules($css, array $expected)
+ {
+ $this->assertSame($expected, $this->tokenizer->rules($css));
+ }
+
+ /**
+ * Stylesheets, and the rules in each
+ *
+ * @return array[]
+ */
+ public function stylesheets()
+ {
+ return [
+ 'nothing' => ['', []],
+ 'whitespace' => [" \n\t", []],
+ 'one rule' => ['p { color: red }', [[null, 'p ', ' color: red ']]],
+ 'two rules' => ['p{color:red}h1{color:blue}', [[null, 'p', 'color:red'], [null, 'h1', 'color:blue']]],
+ 'an empty block' => ['p {}', [[null, 'p ', '']]],
+ 'a selector list' => ['h1, h2 { color: red }', [[null, 'h1, h2 ', ' color: red ']]],
+
+ 'braces in a string' => ['q { quotes: "}" "{"; } p { color: red }', [[null, 'q ', ' quotes: "}" "{"; '], [null, ' p ', ' color: red ']]],
+ 'an unbalanced brace in a string' => ["q { quotes: '}'; } p { color: red }", [[null, 'q ', " quotes: '}'; "], [null, ' p ', ' color: red ']]],
+ 'an escaped quote in a string' => ['q { quotes: "\\"}" "x" } p {}', [[null, 'q ', ' quotes: "\\"}" "x" '], [null, ' p ', '']]],
+ 'an escaped backslash before the closing quote' => ['q { quotes: "\\\\" "}" } p {}', [[null, 'q ', ' quotes: "\\\\" "}" '], [null, ' p ', '']]],
+ 'an escaped line break in a string' => ["q { quotes: \"a\\\n}\" } p {}", [[null, 'q ', " quotes: \"a\\\n}\" "], [null, ' p ', '']]],
+ 'a string left open at a line break' => ["q { quotes: \"a}\n} p { color: red }", [[null, 'q ', " quotes: \"a}\n"], [null, ' p ', ' color: red ']]],
+ 'a string left open at the end' => ['q { quotes: "a}', [[null, 'q ', ' quotes: "a}']]],
+ 'a brace in an attribute selector' => ['a[title="{"] { color: red } p {}', [[null, 'a[title="{"] ', ' color: red '], [null, ' p ', '']]],
+ 'an escaped brace in a selector' => ['.a\\{b { color: red } p {}', [[null, '.a\\{b ', ' color: red '], [null, ' p ', '']]],
+ 'an escaped colon in a selector' => ['.sm\\:hidden { display: none }', [[null, '.sm\\:hidden ', ' display: none ']]],
+
+ 'braces and a semicolon in an unquoted url()' => ['q { background: url(a};{b.png) } p {}', [[null, 'q ', ' background: url(a};{b.png) '], [null, ' p ', '']]],
+ 'an escaped parenthesis in an unquoted url()' => ['q { background: url(a\\)}.png) } p {}', [[null, 'q ', ' background: url(a\\)}.png) '], [null, ' p ', '']]],
+ 'a brace in a quoted url()' => ['q { background: url( "a}.png" ) } p {}', [[null, 'q ', ' background: url( "a}.png" ) '], [null, ' p ', '']]],
+ 'an unquoted url() in upper case' => ['q { background: URL(a}.png) } p {}', [[null, 'q ', ' background: URL(a}.png) '], [null, ' p ', '']]],
+ 'a function whose name ends in url' => ['q { x: myurl(a}b) } p {}', [[null, 'q ', ' x: myurl(a'], [null, 'b) } p ', '']]],
+ 'an unquoted url() left open' => ['q { background: url(a.png } p {}', [[null, 'q ', ' background: url(a.png } p {}']]],
+
+ 'a block left open' => ['p { color: red } q { color: blue', [[null, 'p ', ' color: red '], [null, ' q ', ' color: blue']]],
+ 'a selector with no block' => ['p { color: red } q', [[null, 'p ', ' color: red ']]],
+ 'a semicolon before a selector' => ['p {} ; q { color: red }', [[null, 'p ', ''], [null, ' ; q ', ' color: red ']]],
+ 'a brace closing no block' => ['p {} } q { color: red }', [[null, 'p ', ''], [null, ' } q ', ' color: red ']]],
+
+ 'a statement at-rule' => ['@charset "UTF-8"; p {}', [['charset', '"UTF-8"', null], [null, ' p ', '']]],
+ 'a semicolon in a string in a statement' => ['@import "a;b.css"; p {}', [['import', '"a;b.css"', null], [null, ' p ', '']]],
+ 'a statement at-rule left open' => ['p {} @import url(a.css)', [[null, 'p ', ''], ['import', 'url(a.css)', null]]],
+ 'a block at-rule' => ['@media print { p { color: red } }', [['media', 'print', ' p { color: red } ']]],
+ 'an at-rule name in upper case' => ['@MEDIA print {}', [['media', 'print', '']]],
+ 'an at-rule with a vendor prefix' => ['@-webkit-keyframes x { from {} }', [['-webkit-keyframes', 'x', ' from {} ']]],
+ 'an at-rule without a space before its prelude' => ['@page:first { margin: 0 }', [['page', ':first', ' margin: 0 ']]],
+ 'nested at-rules' => [
+ '@supports (display: grid) { @media print { p { color: red } } } h1 {}',
+ [['supports', '(display: grid)', ' @media print { p { color: red } } '], [null, ' h1 ', '']],
+ ],
+ 'a brace in a string in an at-rule prelude' => ['@supports (content: "}") { p {} } h1 {}', [['supports', '(content: "}")', ' p {} '], [null, ' h1 ', '']]],
+ 'an at sign in a declaration' => ['p { background: url(a@2x.png) } h1 {}', [[null, 'p ', ' background: url(a@2x.png) '], [null, ' h1 ', '']]],
+ 'an at-rule after a semicolon in a selector' => ['; @media print { p {} }', [[null, '; @media print ', ' p {} ']]],
+
+ 'a byte order mark before an at-rule' => ["\xEF\xBB\xBF@charset \"UTF-8\"; p {}", [['charset', '"UTF-8"', null], [null, ' p ', '']]],
+ 'a byte order mark after whitespace, before a rule' => [" \xEF\xBB\xBFp {}", [[null, 'p ', '']]],
+ ];
+ }
+
+ /**
+ * A block splits into its declarations at the semicolons outside strings, url(), escapes and nested blocks
+ *
+ * @dataProvider blocks
+ *
+ * @param string $block
+ * @param array[] $expected
+ */
+ public function testDeclarations($block, array $expected)
+ {
+ $this->assertSame($expected, $this->tokenizer->declarations($block));
+ }
+
+ /**
+ * Blocks, and the declarations in each
+ *
+ * @return array[]
+ */
+ public function blocks()
+ {
+ return [
+ 'nothing' => ['', []],
+ 'one declaration' => [' color: red ', [[' color', ' red ']]],
+ 'two declarations' => ['color:red;margin:0', [['color', 'red'], ['margin', '0']]],
+ 'a trailing semicolon' => ['color: red;', [['color', ' red']]],
+ 'empty declarations' => [' ; ;color: red;; ', [['color', ' red']]],
+ 'a colon in the value' => ['background: url(http://a/b.png)', [['background', ' url(http://a/b.png)']]],
+ 'a part with no colon' => ['color red; margin: 0', [[' margin', ' 0']]],
+ 'a declaration with no value' => ['color:; margin: 0', [['color', ''], [' margin', ' 0']]],
+
+ 'a semicolon in a double-quoted string' => ['font-family: "a;b"; color: red', [['font-family', ' "a;b"'], [' color', ' red']]],
+ 'a semicolon in a single-quoted string' => ["font-family: 'a;color:red'; margin: 0", [['font-family', " 'a;color:red'"], [' margin', ' 0']]],
+ 'a semicolon after an escaped quote' => ['quotes: "\\";" "x"; color: red', [['quotes', ' "\\";" "x"'], [' color', ' red']]],
+ 'a semicolon in an unquoted url()' => ['background: url(data:image/png;base64,AA==); color: red', [['background', ' url(data:image/png;base64,AA==)'], [' color', ' red']]],
+ 'a semicolon in a quoted url()' => ['background: url("a;b.png"); color: red', [['background', ' url("a;b.png")'], [' color', ' red']]],
+ 'an escaped semicolon' => ['font-family: a\\;b; color: red', [['font-family', ' a\\;b'], [' color', ' red']]],
+ 'a string left open at a line break' => ["quotes: \"a;\nb; color: red", [['quotes', " \"a;\nb"], [' color', ' red']]],
+ 'a brace closing no block' => ['color: red }; margin: 0', [['color', ' red }'], [' margin', ' 0']]],
+
+ 'an at-rule with a block' => [
+ 'margin: 10mm; @top-center { content: "x"; } margin-bottom: 20mm; @bottom-left { content: "}" }',
+ [['margin', ' 10mm'], [' margin-bottom', ' 20mm']],
+ ],
+ 'a statement at-rule' => ['@import "a.css"; color: red', [[' color', ' red']]],
+ 'an at-rule with a colon in its prelude' => ['@media (min-width: 1px) { color: blue } color: red', [[' color', ' red']]],
+ 'a nested rule' => ['color: red; &:hover { color: blue; } margin: 0', [['color', ' red'], [' margin', ' 0']]],
+ 'a declaration with a block in its value' => ['color: red { x: y } margin: 0', [[' margin', ' 0']]],
+ 'nested blocks' => ['@a { @b { color: blue } color: blue } color: red', [[' color', ' red']]],
+ 'a nested block left open' => ['color: red; @a { color: blue', [['color', ' red']]],
+ ];
+ }
+}
diff --git a/tests/Mpdf/StylesheetSyntaxTest.php b/tests/Mpdf/StylesheetSyntaxTest.php
new file mode 100644
index 000000000..a5ea8b6a6
--- /dev/null
+++ b/tests/Mpdf/StylesheetSyntaxTest.php
@@ -0,0 +1,120 @@
+assertDrawnInColours(
+ ['after' => self::GREEN, 'plain' => self::BLACK],
+ $this->drawnColours($this->document($context, $css), ['cssMode' => $mode])
+ );
+ }
+
+ /**
+ * Every construct in every context, in each cssMode
+ *
+ * @return array[]
+ */
+ public function constructsInContexts()
+ {
+ $data = [];
+ foreach ([CssMode::LEGACY, CssMode::STANDARD] as $mode) {
+ foreach (['block', 'inline', 'table cell', 'header', 'positioned block'] as $context) {
+ foreach ($this->constructs() as $name => $css) {
+ $data[$mode . ', ' . $context . ': ' . $name] = [$mode, $context, $css];
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Stylesheets that were split in the wrong place, each with the rule after its tricky part
+ *
+ * @return string[]
+ */
+ private function constructs()
+ {
+ return [
+ 'braces in a string' => 'q { quotes: "}" "{"; }',
+ 'an unbalanced brace in a string' => 'q { quotes: "}"; }',
+ 'a declaration in a string' => "{T}.after { color: #008000; font-family: 'x;color:#f00;y', courier; }",
+ 'an escaped quote in a string' => 'q { quotes: "\"}" "x"; }',
+ 'a comment opener in a string' => 'q { quotes: "/*" "x"; } {AFTER} q { quotes: "*/" "x"; }',
+ 'a comment opener in a url()' => 'q { background-image: url(http://example.com/*.png); } {AFTER} /* a comment */',
+ 'a comment with a quote and a brace in it' => '/* it\'s { */',
+ 'a string left open at a line break' => "q { quotes: \"a}b\n; }",
+ 'an escaped brace in a selector' => '.a\{b { color: #f00; }',
+ 'a brace in an attribute selector' => 'q[title="{"] { color: #f00; }',
+ 'braces and a semicolon in a url()' => 'q { background-image: url(a};{b.png); }',
+ 'semicolons in an @import url()' => '@import url(missing.css?family=a:wght@300;400);',
+ 'nested at-rules' => '@supports (display: block) { @media print { q { color: #f00; } } }',
+ 'an at-rule nested in a block' => '{T}.after { color: #f00; @media print { color: #00f; } color: #008000; }',
+ 'a rule nested in a block' => '{T}.after { color: #f00; & b { color: #00f; } color: #008000; }',
+ 'an @page margin box with a brace in a string' => '@page { @top-center { content: "}"; } }',
+ ];
+ }
+
+ /**
+ * A document with an element the rule after the construct names and one it does not, in a context
+ *
+ * @param string $context
+ * @param string $css As constructs() gives it
+ *
+ * @return string
+ */
+ private function document($context, $css)
+ {
+ $tag = $context === 'inline' ? 'span' : ($context === 'table cell' ? 'td' : 'p');
+ if (strpos($css, '{AFTER}') === false && strpos($css, '{T}.after') === false) {
+ $css .= ' {AFTER}';
+ }
+
+ $style = '';
+ $html = '<' . $tag . ' class="after">after' . $tag . '> <' . $tag . '>plain' . $tag . '>';
+
+ switch ($context) {
+ case 'inline':
+ return $style . '
';
+
+ default:
+ return $style . $html;
+ }
+ }
+}
diff --git a/tests/Snapshots/FrameworkStylesheetLegacySnapshotTest.php b/tests/Snapshots/FrameworkStylesheetLegacySnapshotTest.php
new file mode 100644
index 000000000..5ac7ad5bd
--- /dev/null
+++ b/tests/Snapshots/FrameworkStylesheetLegacySnapshotTest.php
@@ -0,0 +1,23 @@
+mode();
+ }
+
+ /**
+ * The components, each under a caption saying what should be seen
+ */
+ public function generatePdf()
+ {
+ ob_start();
+ ?>
+
+
+
A framework's stylesheet
+
Written with cssMode set to mode(); ?>. No text on the page is red.
+
+
Alert
+
Dark green bold text on light green, with a small blue square from a JPEG data URI at its left, and a border. The rule after the close button's content string applies.
+
A saved record, from a data URI icon
+
+
Quotation and breadcrumb
+
The quotation is grey and italic with a grey rule at its left, and the breadcrumb items are blue: rules after strings with braces, semicolons and escapes.
+
A quotation, with its marks set in the stylesheet.
+
HomeLibraryData
+
+
Code
+
Pink, in a monospace font: the font names with a semicolon and braces in them are skipped for the next in the list.
+
Call render(), or set font-mono on any element.
+
+
Card
+
A bordered card with a blue title, not white on black: the dark theme selectors, with a brace and a semicolon in their strings, match nothing here.
+
Card title
Muted text in the card.
+
+
Utilities in nested @media and @supports
+
An A4 page is about 794px wide, so the min-width: 640px block applies and the min-width: 1024px block does not. Standard: the line marked sm:hidden is not shown, the line marked lg:hidden is; sm:text-end is right-aligned; print:bg-warning has a pale yellow background and md:w-1/2 is half the width. Legacy: escaped class names such as .sm\:hidden match nothing, so those lines are all shown, plain and left-aligned. In both, the fallback in @supports not is black, and the last line is centred and bold.
The @import ends at the semicolon after its url(): the paragraph is green and bold.
+
After the @import
+
+
Braces in a string
+
The first paragraph is blue and italic, the second green and bold.
+
Braces in quotes
After the rule
+
+
A brace in a string with no brace to pair with
+
The first paragraph is blue and italic, the second green and bold.
+
One brace in quotes
After the rule
+
+
A declaration in a string
+
The string is a font name, not a color: the first paragraph is blue, in a monospace font; the second green and bold. None is red.
+
Semicolons in quotes
After the rule
+
+
An escaped quote in a string
+
The first paragraph is blue and italic, the second green and bold.
+
An escaped quote before a brace
After the rule
+
+
Comment markers in strings and url()
+
A /* in a string, closed by a */ in a later string, and a /* in an unquoted url() before a real comment, start no comment: each first paragraph is blue and italic, each second green and bold.
+
+
A comment opener in quotes
After the rule
+
A comment opener in url()
After the rule
+
+
+
A string left open
+
The string ends at the line break, taking the brace with it, and the declaration on the next line applies: the first paragraph is blue and italic, the second green and bold.
+
A string with no closing quote
After the rule
+
+
Escapes in selectors
+
.md\:italic and .a\{b\} match the classes md:italic and a{b}: the first two paragraphs are blue and italic, the third green and bold.
+
class="md:italic"
class="a{b}"
After the rules
+
+
A brace in an attribute selector
+
p[title="{"] matches the first paragraph, which is blue and italic; the second is green and bold.
+
title="{"
After the rule
+
+
Braces and a semicolon in an unquoted url()
+
The url() is one value: the first paragraph has a yellow background, the second is green and bold.
+
Braces in url()
After the rule
+
+
A rule and an at-rule nested in a block
+
Each is left out and the declarations after it apply: the first two paragraphs are blue and italic, with no red, and the third green and bold.
+
A rule nested in the block, with bold text
An @media screen block nested in the block
After the rules
+
+
Each stylesheet on its own
+
A block left open ends with its <style>, and the next one starts afresh: the first paragraph is blue and italic, the second green and bold.
+
In a block left open
In the next <style>
+
+
A byte order mark
+
A stylesheet starting with a byte order mark and @charset: the paragraph is green and bold.
+
After the byte order mark
+ mpdf = $this->createMpdf(['cssMode' => CssMode::STANDARD]);
+
+ $this->mpdf->WriteHTML($html);
+ }
+
+}
diff --git a/tests/data/snapshots/framework-stylesheet-legacy.pdf b/tests/data/snapshots/framework-stylesheet-legacy.pdf
new file mode 100644
index 000000000..596d7135e
Binary files /dev/null and b/tests/data/snapshots/framework-stylesheet-legacy.pdf differ
diff --git a/tests/data/snapshots/framework-stylesheet-standard.pdf b/tests/data/snapshots/framework-stylesheet-standard.pdf
new file mode 100644
index 000000000..01fde4e5b
Binary files /dev/null and b/tests/data/snapshots/framework-stylesheet-standard.pdf differ
diff --git a/tests/data/snapshots/stylesheet-syntax.pdf b/tests/data/snapshots/stylesheet-syntax.pdf
new file mode 100644
index 000000000..0475de44b
Binary files /dev/null and b/tests/data/snapshots/stylesheet-syntax.pdf differ