From 71b673f5be87ad2b044e7c792973b4f2134f1fa3 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Wed, 30 Sep 2026 08:07:11 +1000 Subject: [PATCH] Compile the selectors the legacy parser cannot read into compound selectors, combinators and specificity A selector list is split at its top-level commas before anything is uppercased, so :is(h1, h2), :not(.a, .b) and [title="a,b"] stay whole. Each selector the legacy parser reads keeps its path through it. The rest are compiled by SelectorCompiler into their compound selectors and the combinators between them, with their specificity, and kept in a RuleSet on CssManager, filed under their rightmost compound. Nothing matches them yet, so no output changes. The compiler reads type, universal, id and class selectors, the child, adjacent sibling, general sibling and descendant combinators, and :first-child, :nth-child(), :first-of-type and :nth-of-type(). Names are read with their escapes; tags are checked against allowedCSStags. A rule naming * is left out for #530. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-GRAVITYPDF.md | 3 + src/Css/CssParser.php | 92 +++++- src/Css/RuleSet.php | 108 +++++++ src/Css/SelectorCompiler.php | 409 ++++++++++++++++++++++++ src/CssManager.php | 21 ++ tests/Mpdf/Css/CssParserTest.php | 122 +++++++ tests/Mpdf/Css/RuleSetTest.php | 115 +++++++ tests/Mpdf/Css/SelectorCompilerTest.php | 285 +++++++++++++++++ tests/Mpdf/CssManagerTest.php | 18 ++ 9 files changed, 1161 insertions(+), 12 deletions(-) create mode 100644 src/Css/RuleSet.php create mode 100644 src/Css/SelectorCompiler.php create mode 100644 tests/Mpdf/Css/RuleSetTest.php create mode 100644 tests/Mpdf/Css/SelectorCompilerTest.php diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index 5ad64134b..f85e9757e 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -371,6 +371,7 @@ These changes do not change output. * **PHPStan.** Fixes for newer PHP versions and PHPStan 2.2.15. [#6] [#295] [#319] [#451] * **Branch alias.** The Composer branch alias maps `dev-gravitypdf` to `8.x-dev`. [#3] * **Open elements.** `WriteHTML()` keeps a stack of the elements open in the HTML it reads, so that CSS rules can be matched against an element's parents, ancestors and earlier siblings rather than only the blocks mPDF lays out. Each element carries its position among its siblings and a record of the siblings before it. `Mpdf::getOpenElements()` reads it. [#533] +* **Compiled selectors.** A rule whose selector the legacy parser cannot read is compiled into its compound selectors, the combinators between them and its specificity, and filed under its rightmost compound for the selector matcher. A selector list is split at the commas outside parentheses, brackets and strings, so `:is(h1, h2)` and `[title="a,b"]` stay whole. [#538] [#619] [mpdf/mpdf#83]: https://github.com/mpdf/mpdf/issues/83 [mpdf/mpdf#747]: https://github.com/mpdf/mpdf/issues/747 @@ -766,6 +767,7 @@ These changes do not change output. [#527]: https://github.com/GravityPDF/mpdf/issues/527 [#528]: https://github.com/GravityPDF/mpdf/issues/528 [#533]: https://github.com/GravityPDF/mpdf/issues/533 +[#538]: https://github.com/GravityPDF/mpdf/issues/538 [#549]: https://github.com/GravityPDF/mpdf/pull/549 [#552]: https://github.com/GravityPDF/mpdf/issues/552 [#560]: https://github.com/GravityPDF/mpdf/pull/560 @@ -799,6 +801,7 @@ These changes do not change output. [#581]: https://github.com/GravityPDF/mpdf/pull/581 [#614]: https://github.com/GravityPDF/mpdf/pull/614 [#615]: https://github.com/GravityPDF/mpdf/pull/615 +[#619]: https://github.com/GravityPDF/mpdf/pull/619 [#622]: https://github.com/GravityPDF/mpdf/pull/622 [#623]: https://github.com/GravityPDF/mpdf/pull/623 [#625]: https://github.com/GravityPDF/mpdf/pull/625 diff --git a/src/Css/CssParser.php b/src/Css/CssParser.php index 4fabeccb3..4c1b1a551 100644 --- a/src/Css/CssParser.php +++ b/src/Css/CssParser.php @@ -47,6 +47,11 @@ class CssParser */ private $selectorParser; + /** + * @var SelectorCompiler + */ + private $selectorCompiler; + /** * @var NormalizeProperties */ @@ -115,6 +120,12 @@ class CssParser */ private $nthChildFormulas = ['TR' => [], 'TD' => [], 'TH' => []]; + /** + * @var array[] The rules of the last CSS parsed whose selector the legacy parser cannot read, each compiled: + * [compiled selector, declarations], in the order they were written + */ + private $compiledRules = []; + public function __construct( Mpdf $mpdf, Cache $cache, @@ -130,6 +141,7 @@ public function __construct( $this->commentParser = new CommentParser(); $this->inlineStyleParser = new InlineStyleParser($this->normalizeProperties); $this->selectorParser = new SelectorParser($mpdf); + $this->selectorCompiler = new SelectorCompiler($mpdf); $this->shadowParser = new ShadowParser($mpdf, $sizeConverter, $colorConverter); } @@ -143,6 +155,7 @@ public function parse($html) { $this->css = []; $this->cascadeCSS = []; + $this->compiledRules = []; $ind = 0; $css = ''; @@ -207,6 +220,16 @@ public function getCascadeCss() return $this->cascadeCSS; } + /** + * The rules of the last CSS parsed whose selector the legacy parser cannot read, compiled for the matcher + * + * @return array[] Each [compiled selector, declarations], in the order they were written + */ + public function getCompiledRules() + { + return $this->compiledRules; + } + /** * @return array */ @@ -245,11 +268,9 @@ private function processCssString($css) $count = count($styles[1]); for ($i = 0; $i < $count; $i++) { $classProperties = $this->parseCssProperties($styles[2][$i]); - $tagName = strtoupper(trim($styles[1][$i])); - $tags = explode(',', $tagName); - foreach ($tags as $tag) { - $this->processCssSelector($tag, $classProperties); + foreach ($this->selectorCompiler->splitList($styles[1][$i]) as $selector) { + $this->processCssSelector($selector, $classProperties); } } } @@ -257,12 +278,14 @@ private function processCssString($css) /** * Process a CSS selector. * - * @param string $selector Selector string + * @param string $written Selector string, as written * @param array $classProperties CSS properties * @return void */ - private function processCssSelector($selector, $classProperties) + private function processCssSelector($written, $classProperties) { + $selector = strtoupper($written); + // store classes in an index for faster lookups if (strpos($selector, '.') !== false && preg_match_all('/\.([a-zA-Z0-9_\-]+)/', $selector, $matches)) { foreach ($matches[1] as $className) { @@ -290,21 +313,25 @@ private function processCssSelector($selector, $classProperties) if ($level === 1) { $tag = $this->selectorParser->parseSimpleSelector($tags); - if ($tag && isset($this->css[$tag])) { + if (!$this->isLegacySelector($tag === null ? [] : [$tag])) { + $this->compileRule($written, $classProperties); + return; + } + + if (isset($this->css[$tag])) { $this->css[$tag] = Arrays::uniqueRecursiveMerge($this->css[$tag], $classProperties); - } elseif ($tag) { + } else { $this->css[$tag] = $classProperties; } - if ($tag) { - $this->indexStoredKey($tag); - } + $this->indexStoredKey($tag); return; } $cascade = $this->selectorParser->parseCascadedSelector($tags); - if (empty($cascade)) { + if (!$this->isLegacySelector($cascade)) { + $this->compileRule($written, $classProperties); return; } @@ -338,6 +365,47 @@ private function indexStoredKey($key) } } + /** + * Whether the legacy parser reads a selector into levels the merger applies. It keeps an nth-child level for any + * tag, but the merger only looks one up for a table row or cell + * + * @param string[] $levels The keys SelectorParser gave for the selector's parts, none if it could not read one + * @return bool + */ + private function isLegacySelector(array $levels) + { + foreach ($levels as $level) { + $nthChild = strpos($level, '>>SELECTORNTHCHILD>>'); + if ($nthChild !== false && !in_array(substr($level, 0, $nthChild), ['TR', 'TD', 'TH'], true)) { + return false; + } + } + + return (bool) $levels; + } + + /** + * Compiles a rule for the matcher, if its selector is one it can match + * + * @param string $selector As written + * @param array $classProperties + * @return void + */ + private function compileRule($selector, array $classProperties) + { + if (!$classProperties) { + return; + } + + $compiled = $this->selectorCompiler->compile($selector); + + // The universal selector changes which elements existing documents style, so it waits for #530 and the + // standard cascade option + if ($compiled !== null && !$compiled['universal']) { + $this->compiledRules[] = [$compiled, $classProperties]; + } + } + /** * Parse CSS property string into an array. * diff --git a/src/Css/RuleSet.php b/src/Css/RuleSet.php new file mode 100644 index 000000000..0dfbdf3e5 --- /dev/null +++ b/src/Css/RuleSet.php @@ -0,0 +1,108 @@ + [id => positions], 'class' => [...], 'tag' => [...], 'any' => positions] + */ + private $index = ['id' => [], 'class' => [], 'tag' => [], 'any' => []]; + + /** + * Adds a rule after those already read + * + * @param array $selector A selector SelectorCompiler::compile() compiled + * @param array $declarations Its normalised declarations + */ + public function add(array $selector, array $declarations) + { + $position = count($this->rules); + $this->rules[] = [$selector, $declarations]; + + self::file($this->index, $selector['compounds'][count($selector['compounds']) - 1], $position); + } + + /** + * The rules an element could match, going by the rightmost compound of each. The rest of each selector is still + * to be matched + * + * @param string $tag Uppercased + * @param string $id Uppercased, or empty for none + * @param string[] $classes Uppercased + * + * @return int[] Their positions, in no particular order + */ + public function candidates($tag, $id, array $classes) + { + return self::collect($this->index, $tag, $id, $classes); + } + + /** + * @param int $position + * + * @return array The rule at a position, as it is kept: [compiled selector, declarations] + */ + public function rule($position) + { + return $this->rules[$position]; + } + + /** + * @param array $index + * @param array $subject The rightmost compound of a rule's selector + * @param int $position The rule's position + */ + private static function file(array &$index, array $subject, $position) + { + if ($subject['ids']) { + $index['id'][$subject['ids'][0]][] = $position; + } elseif ($subject['classes']) { + $index['class'][$subject['classes'][0]][] = $position; + } elseif ($subject['tag'] !== null) { + $index['tag'][$subject['tag']][] = $position; + } else { + $index['any'][] = $position; + } + } + + /** + * @param array $index + * @param string $tag + * @param string $id + * @param string[] $classes + * + * @return int[] The positions of the rules an index files under an element's tag, id or classes, or for any element + */ + private static function collect(array $index, $tag, $id, array $classes) + { + $positions = $index['any']; + if (isset($index['tag'][$tag])) { + $positions = array_merge($positions, $index['tag'][$tag]); + } + + if ($id !== '' && isset($index['id'][$id])) { + $positions = array_merge($positions, $index['id'][$id]); + } + + foreach (array_unique($classes) as $class) { + if (isset($index['class'][$class])) { + $positions = array_merge($positions, $index['class'][$class]); + } + } + + return $positions; + } +} diff --git a/src/Css/SelectorCompiler.php b/src/Css/SelectorCompiler.php new file mode 100644 index 000000000..6536018ca --- /dev/null +++ b/src/Css/SelectorCompiler.php @@ -0,0 +1,409 @@ +', '+' and '~' + * - specificity: [ids, classes and attributes and pseudo-classes, tags] + * - universal: whether it names the universal selector * + * + * Pseudo-classes are held as [name, arguments...]: ['nth-child', a, b] and ['nth-of-type', a, b] for an+b, which + * :first-child and :first-of-type are written as. + */ +class SelectorCompiler +{ + + /** + * @var Mpdf + */ + private $mpdf; + + /** + * @var array The tags a type selector may name, from Mpdf::$allowedCSStags + */ + private $allowedTags = []; + + /** + * @var string The value of Mpdf::$allowedCSStags that $allowedTags was read from + */ + private $allowedTagsSource; + + /** + * @param Mpdf $mpdf + */ + public function __construct(Mpdf $mpdf) + { + $this->mpdf = $mpdf; + } + + /** + * Splits a selector list at its commas, leaving those inside brackets, parentheses and strings, as in + * :is(h1, h2) or [title="a,b"] + * + * @param string $list + * + * @return string[] Each selector, trimmed. One left empty, as between two commas, matches nothing + */ + public function splitList($list) + { + $selectors = []; + $start = 0; + while (($comma = $this->findOutsideBrackets($list, $start, ',')) !== null) { + $selectors[] = trim(substr($list, $start, $comma - $start)); + $start = $comma + 1; + } + + $selectors[] = trim(substr($list, $start)); + + return $selectors; + } + + /** + * @param string $selector One selector of a list, as written in the stylesheet + * + * @return array|null The compiled selector, as the class describes, or null for one that is not valid or uses + * something mPDF cannot match, which matches nothing + */ + public function compile($selector) + { + $selector = trim($selector); + $pos = 0; + $compiled = $this->parseComplex($selector, $pos); + + return $pos === strlen($selector) ? $compiled : null; + } + + /** + * Reads compound selectors and the combinators between them, up to the end of the text or a comma or closing + * parenthesis outside them + * + * @param string $text + * @param int $pos Where to start, moved past what was read + * + * @return array|null + */ + private function parseComplex($text, &$pos) + { + $length = strlen($text); + $compounds = []; + $combinators = []; + $specificity = [0, 0, 0]; + $universal = false; + + while (true) { + $compound = $this->parseCompound($text, $pos, $specificity, $universal); + if ($compound === null) { + return null; + } + $compounds[] = $compound; + + $space = $this->skipWhitespace($text, $pos); + if ($pos >= $length || $text[$pos] === ',' || $text[$pos] === ')') { + break; + } + + if (strpos('>+~', $text[$pos]) !== false) { + $combinators[] = $text[$pos]; + $pos++; + $this->skipWhitespace($text, $pos); + } elseif ($space) { + $combinators[] = ' '; + } else { + return null; + } + } + + return [ + 'compounds' => $compounds, + 'combinators' => $combinators, + 'specificity' => $specificity, + 'universal' => $universal, + ]; + } + + /** + * Reads one compound selector: a tag or *, then any ids, classes and pseudo-classes, with nothing between them + * + * @param string $text + * @param int $pos + * @param int[] $specificity Added to for what is read + * @param bool $universal Set when the compound names * + * + * @return array|null + */ + private function parseCompound($text, &$pos, array &$specificity, &$universal) + { + $length = strlen($text); + $compound = ['tag' => null, 'ids' => [], 'classes' => [], 'attributes' => [], 'pseudos' => []]; + $start = $pos; + + if ($pos < $length && $text[$pos] === '*') { + $universal = true; + $pos++; + } elseif ($this->startsIdentifier($text, $pos)) { + $compound['tag'] = strtoupper($this->readName($text, $pos)); + if (!$this->isAllowedTag($compound['tag'])) { + return null; + } + $specificity[2]++; + } + + while ($pos < $length) { + $c = $text[$pos]; + if ($c === '#') { + $pos++; + $name = $this->readName($text, $pos); + if ($name === '') { + return null; + } + $compound['ids'][] = strtoupper($name); + $specificity[0]++; + } elseif ($c === '.') { + $pos++; + if (!$this->startsIdentifier($text, $pos)) { + return null; + } + $compound['classes'][] = strtoupper($this->readName($text, $pos)); + $specificity[1]++; + } elseif ($c === ':') { + $pos++; + $pseudo = $this->parsePseudoClass($text, $pos); + if ($pseudo === null) { + return null; + } + $compound['pseudos'][] = $pseudo; + $specificity[1]++; + } else { + break; + } + } + + return $pos > $start ? $compound : null; + } + + /** + * Reads a pseudo-class after its colon + * + * @param string $text + * @param int $pos + * + * @return array|null As the class describes, or null for a pseudo-element or a pseudo-class mPDF cannot match + */ + private function parsePseudoClass($text, &$pos) + { + if (!$this->startsIdentifier($text, $pos)) { + return null; + } + + $name = strtolower($this->readName($text, $pos)); + if ($name === 'first-child') { + return ['nth-child', 0, 1]; + } + if ($name === 'first-of-type') { + return ['nth-of-type', 0, 1]; + } + + if (($name !== 'nth-child' && $name !== 'nth-of-type') || $pos >= strlen($text) || $text[$pos] !== '(') { + return null; + } + + $pos++; + $argument = $this->readArgument($text, $pos); + if ($argument === null) { + return null; + } + + $formula = $this->parseNth($argument); + + return $formula === null ? null : [$name, $formula[0], $formula[1]]; + } + + /** + * Reads an an+b argument, as :nth-child() takes + * + * @param string $argument + * + * @return int[]|null [a, b], or null for one that is not valid + */ + public function parseNth($argument) + { + $argument = strtolower(trim($argument)); + if ($argument === 'odd') { + return [2, 1]; + } + if ($argument === 'even') { + return [2, 0]; + } + if (preg_match('/^[+-]?\d+$/', $argument)) { + return [0, (int) $argument]; + } + if (!preg_match('/^([+-]?)(\d*)n(?:\s*([+-])\s*(\d+))?$/', $argument, $m)) { + return null; + } + + $a = $m[2] === '' ? 1 : (int) $m[2]; + $b = isset($m[4]) ? (int) $m[4] : 0; + + return [$m[1] === '-' ? -$a : $a, isset($m[3]) && $m[3] === '-' ? -$b : $b]; + } + + /** + * Reads a function's argument, up to the parenthesis that closes it + * + * @param string $text + * @param int $pos Just after the opening parenthesis, moved past the closing one + * + * @return string|null Null when it is not closed + */ + private function readArgument($text, &$pos) + { + $end = $this->findOutsideBrackets($text, $pos, ')'); + if ($end === null) { + return null; + } + + $argument = substr($text, $pos, $end - $pos); + $pos = $end + 1; + + return $argument; + } + + /** + * Finds a character that is not inside brackets, parentheses or a string, nor escaped + * + * @param string $text + * @param int $start Where to look from + * @param string $character + * + * @return int|null Where it is, or null if it is not there + */ + private function findOutsideBrackets($text, $start, $character) + { + $depth = 0; + $quote = null; + $length = strlen($text); + + for ($i = $start; $i < $length; $i++) { + $c = $text[$i]; + if ($c === '\\') { + $i++; + } elseif ($quote !== null) { + if ($c === $quote) { + $quote = null; + } + } elseif ($c === '"' || $c === "'") { + $quote = $c; + } elseif ($c === $character && $depth === 0) { + return $i; + } elseif ($c === '(' || $c === '[') { + $depth++; + } elseif (($c === ')' || $c === ']') && $depth > 0) { + $depth--; + } + } + + return null; + } + + /** + * Whether an identifier starts here: a letter, underscore, non-ASCII character or escape, after at most one + * hyphen, or two hyphens + * + * @param string $text + * @param int $pos + * + * @return bool + */ + private function startsIdentifier($text, $pos) + { + return (bool) preg_match('/\G(?:--|-?(?:[A-Za-z_\x80-\xFF]|\\\\[^\r\n\f]))/', $text, $m, 0, $pos); + } + + /** + * Reads a name made of identifier characters and escapes + * + * @param string $text + * @param int $pos + * + * @return string The name with its escapes read, empty when there is none here + */ + private function readName($text, &$pos) + { + $name = ''; + $length = strlen($text); + + while ($pos < $length) { + $c = $text[$pos]; + if (ctype_alnum($c) || $c === '-' || $c === '_' || ord($c) >= 0x80) { + $name .= $c; + $pos++; + } elseif ($c === '\\' && preg_match('/\G\\\\(?:([0-9A-Fa-f]{1,6})[ \t\n\r\f]?|([^\r\n\f]))/', $text, $m, 0, $pos)) { + $name .= isset($m[2]) ? $m[2] : $this->codePoint(hexdec($m[1])); + $pos += strlen($m[0]); + } else { + break; + } + } + + return $name; + } + + /** + * @param int $code + * + * @return string The character a hex escape stands for, as UTF-8. One that is not a character gives U+FFFD + */ + private function codePoint($code) + { + if ($code === 0 || $code > 0x10FFFF || ($code >= 0xD800 && $code <= 0xDFFF)) { + $code = 0xFFFD; + } + + return UtfString::code2utf($code); + } + + /** + * @param string $text + * @param int $pos Moved past any white space + * + * @return bool Whether there was any + */ + private function skipWhitespace($text, &$pos) + { + $start = $pos; + $length = strlen($text); + while ($pos < $length && strpos(" \t\n\r\f", $text[$pos]) !== false) { + $pos++; + } + + return $pos > $start; + } + + /** + * Whether a type selector may name a tag. Which elements take part in the cascade is decided per tag, by + * Mpdf::$allowedCSStags, as it is for the selectors the legacy parser reads + * + * @param string $tag Uppercased + * + * @return bool + */ + private function isAllowedTag($tag) + { + if ($this->allowedTagsSource !== $this->mpdf->allowedCSStags) { + $this->allowedTagsSource = $this->mpdf->allowedCSStags; + $this->allowedTags = array_fill_keys(explode('|', strtoupper($this->allowedTagsSource)), true); + } + + return isset($this->allowedTags[$tag]); + } +} diff --git a/src/CssManager.php b/src/CssManager.php index 0228cfaa1..ca3736981 100644 --- a/src/CssManager.php +++ b/src/CssManager.php @@ -3,6 +3,7 @@ namespace Mpdf; use Mpdf\Css\CssMerger; +use Mpdf\Css\RuleSet; use Mpdf\Exception\InvalidArgumentException; use Mpdf\Utils\Arrays; use Mpdf\Css\CssParser; @@ -86,6 +87,11 @@ class CssManager */ public $tbCSSlvl = 0; + /** + * @var RuleSet The rules whose selector the legacy parser cannot read, compiled, from every stylesheet read + */ + private $rules; + /** * CssManager constructor. * @@ -100,6 +106,7 @@ public function __construct(CssParser $cssParser, CssMerger $cssMerger) $this->cssParser = $cssParser; $this->cssMerger = $cssMerger; $this->cssMerger->setCssManager($this); + $this->rules = new RuleSet(); } /** @@ -123,9 +130,23 @@ public function readCss($html) $this->CSS = Arrays::uniqueRecursiveMerge($this->CSS, $this->cssParser->getCss()); $this->cascadeCSS = Arrays::uniqueRecursiveMerge($this->cascadeCSS, $this->cssParser->getCascadeCss()); + foreach ($this->cssParser->getCompiledRules() as $rule) { + $this->rules->add($rule[0], $rule[1]); + } + return $html; } + /** + * The rules whose selector the legacy parser cannot read, compiled, from every stylesheet read so far + * + * @return RuleSet + */ + public function getRules() + { + return $this->rules; + } + /** * Parse inline CSS style attribute. * diff --git a/tests/Mpdf/Css/CssParserTest.php b/tests/Mpdf/Css/CssParserTest.php index 34a4bbcfd..d7a90f552 100644 --- a/tests/Mpdf/Css/CssParserTest.php +++ b/tests/Mpdf/Css/CssParserTest.php @@ -163,4 +163,126 @@ public function testNthChildKeysOfStoredRulesAreRecorded() $this->assertSame(['TD>>SELECTORNTHCHILD>>2N+1', 'TD>>SELECTORNTHCHILD>>1'], array_keys($this->parser->getNthChildFormulas('TD'))); $this->assertSame([], $this->parser->getNthChildFormulas('TH')); } + + /** + * A rule the legacy parser cannot read is compiled for the matcher, and one it can read is not + * + * @dataProvider routedSelectors + * + * @param string $selector + * @param bool $compiled Whether it goes to the matcher + */ + public function testCompilesOnlyWhatTheLegacyParserCannotRead($selector, $compiled) + { + $this->parser->parse(''); + + $rules = $this->parser->getCompiledRules(); + if (!$compiled) { + $this->assertSame([], $rules); + + return; + } + + $this->assertCount(1, $rules); + $this->assertSame(['COLOR' => 'blue'], $rules[0][1]); + $this->assertSame([], $this->parser->getCss()); + $this->assertSame([], $this->parser->getCascadeCss()); + } + + /** + * A selector, and whether it goes to the matcher rather than the legacy parser + * + * @return array[] + */ + public function routedSelectors() + { + return [ + 'type' => ['p', false], + 'descendant' => ['div p', false], + 'class on a type' => ['p.a', false], + 'lang' => ['p:lang(fr)', false], + 'row nth-child' => ['tr:nth-child(2n)', false], + 'cell nth-child in a descendant rule' => ['table td:nth-child(odd)', false], + 'child' => ['div > p', true], + 'child with no spaces' => ['div>p', true], + 'adjacent sibling' => ['h1 + p', true], + 'general sibling' => ['h1 ~ p', true], + 'first-child' => ['li:first-child', true], + 'nth-child on an element outside a table' => ['li:nth-child(2n)', true], + 'nth-child outside a table as an ancestor' => ['li:nth-child(2) p', true], + 'first-of-type' => ['p:first-of-type', true], + ]; + } + + /** + * A rule the legacy parser cannot read and the matcher cannot match either is dropped, as it was before + * + * @dataProvider droppedSelectors + * + * @param string $selector + */ + public function testDropsWhatNeitherCanRead($selector) + { + $this->parser->parse(''); + + $this->assertSame([], $this->parser->getCompiledRules()); + $this->assertSame([], $this->parser->getCss()); + $this->assertSame([], $this->parser->getCascadeCss()); + } + + /** + * A selector neither the legacy parser nor the matcher can match + * + * @return array[] + */ + public function droppedSelectors() + { + return [ + 'hover' => ['a:hover'], + 'pseudo-element' => ['p::before'], + 'last-child' => ['li:last-child'], + 'a tag outside allowedCSStags' => ['div > sup'], + 'the universal selector, which waits for #530' => ['div > *'], + 'the universal selector as an ancestor' => ['* + p'], + ]; + } + + /** + * A list is split into its selectors before either parser reads them, so commas inside parentheses and strings + * stay with their selector and each selector goes its own way + */ + public function testSplitsAListBeforeReadingItsSelectors() + { + $this->parser->parse(''); + + $this->assertSame(['H1' => ['COLOR' => 'blue']], $this->parser->getCss()); + + $rules = $this->parser->getCompiledRules(); + $this->assertCount(2, $rules); + $this->assertSame([['nth-child', 2, 1]], $rules[0][0]['compounds'][0]['pseudos']); + $this->assertSame(['>'], $rules[1][0]['combinators']); + } + + /** + * A compiled rule without declarations has nothing to apply and is not kept + */ + public function testDoesNotKeepAnEmptyRule() + { + $this->parser->parse(''); + + $this->assertSame([], $this->parser->getCompiledRules()); + } + + /** + * Each parse starts a new list of compiled rules, as it starts new stores for the legacy parser + */ + public function testStartsANewListOfCompiledRulesForEachParse() + { + $this->parser->parse(''); + $this->parser->parse(''); + + $rules = $this->parser->getCompiledRules(); + $this->assertCount(1, $rules); + $this->assertSame(['+'], $rules[0][0]['combinators']); + } } diff --git a/tests/Mpdf/Css/RuleSetTest.php b/tests/Mpdf/Css/RuleSetTest.php new file mode 100644 index 000000000..abed8dbce --- /dev/null +++ b/tests/Mpdf/Css/RuleSetTest.php @@ -0,0 +1,115 @@ +compiler = new SelectorCompiler(new Mpdf()); + $this->rules = new RuleSet(); + + $selectors = [ + 'div > p', // under P + 'p.a#main', // under #MAIN + 'div + .b.c', // under .B + ':first-child', // for any element + 'p ~ span', // under SPAN + '.a > p', // under P: only the rightmost compound counts + ]; + foreach ($selectors as $position => $selector) { + $this->rules->add($this->compiler->compile($selector), ['COLOR' => 'rule ' . $position]); + } + } + + /** + * Lets go of the rule set and compiler + */ + protected function tear_down() + { + unset($this->rules, $this->compiler); + + parent::tear_down(); + } + + /** + * An element's candidates are the rules filed under its tag, its id, each of its classes, and those for any + * element + * + * @dataProvider elements + * + * @param string $tag + * @param string $id + * @param string[] $classes + * @param int[] $expected The positions of the candidate rules + */ + public function testFindsTheRulesFiledUnderAnElement($tag, $id, array $classes, array $expected) + { + $candidates = $this->rules->candidates($tag, $id, $classes); + sort($candidates); + + $this->assertSame($expected, $candidates); + } + + /** + * An element's tag, id and classes, and the rules it could match + * + * @return array[] + */ + public function elements() + { + return [ + 'a paragraph' => ['P', '', [], [0, 3, 5]], + 'a paragraph with the id' => ['P', 'MAIN', [], [0, 1, 3, 5]], + 'a class the rule is filed under' => ['DIV', '', ['B'], [2, 3]], + 'only the other class of the rule' => ['DIV', '', ['C'], [3]], + 'a class written twice' => ['DIV', '', ['B', 'B'], [2, 3]], + 'a span' => ['SPAN', '', [], [3, 4]], + 'a tag no rule names' => ['LI', '', [], [3]], + ]; + } + + /** + * Rules keep the position they were added at, whatever they are filed under + */ + public function testKeepsEachRuleAtItsPosition() + { + $this->assertSame($this->compiler->compile('p ~ span'), $this->rules->rule(4)[0]); + $this->assertSame(['COLOR' => 'rule 4'], $this->rules->rule(4)[1]); + } + + /** + * An empty rule set has no candidates for any element + */ + public function testHasNoCandidatesUntilARuleIsAdded() + { + $rules = new RuleSet(); + $this->assertSame([], $rules->candidates('P', 'MAIN', ['A'])); + + $rules->add($this->compiler->compile('div > p'), ['COLOR' => 'red']); + $this->assertSame([0], $rules->candidates('P', 'MAIN', ['A'])); + } + +} diff --git a/tests/Mpdf/Css/SelectorCompilerTest.php b/tests/Mpdf/Css/SelectorCompilerTest.php new file mode 100644 index 000000000..ddd9891cd --- /dev/null +++ b/tests/Mpdf/Css/SelectorCompilerTest.php @@ -0,0 +1,285 @@ +mpdf = new Mpdf(); + $this->compiler = new SelectorCompiler($this->mpdf); + } + + /** + * Lets go of the compiler and its Mpdf + */ + protected function tear_down() + { + unset($this->compiler, $this->mpdf); + + parent::tear_down(); + } + + /** + * A list is split at the commas between its selectors only + * + * @dataProvider lists + * + * @param string $list + * @param string[] $expected + */ + public function testSplitsAListAtItsTopLevelCommas($list, array $expected) + { + $this->assertSame($expected, $this->compiler->splitList($list)); + } + + /** + * A selector list and the selectors it holds + * + * @return array[] + */ + public function lists() + { + return [ + 'plain list' => ['h1, h2 ,h3', ['h1', 'h2', 'h3']], + 'one selector' => ['div p', ['div p']], + 'commas inside :is()' => [':is(h1, h2) + p, div', [':is(h1, h2) + p', 'div']], + 'commas inside :not()' => ['p:not(.a, .b), li', ['p:not(.a, .b)', 'li']], + 'nested parentheses' => [':is(:not(.a, .b), p), q', [':is(:not(.a, .b), p)', 'q']], + 'comma in a double-quoted attribute value' => ['[title="a,b"], p', ['[title="a,b"]', 'p']], + 'comma in a single-quoted attribute value' => ["[title='a,b'],p", ["[title='a,b']", 'p']], + 'quote inside the other kind of quotes' => ['[title="it\'s, here"], p', ['[title="it\'s, here"]', 'p']], + 'escaped comma' => ['.a\,b, p', ['.a\,b', 'p']], + 'escaped quote inside a string' => ['[title="a\",b"], p', ['[title="a\",b"]', 'p']], + 'empty member' => ['h1,,h2', ['h1', '', 'h2']], + ]; + } + + /** + * A selector is read into its compounds, the combinators between them and its specificity + * + * @dataProvider selectors + * + * @param string $selector + * @param array[] $compounds The tag, ids, classes and pseudo-classes of each compound, left to right + * @param string[] $combinators + */ + public function testCompilesASelector($selector, array $compounds, array $combinators) + { + $compiled = $this->compiler->compile($selector); + + $this->assertNotNull($compiled); + $this->assertSame($combinators, $compiled['combinators']); + $this->assertCount(count($compounds), $compiled['compounds']); + foreach ($compounds as $i => $compound) { + $compound += ['attributes' => []]; + $actual = array_intersect_key($compiled['compounds'][$i], $compound); + ksort($compound); + ksort($actual); + $this->assertSame($compound, $actual, sprintf('Compound %d of "%s"', $i, $selector)); + } + } + + /** + * A selector, the parts of each of its compounds and its combinators + * + * @return array[] + */ + public function selectors() + { + $tag = function ($tag) { + return ['tag' => $tag, 'ids' => [], 'classes' => [], 'pseudos' => []]; + }; + + return [ + 'type' => ['p', [$tag('P')], []], + 'type in any case' => ['Blockquote', [$tag('BLOCKQUOTE')], []], + 'child' => ['div > p', [$tag('DIV'), $tag('P')], ['>']], + 'child with no spaces' => ['div>p', [$tag('DIV'), $tag('P')], ['>']], + 'adjacent sibling' => ['h1+p', [$tag('H1'), $tag('P')], ['+']], + 'general sibling with extra spaces' => ["h1 \t ~ \n p", [$tag('H1'), $tag('P')], ['~']], + 'descendant over several spaces' => ['div p', [$tag('DIV'), $tag('P')], [' ']], + 'a chain of combinators' => [ + 'ul > li + li ~ li a', + [$tag('UL'), $tag('LI'), $tag('LI'), $tag('LI'), $tag('A')], + ['>', '+', '~', ' '], + ], + 'classes and ids uppercased, in the order written' => [ + 'p.Note#Main.b', + [['tag' => 'P', 'ids' => ['MAIN'], 'classes' => ['NOTE', 'B'], 'pseudos' => []]], + [], + ], + 'class with an escaped colon' => ['.md\:flex', [['tag' => null, 'classes' => ['MD:FLEX']]], []], + 'id with a hex escape for a leading digit' => ['#\31 23', [['tag' => null, 'ids' => ['123']]], []], + 'class with a hex escape for a non-ASCII letter' => ['.caf\e9', [['classes' => ['CAFé']]], []], + 'class written in UTF-8' => ['.café', [['classes' => ['CAFé']]], []], + 'first-child' => ['li:first-child', [['tag' => 'LI', 'pseudos' => [['nth-child', 0, 1]]]], []], + 'first-of-type' => ['p:First-Of-Type', [['tag' => 'P', 'pseudos' => [['nth-of-type', 0, 1]]]], []], + 'nth-child with spaces in its argument' => [ + 'li:nth-child( 2n + 1 )', + [['tag' => 'LI', 'pseudos' => [['nth-child', 2, 1]]]], + [], + ], + 'nth-of-type' => ['p:nth-of-type(3)', [['tag' => 'P', 'pseudos' => [['nth-of-type', 0, 3]]]], []], + 'pseudo-class with no type' => [':first-child', [['tag' => null, 'pseudos' => [['nth-child', 0, 1]]]], []], + ]; + } + + /** + * A selector mPDF cannot match, or that is not valid, compiles to nothing + * + * @dataProvider unmatchableSelectors + * + * @param string $selector + */ + public function testRejectsASelectorItCannotMatch($selector) + { + $this->assertNull($this->compiler->compile($selector)); + } + + /** + * A selector that is not valid, or uses something mPDF cannot match + * + * @return array[] + */ + public function unmatchableSelectors() + { + return [ + 'empty' => [''], + 'a combinator at the end' => ['div >'], + 'a combinator at the start' => ['> p'], + 'two combinators together' => ['div > + p'], + 'a tag mPDF does not style' => ['sup'], + 'a tag mPDF does not style as an ancestor' => ['sup > b'], + 'a pseudo-element' => ['p::before'], + 'a pseudo-element written with one colon is not a pseudo-class mPDF knows' => ['p:before'], + 'a pseudo-class with no meaning in a PDF' => ['a:hover'], + 'a pseudo-class that needs the elements after it' => ['li:last-child'], + 'nth-child with an argument it cannot read' => ['li:nth-child(2n+)'], + 'nth-child with "of" and a selector' => ['li:nth-child(2 of .a)'], + 'nth-child left open' => ['li:nth-child(2'], + 'a namespace' => ['svg|p'], + 'a class with no name' => ['p.'], + 'a class starting with a digit' => ['.1a'], + 'an id with no name' => ['#'], + 'a stray closing parenthesis' => ['p)'], + 'a brace left from a nested at-rule' => ['} p'], + 'an at-rule' => ['@font-face'], + 'a percentage from a keyframes block' => ['50%'], + ]; + } + + /** + * Specificity counts ids; then classes and pseudo-classes; then tags. The universal selector counts nothing + * + * @dataProvider specificities + * + * @param string $selector + * @param int[] $expected + */ + public function testCountsSpecificity($selector, array $expected) + { + $this->assertSame($expected, $this->compiler->compile($selector)['specificity']); + } + + /** + * A selector and its specificity + * + * @return array[] + */ + public function specificities() + { + return [ + 'type' => ['p', [0, 0, 1]], + 'universal' => ['*', [0, 0, 0]], + 'two types' => ['div > p', [0, 0, 2]], + 'class' => ['.a', [0, 1, 0]], + 'class on a type' => ['p.a', [0, 1, 1]], + 'id' => ['#i', [1, 0, 0]], + 'every kind' => ['div#i.a.b p:first-child', [1, 3, 2]], + 'pseudo-class on the universal selector' => ['*:nth-of-type(2)', [0, 1, 0]], + 'two ids' => ['#a #b', [2, 0, 0]], + ]; + } + + /** + * Whether a selector names the universal selector, which waits for #530 + */ + public function testMarksASelectorThatNamesTheUniversalSelector() + { + $this->assertTrue($this->compiler->compile('*')['universal']); + $this->assertTrue($this->compiler->compile('div > *')['universal']); + $this->assertTrue($this->compiler->compile('* + p')['universal']); + $this->assertFalse($this->compiler->compile('div > :first-child')['universal']); + } + + /** + * An an+b argument is read into a and b + * + * @dataProvider nthArguments + * + * @param string $argument + * @param int[]|null $expected + */ + public function testReadsAnNthArgument($argument, $expected) + { + $this->assertSame($expected, $this->compiler->parseNth($argument)); + } + + /** + * An an+b argument, and a and b, or null when it is not valid + * + * @return array[] + */ + public function nthArguments() + { + return [ + 'odd' => ['odd', [2, 1]], + 'even in capitals' => ['EVEN', [2, 0]], + 'a number' => ['3', [0, 3]], + 'a signed number' => ['+3', [0, 3]], + 'n' => ['n', [1, 0]], + '-n+3' => ['-n+3', [-1, 3]], + '2n+1' => ['2n+1', [2, 1]], + '2n - 1 with spaces' => ['2n - 1', [2, -1]], + '+3n' => ['+3n', [3, 0]], + 'space inside an+b' => ['2 n', null], + 'sign with nothing after it' => ['2n+', null], + 'a word' => ['first', null], + ]; + } + + /** + * The tags a type selector may name follow Mpdf::$allowedCSStags as it is when the selector is read + */ + public function testFollowsTheAllowedTagsWhenTheyChange() + { + $this->assertNull($this->compiler->compile('sup')); + + $this->mpdf->allowedCSStags .= '|SUP'; + + $this->assertNotNull($this->compiler->compile('sup')); + } +} diff --git a/tests/Mpdf/CssManagerTest.php b/tests/Mpdf/CssManagerTest.php index 57aa8d8d7..ecc771b3f 100644 --- a/tests/Mpdf/CssManagerTest.php +++ b/tests/Mpdf/CssManagerTest.php @@ -885,4 +885,22 @@ public function testHasUnnamedPageRules() $this->assertTrue($this->cssManager->hasUnnamedPageRules()); } + + /** + * The compiled rules of each stylesheet read are kept after those of the ones before, in the order written + */ + public function testKeepsTheCompiledRulesOfEveryStylesheetRead() + { + $rules = $this->cssManager->getRules(); + $this->assertSame([], $rules->candidates('P', '', [])); + + $this->cssManager->readCss(''); + $this->cssManager->readCss(''); + + $candidates = $rules->candidates('P', '', []); + sort($candidates); + $this->assertCount(2, $candidates); + $this->assertSame(['COLOR' => 'red'], $rules->rule($candidates[0])[1]); + $this->assertSame(['COLOR' => 'green'], $rules->rule($candidates[1])[1]); + } }