From dce3ce41e808f7abfd56bff1136873ac7dbeac5c Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Wed, 30 Sep 2026 15:34:53 +1000 Subject: [PATCH] Match :last-child, :only-child, :empty and the other pseudo-classes that look at what follows an element WriteHTML() walks its tokens once before writing them, through the methods that keep the stack of open elements, and records how many children each element has, of each tag, and whether it is empty. Each frame takes that record when it is made, and the matcher counts from the parent's totals. An element a call leaves open is not known in full, so these pseudo-classes, and :not() of them, do not match its children in that call. The spans mPDF adds for a substitute font are marked as script-run spans are, so they are not counted among their siblings. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-GRAVITYPDF.md | 6 + src/Css/SelectorCompiler.php | 32 +- src/Css/SelectorMatcher.php | 59 +- src/Mpdf.php | 14 +- src/TracksOpenElements.php | 144 ++- tests/Mpdf/Css/CssParserTest.php | 2 +- tests/Mpdf/Css/SelectorCompilerTest.php | 17 +- tests/Mpdf/Css/SelectorMatcherTest.php | 143 ++- tests/Mpdf/LookAheadSelectorTest.php | 822 ++++++++++++++++++ tests/Mpdf/OpenElementStackTest.php | 89 ++ tests/Mpdf/StructuralSelectorTest.php | 6 +- tests/Mpdf/SubstitutionRunCutTest.php | 8 +- .../UnmatchableDescendantSelectorTest.php | 2 +- .../LookAheadSelectorSnapshotTest.php | 102 +++ .../LookAheadStylesheetSnapshotTest.php | 135 +++ ...atchableDescendantSelectorSnapshotTest.php | 4 +- tests/data/snapshots/look-ahead-selectors.pdf | Bin 0 -> 41580 bytes .../data/snapshots/look-ahead-stylesheet.pdf | Bin 0 -> 66201 bytes .../unmatchable-descendant-selector.pdf | Bin 61051 -> 61043 bytes 19 files changed, 1540 insertions(+), 45 deletions(-) create mode 100644 tests/Mpdf/LookAheadSelectorTest.php create mode 100644 tests/Snapshots/LookAheadSelectorSnapshotTest.php create mode 100644 tests/Snapshots/LookAheadStylesheetSnapshotTest.php create mode 100644 tests/data/snapshots/look-ahead-selectors.pdf create mode 100644 tests/data/snapshots/look-ahead-stylesheet.pdf diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index f5aa76b08..86e478799 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -111,6 +111,9 @@ New features * **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] +* **Pseudo-classes that look at what follows an element.** `:last-child`, `:nth-last-child()`, `:only-child`, `:last-of-type`, `:nth-last-of-type()`, `:only-of-type` and `:empty` match in the flow, in tables, headers and footers, and positioned blocks. Rules using them were dropped before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep dropping them. [#537] [#634] + * `WriteHTML()` reads ahead through the HTML it is given to count each element's children. An element still open at the end of a call that leaves it open is not known in full, so these do not match its children in that call, and nor does `:not()` of them. + * `:empty` matches as in browsers: an element holding white space is not empty. In the default mode mPDF's CSS reader turns each HTML comment into a space, so an element holding only a comment is not empty either. A table cell holding only white space is empty, as mPDF strips it. Performance ----------- @@ -193,6 +196,7 @@ Bugfixes * `background-size` lengths such as `60mm` or `100px` drew the image 2.83 times too small, because they were used as points. [#574] * A stylesheet `url()` did not load its image when the path had spaces or parentheses, when there was whitespace inside the parentheses, or when it was an SVG data URI that is not base64, such as Bootstrap's `form-select` arrow. [#573] * `linear-gradient(to bottom, …)` and `to top` were drawn upside down, and so was the vertical half of `to bottom right` and the other corners. Angles ran counter-clockwise from pointing right, so `90deg` drew bottom to top. A gradient now runs as CSS Images gives: `0deg` points up and angles turn clockwise, `turn` is read, and a corner keyword leaves the other two corners halfway along. `-moz-`, `-webkit-` and `-o-` gradients keep their legacy angles, and their side keyword, such as `left`, names the side the gradient starts from. [#577] +* Characters mPDF moved into a substitute font (`useSubstitutions`, and the fonts for supplementary planes) were wrapped in a `` that stylesheet rules reached, so `span { border: 1px solid }` drew a box around them, and the span counted among its siblings for `:nth-child()`. In the standard CSS mode no rule reaches that span and it is not counted, as a browser has no such element. The legacy mode styles it as before. [#634] ### Images and SVG @@ -785,6 +789,7 @@ These changes do not change output. [#528]: https://github.com/GravityPDF/mpdf/issues/528 [#533]: https://github.com/GravityPDF/mpdf/issues/533 [#535]: https://github.com/GravityPDF/mpdf/issues/535 +[#537]: https://github.com/GravityPDF/mpdf/issues/537 [#538]: https://github.com/GravityPDF/mpdf/issues/538 [#549]: https://github.com/GravityPDF/mpdf/pull/549 [#552]: https://github.com/GravityPDF/mpdf/issues/552 @@ -831,3 +836,4 @@ These changes do not change output. [#631]: https://github.com/GravityPDF/mpdf/pull/631 [#632]: https://github.com/GravityPDF/mpdf/issues/632 [#633]: https://github.com/GravityPDF/mpdf/pull/633 +[#634]: https://github.com/GravityPDF/mpdf/pull/634 diff --git a/src/Css/SelectorCompiler.php b/src/Css/SelectorCompiler.php index 22e0bf569..2bb2af5ff 100644 --- a/src/Css/SelectorCompiler.php +++ b/src/Css/SelectorCompiler.php @@ -21,10 +21,11 @@ * names attributes, and the operator empty for [name]. Values are compared case-insensitively, and held lowercased, * 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, ['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. + * Pseudo-classes are held as [name, arguments...]: ['nth-child', a, b], ['nth-of-type', a, b], ['nth-last-child', a, b] + * and ['nth-last-of-type', a, b] for an+b, which :first-child, :first-of-type, :last-child and :last-of-type are + * written as, ['only-child'], ['only-of-type'] and ['empty'], ['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 { @@ -44,6 +45,20 @@ class SelectorCompiler 'TARGET' => true, 'TEXT' => true, 'TYPE' => true, 'VALIGN' => true, 'VALUETYPE' => true, 'VLINK' => true, ]; + /** + * @var array[] The pseudo-classes written without an argument, compiled. Those that count from the first or last + * sibling are held as the nth formula they stand for + */ + private static $pseudoClassesWithoutArguments = [ + 'first-child' => ['nth-child', 0, 1], + 'first-of-type' => ['nth-of-type', 0, 1], + 'last-child' => ['nth-last-child', 0, 1], + 'last-of-type' => ['nth-last-of-type', 0, 1], + 'only-child' => ['only-child'], + 'only-of-type' => ['only-of-type'], + 'empty' => ['empty'], + ]; + /** * @var Mpdf */ @@ -242,11 +257,8 @@ private function parsePseudoClass($text, &$pos, array &$specificity, &$universal $specificity[1]++; } - if ($name === 'first-child') { - return ['nth-child', 0, 1]; - } - if ($name === 'first-of-type') { - return ['nth-of-type', 0, 1]; + if (isset(self::$pseudoClassesWithoutArguments[$name])) { + return self::$pseudoClassesWithoutArguments[$name]; } if (!$this->isAt($text, $pos, '(')) { @@ -269,7 +281,7 @@ private function parsePseudoClass($text, &$pos, array &$specificity, &$universal return $ranges === null ? null : ['lang', $ranges]; } - if ($name !== 'nth-child' && $name !== 'nth-of-type') { + if (!in_array($name, ['nth-child', 'nth-of-type', 'nth-last-child', 'nth-last-of-type'], true)) { return null; } diff --git a/src/Css/SelectorMatcher.php b/src/Css/SelectorMatcher.php index eb61a6481..c2666ad95 100644 --- a/src/Css/SelectorMatcher.php +++ b/src/Css/SelectorMatcher.php @@ -22,6 +22,12 @@ class SelectorMatcher /** It fails for this element and for any other whose ancestors are all among this one's */ const FAILS_FOR_ANCESTORS = 2; + /** + * @var bool Whether a pseudo-class that needs what follows an element was matched against one for which that is + * not known, and so did not match. :not() then does not match either + */ + private $undecided = false; + /** * Whether a selector matches the last element of a path * @@ -158,12 +164,14 @@ 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. :is() matches when any selector in its list matches from the element, - * combinators and all, and :not() when none does + * :nth-of-type() among those with its tag. :nth-last-child(), :nth-last-of-type(), :only-child and :only-of-type + * count from the parent's last child, and :empty reads whether the element holds anything, as + * TracksOpenElements::readAhead() found; where it could not, they do not match. The document has no siblings, so + * it matches none of these. :lang() matches the language the element has or inherits. :is() matches when any + * selector in its list matches from the element, combinators and all, and :not() when none does and none was left + * undecided * - * @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 $pseudo A compiled pseudo-class, as SelectorCompiler describes them * @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 @@ -174,13 +182,21 @@ private function matchesCompound(array $compound, array $path, $parent, $index) private function matchesPseudoClass(array $pseudo, array $element, array $path, $parent, $index) { if ($pseudo[0] === 'is' || $pseudo[0] === 'not') { + $outer = $this->undecided; + $this->undecided = false; + $matched = false; foreach ($pseudo[1] as $selector) { if ($this->matchesFrom($selector, count($selector['compounds']) - 1, $path, $parent, $index) === self::MATCHES) { - return $pseudo[0] === 'is'; + $matched = true; + break; } } - return $pseudo[0] === 'not'; + // :not() of what could not be decided cannot be either + $undecided = !$matched && $this->undecided; + $this->undecided = $outer || $undecided; + + return $pseudo[0] === 'is' ? $matched : !$matched && !$undecided; } if ($pseudo[0] === 'lang') { @@ -202,7 +218,31 @@ private function matchesPseudoClass(array $pseudo, array $element, array $path, return $this->isNth($pseudo[1], $pseudo[2], $index + 1); } - return $this->isNth($pseudo[1], $pseudo[2], $element['nthOfType']); + if ($pseudo[0] === 'nth-of-type') { + return $this->isNth($pseudo[1], $pseudo[2], $element['nthOfType']); + } + + // The rest need what follows the element, which is not always known + $siblings = $path[$parent]['childTotal']; + $ofType = isset($path[$parent]['childTypeTotals'][$element['tag']]) ? $path[$parent]['childTypeTotals'][$element['tag']] : null; + if (($pseudo[0] === 'empty' ? $element['empty'] : $ofType) === null) { + $this->undecided = true; + + return false; + } + + switch ($pseudo[0]) { + case 'nth-last-child': + return $this->isNth($pseudo[1], $pseudo[2], $siblings - $index); + case 'nth-last-of-type': + return $this->isNth($pseudo[1], $pseudo[2], $ofType - $element['nthOfType'] + 1); + case 'only-child': + return $siblings === 1; + case 'only-of-type': + return $ofType === 1; + default: + return $element['empty']; + } } /** @@ -296,7 +336,8 @@ private function isNth($a, $b, $position) * @param int $parent The depth of the element's parent on $path, as matchesFrom() takes it * @param int $index The element's index among its parent's children, as matchesFrom() takes it * - * @return array Its tag, id, classes, attr and nthOfType, and for an open element or the document its lang + * @return array Its tag, id, classes, attr and nthOfType, for an element its empty, and for an open element or + * the document its lang */ private function element(array $path, $parent, $index) { diff --git a/src/Mpdf.php b/src/Mpdf.php index fa58372ca..a7f08d4bf 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -60,7 +60,8 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface const OBJECT_IDENTIFIER = "\xbb\xa4\xac"; /** - * The attribute, as the tokenizer names it, that marks a span markScriptToLang() wraps a run of another script in + * The attribute, as the tokenizer names it, that marks a span markScriptToLang() wraps a run of another script in, + * or one the character substitution wraps a run it moves into another font in */ const SCRIPT_RUN_ATTRIBUTE = 'DATA-MPDF-SCRIPT-RUN'; @@ -14698,6 +14699,12 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c $this->openElements = $this->newOpenElementStack(); } $floor = $standIn ? count($this->openElements) : 1; // the frames this HTML cannot close + // What is read ahead is for this call's tokens only. A header or index written in the middle of the flow puts + // the flow's back when it is done. The legacy mode reads nothing ahead, as it drops the selectors that need it + $outerLookAhead = $this->lookAhead; + if ($this->cssMode === CssMode::STANDARD) { + $this->readAhead($a, $floor, $standIn, $close); + } $pbc = 0; $this->subPos = -1; @@ -15239,6 +15246,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c if ($flowElements !== null) { $this->openElements = $flowElements; } + $this->lookAhead = $outerLookAhead; } /* -- CSS-POSITION -- */ @@ -26740,7 +26748,7 @@ function SubstituteCharsSIP(&$writehtml_a, &$writehtml_i, &$writehtml_e) return 0; } $writehtml_a[$writehtml_i] = $writehtml_e = $m[1]; - array_splice($writehtml_a, $writehtml_i + 1, 0, ['span style="font-family: ' . $font . '"', $m[2], '/span', $m[3]]); + array_splice($writehtml_a, $writehtml_i + 1, 0, ['span data-mpdf-script-run style="font-family: ' . $font . '"', $m[2], '/span', $m[3]]); $this->subPos = $writehtml_i; return 4; } @@ -27181,7 +27189,7 @@ private function substitutionStep($text, $from, $start, $l, $font, $replacement return [ 'before' => mb_substr($text, 0, $offset, 'UTF-8'), 'insert' => $replacement === null - ? ['span style="font-family: ' . $font . '"', mb_substr($text, $offset, $l, 'UTF-8'), '/span'] + ? ['span data-mpdf-script-run style="font-family: ' . $font . '"', mb_substr($text, $offset, $l, 'UTF-8'), '/span'] : [$font, $replacement, '/' . $font], 'rest' => mb_substr($text, $offset + $l, null, 'UTF-8'), 'from' => $start + $l, diff --git a/src/TracksOpenElements.php b/src/TracksOpenElements.php index 214a66b25..b235fec4d 100644 --- a/src/TracksOpenElements.php +++ b/src/TracksOpenElements.php @@ -27,6 +27,15 @@ trait TracksOpenElements /** @var array[]|null The open elements as they stood inside the positioned block being written, for its content */ private $fixedPosBlockElements; + /** + * @var array[] What readAhead() found each element to hold, by the key of its frame: its childTotal, + * childTypeTotals and empty, as getOpenElements() describes them. A frame takes them from here when it is made + */ + private $lookAhead = []; + + /** @var bool Whether readAhead() is walking the tokens, so that each element it closes is recorded in $lookAhead */ + private $readingAhead = false; + /** * @var array[] The elements open in the HTML being written, outermost first, kept apart from the blocks in $blk. * See {@see getOpenElements()} @@ -111,9 +120,16 @@ trait TracksOpenElements * - attr: its attributes, as the tag handlers are given them * - nthChild and nthOfType: its position among its element siblings, and among those of its own tag, from 1 * - children: a record of each child closed so far, oldest first, which for the child open inside it are the - * siblings before that child. A record holds a child's tag, id, classes, attr and nthOfType; its position is its - * place in the list + * siblings before that child. A record holds a child's tag, id, classes, attr, nthOfType and empty; its position + * is its place in the list * - childTypes: how many children so far have each tag + * - key: where it is in the document, as the nthChild of it and of each of its ancestors. An element not counted + * among its parent's children, such as a span around a run of another script, shares it with the next one, and + * nothing is read ahead of it + * - childTotal and childTypeTotals: how many children it has in all, and how many of each tag, as readAhead() + * found. Null when that is not known, as for an element still open at the end of a WriteHTML() call that + * leaves it open, and in the legacy CSS mode, which reads nothing ahead + * - empty: whether it has no children and no text, white space included. Null when that is not known * - computed: its computed values, not filled in yet * * @return array[] @@ -214,7 +230,7 @@ private function withChildFrame(array $path, $tag, array $attr) */ private function newOpenElementStack() { - return [$this->newElementFrame('', [], $this->documentLang, 1, 1)]; + return [$this->newElementFrame('', [], $this->documentLang, 1, 1, '')]; } /** @@ -225,12 +241,13 @@ private function newOpenElementStack() * @param string $lang Its own lang attribute, or the one it inherits * @param int $nthChild Its position among its element siblings, from 1 * @param int $nthOfType Its position among the siblings with its tag, from 1 + * @param string $key Where it is in the document, as getOpenElements() describes * * @return array A frame for an element with no children yet, as getOpenElements() describes */ - private function newElementFrame($tag, array $attr, $lang, $nthChild, $nthOfType) + private function newElementFrame($tag, array $attr, $lang, $nthChild, $nthOfType, $key) { - return [ + $frame = [ 'tag' => $tag, 'id' => isset($attr['ID']) ? $attr['ID'] : '', 'classes' => isset($attr['CLASS']) ? preg_split('/\s+/', $attr['CLASS'], -1, PREG_SPLIT_NO_EMPTY) : [], @@ -240,8 +257,14 @@ private function newElementFrame($tag, array $attr, $lang, $nthChild, $nthOfType 'nthOfType' => $nthOfType, 'children' => [], 'childTypes' => [], + 'key' => $key, + 'childTotal' => null, + 'childTypeTotals' => null, + 'empty' => null, 'computed' => null, ]; + + return isset($this->lookAhead[$key]) ? $this->lookAhead[$key] + $frame : $frame; } /** @@ -275,6 +298,97 @@ private function startElement($tag, array $attr, $selfClosing) } } + /** + * Walks the tokens a WriteHTML() call is about to write, to find what each element holds: how many children it + * has, how many of each tag, and whether it is empty. :last-child, :only-child, :empty and the like need that when + * the element opens, before its end has been read. The walk goes through the methods WriteHTML() keeps the stack + * with, on a copy of it, so each element is counted as the stack counts it; no tree is built. What it finds is kept + * in $lookAhead for the frames made while the tokens are written, and given to the frames already open. + * + * An element the call leaves open is not known in full: its totals stay null, and so does whether it is empty, + * unless it holds something already + * + * @param string[] $tokens Text and tags in turn, as WriteHTML() splits the HTML: each tag without its brackets + * @param int $floor How many frames at the foot of the stack the HTML cannot close, as WriteHTML() counts them + * @param bool $standIn Whether the first start tag stands in for the positioned block whose content is written, + * whose frame is open already + * @param bool $close Whether the call closes what is still open at its end + */ + private function readAhead(array $tokens, $floor, $standIn, $close) + { + $openElements = $this->openElements; + $this->lookAhead = []; + $this->readingAhead = true; + // The frames below the floor of a positioned block's content are the flow's, and were read with it + $from = $standIn ? $floor : 0; + + foreach ($tokens as $i => $token) { + if ($i % 2 === 0) { + // White space is text, as browsers have it. Comments are gone by now, though the CSS reader leaves a space + // for each + if ($token !== '') { + $this->openElements[count($this->openElements) - 1]['empty'] = false; + } + } elseif (isset($token[0]) && $token[0] === '/') { + $this->closeElementsEndedBy(trim(strtoupper(substr($token, 1))), $floor); + } elseif (preg_match('/[a-zA-Z][\w:.\-]*/', $token, $name)) { + $tag = strtoupper($name[0]); + $this->closeElementsImpliedBy($tag, $floor); + if ($standIn) { + $standIn = false; + continue; + } + + $this->openElements[count($this->openElements) - 1]['empty'] = false; + $attr = stripos($token, 'data-mpdf-script-run') !== false ? [self::SCRIPT_RUN_ATTRIBUTE => ''] : []; + $this->startElement($tag, $attr, substr($token, -1) === '/'); + } + } + + if ($close) { + $this->closeElementsDownTo($floor); + } + + for ($depth = $from; $depth < count($this->openElements); $depth++) { + $frame = $this->openElements[$depth]; + if ($close) { + // Only the document is left, and nothing more is written into it + $this->lookAhead[$frame['key']] = self::heldBy($frame); + } else { + $this->lookAhead[$frame['key']] = [ + 'childTotal' => null, + 'childTypeTotals' => null, + 'empty' => ($frame['empty'] === false || $frame['children']) ? false : null, + ]; + } + } + + $this->readingAhead = false; + $this->openElements = $openElements; + + foreach ($this->openElements as $depth => $frame) { + if (isset($this->lookAhead[$frame['key']])) { + $this->openElements[$depth] = $this->lookAhead[$frame['key']] + $frame; + } + } + } + + /** + * What readAhead() records of an element whose end has been read + * + * @param array $frame Its frame, as readAhead() kept it: empty is false once text or a start tag is read in it + * + * @return array Its childTotal, childTypeTotals and empty, as getOpenElements() describes them + */ + private static function heldBy(array $frame) + { + return [ + 'childTotal' => count($frame['children']), + 'childTypeTotals' => $frame['childTypes'], + 'empty' => $frame['empty'] !== false && !$frame['children'], + ]; + } + /** * Whether a start tag opens its element inside a tbody that the HTML leaves out. A row written straight into a * table does, as in a browser's tree, so `table > tbody > tr` matches it @@ -306,19 +420,23 @@ private function newChildFrame(array $parent, $tag, array $attr) unset($attr['PAGEBREAKAVOIDCHECKED']); } + $nthChild = count($parent['children']) + 1; + return $this->newElementFrame( $tag, $attr, isset($attr['LANG']) ? $attr['LANG'] : $parent['lang'], - count($parent['children']) + 1, - (isset($parent['childTypes'][$tag]) ? $parent['childTypes'][$tag] : 0) + 1 + $nthChild, + (isset($parent['childTypes'][$tag]) ? $parent['childTypes'][$tag] : 0) + 1, + $parent['key'] . '.' . $nthChild ); } /** * Whether a start tag is for an element of the document, which selectors match and count among its siblings. * The tags mPDF wraps substituted characters in, and the spans it wraps a run of another script in for - * autoScriptToLang, are not. WriteHTML() only tells the merger which element it is styling for such a tag + * autoScriptToLang or a run of a substitute font in, are not. WriteHTML() only tells the merger which element it + * is styling for such a tag * * @param string $tag * @param string[] $attr @@ -390,8 +508,9 @@ private function closeElementsDownTo($depth) /** * Records a closed element among its parent's children, which is what sibling selectors and the nth counts of * later siblings read. The parent is the frame now at the top of the stack. A span mPDF wraps a run of another - * script in is left out, as it is not the document's, and so is an element a browser moves out of the table part - * it is written in + * script or of a substitute font in is left out, as it is not the document's, and so is an element a browser moves + * out of the table part it is written in. While readAhead() walks the tokens, what an element that is recorded + * turned out to hold is kept in $lookAhead too * * @param array $frame The closed element's frame */ @@ -407,12 +526,17 @@ private function recordClosedElement(array $frame) return; } + if ($this->readingAhead) { + $this->lookAhead[$frame['key']] = self::heldBy($frame); + } + $this->openElements[$parent]['children'][] = [ 'tag' => $frame['tag'], 'id' => $frame['id'], 'classes' => $frame['classes'], 'attr' => $frame['attr'], 'nthOfType' => $frame['nthOfType'], + 'empty' => $frame['empty'], ]; $this->openElements[$parent]['childTypes'][$frame['tag']] = $frame['nthOfType']; } diff --git a/tests/Mpdf/Css/CssParserTest.php b/tests/Mpdf/Css/CssParserTest.php index e4907e5b6..22593314b 100644 --- a/tests/Mpdf/Css/CssParserTest.php +++ b/tests/Mpdf/Css/CssParserTest.php @@ -254,7 +254,7 @@ public function droppedSelectors() return [ 'hover' => ['a:hover'], 'pseudo-element' => ['p::before'], - 'last-child' => ['li:last-child'], + 'has' => ['li:has(> a)'], 'a tag outside allowedCSStags' => ['div > sup'], 'the universal selector, which waits for #530' => ['div > *'], 'the universal selector as an ancestor' => ['* + p'], diff --git a/tests/Mpdf/Css/SelectorCompilerTest.php b/tests/Mpdf/Css/SelectorCompilerTest.php index 197c2409d..a3eefdf7f 100644 --- a/tests/Mpdf/Css/SelectorCompilerTest.php +++ b/tests/Mpdf/Css/SelectorCompilerTest.php @@ -143,6 +143,15 @@ public function selectors() [], ], 'nth-of-type' => ['p:nth-of-type(3)', [['tag' => 'P', 'pseudos' => [['nth-of-type', 0, 3]]]], []], + 'last-child' => ['li:Last-Child', [['tag' => 'LI', 'pseudos' => [['nth-last-child', 0, 1]]]], []], + 'last-of-type' => ['p:last-of-type', [['tag' => 'P', 'pseudos' => [['nth-last-of-type', 0, 1]]]], []], + 'nth-last-child' => ['li:nth-last-child(-n + 3)', [['tag' => 'LI', 'pseudos' => [['nth-last-child', -1, 3]]]], []], + 'nth-last-of-type' => ['p:nth-last-of-type(even)', [['tag' => 'P', 'pseudos' => [['nth-last-of-type', 2, 0]]]], []], + 'only-child' => ['li:only-child', [['tag' => 'LI', 'pseudos' => [['only-child']]]], []], + 'only-of-type' => ['p:only-of-type', [['tag' => 'P', 'pseudos' => [['only-of-type']]]], []], + 'empty' => ['p:empty', [['tag' => 'P', 'pseudos' => [['empty']]]], []], + 'first and last together' => ['td:first-child:last-child', [['tag' => 'TD', 'pseudos' => [['nth-child', 0, 1], ['nth-last-child', 0, 1]]]], []], + 'last-child in not' => [':not(:last-child)', [['pseudos' => [['not', [['compounds' => [['tag' => null, 'ids' => [], 'classes' => [], 'attributes' => [], 'pseudos' => [['nth-last-child', 0, 1]]]], 'combinators' => [], 'specificity' => [0, 1, 0], 'universal' => false]]]]]], []], 'pseudo-class with no type' => [':first-child', [['tag' => null, 'pseudos' => [['nth-child', 0, 1]]]], []], 'lang with one range' => [':lang(fr)', [['pseudos' => [['lang', ['fr']]]]], []], 'lang with ranges in any case, some quoted' => [ @@ -281,7 +290,12 @@ public function unmatchableSelectors() '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'], + 'a pseudo-class that looks inside the element' => ['li:has(> a)'], + 'last-child with an argument' => ['li:last-child(2)'], + 'empty with an argument' => ['p:empty()'], + 'nth-last-child with no argument' => ['li:nth-last-child'], + 'nth-last-child with "of" and a selector' => ['li:nth-last-child(2 of .a)'], + 'nth-last-of-type with an argument it cannot read' => ['p:nth-last-of-type(n2)'], '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'], @@ -356,6 +370,7 @@ public function specificities() '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]], + 'each pseudo-class that looks ahead counts as a class' => ['li:last-child:nth-last-child(1):only-child:last-of-type:nth-last-of-type(1):only-of-type:empty', [0, 7, 1]], ]; } diff --git a/tests/Mpdf/Css/SelectorMatcherTest.php b/tests/Mpdf/Css/SelectorMatcherTest.php index ecebb9f1a..1bc6157d2 100644 --- a/tests/Mpdf/Css/SelectorMatcherTest.php +++ b/tests/Mpdf/Css/SelectorMatcherTest.php @@ -243,6 +243,123 @@ public function retriedSelectors() ]; } + /** + * Whether each selector that looks at what follows an element matches the last element of the path below, with + * what is read ahead of each element known: + * + * (5 children) + *

(closed) + *

(open, 3 children) + *

(closed; the p is empty) + *

    (open, 3 children) + *
  • (closed) + *
  • (the element, empty) + *
  • (still to come) + *
    (still to come) + * + * @dataProvider lookAheadSelectors + * + * @param string $selector + * @param bool $expected + */ + public function testMatchesWhatFollowsTheElement($selector, $expected) + { + $this->assertSame($expected, $this->matcher->matches($this->compiler->compile($selector), $this->pathReadAhead(true))); + } + + /** + * A selector, and whether it matches the element + * + * @return array[] + */ + public function lookAheadSelectors() + { + return [ + 'not the last child' => ['li:last-child', false], + 'second from the end' => ['li:nth-last-child(2)', true], + 'not third from the end' => ['li:nth-last-child(3)', false], + 'an odd place from the end' => ['li:nth-last-child(odd)', false], + 'among the last two' => ['li:nth-last-child(-n + 2)', true], + 'second of its type from the end' => ['li:nth-last-of-type(2)', true], + 'not the last of its type' => ['li:last-of-type', false], + 'not the only child' => ['li:only-child', false], + 'not the only one of its type' => ['li:only-of-type', false], + 'empty' => ['li:empty', true], + 'not last' => ['li:not(:last-child)', true], + 'both ends counted' => ['li:nth-child(2):nth-last-child(2)', true], + 'an ancestor that is the last child' => ['ul:last-child > li', true], + 'an ancestor that is not the last child' => ['div:last-child li', false], + 'an ancestor second from the end' => ['div:nth-last-child(2) li', true], + 'an ancestor that is the only one of its type' => ['div:only-of-type li', true], + 'an ancestor that is not empty' => ['ul:empty li', false], + 'a closed sibling of an ancestor that is empty' => ['p:empty + ul > li', true], + 'a closed sibling of an ancestor that is not empty' => ['h2:empty ~ ul li', false], + 'a closed sibling at its place from the end' => ['li:nth-last-child(3) + li', true], + 'a closed sibling of an ancestor, last of its type' => ['p:last-of-type + ul li', true], + 'the document is not a child' => ['body:last-child li', false], + 'the document is not empty' => ['body:empty li', false], + ]; + } + + /** + * Where what follows an element is not known, as for elements still open at the end of a WriteHTML() call that + * leaves them open, a pseudo-class that needs it does not match, and :not() of it does not match either + * + * @dataProvider undecidedSelectors + * + * @param string $selector + * @param bool $expected + */ + public function testDoesNotMatchWhatFollowsWhereItIsNotKnown($selector, $expected) + { + $this->assertSame($expected, $this->matcher->matches($this->compiler->compile($selector), $this->pathReadAhead(false))); + } + + /** + * A selector, and whether it matches the element with what follows it not known + * + * @return array[] + */ + public function undecidedSelectors() + { + return [ + 'last-child' => ['li:last-child', false], + 'not last-child' => ['li:not(:last-child)', false], + 'nth-last-child' => ['li:nth-last-child(2)', false], + 'only-of-type' => ['li:only-of-type', false], + 'empty' => ['li:empty', false], + 'not empty' => ['li:not(:empty)', false], + 'not of a list with something undecided in it' => ['li:not(.y, :last-child)', false], + 'not of a list with something that matches in it' => ['li:not(.x, :last-child)', false], + 'is of a list with something that matches in it' => ['li:is(:last-child, .x)', true], + 'not of not' => ['li:not(:not(:last-child))', false], + 'not of something known beside it' => ['li:not(:first-child)', true], + 'a known ancestor' => ['div:nth-last-child(2) li', true], + 'not on a known ancestor' => ['div:not(:last-child) li', true], + ]; + } + + /** + * The path testMatchesWhatFollowsTheElement() describes + * + * @param bool $known Whether what follows the list and the item is known. Only the rest is if not + * + * @return array[] + */ + private function pathReadAhead($known) + { + $document = $this->readAhead($this->frame('', 1, 1, [], [['H1', null, false], ['P', 'A', false], ['P', null, false]]), 5, ['H1' => 1, 'P' => 2, 'DIV' => 1, 'SECTION' => 1], false); + $div = $this->readAhead($this->frame('DIV', 4, 1, ['ID' => 'MAIN', 'CLASS' => 'CARD'], [['H2', null, false], ['P', null, true]]), 3, ['H2' => 1, 'P' => 1, 'UL' => 1], false); + $list = $this->frame('UL', 3, 1, [], [['LI', null, false]]); + $item = $this->frame('LI', 2, 2, ['CLASS' => 'X'], []); + if ($known) { + $list = $this->readAhead($list, 3, ['LI' => 3], false); + $item = $this->readAhead($item, 0, [], true); + } + + return [$document, $div, $list, $item]; + } + /** * A chain of descendant combinators whose leftmost compound names the nearest ancestor fails without trying every * combination of ancestors. On a path 50 deep, `.x div div div div div p` has nearly two million of them @@ -309,7 +426,7 @@ private function path() * @param int $nthChild * @param int $nthOfType * @param string[] $attr - * @param array[] $children Each closed child's tag and, if any, its class + * @param array[] $children Each closed child's tag and, if any, its class (or null) and whether it is empty * * @return array */ @@ -325,6 +442,7 @@ private function frame($tag, $nthChild, $nthOfType, array $attr, array $children 'classes' => isset($child[1]) ? [$child[1]] : [], 'attr' => isset($child[1]) ? ['CLASS' => $child[1]] : [], 'nthOfType' => $childTypes[$child[0]], + 'empty' => isset($child[2]) ? $child[2] : null, ]; } @@ -338,7 +456,30 @@ private function frame($tag, $nthChild, $nthOfType, array $attr, array $children 'nthOfType' => $nthOfType, 'children' => $records, 'childTypes' => $childTypes, + 'key' => '', + 'childTotal' => null, + 'childTypeTotals' => null, + 'empty' => null, 'computed' => null, ]; } + + /** + * A frame given what TracksOpenElements::readAhead() finds of its element + * + * @param array $frame + * @param int|null $childTotal + * @param int[]|null $childTypeTotals + * @param bool|null $empty + * + * @return array + */ + private function readAhead(array $frame, $childTotal, $childTypeTotals, $empty) + { + $frame['childTotal'] = $childTotal; + $frame['childTypeTotals'] = $childTypeTotals; + $frame['empty'] = $empty; + + return $frame; + } } diff --git a/tests/Mpdf/LookAheadSelectorTest.php b/tests/Mpdf/LookAheadSelectorTest.php new file mode 100644 index 000000000..5110f0d51 --- /dev/null +++ b/tests/Mpdf/LookAheadSelectorTest.php @@ -0,0 +1,822 @@ +writeParts($this->document($context, $css, $groups), ['cssMode' => CssMode::STANDARD]); + + $expected = []; + foreach ($groups as $group) { + foreach ($group as $child) { + if ($child[1] !== '') { + $expected[$child[1]] = $child[2]; + } + } + } + + $this->assertDrawnInColours($expected, $this->keyedByText($mpdf, $mpdf->drawnColours)); + } + + /** + * Every case in every context + * + * @return array[] + */ + public function casesInContexts() + { + $data = []; + foreach (self::$contexts as $context) { + foreach ($this->cases() as $name => $case) { + $data[$context . ': ' . $name] = [$context, $case[0], $case[1]]; + } + } + + return $data; + } + + /** + * Rules, with the elements they are matched against: those that match, and near misses that must not + * + * @return array[] Each [css, groups]. A group is the children of one parent, each [T or O for its tag, its text or + * '' for an empty element, the colour it should be drawn in, its attributes] + */ + private function cases() + { + $r = self::RED; + $g = self::GREEN; + $k = self::BLACK; + + return [ + 'last-child' => [ + '{T}:last-child { color: #f00; }', + [ + [['T', 'first', $k], ['T', 'middle', $k], ['T', 'last', $r]], + [['T', 'before another tag', $k], ['O', 'another tag last', $k]], + ], + ], + 'nth-last-child with a number' => [ + '{T}:nth-last-child(2) { color: #f00; }', + [[['T', 'third from the end', $k], ['T', 'second from the end', $r], ['T', 'at the end', $k]]], + ], + 'nth-last-child with a formula' => [ + '{T}:nth-last-child(2n + 1) { color: #f00; }', + [[['T', 'fourth from the end', $k], ['T', 'third from the end', $r], ['T', 'second from the end', $k], ['T', 'at the end', $r]]], + ], + 'nth-last-child counts siblings of another tag' => [ + '{T}:nth-last-child(2) { color: #f00; }', + [[['T', 'before another tag', $r], ['O', 'another tag last', $k]]], + ], + 'only-child' => [ + '{T}:only-child { color: #f00; }', + [ + [['T', 'alone', $r]], + [['T', 'first of two', $k], ['T', 'second of two', $k]], + [['T', 'beside another tag', $k], ['O', 'another tag', $k]], + ], + ], + 'last-of-type' => [ + '{T}:last-of-type { color: #f00; }', + [[['T', 'first of its tag', $k], ['T', 'last of its tag', $r], ['O', 'another tag after it', $k]]], + ], + 'nth-last-of-type' => [ + '{T}:nth-last-of-type(2) { color: #f00; }', + [[['T', 'second last of its tag', $r], ['O', 'another tag between', $k], ['T', 'last of its tag', $k]]], + ], + 'only-of-type' => [ + '{T}:only-of-type { color: #f00; }', + [ + [['O', 'another tag', $k], ['T', 'only one of its tag', $r], ['O', 'another tag again', $k]], + [['T', 'one of two of its tag', $k], ['T', 'two of two of its tag', $k]], + ], + ], + 'empty' => [ + '{T}:empty + {T} { color: #f00; }', + [[['T', '', null], ['T', 'after the empty one', $r], ['T', 'after one with text', $k]]], + ], + 'not empty' => [ + '{T}:not(:empty) { color: #f00; }', + [[['T', '', null], ['T', 'with text', $r]]], + ], + 'not last-child' => [ + '{T}:not(:last-child) { color: #008000; }', + [[['T', 'not last', $g], ['T', 'nor this', $g], ['T', 'the last', $k]]], + ], + 'last-child with a class' => [ + '{T}.c:last-child { color: #f00; }', + [ + [['T', 'with the class, not last', $k, 'class="c"'], ['T', 'last, without the class', $k]], + [['T', 'before', $k], ['T', 'last, with the class', $r, 'class="c"']], + ], + ], + 'first-child and last-child compete' => [ + '{T}:first-child { color: #008000; } {T}:last-child { color: #f00; }', + [ + [['T', 'first and last', $r]], + [['T', 'first only', $g], ['T', 'last only', $r]], + ], + ], + ]; + } + + /** + * @param string $context + * + * @return string[] The tag of the subjects in a context, another tag for their siblings, and the start and end of + * the element each group is written in + */ + private function tags($context) + { + switch ($context) { + case 'inline': + return ['em', 'b', '

    ', '

    ']; + case 'table cell': + return ['td', 'th', '', '
    ']; + case 'list item': + return ['li', 'div', '
      ', '
    ']; + default: + return ['p', 'h5', '
    ', '
    ']; + } + } + + /** + * A document with each group in its own parent, in a context, under a stylesheet naming the tags the context + * gives them + * + * @param string $context + * @param string $css With {T} for the subjects' tag and {O} for the other tag + * @param array[] $groups As cases() gives them + * + * @return array[] The parts to write it in, each [html, whether it starts the document, whether it closes it] + */ + private function document($context, $css, array $groups) + { + list($subject, $other, $open, $close) = $this->tags($context); + + $style = ''; + $html = ''; + foreach ($groups as $group) { + $html .= $open; + foreach ($group as $child) { + $tag = $child[0] === 'T' ? $subject : $other; + $html .= '<' . $tag . (isset($child[3]) ? ' ' . $child[3] : '') . '>' . $child[1] . ' '; + } + $html .= $close; + } + + switch ($context) { + case 'header': + $html = '' . $html . '

    body

    '; + break; + + case 'footer': + $html = '' . $html . '

    body

    '; + break; + + case 'positioned block': + $html = '
    ' . $html . '

    after

    '; + break; + + case 'kept block': + // The block starts on the first page, runs over, and is laid out again on the second + $html = str_repeat('

    filler

    ', 24) . '
    ' . str_repeat('

    kept

    ', 4) . $html . '
    '; + break; + + case 'forced page break': + $html = '

    before the break

    ' . $html . '
    '; + break; + + case 'written in parts': + return [[$style . '
    ', true, false], [$html, false, false], ['
    ', false, true]]; + } + + return [[$style . $html, true, true]]; + } + + /** + * The kept-block context does lay its block out twice for every case: it starts on the first page, runs over, and + * is put back and laid out again from the top of the second + */ + public function testTheKeptBlockUnwindsForEveryCase() + { + foreach ($this->cases() as $name => $case) { + $mpdf = new UnwindCountingMpdf(['mode' => 'c']); + $mpdf->WriteHTML($this->document('kept block', $case[0], $case[1])[0][0]); + + $this->assertSame(1, $mpdf->unwinds, $name); + $this->assertCount(2, $mpdf->pages, $name); + } + } + + /** + * :empty matches an element with no child elements and no text, as browsers have it: white space is text. A + * comment is not, but in the default mode the CSS reader turns each comment into a space before the HTML is read, + * so an element holding one is not empty; the body mode, which does not read CSS, drops comments outright. The + * white space at the end of a table cell is stripped before the HTML is read, so a cell holding only that is empty + * + * @dataProvider emptyElements + * + * @param string $element The element tested, followed by a paragraph that is red if it is empty + * @param bool $empty + */ + public function testWhatCountsAsEmpty($element, $empty) + { + $colours = $this->drawnColours('
    ' . $element . '

    probe

    ', ['cssMode' => CssMode::STANDARD]); + + $this->assertSame($empty ? self::RED : self::BLACK, $colours['probe']); + } + + /** + * HTML written in the body mode does not go through the CSS reader, so a comment is dropped and the element that + * held only the comment is empty, as in a browser + */ + public function testAnElementHoldingOnlyACommentInTheBodyMode() + { + $mpdf = new TextRecordingMpdf(['mode' => 'c', 'cssMode' => CssMode::STANDARD]); + $mpdf->WriteHTML('p:empty + p.probe { color: #f00; }', HTMLParserMode::HEADER_CSS); + $mpdf->WriteHTML('

    probe

    ', HTMLParserMode::HTML_BODY); + + $this->assertSame(self::RED, $this->keyedByText($mpdf, $mpdf->drawnColours)['probe']); + } + + /** + * An element, and whether it is empty + * + * @return array[] + */ + public function emptyElements() + { + return [ + 'nothing in it' => ['

    ', true], + 'a space' => ['

    ', false], + 'line breaks and tabs' => ["

    \n\t\n

    ", false], + 'a comment, which the CSS reader turns into a space' => ['

    ', false], + 'comments and white space' => ['

    ', false], + 'a closed start tag' => ['
    ', true], + 'a void element' => ['
    ', true], + 'text' => ['

    text

    ', false], + 'a zero' => ['

    0

    ', false], + 'a non-breaking space' => ['

     

    ', false], + 'a non-breaking space as a character' => ["

    \xc2\xa0

    ", false], + 'a void child' => ['


    ', false], + 'an image child' => ['

    ', false], + 'an empty child' => ['

    ', false], + 'a hidden child' => ['

    hidden

    ', false], + 'an empty list item' => ['
    ', false], + ]; + } + + /** + * Rows and cells are counted within their row group and row: the implied tbody, thead and tfoot each have their + * own last row, and an element a browser moves out of the table does not count + * + * @dataProvider tables + * + * @param string $css + * @param string $html + * @param array $expected + */ + public function testTableRowsAndCells($css, $html, array $expected) + { + $this->assertDrawnInColours($expected, $this->drawnColours('' . $html, ['cssMode' => CssMode::STANDARD])); + } + + /** + * A rule for rows or cells, a table, and the colour of each cell's text + * + * @return array[] + */ + public function tables() + { + $r = self::RED; + $b = self::BLUE; + $k = self::BLACK; + + $tables = [ + 'the last row of an implied tbody' => [ + 'tr:last-child td { color: #f00; }', + '
    row one
    row two
    ', + ['row one' => $k, 'row two' => $r], + ], + 'the last row of each row group' => [ + 'tr:last-child td, tr:last-child th { color: #f00; }', + '' + . '' + . '
    head one
    head two
    body one
    body two
    foot one
    ', + ['head one' => $k, 'head two' => $r, 'body one' => $k, 'body two' => $r, 'foot one' => $r], + ], + 'the last row group' => [ + 'tbody:last-child td { color: #f00; }', + '
    first group
    last group
    ', + ['first group' => $k, 'last group' => $r], + ], + 'the last cell' => [ + 'td:last-child { color: #f00; }', + '
    a1a2a3
    b1
    ', + ['a1' => $k, 'a2' => $k, 'a3' => $r, 'b1' => $r], + ], + 'a cell spanning columns is one element' => [ + 'td:nth-last-child(2) { color: #f00; }', + '
    widenarrow
    c1c2c3
    ', + ['wide' => $r, 'narrow' => $k, 'c1' => $k, 'c2' => $r, 'c3' => $k], + ], + 'the only cell' => [ + 'td:only-child { color: #f00; }', + '
    alone
    one of twotwo of two
    ', + ['alone' => $r, 'one of two' => $k, 'two of two' => $k], + ], + 'the last header cell of its type' => [ + 'th:last-of-type { color: #f00; }', + '
    th oneth twotd after
    ', + ['th one' => $k, 'th two' => $r, 'td after' => $k], + ], + 'cells with no end tags' => [ + 'td:last-child { color: #f00; } tr:last-child td { color: #00f; }', + '
    x1x2
    y1y2
    ', + ['x1' => $k, 'x2' => $r, 'y1' => $b, 'y2' => $b], + ], + 'an element moved out of the row is not a cell' => [ + 'td:last-child { color: #f00; }', + '
    before the bookmark
    firstafter the entry
    ', + ['before the bookmark' => $r, 'first' => $k, 'after the entry' => $r], + ], + 'a nested table has its own rows' => [ + 'tr:last-child > td:last-child { color: #f00; }', + '
    outer one
    outer two
    inner oneinner two
    inner three
    outer three
    ', + ['outer one' => $k, 'outer two' => $k, 'inner one' => $k, 'inner two' => $k, 'inner three' => $r, 'outer three' => $r], + ], + 'an empty cell' => [ + 'td:empty + td { color: #f00; }', + '
    after the empty cellafter a full one
    ', + ['after the empty cell' => $r, 'after a full one' => $k], + ], + 'a cell holding only a space, which mPDF strips' => [ + 'td:empty + td { color: #f00; }', + '
    after the cell with a space
    ', + ['after the cell with a space' => $r], + ], + ]; + + return $tables; + } + + /** + * A table header repeated at the top of each page keeps the style its last row was given + */ + public function testARepeatedTableHeader() + { + $rows = ''; + for ($i = 1; $i <= 60; $i++) { + $rows .= 'row ' . $i . ''; + } + $mpdf = $this->drawDocument('' + . '' . $rows . '
    top heading
    last heading
    ', ['cssMode' => CssMode::STANDARD]); + + $headings = []; + foreach ($mpdf->drawnText as $i => $text) { + if (in_array(trim($text), ['top heading', 'last heading'], true)) { + $headings[] = [trim($text), $mpdf->drawnBoxes[$i][0], $mpdf->drawnColours[$i]]; + } + } + + $this->assertSame([ + ['top heading', 1, self::BLACK], ['last heading', 1, self::RED], + ['top heading', 2, self::BLACK], ['last heading', 2, self::RED], + ], $headings); + + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + $this->assertSame(self::BLACK, $colours['row 59']); + $this->assertSame(self::BLUE, $colours['row 60']); + } + + /** + * A table in a page-break-inside: avoid block that runs over the page is read again from the block's start tag, + * and its last row matches both times + */ + public function testATableInABlockThatUnwinds() + { + $mpdf = new TextRecordingMpdf(['mode' => 'c', 'cssMode' => CssMode::STANDARD]); + $unwinds = new UnwindCountingMpdf(['mode' => 'c', 'cssMode' => CssMode::STANDARD]); + $html = '' + . str_repeat('

    filler

    ', 24) + . '
    ' . str_repeat('

    kept

    ', 3) + . '
    onetwo
    threefour

    after

    '; + $mpdf->WriteHTML($html); + $unwinds->WriteHTML($html); + + $this->assertSame(1, $unwinds->unwinds); + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + $pages = $this->keyedByText($mpdf, array_map(function ($box) { + return $box[0]; + }, $mpdf->drawnBoxes)); + $this->assertSame(2, $pages['one']); + $this->assertSame(self::BLACK, $colours['one']); + $this->assertSame(self::BLUE, $colours['two']); + $this->assertSame(self::RED, $colours['three']); + $this->assertSame(self::RED, $colours['four'], 'tr:last-child td is the heavier rule'); + } + + /** + * Elements are counted as the open elements are: a hidden element is still a sibling, end tags left out are + * implied, mPDF's own tags count and a span mPDF adds for a run of another script or font does not + * + * @dataProvider elementModel + * + * @param string $css + * @param string $html + * @param array $expected + * @param array $config + */ + public function testCountsElementsAsTheOpenElementsDo($css, $html, array $expected, array $config = []) + { + $this->assertDrawnInColours($expected, $this->drawnColours('' . $html, $config + ['cssMode' => CssMode::STANDARD])); + } + + /** + * A rule, a document, the colour of each piece of text, and any other configuration it needs + * + * @return array[] + */ + public function elementModel() + { + $r = self::RED; + $k = self::BLACK; + + $documents = [ + 'a hidden element is a sibling' => [ + 'li:last-child { color: #f00; }', + '
    • shown
    • hidden
    ', + ['shown' => $k], + ], + 'an element inside a hidden one is counted' => [ + 'p:last-child { color: #f00; }', + '

    shown

    hidden

    ', + ['shown' => $k], + ], + 'list items with no end tags' => [ + 'li:last-child { color: #f00; } li:first-child { font-weight: bold; }', + '
    • one
    • two
    • three

    after

    ', + ['one' => $k, 'two' => $k, 'three' => $r, 'after' => $k], + ], + 'paragraphs with no end tags' => [ + 'p:last-child { color: #f00; }', + '

    one

    two

    ', + ['one' => $k, 'two' => $r], + ], + 'definitions with no end tags' => [ + 'dd:last-of-type { color: #f00; }', + '
    term
    first
    term two
    second
    ', + ['first' => $k, 'second' => $r], + ], + 'a void element is a sibling' => [ + 'p:last-child { color: #f00; }', + '

    before the rule


    last

    ', + ['before the rule' => $k, 'last' => $r], + ], + 'an element closed by its start tag is a sibling' => [ + 'p:last-child { color: #f00; }', + '

    before the empty div

    ', + ['before the empty div' => $k], + ], + 'a forced page break is a sibling' => [ + 'p:last-child { color: #f00; }', + '

    before the break

    after the break

    ', + ['before the break' => $k, 'after the break' => $r], + ], + 'a block reopened after a forced page break is still one element' => [ + 'div > p:last-child { color: #f00; } div:only-child > p:first-child { font-weight: bold; }', + '

    before

    last

    ', + ['before' => $k, 'last' => $r], + ], + 'a positioned block and the element around it' => [ + 'p:last-child, em:last-child { color: #f00; } div:nth-last-child(2) > p:first-child { color: #00f; }', + 'before the box

    in the box

    last in the box

    after the box
    ', + ['before the box' => $k, 'in the box' => self::BLUE, 'last in the box' => $r, 'after the box' => $r], + ], + 'a span around a run of another script is not an element' => [ + 'span:last-child { color: #f00; }', + '

    latin ελληνικά

    ', + ['latin' => $r], + ['mode' => '', 'autoScriptToLang' => true, 'autoLangToFont' => true, 'default_font' => 'dejavusans'], + ], + 'a span around a run of a substitute font is not an element' => [ + 'span:last-child { color: #f00; } span:nth-child(2) { font-weight: bold; }', + '

    first α second

    ', + ['first' => $k, 'second' => $r], + ['mode' => '', 'useSubstitutions' => true], + ], + 'an ancestor that is the last child' => [ + 'div:last-child > p { color: #f00; }', + '

    in the first

    in the last

    ', + ['in the first' => $k, 'in the last' => $r], + ], + 'an earlier sibling at its place from the end' => [ + 'p:nth-last-child(3) + p { color: #f00; }', + '

    one

    two

    three

    ', + ['one' => $k, 'two' => $r, 'three' => $k], + ], + ]; + + return $documents; + } + + /** + * The span mPDF adds around characters it moves into a substitute font is not the document's, so in the standard + * mode no stylesheet rule reaches it, as a browser has no such element. The legacy mode styles it as before + * + * @dataProvider substituteFontSpan + * + * @param string $mode + * @param string $expected The colour the substituted character is drawn in + */ + public function testAStylesheetRuleForSpansAndTheSpanAroundASubstituteFont($mode, $expected) + { + $colours = $this->drawnColours('

    latin α latin a span

    ', ['mode' => '', 'useSubstitutions' => true, 'cssMode' => $mode]); + + $this->assertSame($expected, $colours['α']); + $this->assertSame(self::RED, $colours['a span']); + } + + /** + * Each CSS mode, and the colour the substituted character is drawn in + * + * @return array[] + */ + public function substituteFontSpan() + { + return [ + 'standard' => [CssMode::STANDARD, self::BLACK], + 'legacy' => [CssMode::LEGACY, self::RED], + ]; + } + + /** + * HTML written in parts: what follows an element open at the end of a call is not known in that call, so the + * pseudo-classes that need it do not match its children there, and :not() of them does not either. Once the + * element closes in a later call, its children written in that call match + * + * @dataProvider htmlInParts + * + * @param string $css + * @param string $first Written first, leaving elements open + * @param string $second Written after, closing them + * @param array $expected + */ + public function testHtmlWrittenInParts($css, $first, $second, array $expected) + { + $mpdf = $this->writeParts([['' . $first, true, false], [$second, false, true]], ['cssMode' => CssMode::STANDARD]); + + $this->assertDrawnInColours($expected, $this->keyedByText($mpdf, $mpdf->drawnColours)); + } + + /** + * A rule, the two parts of a document, and the colour of each piece of text + * + * @return array[] + */ + public function htmlInParts() + { + $r = self::RED; + $k = self::BLACK; + + $documents = [ + 'the last child' => [ + 'li:last-child { color: #f00; }', + '
    • one
    • two
    • ', + '
    • three
    ', + ['one' => $k, 'two' => $k, 'three' => $r], + ], + 'counting from the end' => [ + 'li:nth-last-child(3) { color: #f00; }', + '
    • one
    • two
    • ', + '
    • three
    • four
    • five
    ', + ['one' => $k, 'two' => $k, 'three' => $r, 'four' => $k, 'five' => $k], + ], + 'not the last child' => [ + 'li:not(:last-child) { color: #f00; }', + '
    • one
    • ', + '
    • two
    • three
    ', + ['one' => $k, 'two' => $r, 'three' => $k], + ], + 'the body is open across the calls' => [ + 'p:last-child { color: #f00; }', + '

    one

    two

    ', + '

    three

    ', + ['one' => $k, 'two' => $k, 'three' => $r], + ], + 'an element open at the end of a call with nothing in it yet' => [ + 'p:not(:empty) { color: #f00; }', + '

    closed

    ', + 'written later

    ', + ['closed' => $r, 'written later' => $k], + ], + 'an element open at the end of a call with text in it' => [ + 'p:not(:empty) { color: #f00; }', + '

    started', + ' finished

    ', + ['started' => $r, 'finished' => $r], + ], + ]; + + return $documents; + } + + /** + * A header written, or an index inserted, while the flow's elements are open is a document of its own, read ahead + * on its own. The flow carries on with what was read ahead of it + * + * @dataProvider interruptions + * + * @param string $interruption Written between the first paragraph and the list + * @param array $config + */ + public function testTheFlowCarriesOnAfterADocumentWrittenInsideIt($interruption, array $config) + { + $colours = $this->drawnColours('' + . '

    head one

    head two

    ' + . '

    one

    ' . $interruption . '
    • a
    • b
    • c
    ', $config + ['cssMode' => CssMode::STANDARD]); + + $this->assertDrawnInColours(['b' => self::BLACK, 'c' => self::RED], $colours); + } + + /** + * Something written as a document of its own in the middle of the flow, and the configuration it needs + * + * @return array[] + */ + public function interruptions() + { + $interruptions = [ + 'a header measured for the top margin' => ['', ['setAutoTopMargin' => 'stretch']], + 'an index' => ['', []], + ]; + + return $interruptions; + } + + /** + * Every document written as one call is read ahead in full, so the last element of the body is known to be last + */ + public function testEachCallIsADocumentOfItsOwn() + { + $mpdf = new TextRecordingMpdf(['mode' => 'c', 'cssMode' => CssMode::STANDARD]); + $mpdf->WriteHTML('

    first call, first

    first call, last

    '); + $mpdf->WriteHTML('

    second call, first

    second call, last

    '); + + $this->assertDrawnInColours([ + 'first call, first' => self::BLACK, + 'first call, last' => self::RED, + 'second call, first' => self::BLACK, + 'second call, last' => self::RED, + ], $this->keyedByText($mpdf, $mpdf->drawnColours)); + } + + /** + * These pseudo-classes weigh as a class, and take their place among the rules by specificity and then by source + * order + * + * @dataProvider precedence + * + * @param string $css + * @param string $html + * @param string $expected The colour of the text "subject" + */ + public function testPrecedence($css, $html, $expected) + { + $this->assertSame($expected, $this->drawnColours('' . $html, ['cssMode' => CssMode::STANDARD])['subject']); + } + + /** + * Rules competing for the last of three list items, or for a table's only cell + * + * @return array[] + */ + public function precedence() + { + $list = '
    • one
    • two
    • subject
    '; + $cell = '
    subject
    '; + + return [ + 'a later first-child on a cell' => ['td:last-child { color: #f00; } td:first-child { color: #008000; }', $cell, self::GREEN], + 'an earlier first-child on a cell' => ['td:first-child { color: #008000; } td:last-child { color: #f00; }', $cell, self::RED], + 'a later first-child elsewhere' => ['li:last-child { color: #f00; } li:first-child { color: #008000; }', '
    • subject
    ', self::GREEN], + 'a later class of the same weight' => ['li:last-child { color: #f00; } li.c { color: #008000; }', $list, self::GREEN], + 'an earlier class of the same weight' => ['li.c { color: #008000; } li:last-child { color: #f00; }', $list, self::RED], + 'an id' => ['#i { color: #008000; } li:last-child { color: #f00; }', $list, self::GREEN], + 'a heavier compound' => ['li.c:last-child { color: #f00; } ul li.c { color: #008000; }', $list, self::RED], + 'two pseudo-classes beat one' => ['li:last-child:nth-child(3) { color: #f00; } li:last-child { color: #008000; }', $list, self::RED], + 'one pseudo-class loses to two' => ['li:last-child { color: #008000; } ul > li:only-of-type, li:nth-last-child(1):nth-child(3) { color: #f00; }', $list, self::RED], + ]; + } + + /** + * In the legacy CSS mode these pseudo-classes are dropped, as the legacy parser drops them: a document with rules + * using them is drawn as it is with those rules taken out + * + * @dataProvider legacyDocuments + * + * @param array[] $with The parts of the document, as document() gives them + * @param array[] $without The same with the rules that use these pseudo-classes taken out + * @param array $config + */ + public function testTheLegacyModeDropsThem(array $with, array $without, array $config) + { + $config += ['cssMode' => CssMode::LEGACY]; + $dropped = $this->writeParts($without, $config); + $written = $this->writeParts($with, $config); + + $this->assertSame($dropped->drawnText, $written->drawnText); + $this->assertSame($dropped->drawnColours, $written->drawnColours); + } + + /** + * Every case in every context, every table and every document of the element model, each with its rules and + * without those that use these pseudo-classes + * + * @return array[] + */ + public function legacyDocuments() + { + $data = []; + foreach (self::$contexts as $context) { + foreach ($this->cases() as $name => $case) { + $parts = $this->document($context, $case[0], $case[1]); + $data[$context . ': ' . $name] = [$parts, self::withoutLookAhead($parts), []]; + } + } + + foreach (['table' => $this->tables(), 'element model' => $this->elementModel()] as $set => $documents) { + foreach ($documents as $name => $document) { + $parts = [['' . $document[1], true, true]]; + $data[$set . ': ' . $name] = [$parts, self::withoutLookAhead($parts), isset($document[3]) ? $document[3] : []]; + } + } + + return $data; + } + + /** + * @param array[] $parts A document's parts, as document() gives them + * + * @return array[] The same parts, with the rules whose selector uses a pseudo-class that looks at what follows + * taken out of their stylesheets + */ + private static function withoutLookAhead(array $parts) + { + foreach ($parts as $i => $part) { + $parts[$i][0] = preg_replace_callback('/'; + }, $part[0]); + } + + return $parts; + } + + /** + * Writes a document in parts, each [html, whether it starts the document, whether it closes it], in core-font mode + * + * @param array[] $parts + * @param array $config + * + * @return TextRecordingMpdf + */ + private function writeParts(array $parts, array $config) + { + $mpdf = new TextRecordingMpdf($config + ['mode' => 'c']); + foreach ($parts as $part) { + $mpdf->WriteHTML($part[0], HTMLParserMode::DEFAULT_MODE, $part[1], $part[2]); + } + + return $mpdf; + } +} diff --git a/tests/Mpdf/OpenElementStackTest.php b/tests/Mpdf/OpenElementStackTest.php index c811d00e6..81c38adf5 100644 --- a/tests/Mpdf/OpenElementStackTest.php +++ b/tests/Mpdf/OpenElementStackTest.php @@ -329,6 +329,95 @@ public function testAnElementInsideAHiddenOneKeepsItsAttributes() $this->assertSame(['X'], $open[1]['children'][0]['classes']); } + /** + * Each frame carries what was read ahead of its element: how many children it has in all, of each tag, and + * whether it is empty. The record of a closed child keeps whether it is empty + */ + public function testAFrameKnowsWhatItsElementHolds() + { + $mpdf = $this->mpdfRecording(); + $mpdf->WriteHTML('

    x

    y

    after

    '); + + $div = $this->frameAt($mpdf, 'x', 1); + $this->assertSame('.1', $div['key']); + $this->assertSame(3, $div['childTotal']); + $this->assertSame(['P' => 2, 'H2' => 1], $div['childTypeTotals']); + $this->assertFalse($div['empty']); + + $p = $this->frameAt($mpdf, 'x', 2); + $this->assertSame('.1.1', $p['key']); + $this->assertSame(0, $p['childTotal']); + $this->assertSame([], $p['childTypeTotals']); + $this->assertFalse($p['empty']); + + $this->assertSame([false, true], array_column($this->frameAt($mpdf, 'y', 1)['children'], 'empty')); + $this->assertSame(2, $this->frameAt($mpdf, 'after', 0)['childTotal']); + } + + /** + * Rows and cells are counted in the tbody the stack puts a row written straight into a table in, with their end + * tags implied + */ + public function testWhatARowGroupHoldsIsCountedWithItsImpliedTags() + { + $mpdf = $this->write('
    ab
    c
    '); + + $this->assertSame(1, $this->frameAt($mpdf, 'a', 1)['childTotal']); + $this->assertSame(2, $this->frameAt($mpdf, 'a', 2)['childTotal']); + $this->assertSame(2, $this->frameAt($mpdf, 'a', 3)['childTotal']); + $this->assertSame(1, $this->frameAt($mpdf, 'c', 3)['childTotal']); + } + + /** + * An element the call leaves open is not known in full: its totals are null, and it is known not to be empty + * only once something is in it. A later call that closes it knows it, and tells its frame + */ + public function testWhatAnElementHoldsIsKnownOnceItsEndIsRead() + { + $mpdf = $this->mpdfRecording(); + $mpdf->WriteHTML('

    one

    ', HTMLParserMode::DEFAULT_MODE, true, false); + + $open = $mpdf->getOpenElements(); + $this->assertNull($open[0]['childTotal']); + $this->assertNull($open[1]['childTotal']); + $this->assertNull($open[1]['childTypeTotals']); + $this->assertFalse($open[1]['empty'], 'it holds a paragraph'); + $this->assertNull($open[2]['empty'], 'nothing is in it yet'); + $this->assertSame(0, $this->frameAt($mpdf, 'one', 2)['childTotal']); + + $mpdf->WriteHTML('two

    three

    ', HTMLParserMode::DEFAULT_MODE, false, true); + + $this->assertSame(3, $this->frameAt($mpdf, 'three', 1)['childTotal']); + $this->assertSame(1, $this->frameAt($mpdf, 'three', 0)['childTotal']); + $this->assertFalse($this->frameAt($mpdf, 'two', 2)['empty']); + } + + /** + * A kept block laid out again, a header written as a document of its own, and a positioned block's content + * written after the page all see what was read ahead of their elements + */ + public function testWhatWasReadAheadHoldsInEveryContext() + { + $mpdf = $this->write($this->filler(22) . '
    ' + . str_repeat('

    Kept

    ', 11) . '

    last kept

    after

    '); + $this->assertSame(1, $mpdf->unwinds); + $this->assertSame(12, $this->frameAt($mpdf, 'last kept', 1)['childTotal']); + + $mpdf = $this->mpdfRecording(); + $mpdf->SetHTMLHeader('
    headerbold
    '); + $mpdf->WriteHTML('

    body

    '); + $this->output($mpdf); + $this->assertSame(2, $this->frameAt($mpdf, 'header', 1)['childTotal']); + $this->assertSame(1, $this->frameAt($mpdf, 'header', 0)['childTotal']); + + $mpdf = $this->mpdfRecording(); + $mpdf->WriteHTML('
    ' + . '

    first

    nested

    beside
    '); + $this->assertSame(2, $this->frameAt($mpdf, 'nested', 1)['childTotal']); + $this->assertSame(2, $this->frameAt($mpdf, 'nested', 2)['childTotal']); + $this->assertSame(1, $this->frameAt($mpdf, 'nested', 3)['childTotal']); + } + /** * Closing the document closes every element still open, into the record of the document's children */ diff --git a/tests/Mpdf/StructuralSelectorTest.php b/tests/Mpdf/StructuralSelectorTest.php index fb414c176..0c32fa4c6 100644 --- a/tests/Mpdf/StructuralSelectorTest.php +++ b/tests/Mpdf/StructuralSelectorTest.php @@ -248,9 +248,9 @@ public function selectors() '

    child

    next

    ', ['child' => self::BLACK, 'next' => self::BLACK], ], - 'a pseudo-class that needs the elements after it is still dropped' => [ - 'li:last-child { color: #f00; }', - '
    • one
    • last
    ', + 'a pseudo-class that looks inside the element is still dropped' => [ + 'li:has(> b) { color: #f00; }', + '
    • one
    • bold last
    ', ['last' => self::BLACK], ], ]; diff --git a/tests/Mpdf/SubstitutionRunCutTest.php b/tests/Mpdf/SubstitutionRunCutTest.php index b2c38e91e..a4307090c 100644 --- a/tests/Mpdf/SubstitutionRunCutTest.php +++ b/tests/Mpdf/SubstitutionRunCutTest.php @@ -68,7 +68,7 @@ public function testTheRunIsCutWhereTheScanFoundItRatherThanAtAnEarlierCopy() $this->assertSame([ $this->text([0x61, 0x2665, 0xFE0E, 0x62]), - 'span style="font-family: dejavusans"', + 'span data-mpdf-script-run style="font-family: dejavusans"', $this->text([0xFE0E]), '/span', 'c', @@ -92,7 +92,7 @@ public function testANewlineKeepsTheTextEitherSideOfARun() "one\n", '', 'two', - 'span style="font-family: garuda"', + 'span data-mpdf-script-run style="font-family: garuda"', $this->text([0x0E01]), '/span', "three\n", @@ -115,7 +115,7 @@ public function testANewlineKeepsTheTextEitherSideOfARunInACoreFont() $this->assertSame([ "one\ntwo", - 'span style="font-family: garuda"', + 'span data-mpdf-script-run style="font-family: garuda"', $this->text([0x0E01]), '/span', "three\nfour", @@ -144,7 +144,7 @@ public function testAPlane2RunKeepsTheTextEitherSideOfIt($before, $after) $this->assertSame([ $before, - 'span style="font-family: sun-extb"', + 'span data-mpdf-script-run style="font-family: sun-extb"', $run, '/span', $after, diff --git a/tests/Mpdf/UnmatchableDescendantSelectorTest.php b/tests/Mpdf/UnmatchableDescendantSelectorTest.php index 3b5b32507..e16143d78 100644 --- a/tests/Mpdf/UnmatchableDescendantSelectorTest.php +++ b/tests/Mpdf/UnmatchableDescendantSelectorTest.php @@ -52,7 +52,7 @@ public function rules() self::BLACK, ], 'an unsupported pseudo-class after two parts' => [ - 'nav ul li:last-child { color: #ff0000; }', + 'nav ul li:hover { color: #ff0000; }', '', self::BLACK, ], diff --git a/tests/Snapshots/LookAheadSelectorSnapshotTest.php b/tests/Snapshots/LookAheadSelectorSnapshotTest.php new file mode 100644 index 000000000..482e80377 --- /dev/null +++ b/tests/Snapshots/LookAheadSelectorSnapshotTest.php @@ -0,0 +1,102 @@ + + + +

    Header, first line

    Header, last line: red

    + + +

    Pseudo-classes that look at what follows an element

    + +

    :last-child. The third item is red; the first two are black.

    +
    • First item
    • Second item
    • Third item
    + +

    :nth-last-child(2). The third of four items is red.

    +
    1. One
    2. Two
    3. Three
    4. Four
    + +

    :only-child. The item alone in its list is red; the two items of the second list are black.

    +
    • Alone
    +
    • One of two
    • Two of two
    + +

    :last-of-type. The second paragraph is red, although a heading follows it.

    +

    First paragraph

    Second paragraph

    A heading after them
    + +

    :nth-last-of-type(2). The first of the two paragraphs is red; the heading between them does not count.

    +

    First paragraph

    A heading between

    Last paragraph

    + +

    :only-of-type. The heading alone of its tag is red; the paragraphs around it are black.

    +

    A paragraph

    The only heading

    Another paragraph

    + +

    :empty. The second cell, with nothing in it, is red. The third, holding a non-breaking space, and the fourth, holding an empty span, are not. The paragraph after an empty one is red; the one after a paragraph holding a space is black, as white space is content in a browser.

    +
    Text 
    +

    After an empty paragraph: red

    After a paragraph holding a space: black

    + +

    tr:last-child td. The last row, written without a tbody, is pink.

    +
    Row one1
    Row two2
    Row three3
    + +

    td:last-child. The last cell of each row is red, whatever the number of cells in the row.

    +
    a1a2a3
    b1, two columnsb2
    c1, alone
    + +

    :not(:last-child). Every item but the last is red.

    +
    • First item
    • Second item
    • Last item
    + +

    In the page header, the second line is red. In the positioned box at the foot of the page, its last line is red.

    +

    Positioned box, first line

    Positioned box, last line: red

    + mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD, 'margin_top' => 28]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/LookAheadStylesheetSnapshotTest.php b/tests/Snapshots/LookAheadStylesheetSnapshotTest.php new file mode 100644 index 000000000..b80eed1ee --- /dev/null +++ b/tests/Snapshots/LookAheadStylesheetSnapshotTest.php @@ -0,0 +1,135 @@ +Line ' . $i . '' . number_format($i * 12.5, 2) . ''; + } + + ob_start(); + ?> + + +

    A framework's stylesheet, with :last-child and :empty

    + +

    Card list

    +

    .card-list .item:last-child against .card-list .item, as heavy and written later: a blue rule under every item but the last, which sits on the card's grey border alone. The first item is bold.

    +
    +
      +
    • First item
    • +
    • Second item
    • +
    • Third item
    • +
    +
    +
    +
      +
    • A single item, bold and with no blue rule
    • +
    +
    + +

    Menus and steps

    +

    li:only-child. The menu with a single entry is green with no bullet; the entries of the menu of two are blue with bullets.

    + + +

    :first-child then :last-child. The first step is green and the last red; a list with one step is red, the later rule winning.

    +
    1. Start
    2. Middle
    3. Finish
    +
    1. A single step
    +

    li.highlight written after li:last-child, as heavy. The highlighted last tag is orange, the later rule winning.

    +
    • First tag
    • Highlighted last tag
    + +

    Content from an editor

    +

    p:empty { display: none }. No blue strip between the first two paragraphs, where the editor left an empty paragraph. The paragraph holding a space, between the second and third, is not empty, as in a browser, and shows as a thin blue strip.

    +
    +

    First paragraph.

    +

    +

    Second paragraph.

    +

    +

    Third paragraph.

    +
    + +

    Alert

    +

    #notice p against .alert p:last-child. Both lines are blue, the id outweighing the pseudo-class, and the last line has no space under it.

    +

    The first line of the notice.

    The last line of the notice.

    + +

    Table

    +

    .table tr:last-child td against .table td. A grey rule under each body row but the last, a black rule under the last header row, on this page and the next, and no rule under the total. The note spanning the row is grey and italic.

    + + + + +
    Statement
    ItemAmount
    Total10,250.00
    A note spanning the row
    + mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php b/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php index 4e391084d..2e94c260a 100644 --- a/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php +++ b/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php @@ -45,7 +45,7 @@ public function generatePdf() div.combinators p + p { color: #ff0000; font-size: 16pt; } div.combinators p ~ p { color: #ff0000; font-size: 16pt; } - nav ul li:last-child { color: #ff0000; font-weight: bold; } + nav ul li:has(+ li) { color: #ff0000; font-weight: bold; } div.links p a:hover { background-color: #ff0000; } div.quotes p::before { color: #ff0000; font-size: 16pt; } @@ -68,7 +68,7 @@ public function generatePdf()

    Pseudo-classes and pseudo-elements

    -

    Rules using :last-child, :hover and ::before are dropped. Nothing on this list or these paragraphs is red or bold, and no paragraph has a red background.

    +

    Rules using :has(), :hover and ::before are dropped. Nothing on this list or these paragraphs is red or bold, and no paragraph has a red background.