diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index 359f7d031..746bc8453 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -105,6 +105,7 @@ New features * **Child and sibling combinators, and structural pseudo-classes.** `div > p`, `h1 + p`, `h1 ~ p`, and `:first-child`, `:nth-child()`, `:first-of-type` and `:nth-of-type()` on any element, are matched against the elements open around the one being styled. Rules using them were dropped before, so a document that has them changes. They are applied with the descendant rules, after them, in order of specificity and then of source order. [mpdf/mpdf#7] [mpdf/mpdf#318] [#538] [#620] * **Descendant rules through inline elements.** A descendant rule whose ancestor is an inline element, a block inside a table cell, or the `tbody` a table leaves out, such as `span em`, or `.note b` for a ``, applies. [mpdf/mpdf#830] [#538] [#621] * **Attribute selectors and `:lang()`.** `[a]`, `[a=v]`, `[a~=v]`, `[a|=v]`, `[a^=v]`, `[a$=v]` and `[a*=v]`, with the `i` and `s` flags, match as in HTML: case-insensitively for the attributes HTML lists as such, and case-sensitively for the rest. `:lang()` matches the language an element inherits, from an ancestor or from the `` or `` tag. [mpdf/mpdf#134] [mpdf/mpdf#1838] [#538] [#627] +* **`:not()`, `:is()` and `:where()`.** Each takes a selector list whose selectors may have combinators, such as `p:not(.note, div > p)` or `:is(h2, h3) + p`. `:is()` and `:where()` leave out a selector they cannot read, as browsers do. `:not()` and `:is()` weigh as their most specific argument, and `:where()` as nothing. Rules using them were dropped before. [#538] [#628] Performance ----------- @@ -816,3 +817,4 @@ These changes do not change output. [#623]: https://github.com/GravityPDF/mpdf/pull/623 [#625]: https://github.com/GravityPDF/mpdf/pull/625 [#627]: https://github.com/GravityPDF/mpdf/pull/627 +[#628]: https://github.com/GravityPDF/mpdf/pull/628 diff --git a/src/Css/RuleSet.php b/src/Css/RuleSet.php index 30143f084..d3c606830 100644 --- a/src/Css/RuleSet.php +++ b/src/Css/RuleSet.php @@ -276,6 +276,9 @@ private static function hasAncestorOpeningNoLevel(array $path) } /** + * Only the compounds' own pseudo-classes are looked at: the legacy parser reads no :not() or :is(), so a legacy + * rule has no :lang() inside one + * * @param array $selector * * @return bool Whether a compiled selector names :lang() diff --git a/src/Css/SelectorCompiler.php b/src/Css/SelectorCompiler.php index d2becc458..22e0bf569 100644 --- a/src/Css/SelectorCompiler.php +++ b/src/Css/SelectorCompiler.php @@ -22,7 +22,9 @@ * for the attributes HTML lists as such, for id and class, whose values the tokenizer uppercases, and with the i flag. * * 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, and ['lang', ranges] with each language range lowercased. + * :first-child and :first-of-type are written as, ['lang', ranges] with each language range lowercased, and + * ['not', selectors] and ['is', selectors] with each selector compiled. :where() is held as :is(), its specificity + * being the only difference. */ class SelectorCompiler { @@ -205,12 +207,11 @@ private function parseCompound($text, &$pos, array &$specificity, &$universal) $specificity[1]++; } elseif ($c === ':') { $pos++; - $pseudo = $this->parsePseudoClass($text, $pos); + $pseudo = $this->parsePseudoClass($text, $pos, $specificity, $universal); if ($pseudo === null) { return null; } $compound['pseudos'][] = $pseudo; - $specificity[1]++; } else { break; } @@ -220,20 +221,27 @@ private function parseCompound($text, &$pos, array &$specificity, &$universal) } /** - * Reads a pseudo-class after its colon + * Reads a pseudo-class after its colon. :not() and :is() count as their most specific argument, :where() as + * nothing, and the others as a class * * @param string $text * @param int $pos + * @param int[] $specificity Added to for the pseudo-class + * @param bool $universal Set when an argument names * * * @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) + private function parsePseudoClass($text, &$pos, array &$specificity, &$universal) { if (!$this->startsIdentifier($text, $pos)) { return null; } $name = strtolower($this->readName($text, $pos)); + if ($name !== 'not' && $name !== 'is' && $name !== 'where') { + $specificity[1]++; + } + if ($name === 'first-child') { return ['nth-child', 0, 1]; } @@ -251,6 +259,10 @@ private function parsePseudoClass($text, &$pos) return null; } + if ($name === 'not' || $name === 'is' || $name === 'where') { + return $this->parseSelectorArgument($name, $argument, $specificity, $universal); + } + if ($name === 'lang') { $ranges = $this->parseLanguageRanges($argument); @@ -266,6 +278,50 @@ private function parsePseudoClass($text, &$pos) return $formula === null ? null : [$name, $formula[0], $formula[1]]; } + /** + * Reads the selector list :not(), :is() and :where() take. :is() and :where() leave out a selector they cannot + * read, as browsers do, and :not() cannot be read with one + * + * @param string $name not, is or where + * @param string $argument + * @param int[] $specificity Added to for the most specific selector, except for :where() + * @param bool $universal Set when a selector names * + * + * @return array|null + */ + private function parseSelectorArgument($name, $argument, array &$specificity, &$universal) + { + $selectors = []; + $heaviest = [0, 0, 0]; + foreach ($this->splitList($argument) as $member) { + $compiled = $this->compile($member); + if ($compiled === null) { + if ($name === 'not') { + return null; + } + continue; + } + + $selectors[] = $compiled; + $universal = $universal || $compiled['universal']; + if ($compiled['specificity'] > $heaviest) { + $heaviest = $compiled['specificity']; + } + } + + if (!$selectors) { + return null; + } + + if ($name !== 'where') { + foreach ($heaviest as $i => $count) { + $specificity[$i] += $count; + } + } + + return [$name === 'not' ? 'not' : 'is', $selectors]; + } + /** * Reads the language ranges :lang() takes: identifiers or strings, separated by commas * diff --git a/src/Css/SelectorMatcher.php b/src/Css/SelectorMatcher.php index 97b7eefb2..88d63ff9b 100644 --- a/src/Css/SelectorMatcher.php +++ b/src/Css/SelectorMatcher.php @@ -135,9 +135,11 @@ private function matchesCompound(array $compound, array $path, $parent, $index, /** * Whether a pseudo-class matches an element. :nth-child() counts the element among all its element siblings, and * :nth-of-type() among those with its tag; the document has no siblings, so it matches neither. :lang() matches - * the language the element has or inherits, or, in the legacy view, only its own lang attribute + * the language the element has or inherits, or, in the legacy view, only its own lang attribute. :is() matches + * when any selector in its list matches from the element, combinators and all, and :not() when none does * - * @param array $pseudo A compiled pseudo-class: ['nth-child', a, b], ['nth-of-type', a, b] or ['lang', ranges] + * @param array $pseudo A compiled pseudo-class: ['nth-child', a, b], ['nth-of-type', a, b], ['lang', ranges], + * or ['is', selectors] or ['not', selectors] * @param array $element The element's frame or record, as element() gives it * @param array[] $path As matchesFrom() takes it * @param int $parent The depth of the element's parent on $path, as matchesFrom() takes it @@ -148,6 +150,16 @@ private function matchesCompound(array $compound, array $path, $parent, $index, */ private function matchesPseudoClass(array $pseudo, array $element, array $path, $parent, $index, $legacyView) { + if ($pseudo[0] === 'is' || $pseudo[0] === 'not') { + foreach ($pseudo[1] as $selector) { + if ($this->matchesFrom($selector, count($selector['compounds']) - 1, $path, $parent, $index, $legacyView)) { + return $pseudo[0] === 'is'; + } + } + + return $pseudo[0] === 'not'; + } + if ($pseudo[0] === 'lang') { if (!$legacyView) { // A closed sibling's record keeps no lang: it has its own, or its parent's diff --git a/tests/Mpdf/Css/SelectorCompilerTest.php b/tests/Mpdf/Css/SelectorCompilerTest.php index 22fbf7251..197c2409d 100644 --- a/tests/Mpdf/Css/SelectorCompilerTest.php +++ b/tests/Mpdf/Css/SelectorCompilerTest.php @@ -208,6 +208,50 @@ public function attributeSelectors() ]; } + /** + * :not(), :is() and :where() hold their arguments compiled. :is() and :where() leave out an argument they cannot + * read, and :where() is held as :is() + * + * @dataProvider selectorArguments + * + * @param string $selector + * @param string $name The name it is held under + * @param array[] $subjects The tag and classes of the last compound of each argument kept + */ + public function testReadsTheSelectorsAPseudoClassTakes($selector, $name, array $subjects) + { + $compiled = $this->compiler->compile($selector); + + $this->assertNotNull($compiled); + $pseudo = $compiled['compounds'][0]['pseudos'][0]; + $this->assertSame($name, $pseudo[0]); + $this->assertSame($subjects, array_map(function ($argument) { + $subject = $argument['compounds'][count($argument['compounds']) - 1]; + + return [$subject['tag'], $subject['classes']]; + }, $pseudo[1])); + } + + /** + * A selector using :not(), :is() or :where(), the name its pseudo-class is held under, and its arguments + * + * @return array[] + */ + public function selectorArguments() + { + return [ + 'not with one class' => ['p:not(.a)', 'not', [[null, ['A']]]], + 'not with a list, spaces and all' => ['p:not( .a ,.b )', 'not', [[null, ['A']], [null, ['B']]]], + 'not with a complex selector' => ['p:not(div > p)', 'not', [['P', []]]], + 'is' => [':is(h1, h2)', 'is', [['H1', []], ['H2', []]]], + 'where' => [':where(h1, .x)', 'is', [['H1', []], [null, ['X']]]], + 'is leaves out what it cannot read' => [':is(h1, a:hover, ::before, h2)', 'is', [['H1', []], ['H2', []]]], + 'is nested in is' => [':is(:is(h1), p)', 'is', [[null, []], ['P', []]]], + 'not with a comma inside an attribute value' => ['p:not([title="a, b"])', 'not', [[null, []]]], + 'not with a closing parenthesis inside an attribute value' => ['p:not([title=")"], .x)', 'not', [[null, []], [null, ['X']]]], + ]; + } + /** * A selector mPDF cannot match, or that is not valid, compiles to nothing * @@ -260,6 +304,13 @@ public function unmatchableSelectors() 'lang with no range' => [':lang()'], 'lang with an empty range between commas' => [':lang(fr,,de)'], 'lang with no parentheses' => [':lang'], + 'not with an argument it cannot read' => ['p:not(.a, a:hover)'], + 'not with a pseudo-element' => ['p:not(::before)'], + 'not left open' => ['p:not(.a'], + 'not with nothing' => ['p:not()'], + 'is with nothing it can read' => [':is(a:hover, ::before)'], + 'where with nothing' => [':where()'], + 'a stray comma in not' => ['p:not(.a,)'], ]; } @@ -297,6 +348,14 @@ public function specificities() 'attribute on a type' => ['a[href^="http"]', [0, 1, 1]], 'lang' => ['p:lang(fr)', [0, 1, 1]], 'attributes, classes and pseudo-classes together' => ['li.a[data-x]:first-child:lang(en)', [0, 4, 1]], + 'not counts as its argument' => ['p:not(.a)', [0, 1, 1]], + 'not counts as its most specific argument' => ['p:not(.a, #b, div span)', [1, 0, 1]], + 'is counts as its most specific argument' => [':is(h1, .a.b, div p) + p', [0, 2, 1]], + 'is with a type and an id' => [':is(p, #x)', [1, 0, 0]], + 'where counts nothing' => [':where(#a, .b) > p', [0, 0, 1]], + 'where inside is' => [':is(:where(#a), .b)', [0, 1, 0]], + 'is leaves out what it cannot read from its specificity too' => [':is(.a, #b:hover)', [0, 1, 0]], + 'nested' => ['li:not(:is(.a, #b)):nth-child(2)', [1, 1, 1]], ]; } @@ -309,6 +368,9 @@ public function testMarksASelectorThatNamesTheUniversalSelector() $this->assertTrue($this->compiler->compile('div > *')['universal']); $this->assertTrue($this->compiler->compile('* + p')['universal']); $this->assertFalse($this->compiler->compile('div > :first-child')['universal']); + $this->assertTrue($this->compiler->compile('p:not(*)')['universal']); + $this->assertTrue($this->compiler->compile(':is(h1, *) + p')['universal']); + $this->assertFalse($this->compiler->compile(':is(h1, h2) + p')['universal']); } /** diff --git a/tests/Mpdf/Css/SelectorMatcherTest.php b/tests/Mpdf/Css/SelectorMatcherTest.php index 1999e779c..9d448194f 100644 --- a/tests/Mpdf/Css/SelectorMatcherTest.php +++ b/tests/Mpdf/Css/SelectorMatcherTest.php @@ -98,6 +98,20 @@ public function selectors() 'the wrong closed sibling of an ancestor' => ['h1 + div li', false], 'body has no siblings' => ['body:first-child li', false], 'a chain through siblings and ancestors' => ['h1 ~ .card > h2 ~ ul > li + .x', true], + 'not the first child' => ['li:not(:first-child)', true], + 'not a class it has' => ['li:not(.x)', false], + 'not any of a list, one of which it is' => ['li:not(.y, :nth-child(2))', false], + 'not a complex selector it matches' => ['li:not(ul > li)', false], + 'not a complex selector it does not match' => ['li:not(ol > li)', true], + 'is one of a list' => [':is(ol, ul) > li', true], + 'is none of a list' => [':is(ol, dl) > li', false], + 'is a complex selector, matched from the element' => ['li:is(li + li)', true], + 'is on an ancestor' => ['div:is(#main, .other) li', true], + 'not on an ancestor' => ['div:not(.card) li', false], + 'is on a closed sibling' => [':is(h2, h3) ~ ul > li', true], + 'where' => [':where(#main) li.x', true], + 'nested' => ['li:not(:is(.y, :first-child))', true], + 'body in is' => [':is(body) > div li', true], ]; } diff --git a/tests/Mpdf/LogicalSelectorTest.php b/tests/Mpdf/LogicalSelectorTest.php new file mode 100644 index 000000000..2da78ccf5 --- /dev/null +++ b/tests/Mpdf/LogicalSelectorTest.php @@ -0,0 +1,268 @@ + $expected Pieces of text and the colour each should be drawn in + */ + public function testMatchesTheElementsTheSelectorNames($css, $html, array $expected) + { + $colours = $this->drawnColours('' . $html); + + foreach ($expected as $text => $colour) { + $this->assertArrayHasKey($text, $colours, sprintf('"%s" is not drawn', $text)); + $this->assertSame($colour, $colours[$text], sprintf('"%s" is drawn in the wrong colour', $text)); + } + } + + /** + * A rule, a document for it, and the colours its text should be drawn in: elements that match, and near misses + * that must not + * + * @return array[] + */ + public function selectors() + { + return [ + 'not a class' => [ + 'p:not(.a) { color: #f00; }', + '

with a

with b

with none

', + ['with a' => self::BLACK, 'with b' => self::RED, 'with none' => self::RED], + ], + 'not any of a list' => [ + 'p:not(.a, .b) { color: #f00; }', + '

a

b

c

', + ['a' => self::BLACK, 'b' => self::BLACK, 'c' => self::RED], + ], + 'not a structural pseudo-class' => [ + 'li:not(:first-child) { color: #f00; }', + '', + ['one' => self::BLACK, 'two' => self::RED, 'three' => self::RED], + ], + 'not an attribute' => [ + 'a:not([href^="https://"]) { color: #f00; }', + '

secure plain

', + ['secure' => self::BLUE, 'plain' => self::RED], + ], + 'not a complex selector' => [ + 'p:not(div > p) { color: #f00; }', + '

in div

in section

', + ['in div' => self::BLACK, 'in section' => self::RED], + ], + 'not on an ancestor' => [ + 'div:not(.card) > p { color: #f00; }', + '

in card

in panel

', + ['in card' => self::BLACK, 'in panel' => self::RED], + ], + 'is before an adjacent sibling' => [ + ':is(h1, h2) + p { color: #f00; }', + '

One

after h1

Two

after h2

Three

after h3

', + ['after h1' => self::RED, 'after h2' => self::RED, 'after h3' => self::BLACK], + ], + 'is on the element' => [ + 'p:is(.a, #b) { color: #f00; }', + '

class

id

other

', + ['class' => self::RED, 'id' => self::RED, 'other' => self::BLACK], + ], + 'is with a complex selector, matched from the element' => [ + 'p:is(h2 + p) { color: #f00; }', + '

Title

first

second

', + ['first' => self::RED, 'second' => self::BLACK], + ], + 'is leaves out an argument it cannot read' => [ + ':is(h1, a:hover) + p { color: #f00; }', + '

One

after h1

', + ['after h1' => self::RED], + ], + 'where' => [ + ':where(ul, ol) > li { color: #f00; }', + '
  1. number
', + ['bullet' => self::RED, 'number' => self::RED], + ], + 'is through an inline ancestor' => [ + ':is(span, em) b { color: #f00; }', + '

in span in em bare

', + ['in span' => self::RED, 'in em' => self::RED, 'bare' => self::BLACK], + ], + 'nested, and chained with other pseudo-classes' => [ + ':is(ul, ol) > li:not(.skip):nth-child(odd) { color: #f00; }', + '
  1. one
  2. two
  3. four
  4. five
', + ['one' => self::RED, 'two' => self::BLACK, 'three' => self::BLACK, 'five' => self::RED], + ], + 'is with an inherited language' => [ + 'p:is(:lang(fr), .x) { color: #f00; }', + '

french

classed

plain

', + ['french' => self::RED, 'classed' => self::RED, 'plain' => self::BLACK], + ], + 'not an inherited language' => [ + 'p:not(:lang(fr)) { color: #f00; }', + '

french

plain

', + ['french' => self::BLACK, 'plain' => self::RED], + ], + 'in a table cell' => [ + 'td:not(:first-child) { color: #f00; }', + '
c1c2
', + ['c1' => self::BLACK, 'c2' => self::RED], + ], + 'not an argument it cannot read drops the rule' => [ + 'p:not(.a, a:hover) { color: #f00; }', + '

plain

', + ['plain' => self::BLACK], + ], + 'the universal selector as an argument waits for #530' => [ + 'p:not(*) { color: #f00; } :is(*) + p { color: #f00; }', + '

one

two

', + ['one' => self::BLACK, 'two' => self::BLACK], + ], + ]; + } + + /** + * :not() and :is() count as their most specific argument, and :where() as nothing + * + * @dataProvider precedence + * + * @param string $css + * @param string $expected The colour the text "text" should be drawn in + */ + public function testCountsTheSpecificityOfTheirArguments($css, $expected) + { + $colours = $this->drawnColours( + '

text

' + ); + + $this->assertSame($expected, $colours['text']); + } + + /** + * Competing rules and the colour "text" should be drawn in + * + * @return array[] + */ + public function precedence() + { + return [ + 'is with an id beats two classes, written first' => [':is(#main, .x) > p { color: #f00; } .box > p.a { color: #00f; }', self::RED], + 'where with an id loses to two types, written last' => ['div > p { color: #f00; } :where(#main) > p { color: #00f; }', self::RED], + 'where with an id against where with a class, the later wins' => [':where(#main) > p { color: #f00; } :where(.box) > p { color: #00f; }', self::BLUE], + 'not with an id beats a class' => ['p:not(#other) { color: #f00; } div > .a { color: #00f; }', self::RED], + 'not with a class ties with a class, the later wins' => ['div > p:not(.b) { color: #f00; } div > p.a { color: #00f; }', self::BLUE], + ]; + } + + /** + * A header is matched against its own elements, not the flow's elements open when it is set + */ + public function testMatchesAHeaderAgainstItsOwnElements() + { + $mpdf = $this->drawDocument( + '' + . '

header first

header side

' + . '

flow first

flow second

', + ['setAutoTopMargin' => 'stretch'] + ); + $mpdf->Output('', 'S'); + + $colours = array_combine(array_map('trim', $mpdf->drawnText), $mpdf->drawnColours); + $this->assertSame(self::RED, $colours['header first']); + $this->assertSame(self::BLACK, $colours['header side']); + $this->assertSame(self::RED, $colours['flow first']); + $this->assertSame(self::BLUE, $colours['flow second']); + } + + /** + * A positioned block is matched against the elements it was written in, and its content against the block + */ + public function testMatchesAPositionedBlockAndItsContent() + { + $colours = $this->drawnColours( + '' + . '
loose

first

second

' + . '
plain
' + ); + + $this->assertSame(self::RED, $colours['loose']); + $this->assertSame(self::GREEN, $colours['first']); + $this->assertSame(self::BLUE, $colours['second']); + $this->assertSame(self::BLACK, $colours['plain']); + } + + /** + * A kept block that runs onto the next page is matched again from its start tag, and its children are counted + * once + */ + public function testMatchesAKeptBlockLaidOutAgain() + { + $mpdf = $this->drawDocument( + '' + . str_repeat('

filler

', 45) + . '

one

two

three

four

' + ); + + $colours = array_combine(array_map('trim', $mpdf->drawnText), $mpdf->drawnColours); + $pages = array_combine(array_map('trim', $mpdf->drawnText), array_column($mpdf->drawnBoxes, 0)); + $this->assertSame(2, $pages['one'], 'The block should have moved to the next page'); + $this->assertSame(self::BLACK, $colours['one']); + $this->assertSame(self::BLACK, $colours['two']); + $this->assertSame(self::BLUE, $colours['three']); + $this->assertSame(self::RED, $colours['four']); + } + + /** + * Elements inside one hidden with display: none are matched as earlier siblings and ancestors, with their + * attributes and classes as written + * + * @dataProvider hiddenSelectors + * + * @param string $selector + * @param bool $expected Whether it matches the span still open inside the hidden block + */ + public function testMatchesAgainstHiddenElements($selector, $expected) + { + $this->assertSame($expected, $this->matchesLastOpenElement( + '