diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index f85e9757e..e3b577a15 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -102,6 +102,7 @@ New features ### CSS * **An id written with classes.** Selectors such as `p#note.warning`, `p.warning#note`, `#note.warning` and `p.a.b#note` match, on their own and as parts of descendant rules, in any order of id and classes. They are applied after `p#note`. A selector with a class or id followed by something mPDF cannot match, such as `.a:hover` or `#note::before`, is dropped like any other selector mPDF cannot match. [#527] [#568] +* **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] Performance ----------- @@ -373,7 +374,9 @@ These changes do not change output. * **Open elements.** `WriteHTML()` keeps a stack of the elements open in the HTML it reads, so that CSS rules can be matched against an element's parents, ancestors and earlier siblings rather than only the blocks mPDF lays out. Each element carries its position among its siblings and a record of the siblings before it. `Mpdf::getOpenElements()` reads it. [#533] * **Compiled selectors.** A rule whose selector the legacy parser cannot read is compiled into its compound selectors, the combinators between them and its specificity, and filed under its rightmost compound for the selector matcher. A selector list is split at the commas outside parentheses, brackets and strings, so `:is(h1, h2)` and `[title="a,b"]` stay whole. [#538] [#619] +[mpdf/mpdf#7]: https://github.com/mpdf/mpdf/issues/7 [mpdf/mpdf#83]: https://github.com/mpdf/mpdf/issues/83 +[mpdf/mpdf#318]: https://github.com/mpdf/mpdf/issues/318 [mpdf/mpdf#747]: https://github.com/mpdf/mpdf/issues/747 [mpdf/mpdf#833]: https://github.com/mpdf/mpdf/issues/833 [mpdf/mpdf#1010]: https://github.com/mpdf/mpdf/issues/1010 @@ -802,6 +805,7 @@ These changes do not change output. [#614]: https://github.com/GravityPDF/mpdf/pull/614 [#615]: https://github.com/GravityPDF/mpdf/pull/615 [#619]: https://github.com/GravityPDF/mpdf/pull/619 +[#620]: https://github.com/GravityPDF/mpdf/pull/620 [#622]: https://github.com/GravityPDF/mpdf/pull/622 [#623]: https://github.com/GravityPDF/mpdf/pull/623 [#625]: https://github.com/GravityPDF/mpdf/pull/625 diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 065bc206c..18a20e18f 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -146,6 +146,7 @@ public function merge($inherit, $tag, $attr) $this->mergeStylesheetSelectors($tag, $attr, $classes, $languageCode); $this->mergeTagSpecificSelectors($tag, $attr, $classes, $languageCode); $this->mergeDescendantSelectors($inherit, $tag, $attr, $classes, $languageCode); + $this->mergeCompiledRules($tag, $attr); $this->mergeInlineStyle($tag, $attr); return $this->cssProperties; @@ -832,6 +833,27 @@ private function idClassKeys($tag, $id, $classes) return $keys; } + /** + * Apply the rules whose selector only the matcher reads, such as div > p, h1 + p and li:first-child, matched + * against the element and the open elements around it. They go with the descendant rules, after them, in the + * order of their specificity and then of their position in the stylesheets. + * + * @param string $tag HTML tag name, uppercased + * @param array $attr HTML attributes, with ID and CLASS uppercased and ID empty for none + * @return void + */ + protected function mergeCompiledRules($tag, $attr) + { + $classes = isset($attr['CLASS']) ? preg_split('/\s+/', $attr['CLASS'], -1, PREG_SPLIT_NO_EMPTY) : []; + $path = [$this->mpdf, 'getStyledElementPath']; + + $dominance = $tag === 'TD' || $tag === 'TH' ? 9 : false; + + foreach ($this->cssManager->getRules()->matchingDeclarations($tag, $attr['ID'], $classes, $path) as $properties) { + $this->setMergedCss($properties, false, $dominance); + } + } + /** * Merge CSS properties into target array. * diff --git a/src/Css/RuleSet.php b/src/Css/RuleSet.php index 0dfbdf3e5..a67713c39 100644 --- a/src/Css/RuleSet.php +++ b/src/Css/RuleSet.php @@ -11,7 +11,8 @@ class RuleSet { /** - * @var array[] Each rule, by its position in the stylesheets read: [compiled selector, declarations] + * @var array[] Each rule, by its position in the stylesheets read: [compiled selector, declarations, the ancestors + * it requires as requiredAncestors() gives them] */ private $rules = []; @@ -21,6 +22,19 @@ class RuleSet */ private $index = ['id' => [], 'class' => [], 'tag' => [], 'any' => []]; + /** + * @var SelectorMatcher Matches each rule filed under an element against the open elements around it + */ + private $matcher; + + /** + * An empty rule set + */ + public function __construct() + { + $this->matcher = new SelectorMatcher(); + } + /** * Adds a rule after those already read * @@ -30,7 +44,7 @@ class RuleSet public function add(array $selector, array $declarations) { $position = count($this->rules); - $this->rules[] = [$selector, $declarations]; + $this->rules[] = [$selector, $declarations, self::requiredAncestors($selector)]; self::file($this->index, $selector['compounds'][count($selector['compounds']) - 1], $position); } @@ -50,10 +64,66 @@ public function candidates($tag, $id, array $classes) return self::collect($this->index, $tag, $id, $classes); } + /** + * The declarations of the rules an element matches, in the order they apply: by specificity, then by position + * + * @param string $tag Uppercased + * @param string $id Uppercased, or empty for none + * @param string[] $classes Uppercased + * @param callable $path Gives the open elements from the document down to the element, or null for none. Only + * called when a rule is filed under the element + * + * @return array[] + */ + public function matchingDeclarations($tag, $id, array $classes, callable $path) + { + $candidates = $this->candidates($tag, $id, $classes); + if (!$candidates) { + return []; + } + + $path = call_user_func($path); + if ($path === null || $path[count($path) - 1]['tag'] !== $tag) { + return []; + } + + $ancestors = self::ancestorsOf($path); + $matched = []; + foreach ($candidates as $position) { + list($selector, , $required) = $this->rules[$position]; + foreach ($required as $key) { + if (!isset($ancestors[$key])) { + continue 2; + } + } + + if ($this->matcher->matches($selector, $path)) { + $matched[] = $position; + } + } + + $rules = $this->rules; + usort($matched, function ($a, $b) use ($rules) { + // Arrays of the same keys compare element by element: ids, then classes, then tags + if ($rules[$a][0]['specificity'] != $rules[$b][0]['specificity']) { + return $rules[$a][0]['specificity'] < $rules[$b][0]['specificity'] ? -1 : 1; + } + + return $a - $b; + }); + + $declarations = []; + foreach ($matched as $position) { + $declarations[] = $this->rules[$position][1]; + } + + return $declarations; + } + /** * @param int $position * - * @return array The rule at a position, as it is kept: [compiled selector, declarations] + * @return array The rule at a position, as it is kept: [compiled selector, declarations, required ancestors] */ public function rule($position) { @@ -105,4 +175,59 @@ private static function collect(array $index, $tag, $id, array $classes) return $positions; } + + /** + * A key for each compound of a selector that an ancestor of the element has to match: the compound's first id, + * else its first class, else its tag. A compound followed by a descendant or child combinator names an ancestor, + * since an ancestor of a sibling is one too. A rule is only matched for an element whose ancestors give every key + * + * @param array $selector A selector SelectorCompiler::compile() compiled + * + * @return string[] Each key prefixed with I, C or T for an id, a class or a tag + */ + private static function requiredAncestors(array $selector) + { + $keys = []; + foreach ($selector['combinators'] as $i => $combinator) { + if ($combinator !== ' ' && $combinator !== '>') { + continue; + } + + $compound = $selector['compounds'][$i]; + if ($compound['ids']) { + $keys[] = 'I' . $compound['ids'][0]; + } elseif ($compound['classes']) { + $keys[] = 'C' . $compound['classes'][0]; + } elseif ($compound['tag'] !== null) { + $keys[] = 'T' . $compound['tag']; + } + } + + return $keys; + } + + /** + * The other half of the pre-filter requiredAncestors() sets up: the keys an element's ancestors give, so that a + * rule naming an ancestor the element does not have is passed over without running the matcher + * + * @param array[] $path The open elements from the document down to the element, with the element last + * + * @return array The keys requiredAncestors() gives for the ids, classes and tags of the element's + * ancestors, the document's frame being body + */ + private static function ancestorsOf(array $path) + { + $keys = ['T' . 'BODY' => true]; // the document's frame + for ($depth = count($path) - 2; $depth > 0; $depth--) { + $keys['T' . $path[$depth]['tag']] = true; + if ($path[$depth]['id'] !== '') { + $keys['I' . $path[$depth]['id']] = true; + } + foreach ($path[$depth]['classes'] as $class) { + $keys['C' . $class] = true; + } + } + + return $keys; + } } diff --git a/src/Css/SelectorMatcher.php b/src/Css/SelectorMatcher.php new file mode 100644 index 000000000..f63c2b6bb --- /dev/null +++ b/src/Css/SelectorMatcher.php @@ -0,0 +1,186 @@ +matchesFrom($selector, count($selector['compounds']) - 1, $path, $depth - 1, $path[$depth]['nthChild'] - 1); + } + + /** + * Whether a compound matches an element, and the compounds to its left match the elements its combinators lead to. + * A descendant or general sibling combinator tries each ancestor or earlier sibling in turn, nearest first, so a + * compound further left that fails on the nearest one is tried again on the next + * + * @param array $selector A selector SelectorCompiler::compile() compiled + * @param int $compound Which of its compounds to match, from the left + * @param array[] $path The open elements from the document down to the element being styled + * @param int $parent The depth on $path of the element's parent, or -1 for the document itself + * @param int $index The element's index among its parent's children, from 0. At the number of children the parent + * has closed, it is the open element at the next depth + * + * @return bool + */ + private function matchesFrom(array $selector, $compound, array $path, $parent, $index) + { + if (!$this->matchesCompound($selector['compounds'][$compound], $path, $parent, $index)) { + return false; + } + + if ($compound === 0) { + return true; + } + + $compound--; + switch ($selector['combinators'][$compound]) { + case '>': + return $parent >= 0 && $this->matchesFrom($selector, $compound, $path, $parent - 1, $path[$parent]['nthChild'] - 1); + + case ' ': + for ($depth = $parent; $depth >= 0; $depth--) { + if ($this->matchesFrom($selector, $compound, $path, $depth - 1, $path[$depth]['nthChild'] - 1)) { + return true; + } + } + + return false; + + case '+': + return $parent >= 0 && $index > 0 && $this->matchesFrom($selector, $compound, $path, $parent, $index - 1); + + default: + for ($sibling = $index - 1; $parent >= 0 && $sibling >= 0; $sibling--) { + if ($this->matchesFrom($selector, $compound, $path, $parent, $sibling)) { + return true; + } + } + + return false; + } + } + + /** + * Whether one compound selector matches an element: its tag, each of its ids and classes, and each of its + * pseudo-classes + * + * @param array $compound One of a compiled selector's compounds, as SelectorCompiler describes them + * @param array[] $path As matchesFrom() takes it + * @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 bool + */ + private function matchesCompound(array $compound, array $path, $parent, $index) + { + $element = $this->element($path, $parent, $index); + + if ($compound['tag'] !== null && $compound['tag'] !== $element['tag']) { + return false; + } + + foreach ($compound['ids'] as $id) { + if ($id !== $element['id']) { + return false; + } + } + + foreach ($compound['classes'] as $class) { + if (!in_array($class, $element['classes'], true)) { + return false; + } + } + + foreach ($compound['pseudos'] as $pseudo) { + if (!$this->matchesPseudoClass($pseudo, $element, $parent, $index)) { + return false; + } + } + + return true; + } + + /** + * Whether a structural 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 + * + * @param array $pseudo A compiled pseudo-class: ['nth-child', a, b] or ['nth-of-type', a, b] + * @param array $element The element's frame or record, as element() gives it + * @param int $parent The depth of the element's parent on the path, as matchesFrom() takes it + * @param int $index The element's index among its parent's children, as matchesFrom() takes it + * + * @return bool + */ + private function matchesPseudoClass(array $pseudo, array $element, $parent, $index) + { + if ($parent < 0) { + return false; + } + + if ($pseudo[0] === 'nth-child') { + return $this->isNth($pseudo[1], $pseudo[2], $index + 1); + } + + return $this->isNth($pseudo[1], $pseudo[2], $element['nthOfType']); + } + + /** + * The an+b test behind :nth-child() and :nth-of-type(): whether a position is a*n+b for some n from 0 up + * + * @param int $a The step, which may be 0 or negative + * @param int $b The offset + * @param int $position The element's position among the siblings counted, from 1 + * + * @return bool + */ + private function isNth($a, $b, $position) + { + if ($a === 0) { + return $position === $b; + } + + return ($position - $b) % $a === 0 && ($position - $b) / $a >= 0; + } + + /** + * An element: an open element's frame, a closed sibling's record, or body for the document + * + * @param array[] $path As matchesFrom() takes it + * @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 + */ + private function element(array $path, $parent, $index) + { + if ($parent < 0) { + return ['tag' => 'BODY', 'id' => '', 'classes' => [], 'attr' => []]; + } + + if ($index === count($path[$parent]['children'])) { + return $path[$parent + 1]; + } + + return $path[$parent]['children'][$index]; + } +} diff --git a/src/Mpdf.php b/src/Mpdf.php index f4e3e16db..236b6cf3d 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -59,6 +59,11 @@ 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 + */ + const SCRIPT_RUN_ATTRIBUTE = 'DATA-MPDF-SCRIPT-RUN'; + /** * A zero-width space (U+200B) in UTF-8, as Unicode-font text carries it */ @@ -3501,10 +3506,9 @@ function _postForcedPagebreak($pagebreaktype, $startpage, $save_blk, $save_blklv $this->blk[0] = $save_blk[0]; // Re-open block tags $this->blklvl = 0; - $arr = []; - $i = 0; + $depths = $this->openBlockDepths($save_blk, $save_blklvl); for ($b = 1; $b <= $save_blklvl; $b++) { - $this->tag->OpenTag($save_blk[$b]['tag'], $save_blk[$b]['attr'], $arr, $i); + $this->reopenBlock($save_blk[$b], $depths[$b]); } } elseif ($pagebreaktype == 'clonebycss') { $this->blk = []; @@ -3526,13 +3530,12 @@ function _postForcedPagebreak($pagebreaktype, $startpage, $save_blk, $save_blklv } // Re-open block tags for any that have box_decoration_break==clone - $arr = []; - $i = 0; + $depths = $this->openBlockDepths($save_blk, $save_blklvl); for ($b = $this->blklvl + 1; $b <= $save_blklvl; $b++) { if ($b < $this->blklvl) { $this->lastblocklevelchange = -1; } - $this->tag->OpenTag($save_blk[$b]['tag'], $save_blk[$b]['attr'], $arr, $i); + $this->reopenBlock($save_blk[$b], $depths[$b]); } if ($this->blk[$this->blklvl]['box_decoration_break'] != 'clone') { $this->lastblocklevelchange = -1; @@ -15107,6 +15110,9 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c $this->closeElementsImpliedBy($tag, $floor); $token = $i; + // The
standing in for a positioned block has had the block's rules, through its style + $outerElement = $this->styledElement; + $this->styledElement = !$standIn && $this->isDocumentElement($tag, $attr) ? ['path' => $this->openElements, 'tag' => $tag, 'attr' => $attr] : null; $this->tag->OpenTag($tag, $attr, $a, $i); // mPDF 6 // Unless it put back a page-break-inside: avoid block, which rewinds the parser to that block's start // tag and the open elements to how they were there @@ -15117,6 +15123,8 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c $this->startElement($tag, $attr, $selfClosing); } } + // Held on to, the copy of the stack would make recording each closed child copy its parent's record + $this->styledElement = $outerElement; /* -- CSS-POSITION -- */ if ($this->inFixedPosBlock) { $this->fixedPosBlockBBox = [$tag, $attr, $this->x, $this->y]; @@ -15298,7 +15306,10 @@ function WriteFixedPosHTML($html, $x, $y, $w, $h, $overflow = 'visible', $boundi $this->blk[1]['tag'] = $tag; $this->blk[1]['attr'] = $attr; $this->Reset(); + $outerElement = $this->styledElement; + $this->styledElement = $this->fixedPosBlockElements === null ? null : ['path' => $this->fixedPosBlockElements, 'tag' => null, 'attr' => []]; $p = $this->cssManager->MergeCSS('BLOCK', $tag, $attr); + $this->styledElement = $outerElement; $this->fixedPosBlockCascadeCSS = $this->blk[1]['cascadeCSS']; if (isset($p['ROTATE'])) { $rotate = Rotation::angle($p['ROTATE'], [90, -90, 180]); @@ -28035,25 +28046,25 @@ function markScriptToLang($html) // Check Vietnamese if Latin script - even if Basescript if ($scriptblocks[$sch] == Ucdn::SCRIPT_LATIN && $this->autoVietnamese && preg_match("/([" . $this->scriptToLanguage->getLanguageDelimiters('viet') . "])/u", $s)) { - $o .= '' . $s . ''; + $o .= '' . $s . ''; } elseif ($scriptblocks[$sch] == Ucdn::SCRIPT_ARABIC && $this->autoArabic) { // Check Arabic for different languages if Arabic script - even if Basescript if (preg_match("/[" . $this->scriptToLanguage->getLanguageDelimiters('sindhi') . "]/u", $s)) { - $o .= '' . $s . ''; + $o .= '' . $s . ''; } elseif (preg_match("/[" . $this->scriptToLanguage->getLanguageDelimiters('urdu') . "]/u", $s)) { - $o .= '' . $s . ''; + $o .= '' . $s . ''; } elseif (preg_match("/[" . $this->scriptToLanguage->getLanguageDelimiters('pashto') . "]/u", $s)) { - $o .= '' . $s . ''; + $o .= '' . $s . ''; } elseif (preg_match("/[" . $this->scriptToLanguage->getLanguageDelimiters('persian') . "]/u", $s)) { - $o .= '' . $s . ''; + $o .= '' . $s . ''; } elseif ($this->baseScript != Ucdn::SCRIPT_ARABIC && $this->scriptToLanguage->getLanguageByScript($scriptblocks[$sch])) { - $o .= '' . $s . ''; + $o .= '' . $s . ''; } else { // Just output chars $o .= $s; } } elseif ($scriptblocks[$sch] > 0 && $scriptblocks[$sch] != $this->baseScript && $this->scriptToLanguage->getLanguageByScript($scriptblocks[$sch])) { // Identify Script block if not Basescript, and mark up as language // Encase in - $o .= ''; + $o .= ''; $o .= $s; $o .= ''; } else { diff --git a/src/TracksOpenElements.php b/src/TracksOpenElements.php index 5e910d345..4178b415a 100644 --- a/src/TracksOpenElements.php +++ b/src/TracksOpenElements.php @@ -13,6 +13,9 @@ * splits an element or a page-break-inside: avoid block is laid out twice. WriteHTML() drives it through these * methods. * + * It also holds the element whose CSS is being merged, which the selector matcher reads through + * getStyledElementPath(). + * * @internal */ trait TracksOpenElements @@ -27,6 +30,15 @@ trait TracksOpenElements */ private $openElements; + /** + * @var array|null The element whose CSS is being merged: the open elements it sits in (path), and its tag and + * attributes (attr), or, for an element that is already open, the open elements down to it and a null tag. Null + * while no element of the document is being styled. WriteHTML() sets it while a start tag's handler runs, + * reopenBlock() while a block is opened again, and WriteFixedPosHTML() while a positioned block's own CSS is + * merged. See getStyledElementPath() + */ + private $styledElement; + /** * @var array[] For each start tag that ends an open element with no end tag of its own: the tags it ends the nearest * of, and the tags of the elements the search for one stops at @@ -75,8 +87,8 @@ 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 and attr; its position is its place in - * the list + * siblings before that child. A record holds a child's tag, id, classes, attr and nthOfType; its position is its + * place in the list * - childTypes: how many children so far have each tag * - computed: its computed values, not filled in yet * @@ -87,6 +99,38 @@ public function getOpenElements() return $this->openElements; } + /** + * The element whose CSS is being merged, with the open elements around it, for the selectors that look at its + * parent, ancestors and earlier siblings. CssMerger asks for it when a compiled rule is filed under the element's + * tag, id or class. An element whose start tag is being read is not on the stack yet, so its frame is made here, + * counted after the siblings closed before it, and a row written straight into a table is put under the tbody + * the stack will give it + * + * @return array[]|null The open elements from the document down to it, in the shape getOpenElements() gives, + * with the element's own frame last. Null when no element of the document is being styled + */ + public function getStyledElementPath() + { + if ($this->styledElement === null) { + return null; + } + + $path = $this->styledElement['path']; + $tag = $this->styledElement['tag']; + if ($tag === null) { + return $path; + } + + $parent = count($path) - 1; + if (self::impliesTbody($tag, $path[$parent])) { + $path[] = $this->newChildFrame($path[$parent], 'TBODY', []); + $parent++; + } + $path[] = $this->newChildFrame($path[$parent], $tag, $this->styledElement['attr']); + + return $path; + } + /** * Makes the stack a document starts from: a single frame standing for the document, with nothing written into it * yet. The constructor starts from one, and so does a WriteHTML() call that starts a new document, such as the one @@ -143,30 +187,117 @@ private function startElement($tag, array $attr, $selfClosing) } $parent = count($this->openElements) - 1; - // A row written straight into a table sits in a tbody, as it does in a browser's tree - if ($tag === 'TR' && $this->openElements[$parent]['tag'] === 'TABLE') { + if (self::impliesTbody($tag, $this->openElements[$parent])) { $this->startElement('TBODY', [], false); $parent++; } + $frame = $this->newChildFrame($this->openElements[$parent], $tag, $attr); + + if ($selfClosing || isset(self::$voidTags[$tag])) { + $this->recordClosedElement($frame); + } else { + $this->openElements[] = $frame; + } + } + + /** + * 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 + * + * @param string $tag + * @param array $parent The frame of the element it is opened in + * + * @return bool + */ + private static function impliesTbody($tag, array $parent) + { + return $tag === 'TR' && $parent['tag'] === 'TABLE'; + } + + /** + * Makes the frame for an element opened in $parent after the children $parent has so far. Its nthChild and + * nthOfType are counted from those children, and it inherits $parent's lang when it has no lang of its own + * + * @param array $parent The frame of the element it is opened in + * @param string $tag + * @param string[] $attr + * + * @return array A frame for an element opened after the children its parent has so far + */ + private function newChildFrame(array $parent, $tag, array $attr) + { // Marks a page-break-inside: avoid block laid out a second time. unset() would copy the array even without it if (isset($attr['PAGEBREAKAVOIDCHECKED'])) { unset($attr['PAGEBREAKAVOIDCHECKED']); } - $frame = $this->newElementFrame( + return $this->newElementFrame( $tag, $attr, - isset($attr['LANG']) ? $attr['LANG'] : $this->openElements[$parent]['lang'], - count($this->openElements[$parent]['children']) + 1, - (isset($this->openElements[$parent]['childTypes'][$tag]) ? $this->openElements[$parent]['childTypes'][$tag] : 0) + 1 + isset($attr['LANG']) ? $attr['LANG'] : $parent['lang'], + count($parent['children']) + 1, + (isset($parent['childTypes'][$tag]) ? $parent['childTypes'][$tag] : 0) + 1 ); + } - if ($selfClosing || isset(self::$voidTags[$tag])) { - $this->recordClosedElement($frame); - } else { - $this->openElements[] = $frame; + /** + * 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 + * + * @param string $tag + * @param string[] $attr + * + * @return bool + */ + private function isDocumentElement($tag, array $attr) + { + return $tag !== '' && !isset(self::$substitutionTags[$tag]) && !isset($attr[self::SCRIPT_RUN_ATTRIBUTE]); + } + + /** + * Finds each open block's element on the stack. The blocks are open elements too, in the same order, with the + * inline elements and table parts they sit in between them. _postForcedPagebreak() calls it before it opens again + * the blocks a forced page break closed, so that each is styled as its own element + * + * @param array[] $blocks The open blocks, from level 1 + * @param int $count How many there are + * + * @return array For each level, the depth of its element's frame, or null if it has none + */ + private function openBlockDepths(array $blocks, $count) + { + $depths = []; + $depth = 1; + $frames = count($this->openElements); + for ($b = 1; $b <= $count; $b++) { + while ($depth < $frames && $this->openElements[$depth]['tag'] !== $blocks[$b]['tag']) { + $depth++; + } + $depths[$b] = $depth < $frames ? $depth : null; + $depth++; } + + return $depths; + } + + /** + * Opens again, through Tag::OpenTag(), a block a forced page break closed, with the selectors matched against the + * block's own element. _postForcedPagebreak() calls it for each block it opens again. The element being styled + * before is put back after, as the page break may come from the start tag of an element still to be styled + * + * @param array $block The block as it was before the page break + * @param int|null $depth Where its element is on the stack of open elements, or null if it has none + */ + private function reopenBlock(array $block, $depth) + { + $arr = []; + $i = 0; + $outerElement = $this->styledElement; + $this->styledElement = $depth === null ? null : ['path' => array_slice($this->openElements, 0, $depth + 1), 'tag' => null, 'attr' => []]; + $this->tag->OpenTag($block['tag'], $block['attr'], $arr, $i); + $this->styledElement = $outerElement; } /** @@ -184,18 +315,24 @@ 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 + * 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 * * @param array $frame The closed element's frame */ private function recordClosedElement(array $frame) { + if (isset($frame['attr'][self::SCRIPT_RUN_ATTRIBUTE])) { + return; + } + $parent = count($this->openElements) - 1; $this->openElements[$parent]['children'][] = [ 'tag' => $frame['tag'], 'id' => $frame['id'], 'classes' => $frame['classes'], 'attr' => $frame['attr'], + 'nthOfType' => $frame['nthOfType'], ]; $this->openElements[$parent]['childTypes'][$frame['tag']] = $frame['nthOfType']; } diff --git a/tests/Mpdf/Css/RuleSetTest.php b/tests/Mpdf/Css/RuleSetTest.php index abed8dbce..52230f164 100644 --- a/tests/Mpdf/Css/RuleSetTest.php +++ b/tests/Mpdf/Css/RuleSetTest.php @@ -112,4 +112,87 @@ public function testHasNoCandidatesUntilARuleIsAdded() $this->assertSame([0], $rules->candidates('P', 'MAIN', ['A'])); } + /** + * The declarations of the rules an element matches come in the order they apply: by specificity, and rules of + * the same specificity by position + */ + public function testGivesTheMatchingDeclarationsBySpecificityThenPosition() + { + $rules = new RuleSet(); + foreach (['.a > p', 'div > p', 'p:first-child', 'body > div > p', 'div > p', '#main > p', 'ul > p'] as $position => $selector) { + $rules->add($this->compiler->compile($selector), ['COLOR' => 'rule ' . $position]); + } + + $path = function () { + return $this->path(); + }; + + $this->assertSame( + [['COLOR' => 'rule 1'], ['COLOR' => 'rule 4'], ['COLOR' => 'rule 3'], ['COLOR' => 'rule 0'], ['COLOR' => 'rule 2'], ['COLOR' => 'rule 5']], + $rules->matchingDeclarations('P', '', [], $path) + ); + } + + /** + * The open elements are only asked for when a rule is filed under the element + */ + public function testOnlyAsksForThePathWhenARuleIsFiledUnderTheElement() + { + $asked = false; + $path = function () use (&$asked) { + $asked = true; + + return null; + }; + + $this->assertSame([], $this->rules->matchingDeclarations('LI', '', [], $path)); + $this->assertTrue($asked, 'The rule for any element should ask'); + + $rules = new RuleSet(); + $rules->add($this->compiler->compile('div > p'), ['COLOR' => 'red']); + $asked = false; + $this->assertSame([], $rules->matchingDeclarations('LI', '', [], $path)); + $this->assertFalse($asked); + } + + /** + * A path that ends in another element than the one being styled matches nothing + */ + public function testMatchesNothingForThePathOfAnotherElement() + { + $rules = new RuleSet(); + $rules->add($this->compiler->compile('div > span'), ['COLOR' => 'red']); + + $path = function () { + return $this->path(); + }; + + $this->assertSame([], $rules->matchingDeclarations('SPAN', '', [], $path)); + } + + /** + * The open elements a rule is matched against: a `

