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, '/]*media=["\']([^"\'>]*)["\'].*?<\/style>/is'); $html = $this->mediaQueryProcessor->filterByMediaQuery($html, '/]*media=["\']([^"\'>]*)["\'].*?>/is'); @@ -177,7 +182,7 @@ public function parse($html) $stylesheetCss = $this->cssLoader->loadStylesheet($path); if ($stylesheetCss) { - $css .= $this->cssLoader->processExternalCssImports($stylesheetCss, $path, $externalCss, $externalCssCount); + $stylesheets[] = $this->cssLoader->processExternalCssImports($stylesheetCss, $path, $externalCss, $externalCssCount); } $externalCssCount--; @@ -185,19 +190,16 @@ public function parse($html) } // CSS as + + '; + + $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 . '>plain'; + + switch ($context) { + case 'inline': + return $style . '

' . $html . '

'; + + case 'table cell': + return $style . '' . $html . '
'; + + case 'header': + return $style . '' . $html . '

body

'; + + case 'positioned block': + return $style . '
' . $html . '
'; + + 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.
+

Home Library Data

+ +

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.

+

sm:hidden, which should not be shown

+

lg:hidden, which is shown

+

sm:text-end

+

print:bg-warning md:w-1/2

+

grid-fallback

+

text-center fw-bold, not underlined

+ mpdf = $this->createMpdf(['cssMode' => $this->mode()]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/FrameworkStylesheetStandardSnapshotTest.php b/tests/Snapshots/FrameworkStylesheetStandardSnapshotTest.php new file mode 100644 index 000000000..f6411880b --- /dev/null +++ b/tests/Snapshots/FrameworkStylesheetStandardSnapshotTest.php @@ -0,0 +1,23 @@ + + + + + + +

Strings, url() and escapes in a stylesheet

+ +

An @import with semicolons in its url()

+

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