` as the first child of `

`, the + * document's first child + * + * @return array[] The frames from the document down to the paragraph + */ + private function path() + { + $frame = function ($tag, array $attr) { + return [ + 'tag' => $tag, + 'id' => isset($attr['ID']) ? $attr['ID'] : '', + 'classes' => isset($attr['CLASS']) ? [$attr['CLASS']] : [], + 'lang' => '', + 'attr' => $attr, + 'nthChild' => 1, + 'nthOfType' => 1, + 'children' => [], + 'childTypes' => [], + 'computed' => null, + ]; + }; + + return [$frame('', []), $frame('DIV', ['ID' => 'MAIN', 'CLASS' => 'A']), $frame('P', [])]; + } } diff --git a/tests/Mpdf/Css/SelectorMatcherTest.php b/tests/Mpdf/Css/SelectorMatcherTest.php new file mode 100644 index 000000000..96e31dc89 --- /dev/null +++ b/tests/Mpdf/Css/SelectorMatcherTest.php @@ -0,0 +1,160 @@ +compiler = new SelectorCompiler(new Mpdf()); + $this->matcher = new SelectorMatcher(); + } + + /** + * Lets go of the compiler and matcher + */ + protected function tear_down() + { + unset($this->compiler, $this->matcher); + + parent::tear_down(); + } + + /** + * Whether each selector matches the last element of the path below: + * + * + *

(closed) + *

(open, third element) + *

(closed) + *

    (open) + *
  • (closed) + *
  • (the element) + * + * @dataProvider selectors + * + * @param string $selector + * @param bool $expected + */ + public function testMatchesTheLastElementOfThePath($selector, $expected) + { + $this->assertSame($expected, $this->matcher->matches($this->compiler->compile($selector), $this->path())); + } + + /** + * A selector, and whether it matches the element + * + * @return array[] + */ + public function selectors() + { + return [ + 'its tag' => ['li', true], + 'another tag' => ['p', false], + 'its class' => ['.x', true], + 'a class it does not have' => ['.y', false], + 'its parent' => ['ul > li', true], + 'not its parent' => ['div > li', false], + 'an ancestor' => ['div li', true], + 'an ancestor by id and class' => ['#main.card li', true], + 'body as the root' => ['body > div > ul > li', true], + 'body with a parent' => ['div > body li', false], + 'the sibling before it' => ['li + li', true], + 'the sibling before it must be the one right before' => ['h2 + li', false], + 'a sibling before it' => ['li ~ .x', true], + 'the first child is not it' => ['li:first-child', false], + 'the second child' => ['li:nth-child(2)', true], + 'the second child of its type' => ['li:nth-of-type(2)', true], + 'the first of its type' => ['li:first-of-type', false], + 'an ancestor at its position among its siblings' => ['div:nth-child(4) li', true], + 'an ancestor at the wrong position' => ['div:first-child li', false], + 'an ancestor that is the first of its type' => ['div:first-of-type li', true], + 'a closed sibling of an ancestor' => ['p + div li', true], + 'a closed sibling of an ancestor by class and position' => ['p.a:nth-of-type(1) ~ div li', true], + 'a closed sibling of an ancestor at its position among its type' => ['p:nth-of-type(2) + div li', true], + '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], + ]; + } + + /** + * The open elements the selectors are matched against: `
  • `, the second item of a `
      ` that follows + * an `

      ` and a `

      ` in `

      `, which follows an `

      `, a `

      ` and a `

      ` in + * the document + * + * @return array[] The frames from the document down to the item + */ + private function path() + { + return [ + $this->frame('', 1, 1, [], [['H1'], ['P', 'A'], ['P']]), + $this->frame('DIV', 4, 1, ['ID' => 'MAIN', 'CLASS' => 'CARD'], [['H2'], ['P']]), + $this->frame('UL', 3, 1, [], [['LI']]), + $this->frame('LI', 2, 2, ['CLASS' => 'X'], []), + ]; + } + + /** + * A frame in the shape Mpdf::getOpenElements() gives + * + * @param string $tag + * @param int $nthChild + * @param int $nthOfType + * @param string[] $attr + * @param array[] $children Each closed child's tag and, if any, its class + * + * @return array + */ + private function frame($tag, $nthChild, $nthOfType, array $attr, array $children) + { + $records = []; + $childTypes = []; + foreach ($children as $child) { + $childTypes[$child[0]] = isset($childTypes[$child[0]]) ? $childTypes[$child[0]] + 1 : 1; + $records[] = [ + 'tag' => $child[0], + 'id' => '', + 'classes' => isset($child[1]) ? [$child[1]] : [], + 'attr' => isset($child[1]) ? ['CLASS' => $child[1]] : [], + 'nthOfType' => $childTypes[$child[0]], + ]; + } + + return [ + 'tag' => $tag, + 'id' => isset($attr['ID']) ? $attr['ID'] : '', + 'classes' => isset($attr['CLASS']) ? [$attr['CLASS']] : [], + 'lang' => '', + 'attr' => $attr, + 'nthChild' => $nthChild, + 'nthOfType' => $nthOfType, + 'children' => $records, + 'childTypes' => $childTypes, + 'computed' => null, + ]; + } +} diff --git a/tests/Mpdf/DrawnStyles.php b/tests/Mpdf/DrawnStyles.php new file mode 100644 index 000000000..b50c170fa --- /dev/null +++ b/tests/Mpdf/DrawnStyles.php @@ -0,0 +1,55 @@ + 'c']); + $mpdf->WriteHTML($html); + + return $mpdf; + } + + /** + * The colour each piece of text in a document is drawn in, keyed by the text + * + * @param string $html + * @param array $config Merged over the core-font mode + * + * @return array Each piece of text drawn, trimmed, and the operator that set its colour + */ + private function drawnColours($html, array $config = []) + { + $mpdf = $this->drawDocument($html, $config); + + return $this->keyedByText($mpdf, $mpdf->drawnColours); + } + + /** + * Keys what was recorded of each piece of text a document drew by the text itself, so a test can look a piece up + * by what it says + * + * @param TextRecordingMpdf $mpdf A document, written + * @param array $values One value for each piece of text drawn, in the order they were drawn + * + * @return array Each value, keyed by its piece of text, trimmed + */ + private function keyedByText(TextRecordingMpdf $mpdf, array $values) + { + return array_combine(array_map('trim', $mpdf->drawnText), $values); + } +} diff --git a/tests/Mpdf/StructuralSelectorTest.php b/tests/Mpdf/StructuralSelectorTest.php new file mode 100644 index 000000000..bcb71bb35 --- /dev/null +++ b/tests/Mpdf/StructuralSelectorTest.php @@ -0,0 +1,501 @@ + $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 [ + 'child: a child matches, a grandchild does not' => [ + 'div > p { color: #f00; }', + '

      child

      grandchild

      outside

      ', + ['child' => self::RED, 'grandchild' => self::BLACK, 'outside' => self::BLACK], + ], + 'child written with no spaces' => [ + 'div>p { color: #f00; }', + '

      child

      ', + ['child' => self::RED], + ], + 'child of body: a top-level element matches, a nested one does not' => [ + 'body > p { color: #f00; }', + '

      top

      nested

      ', + ['top' => self::RED, 'nested' => self::BLACK], + ], + 'child chain' => [ + 'div > section > p { color: #f00; }', + '

      chain

      no div

      deeper

      ', + ['chain' => self::RED, 'no div' => self::BLACK, 'deeper' => self::BLACK], + ], + 'child of an inline element' => [ + 'p > em { color: #f00; }', + '

      direct and inside b

      ', + ['direct' => self::RED, 'inside b' => self::BLACK], + ], + 'adjacent sibling: only the next element' => [ + 'h1 + p { color: #f00; }', + '

      before

      Title

      adjacent

      second

      ', + ['before' => self::BLACK, 'adjacent' => self::RED, 'second' => self::BLACK], + ], + 'adjacent sibling: another element between them' => [ + 'h1 + p { color: #f00; }', + '

      Title

      between

      after the div

      ', + ['after the div' => self::BLACK], + ], + 'adjacent sibling: text between them does not count' => [ + 'h1 + p { color: #f00; }', + '

      Title

      loose text

      after text

      ', + ['after text' => self::RED], + ], + 'adjacent sibling: not across a parent' => [ + 'h1 + p { color: #f00; }', + '

      Title

      first in div

      ', + ['first in div' => self::BLACK], + ], + 'adjacent sibling: an empty element counts' => [ + 'hr + p { color: #f00; } h2 + p { color: #00f; }', + '

      Heading


      after rule

      ', + ['after rule' => self::RED], + ], + 'adjacent inline siblings' => [ + 'b + i { color: #f00; }', + '

      bold next later

      ', + ['bold' => self::BLACK, 'next' => self::RED, 'later' => self::BLACK], + ], + 'general sibling: every later sibling' => [ + 'h1 ~ p { color: #f00; }', + '

      before

      Title

      one

      div

      two

      nested

      ', + ['before' => self::BLACK, 'one' => self::RED, 'two' => self::RED, 'nested' => self::BLACK], + ], + 'first-child' => [ + 'li:first-child { color: #f00; }', + '
      • one
      • two
      1. uno
      ', + ['one' => self::RED, 'two' => self::BLACK, 'uno' => self::RED], + ], + 'first-child: an element of another tag before it' => [ + 'p:first-child { color: #f00; }', + '

      Heading

      second child

      first child

      ', + ['second child' => self::BLACK, 'first child' => self::RED], + ], + 'first-child: an empty element before it' => [ + 'p:first-child { color: #f00; }', + '

      after img

      ', + ['after img' => self::BLACK], + ], + 'first-child on its own' => [ + ':first-child { color: #f00; }', + '

      Title

      first

      second

      ', + ['Title' => self::RED, 'first' => self::RED, 'second' => self::BLACK], + ], + 'nth-child even' => [ + 'li:nth-child(2n) { color: #f00; }', + '
      • one
      • two
      • three
      • four
      ', + ['one' => self::BLACK, 'two' => self::RED, 'three' => self::BLACK, 'four' => self::RED], + ], + 'nth-child odd' => [ + 'li:nth-child(odd) { color: #f00; }', + '
      • one
      • two
      • three
      ', + ['one' => self::RED, 'two' => self::BLACK, 'three' => self::RED], + ], + 'nth-child with a number' => [ + 'li:nth-child(3) { color: #f00; }', + '
      • one
      • two
      • three
      • four
      ', + ['two' => self::BLACK, 'three' => self::RED, 'four' => self::BLACK], + ], + 'nth-child with the first few' => [ + 'li:nth-child(-n + 2) { color: #f00; }', + '
      • one
      • two
      • three
      ', + ['one' => self::RED, 'two' => self::RED, 'three' => self::BLACK], + ], + 'nth-child from a start' => [ + 'li:nth-child(n+3) { color: #f00; }', + '
      • one
      • two
      • three
      • four
      ', + ['two' => self::BLACK, 'three' => self::RED, 'four' => self::RED], + ], + 'nth-child counts every element, not only its own tag' => [ + 'p:nth-child(2) { color: #f00; }', + '

      Heading

      second child

      third child

      ', + ['second child' => self::RED, 'third child' => self::BLACK], + ], + 'nth-child counts omitted end tags as closed' => [ + 'li:nth-child(2) { color: #f00; }', + '
      • one
      • two
      • three
      ', + ['one' => self::BLACK, 'two' => self::RED, 'three' => self::BLACK], + ], + 'first-of-type' => [ + 'p:first-of-type { color: #f00; }', + '

      Heading

      first p

      second p

      ', + ['first p' => self::RED, 'second p' => self::BLACK], + ], + 'nth-of-type' => [ + 'p:nth-of-type(2) { color: #f00; }', + '

      first p

      Heading

      second p

      third p

      ', + ['first p' => self::BLACK, 'second p' => self::RED, 'third p' => self::BLACK], + ], + 'nth-of-type on an ancestor' => [ + 'section:nth-of-type(2) p { color: #f00; }', + '

      in first

      x

      in second

      ', + ['in first' => self::BLACK, 'in second' => self::RED], + ], + 'nth-child on an ancestor' => [ + 'li:nth-child(2) p { color: #f00; }', + '
      • in one

      • in two

      ', + ['in one' => self::BLACK, 'in two' => self::RED], + ], + 'combinators and pseudo-classes chained' => [ + 'div.card > ul li:first-child + li { color: #f00; }', + '
      • one
      • two
      • three
      • a
      • b
      ', + ['one' => self::BLACK, 'two' => self::RED, 'three' => self::BLACK, 'b' => self::BLACK], + ], + 'a descendant combinator backtracks past an ancestor that fails' => [ + 'div > p span > b { color: #f00; }', + '

      deep

      ', + ['deep' => self::RED], + ], + 'a general sibling combinator backtracks past a sibling that fails' => [ + 'h2.a ~ p + p { color: #f00; }', + '

      A

      B

      one

      two

      ', + ['one' => self::BLACK, 'two' => self::RED], + ], + 'nested lists' => [ + 'ul > li > ul > li:first-child { color: #f00; }', + '
      • outer
        • inner one
        • inner two
      ', + ['outer' => self::BLACK, 'inner one' => self::RED, 'inner two' => self::BLACK], + ], + 'table cells' => [ + 'td:first-child { color: #f00; } td + td + td { color: #00f; }', + '
      c1c2c3
      ', + ['c1' => self::RED, 'c2' => self::BLACK, 'c3' => self::BLUE], + ], + 'a cell after a colspan is the next sibling' => [ + 'td + td { color: #f00; }', + '
      widesecond cell
      ', + ['wide' => self::BLACK, 'second cell' => self::RED], + ], + 'a row written straight into a table sits in a tbody' => [ + 'table > tbody > tr > td { color: #f00; } table > tr > td { color: #00f; }', + '
      cell
      ', + ['cell' => self::RED], + ], + 'rows of a thead and a tbody are counted apart' => [ + 'tr:first-child > td { color: #f00; }', + '
      head
      body one
      body two
      ', + ['head' => self::RED, 'body one' => self::RED, 'body two' => self::BLACK], + ], + 'block and inline content of a cell' => [ + 'td > p { color: #f00; } td > span { color: #00f; }', + '

      block

      inline
      ', + ['block' => self::RED, 'inline' => self::BLUE], + ], + 'nested tables' => [ + 'td > table td:first-child { color: #f00; }', + '
      outer first
      inner firstinner second
      ', + ['outer first' => self::BLACK, 'inner first' => self::RED, 'inner second' => self::BLACK], + ], + 'a tag the legacy parser reads keeps its own rule, the matcher adds to it' => [ + 'p { color: #00f; } div > p { color: #f00; }', + '

      child

      top

      ', + ['child' => self::RED, 'top' => self::BLUE], + ], + 'a rule naming the universal selector waits for #530' => [ + 'div > * { color: #f00; } * + p { color: #f00; }', + '

      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
      ', + ['last' => self::BLACK], + ], + ]; + } + + /** + * Matched rules are applied with the descendant rules, after them and before the inline style: by specificity, + * then by position + * + * @dataProvider precedence + * + * @param string $css + * @param string $html + * @param string $expected The colour the text "text" should be drawn in + */ + public function testAppliesMatchedRulesBySpecificityThenPosition($css, $html, $expected) + { + $colours = $this->drawnColours('' . $html); + + $this->assertSame($expected, $colours['text']); + } + + /** + * Competing rules, a document, and the colour its text should be drawn in + * + * @return array[] + */ + public function precedence() + { + $html = '

      text

      '; + + return [ + 'the more specific wins, written first' => ['div > p.note { color: #f00; } div > p { color: #00f; }', $html, self::RED], + 'the more specific wins, written last' => ['div > p { color: #00f; } div > p.note { color: #f00; }', $html, self::RED], + 'a class beats any number of tags' => [ + 'div > p:first-child { color: #f00; } body > div > p { color: #00f; }', + $html, + self::RED, + ], + 'an id beats any number of classes' => [ + '#outer > p { color: #f00; } .box > .note:first-child:nth-child(1) { color: #00f; }', + $html, + self::RED, + ], + 'the same specificity: the later wins' => ['div > p { color: #f00; } div > p { color: #00f; }', $html, self::BLUE], + 'the same specificity from different selectors: the later wins' => [ + '.box > p { color: #f00; } div > .note { color: #00f; }', + $html, + self::BLUE, + ], + 'the later of two stylesheets wins' => [ + 'div > p { color: #f00; }' + . '
      ab
      ' + ); + $pdf = $mpdf->Output('', 'S'); + + $this->assertGreaterThan(strrpos($pdf, '0.000 0.000 1.000 RG'), strrpos($pdf, '1.000 0.000 0.000 RG')); + } + + /** + * A kept block that runs onto the next page is laid out again from its start tag, and its children are + * counted once + */ + public function testCountsTheChildrenOfAKeptBlockOnceWhenItIsLaidOutAgain() + { + $filler = str_repeat('

      filler

      ', 45); + $mpdf = $this->drawDocument( + '' + . $filler + . '

      one

      two

      three

      four

      five

      ' + ); + + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + $pages = $this->keyedByText($mpdf, array_column($mpdf->drawnBoxes, 0)); + $this->assertSame(2, $pages['one'], 'The block should have moved to the next page'); + $this->assertSame(self::BLACK, $colours['two']); + $this->assertSame(self::RED, $colours['three']); + $this->assertSame(self::BLUE, $colours['four']); + $this->assertSame(self::BLUE, $colours['five']); + } + + /** + * A block closed and opened again around a forced page break keeps the rules its element matches + * + * @dataProvider pageBreakTypes + * + * @param string $type + */ + public function testKeepsTheRulesOfBlocksOpenedAgainAfterAPageBreak($type) + { + $mpdf = $this->drawDocument( + '' + . '

      beforeafter

      next

      ' + ); + + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + $pages = $this->keyedByText($mpdf, array_column($mpdf->drawnBoxes, 0)); + $this->assertSame(1, $pages['before']); + $this->assertSame(2, $pages['after']); + $this->assertSame(self::BLUE, $colours['before']); + $this->assertSame(self::BLUE, $colours['after']); + $this->assertSame(self::RED, $colours['next']); + } + + /** + * The ways a forced page break closes the open blocks and opens them again + * + * @return array[] + */ + public function pageBreakTypes() + { + return [ + 'every block' => ['cloneall'], + 'blocks with box-decoration-break: clone' => ['clonebycss'], + ]; + } + + /** + * A header is matched against its own elements, not the flow's elements open when it is set and measured + */ + public function testMatchesAHeaderAgainstItsOwnElements() + { + $mpdf = $this->drawDocument( + '' + . '

      header p

      header div p

      ' + . '

      flow one

      flow two

      ' + . '

      page two

      ', + ['setAutoTopMargin' => 'stretch'] + ); + $mpdf->Output('', 'S'); + + $colours = []; + foreach ($mpdf->drawnText as $i => $text) { + $colours[trim($text)][] = $mpdf->drawnColours[$i]; + } + + $this->assertGreaterThan(1, count($colours['header p']), 'The header should be written on each page'); + $this->assertSame([self::BLUE], array_unique($colours['header p'])); + $this->assertSame([self::RED], array_unique($colours['header div p'])); + $this->assertSame([self::RED], $colours['flow two']); + $this->assertSame([self::RED], $colours['page two']); + } + + /** + * An element whose page-break-before closes and opens again the blocks around it is matched against its own + * elements once they are open again + */ + public function testMatchesAnElementThatStartsAPageInsideBlocks() + { + $mpdf = $this->drawDocument( + '' + . '

      first

      second

      ' + ); + + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + $pages = $this->keyedByText($mpdf, array_column($mpdf->drawnBoxes, 0)); + $this->assertSame(2, $pages['second']); + $this->assertSame(self::BLUE, $colours['first']); + $this->assertSame(self::RED, $colours['second']); + } + + /** + * A positioned block is matched against the elements it was written in, and its content against the block. The + * block's box comes from its rules once, not again for the
      that stands in for it + */ + public function testMatchesAPositionedBlockAndItsContent() + { + $block = '
      ' + . 'loose

      first

      second

      '; + $mpdf = $this->drawDocument( + '' + . sprintf($block, '') + ); + $inline = $this->drawDocument(sprintf($block, '; left: 60mm; padding-left: 10mm')); + + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + $this->assertSame(self::RED, $colours['loose']); + $this->assertSame(self::BLUE, $colours['first']); + $this->assertSame(self::GREEN, $colours['second']); + + $this->assertEqualsWithDelta($inline->drawnBoxes[1][1], $mpdf->drawnBoxes[1][1], 0.001, 'The block should be where its inline style would put it'); + } + + /** + * Content written in several calls to WriteHTML() is matched against the elements the earlier calls left open + */ + public function testMatchesContentWrittenInParts() + { + $mpdf = new TextRecordingMpdf(['mode' => 'c']); + $mpdf->WriteHTML(''); + $mpdf->WriteHTML('

      one

      ', HTMLParserMode::HTML_BODY, true, false); + $mpdf->WriteHTML('

      two

      ', HTMLParserMode::HTML_BODY, false, true); + + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + $this->assertSame(self::BLACK, $colours['one']); + $this->assertSame(self::RED, $colours['two']); + } + + /** + * The spans autoScriptToLang wraps a run of another script in are not elements of the document: they are not + * counted among their siblings or matched themselves + */ + public function testIgnoresTheSpansWrappedAroundARunOfAnotherScript() + { + $colours = $this->drawnColours( + '' + . '

      שלום bold שלום italic שלום

      ', + ['mode' => '', 'autoScriptToLang' => true, 'autoLangToFont' => true] + ); + + $this->assertSame(self::RED, $colours['bold']); + $this->assertSame(self::BLUE, $colours['italic']); + foreach ($colours as $text => $colour) { + $this->assertNotSame(self::GREEN, $colour, sprintf('"%s" is drawn as a span', $text)); + } + } +} diff --git a/tests/Mpdf/StyledElementPathTest.php b/tests/Mpdf/StyledElementPathTest.php new file mode 100644 index 000000000..7b41e2dd0 --- /dev/null +++ b/tests/Mpdf/StyledElementPathTest.php @@ -0,0 +1,84 @@ +, the document's own frame first with no tag + */ + public function testGivesThePathToTheElementBeingStyled($html, array $expected) + { + $mpdf = new StyledPathRecordingMpdf(['mode' => 'c']); + $mpdf->WriteHTML($html); + + $this->assertSame($expected, $mpdf->styledPaths); + } + + /** + * A document with rules the matcher reads, and the paths asked for while it is written + * + * @return array[] + */ + public function documents() + { + return [ + 'elements in the flow' => [ + '

      a b

      c

      ', + ['>DIV>P', '>DIV>P', '>DIV>P>B', '>P', '>P'], + ], + 'a row written straight into a table sits in a tbody' => [ + '
      a
      ', + ['>TABLE>TBODY>TR'], + ], + 'a row in a tbody of its own' => [ + '
      a
      ', + ['>TABLE>TBODY>TR'], + ], + 'blocks opened again after a forced page break' => [ + '

      ab

      ', + ['>DIV', '>DIV', '>DIV>P', '>DIV>P', '>DIV', '>DIV', '>DIV>P', '>DIV>P'], + ], + 'a positioned block and its content, which is written twice, but not the div standing in for it' => [ + '' + . '

      a

      ', + ['>SPAN>DIV', '>SPAN>DIV', null, null, '>SPAN>DIV>P', '>SPAN>DIV>P', null, null, '>SPAN>DIV>P', '>SPAN>DIV>P'], + ], + ]; + } + + /** + * Once the start tags are read, no element is being styled, and the stack is not held on to + */ + public function testLetsGoOfTheElementOnceItsStartTagIsRead() + { + $mpdf = new StyledPathRecordingMpdf(['mode' => 'c']); + $mpdf->WriteHTML('

      a

      b

      '); + + $this->assertNull($mpdf->getStyledElementPath()); + } + + /** + * The spans autoScriptToLang wraps a run of another script in are not the document's elements + */ + public function testGivesNoPathForASpanWrappedAroundARunOfAnotherScript() + { + $mpdf = new StyledPathRecordingMpdf(['mode' => '', 'autoScriptToLang' => true]); + $mpdf->WriteHTML('

      abc שלום def

      '); + + $this->assertSame([null, '>P>SPAN'], $mpdf->styledPaths); + } +} diff --git a/tests/Mpdf/StyledPathRecordingMpdf.php b/tests/Mpdf/StyledPathRecordingMpdf.php new file mode 100644 index 000000000..bd22df99d --- /dev/null +++ b/tests/Mpdf/StyledPathRecordingMpdf.php @@ -0,0 +1,27 @@ +, or null for none */ + public $styledPaths = []; + + /** + * Records the path before handing it back + * + * @return array[]|null + */ + public function getStyledElementPath() + { + $path = parent::getStyledElementPath(); + $this->styledPaths[] = $path === null ? null : implode('>', array_column($path, 'tag')); + + return $path; + } +} diff --git a/tests/Mpdf/TextRecordingMpdf.php b/tests/Mpdf/TextRecordingMpdf.php index 48f7d048b..8fa89470b 100644 --- a/tests/Mpdf/TextRecordingMpdf.php +++ b/tests/Mpdf/TextRecordingMpdf.php @@ -32,6 +32,9 @@ class TextRecordingMpdf extends Mpdf /** The top of each of those lines, in the same order. */ public $drawnY = []; + /** The text colour each of those lines was drawn in, as the PDF operator that sets it, in the same order. */ + public $drawnColours = []; + function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = 0, $link = '', $currentx = 0, $lcpaddingL = 0, $lcpaddingR = 0, $valign = 'M', $spanfill = 0, $exactWidth = false, $OTLdata = false, $textvar = 0, $lineBox = false) { if (is_string($txt) && trim($txt) !== '') { @@ -41,6 +44,7 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = $this->drawnFontSize[] = $this->FontSizePt; $this->drawnBoxes[] = [$this->page, $this->x, $this->x + $w, $this->y]; $this->drawnY[] = $this->y; + $this->drawnColours[] = $this->TextColor; } return parent::Cell($w, $h, $txt, $border, $ln, $align, $fill, $link, $currentx, $lcpaddingL, $lcpaddingR, $valign, $spanfill, $exactWidth, $OTLdata, $textvar, $lineBox); diff --git a/tests/Snapshots/StructuralSelectorSnapshotTest.php b/tests/Snapshots/StructuralSelectorSnapshotTest.php new file mode 100644 index 000000000..e96ed1043 --- /dev/null +++ b/tests/Snapshots/StructuralSelectorSnapshotTest.php @@ -0,0 +1,148 @@ + + + +

      Child and sibling combinators, and structural pseudo-classes

      + +

      div > p

      +

      Only the paragraph directly inside the box is red and bold. The one inside the nested section stays black.

      +
      +

      Child of the box

      +

      Grandchild, inside a section

      +
      + +

      h3 + p

      +

      Only the paragraph right after the heading is red and bold. The paragraphs before the heading and after the first one stay black.

      +
      +

      Before the heading

      +

      Heading

      +

      Right after the heading

      +

      After that one

      +
      + +

      h3 ~ p

      +

      Every paragraph after the heading is red and bold, even past the div between them. The paragraph before the heading and the one inside the div stay black.

      +
      +

      Before the heading

      +

      Heading

      +

      First after the heading

      +

      Inside a div after the heading

      +

      After the div

      +
      + +

      li:first-child

      +

      The first item of each list is red and bold, the rest black.

      +
      +
      • First bullet
      • Second bullet
      • Third bullet
      +
      1. First number
      2. Second number
      +
      + +

      li:nth-child(2n)

      +

      The second, fourth and sixth items have a yellow background.

      +
      +
      1. One
      2. Two
      3. Three
      4. Four
      5. Five
      6. Six
      +
      + +

      li:nth-child(-n+2)

      +

      The first two items are blue and italic, the third and fourth black.

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

      p:first-of-type

      +

      The first paragraph is red and bold, though the heading comes before it. The second paragraph stays black.

      +
      +

      Heading

      +

      First paragraph

      +

      Second paragraph

      +
      + +

      p:nth-of-type(2)

      +

      Only the second paragraph is red and bold, counting past the heading between them.

      +
      +

      First paragraph

      +

      Heading

      +

      Second paragraph

      +

      Third paragraph

      +
      + +

      p > b

      +

      The bold word directly in the paragraph is red. The one inside the span stays black.

      +
      +

      A direct bold word and a nested one.

      +
      + +

      td:first-child and td + td + td

      +

      The first cell of each row is blue. Every cell from the third on is red and bold, and a cell spanning two columns counts as one cell: R1 C3, R1 C4 and R2 C3.

      + + + +
      R1 C1R1 C2R1 C3R1 C4
      R2 C1, two columns wideR2 C2R2 C3
      + +

      table > tbody > tr > td

      +

      Rows written straight into the table sit in a tbody, as in a browser: every cell is green.

      + + + +
      R1 C1R1 C2
      R2 C1R2 C2
      + mpdf = $this->createMpdf(); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/StructuralStylesheetSnapshotTest.php b/tests/Snapshots/StructuralStylesheetSnapshotTest.php new file mode 100644 index 000000000..88d7f18cb --- /dev/null +++ b/tests/Snapshots/StructuralStylesheetSnapshotTest.php @@ -0,0 +1,158 @@ + + + +

      Structural selectors in real stylesheets

      + +

      A card and a list group

      +

      The card body's first paragraph is blue and bold, the title's next sibling italic, and the paragraphs after the first grey. In the list, the first item is bold, the first, third and fifth items have a grey background, and a rule separates each item from the one before it.

      +
      +
      +

      Blue and bold: the first child of the card body.

      +

      Grey: a paragraph after the first.

      +
      Card title
      +

      Grey and italic: right after the title.

      +
      +
        +
      • First item
      • +
      • Second item
      • +
      • Third item
      • +
      • Fourth item
      • +
      • Fifth item
      • +
      +
      + +

      A CMS article

      +

      The first paragraph is 12pt. The paragraph right after each subheading is italic. In the list after the subheading, the second and fourth items are red, and the first item of the nested list is bold. In the quote, only the second paragraph is purple.

      +
      +

      The opening paragraph, at 12pt.

      +

      A second paragraph, at the normal size.

      +

      A subheading

      +

      Italic: right after the subheading.

      +
        +
      • One
      • +
      • Two, red
      • +
      • Three +
        • Nested one, bold
        • Nested two
        +
      • +
      • Four, red
      • +
      +
      +

      The first paragraph of the quote.

      +

      The second paragraph of the quote, purple.

      +

      The third paragraph of the quote.

      +
      +
      + +

      An invoice

      +

      The header row is dark with white text, its first column left-aligned and the rest right-aligned. The body's second and fourth rows are shaded, its first column is bold, and its amounts are right-aligned. The total row is bold with its label on the left.

      + + + + + + + + + + + + + +
      ItemQuantityPriceAmount
      Consulting4120.00480.00
      Design295.00190.00
      Hosting128.50102.00
      Support160.0060.00
      Total832.00
      + +

      Competing rules

      +

      Rules the matcher reads come after the descendant rules the legacy parser reads, whatever their weight, and among themselves go by specificity, then by source order. Each paragraph says the colour it should be.

      +
      +

      Orange: div.pricing > p comes after .pricing > p, with the same weight, and after .pricing p.

      +

      Blue: .pricing > p.note is heavier than div.pricing > p.

      +

      Purple: #plans > p:nth-child(3) outweighs .pricing > p:nth-child(3), though it is written first.

      +

      Grey: #plans > .fine outweighs .pricing > .fine.

      +
      + mpdf = $this->createMpdf(); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php b/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php index 27e3f1e32..4e391084d 100644 --- a/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php +++ b/tests/Snapshots/UnmatchableDescendantSelectorSnapshotTest.php @@ -3,8 +3,9 @@ namespace Snapshots; /** - * Stylesheet rules with a descendant selector that has a part mPDF cannot match, dropped whole rather than cut short - * and applied to whichever element the parts before it name, and rules with two nth-child parts keeping both. + * Stylesheet rules with a descendant selector that has a part the legacy parser cannot read, matched whole or dropped + * whole rather than cut short and applied to whichever element the parts before it name, and rules with two + * nth-child parts keeping both. * * @group snapshot */ @@ -56,10 +57,10 @@ public function generatePdf() table.absent tr:nth-child(2n) td:nth-child(5) { background-color: #ff0000; } -

      Descendant rules mPDF cannot match

      +

      Descendant rules the legacy parser cannot read

      Child and sibling combinators

      -

      Rules using >, + and ~ are dropped. All three paragraphs are black at the normal size.

      +

      Rules using >, + and ~ apply to the element their last part names and to nothing before it. The span, and the second and third paragraphs, are red at 16pt. The rest of the first paragraph is black at the normal size.

      First paragraph, with a span

      Second paragraph

      @@ -82,8 +83,8 @@ public function generatePdf()

      A paragraph with no generated content

      -

      An unmatchable selector beside a valid one

      -

      div.mixed p.keep still applies: the second paragraph is green and bold, the first is black.

      +

      A selector the legacy parser cannot read beside one it can

      +

      Both selectors apply: the span and the second paragraph are green and bold. The rest of the first paragraph is black.

      First paragraph, with a span

      Second paragraph

      diff --git a/tests/data/snapshots/structural-selectors-stylesheet.pdf b/tests/data/snapshots/structural-selectors-stylesheet.pdf new file mode 100644 index 000000000..2c72a8f60 Binary files /dev/null and b/tests/data/snapshots/structural-selectors-stylesheet.pdf differ diff --git a/tests/data/snapshots/structural-selectors.pdf b/tests/data/snapshots/structural-selectors.pdf new file mode 100644 index 000000000..e4f0a352e Binary files /dev/null and b/tests/data/snapshots/structural-selectors.pdf differ diff --git a/tests/data/snapshots/unmatchable-descendant-selector.pdf b/tests/data/snapshots/unmatchable-descendant-selector.pdf index 39b3faffd..a93882ab1 100644 Binary files a/tests/data/snapshots/unmatchable-descendant-selector.pdf and b/tests/data/snapshots/unmatchable-descendant-selector.pdf differ