diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index ee436a7a5..8ddf87fbd 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -127,6 +127,9 @@ New features * `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, and one holding only a comment is. A table cell holding only white space is empty, as mPDF strips it. * **`:root`, `html`, `:link` and `:any-link`, in standard mode.** `a:link` and `:any-link` match a link with an `href`. mPDF has no `html` element, so `html` and `:root` match it as the parent of `body`: their rules reach the text as a parent's would, and rules for `body` win over them. `:visited`, `:hover`, `:focus`, `:active`, `:focus-within`, `:focus-visible` and `:target` never match, as a PDF is never visited or hovered, so `:not(:hover)` always does. Rules using them were dropped before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep dropping them. [#529] [#642] +* **The universal selector.** `*` matches every element: alone, with a class, an id, an attribute or a pseudo-class, on either side of a combinator, and inside `:is()`, `:where()` and `:not()`. It weighs nothing, so any rule naming a tag, class, id, attribute or pseudo-class beats it. It reaches `html` and `body` too, so `* { color: … }` colours text written straight into the body, and `* { font-size: … }` sets the size `rem` is read against. Rules using it were dropped before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep dropping them. [mpdf/mpdf#312] [mpdf/mpdf#1168] [mpdf/mpdf#1838] [#530] [#644] + * A reset such as `* { margin: 0; padding: 0 }` now takes away the default margins of paragraphs, headings and lists, a list's indent and a cell's padding, as in a browser. It reaches the lines of the table of contents and the index as well, which are the document's elements, so their levels are no longer indented. Style `div.mpdf_toc_level_1` and the like to indent them again. + * mPDF's own ``, `` and `` are not HTML elements. A rule reaches them only by naming their tag, or an id or class they carry, so `*`, and `:empty`, `[attr]` or `:not()` alone, leave them as they are: `:empty { display: none }` does not hide a barcode. Performance ----------- @@ -218,6 +221,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. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep the directions mPDF v7 drew, prefixed or not. [#577] [#639] +* In a table with collapsed borders, a row with a `border-color` and no border style, or a border of no width, took the borders of its cells away. A reset such as `* { border-color: #dee2e6 }` or `* { border: 0 solid }` gives every row a border like that. A row border that draws nothing now leaves the cells' borders; `hidden` still takes them away. A row's `border-top` or `border-bottom` was drawn only when the row also set `border-left`; each side is now drawn on its own. [#644] * 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 @@ -414,12 +418,14 @@ These changes do not change output. [mpdf/mpdf#7]: https://github.com/mpdf/mpdf/issues/7 [mpdf/mpdf#83]: https://github.com/mpdf/mpdf/issues/83 [mpdf/mpdf#134]: https://github.com/mpdf/mpdf/issues/134 +[mpdf/mpdf#312]: https://github.com/mpdf/mpdf/issues/312 [mpdf/mpdf#318]: https://github.com/mpdf/mpdf/issues/318 [mpdf/mpdf#747]: https://github.com/mpdf/mpdf/issues/747 [mpdf/mpdf#830]: https://github.com/mpdf/mpdf/issues/830 [mpdf/mpdf#833]: https://github.com/mpdf/mpdf/issues/833 [mpdf/mpdf#1010]: https://github.com/mpdf/mpdf/issues/1010 [mpdf/mpdf#1089]: https://github.com/mpdf/mpdf/issues/1089 +[mpdf/mpdf#1168]: https://github.com/mpdf/mpdf/issues/1168 [mpdf/mpdf#1220]: https://github.com/mpdf/mpdf/issues/1220 [mpdf/mpdf#1334]: https://github.com/mpdf/mpdf/issues/1334 [mpdf/mpdf#1368]: https://github.com/mpdf/mpdf/issues/1368 @@ -810,6 +816,7 @@ These changes do not change output. [#527]: https://github.com/GravityPDF/mpdf/issues/527 [#528]: https://github.com/GravityPDF/mpdf/issues/528 [#529]: https://github.com/GravityPDF/mpdf/issues/529 +[#530]: https://github.com/GravityPDF/mpdf/issues/530 [#531]: https://github.com/GravityPDF/mpdf/issues/531 [#532]: https://github.com/GravityPDF/mpdf/issues/532 [#533]: https://github.com/GravityPDF/mpdf/issues/533 @@ -871,5 +878,6 @@ These changes do not change output. [#639]: https://github.com/GravityPDF/mpdf/pull/639 [#641]: https://github.com/GravityPDF/mpdf/pull/641 [#646]: https://github.com/GravityPDF/mpdf/pull/646 -[#643]: https://github.com/GravityPDF/mpdf/pull/643 [#642]: https://github.com/GravityPDF/mpdf/pull/642 +[#643]: https://github.com/GravityPDF/mpdf/pull/643 +[#644]: https://github.com/GravityPDF/mpdf/pull/644 diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 7c1b40f1f..4c363220e 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -241,7 +241,7 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) * Merges html's rules into body's CSS, and body's own rules again over them. mPDF has no html element: the * document's frame stands for body, and html is matched as its parent, so html's declarations, its !important * ones included, reach the text as a parent's would, and lose to body's. Both win over what SetDefaultBodyCSS() and - * the like set. + * the like set. The rules for any element, such as *, are among the rules of each. * * html's font size, read against the default font size, is the root's, which rem and body's own size are read * against diff --git a/src/Css/CssParser.php b/src/Css/CssParser.php index 0b4da5eba..fff58e413 100644 --- a/src/Css/CssParser.php +++ b/src/Css/CssParser.php @@ -468,9 +468,7 @@ private function compileRule($selector, array $classProperties, array $important } $compiled = $this->selectorCompiler->compile($selector); - - // The universal selector changes which elements existing documents style, so it waits for #530 - if ($compiled !== null && !$compiled['universal'] && !$compiled['never']) { + if ($compiled !== null && !$compiled['never']) { $this->compiledRules[] = [$compiled, $classProperties, $important]; } } diff --git a/src/Css/RuleSet.php b/src/Css/RuleSet.php index 1c7b69441..126617429 100644 --- a/src/Css/RuleSet.php +++ b/src/Css/RuleSet.php @@ -10,6 +10,13 @@ class RuleSet { + /** + * @var array The tags mPDF adds to HTML that take CSS. They are not HTML elements, and draw what + * their attributes describe, so a rule reaches one only by naming its tag, or an id or a + * class it has: *, :empty or [title] alone does not + */ + private static $ownTags = ['BARCODE' => true, 'DOTTAB' => true, 'TEXTCIRCLE' => true]; + /** * @var array[] Each rule, by its position in the stylesheets read: [compiled selector, declarations, the ancestors * it requires as requiredAncestors() gives them, !important declarations] @@ -54,7 +61,7 @@ public function add(array $selector, array $declarations, array $important = []) * The rules an element could match, going by the rightmost compound of each. The rest of each selector is still * to be matched * - * @param string $tag Uppercased + * @param string $tag Uppercased, or empty for the document itself, which is matched as body * @param string $id Uppercased, or empty for none * @param string[] $classes Uppercased * @@ -69,7 +76,7 @@ public function candidates($tag, $id, array $classes) * The declarations of the rules an element matches, in the order they apply: by specificity, then by position. * The normal declarations and the !important ones are given apart, as they apply in different layers * - * @param string $tag Uppercased + * @param string $tag Uppercased, or empty for the document itself, which is matched as body * @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 @@ -204,7 +211,7 @@ private static function file(array &$index, array $subject, $position) */ private static function collect(array $index, $tag, $id, array $classes) { - $positions = $index['any']; + $positions = isset(self::$ownTags[$tag]) ? [] : $index['any']; if (isset($index['tag'][$tag])) { $positions = array_merge($positions, $index['tag'][$tag]); } diff --git a/src/Css/SelectorCompiler.php b/src/Css/SelectorCompiler.php index f1e7676f1..283577845 100644 --- a/src/Css/SelectorCompiler.php +++ b/src/Css/SelectorCompiler.php @@ -15,7 +15,6 @@ * Mpdf::WriteHTML() leaves an element's tag, id and classes. * - combinators: the combinator after each compound but the last, one of ' ', '>', '+' and '~' * - specificity: [ids, classes and attributes and pseudo-classes, tags] - * - universal: whether it names the universal selector * * - never: whether one of its compounds holds a pseudo-class that no element in a PDF matches, so that it matches * nothing * @@ -150,11 +149,10 @@ private function parseComplex($text, &$pos) $compounds = []; $combinators = []; $specificity = [0, 0, 0]; - $universal = false; $never = false; while (true) { - $compound = $this->parseCompound($text, $pos, $specificity, $universal); + $compound = $this->parseCompound($text, $pos, $specificity); if ($compound === null) { return null; } @@ -181,7 +179,6 @@ private function parseComplex($text, &$pos) 'compounds' => $compounds, 'combinators' => $combinators, 'specificity' => $specificity, - 'universal' => $universal, 'never' => $never, ]; } @@ -193,18 +190,16 @@ private function parseComplex($text, &$pos) * @param string $text * @param int $pos * @param int[] $specificity Added to for what is read - * @param bool $universal Set when the compound names * * * @return array|null */ - private function parseCompound($text, &$pos, array &$specificity, &$universal) + private function parseCompound($text, &$pos, array &$specificity) { $length = strlen($text); $compound = ['tag' => null, 'ids' => [], 'classes' => [], 'attributes' => [], 'pseudos' => []]; $start = $pos; if ($pos < $length && $text[$pos] === '*') { - $universal = true; $pos++; } elseif ($this->startsIdentifier($text, $pos)) { $compound['tag'] = strtoupper($this->readName($text, $pos)); @@ -241,7 +236,7 @@ private function parseCompound($text, &$pos, array &$specificity, &$universal) $specificity[1]++; } elseif ($c === ':') { $pos++; - $pseudo = $this->parsePseudoClass($text, $pos, $specificity, $universal); + $pseudo = $this->parsePseudoClass($text, $pos, $specificity); if ($pseudo === null) { return null; } @@ -261,11 +256,10 @@ private function parseCompound($text, &$pos, array &$specificity, &$universal) * @param string $text * @param int $pos * @param int[] $specificity Added to for the pseudo-class - * @param bool $universal Set when an argument names * * * @return array|null As the class describes, or null for a pseudo-element or a pseudo-class mPDF cannot match */ - private function parsePseudoClass($text, &$pos, array &$specificity, &$universal) + private function parsePseudoClass($text, &$pos, array &$specificity) { if (!$this->startsIdentifier($text, $pos)) { return null; @@ -291,7 +285,7 @@ private function parsePseudoClass($text, &$pos, array &$specificity, &$universal } if ($name === 'not' || $name === 'is' || $name === 'where') { - return $this->parseSelectorArgument($name, $argument, $specificity, $universal); + return $this->parseSelectorArgument($name, $argument, $specificity); } if ($name === 'lang') { @@ -316,11 +310,10 @@ private function parsePseudoClass($text, &$pos, array &$specificity, &$universal * @param string $name not, is or where * @param string $argument * @param int[] $specificity Added to for the most specific selector, except for :where() - * @param bool $universal Set when a selector names * * * @return array|null */ - private function parseSelectorArgument($name, $argument, array &$specificity, &$universal) + private function parseSelectorArgument($name, $argument, array &$specificity) { $selectors = []; $heaviest = [0, 0, 0]; @@ -334,7 +327,6 @@ private function parseSelectorArgument($name, $argument, array &$specificity, &$ } $selectors[] = $compiled; - $universal = $universal || $compiled['universal']; if ($compiled['specificity'] > $heaviest) { $heaviest = $compiled['specificity']; } diff --git a/src/Tag/Td.php b/src/Tag/Td.php index 2a137794e..7d19c5bc6 100644 --- a/src/Tag/Td.php +++ b/src/Tag/Td.php @@ -337,9 +337,9 @@ public function open($attr, &$ahtml, &$ihtml) $table['border'] = $table['simple']['border']; } - // Border set on TR (if collapsed only) - if (!$table['borders_separate'] && !$this->mpdf->simpleTables && isset($table['trborder-left'][$this->mpdf->row])) { - if ($this->mpdf->col == 0) { + // Border set on TR (if collapsed only), on each side it sets + if (!$table['borders_separate'] && !$this->mpdf->simpleTables) { + if ($this->mpdf->col == 0 && isset($table['trborder-left'][$this->mpdf->row])) { $left = $this->mpdf->border_details($table['trborder-left'][$this->mpdf->row]); $c['border_details']['L'] = $left; $this->mpdf->setBorder($c['border'], Border::LEFT, $c['border_details']['L']['s']); diff --git a/src/Tag/Tr.php b/src/Tag/Tr.php index 175489e72..4d8d8f8fa 100644 --- a/src/Tag/Tr.php +++ b/src/Tag/Tr.php @@ -30,17 +30,12 @@ public function open($attr, &$ahtml, &$ihtml) if (!$this->mpdf->simpleTables && (!isset($this->mpdf->table[$this->mpdf->tableLevel][$this->mpdf->tbctr[$this->mpdf->tableLevel]]['borders_separate']) || !$this->mpdf->table[$this->mpdf->tableLevel][$this->mpdf->tbctr[$this->mpdf->tableLevel]]['borders_separate'])) { - if (!empty($properties['BORDER-LEFT'])) { - $this->mpdf->table[$this->mpdf->tableLevel][$this->mpdf->tbctr[$this->mpdf->tableLevel]]['trborder-left'][$this->mpdf->row] = $properties['BORDER-LEFT']; - } - if (!empty($properties['BORDER-RIGHT'])) { - $this->mpdf->table[$this->mpdf->tableLevel][$this->mpdf->tbctr[$this->mpdf->tableLevel]]['trborder-right'][$this->mpdf->row] = $properties['BORDER-RIGHT']; - } - if (!empty($properties['BORDER-TOP'])) { - $this->mpdf->table[$this->mpdf->tableLevel][$this->mpdf->tbctr[$this->mpdf->tableLevel]]['trborder-top'][$this->mpdf->row] = $properties['BORDER-TOP']; - } - if (!empty($properties['BORDER-BOTTOM'])) { - $this->mpdf->table[$this->mpdf->tableLevel][$this->mpdf->tbctr[$this->mpdf->tableLevel]]['trborder-bottom'][$this->mpdf->row] = $properties['BORDER-BOTTOM']; + // Kept only for a side the row draws, or hides with hidden: a border that draws nothing, such as a + // border-color alone, leaves the cells' borders + foreach (['LEFT', 'RIGHT', 'TOP', 'BOTTOM'] as $side) { + if (!empty($properties['BORDER-' . $side]) && $this->mpdf->border_details($properties['BORDER-' . $side])['s']) { + $this->mpdf->table[$this->mpdf->tableLevel][$this->mpdf->tbctr[$this->mpdf->tableLevel]]['trborder-' . strtolower($side)][$this->mpdf->row] = $properties['BORDER-' . $side]; + } } } diff --git a/tests/Mpdf/Css/CssParserTest.php b/tests/Mpdf/Css/CssParserTest.php index bff52d2bb..7fe14bbca 100644 --- a/tests/Mpdf/Css/CssParserTest.php +++ b/tests/Mpdf/Css/CssParserTest.php @@ -277,6 +277,13 @@ public function routedSelectors() 'link' => ['a:link', true], 'any-link' => [':any-link', true], 'not hover' => ['p:not(:hover)', true], + 'universal' => ['*', true], + 'universal with a class' => ['*.a', true], + 'universal with an id' => ['*#i', true], + 'universal as a descendant' => ['div *', true], + 'universal as an ancestor' => ['* p', true], + 'universal as a sibling' => ['* + p', true], + 'universal in not' => ['p:not(*)', true], ]; } @@ -315,8 +322,6 @@ public function droppedSelectors() 'first-line and first-letter' => ['p::first-line, p:first-letter'], '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/RuleSetTest.php b/tests/Mpdf/Css/RuleSetTest.php index 713f4642d..d647e1582 100644 --- a/tests/Mpdf/Css/RuleSetTest.php +++ b/tests/Mpdf/Css/RuleSetTest.php @@ -88,6 +88,12 @@ public function elements() 'a class written twice' => ['DIV', '', ['B', 'B'], [2, 3]], 'a span' => ['SPAN', '', [], [3, 4]], 'a tag no rule names' => ['LI', '', [], [3]], + 'the document, matched as body' => ['', '', [], [3]], + "mPDF's barcode" => ['BARCODE', '', [], []], + "mPDF's dot tab" => ['DOTTAB', '', [], []], + "mPDF's text circle" => ['TEXTCIRCLE', '', [], []], + "mPDF's barcode with a class a rule is filed under" => ['BARCODE', '', ['B'], [2]], + "mPDF's barcode with an id a rule is filed under" => ['BARCODE', 'MAIN', [], [1]], ]; } @@ -159,6 +165,30 @@ public function testGivesTheImportantDeclarationsApartInTheSameOrder() ); } + /** + * html and body are matched by the rules filed for any element too: the universal selector and the other rules + * whose subject names no tag, id or class, when they match there. html is body's parent and has no parent, and + * neither has siblings + */ + public function testMatchesHtmlAndBodyWithTheRulesForAnyElement() + { + $rules = new RuleSet(); + foreach (['*', 'body', '* > *', ':not(p)', ':not(body)', '*:first-child', '*.a', '*:lang(fr)', '* + *'] as $position => $selector) { + $rules->add($this->compiler->compile($selector), ['COLOR' => 'rule ' . $position]); + } + + $document = [$this->path()[0]]; + + $this->assertSame( + [[['COLOR' => 'rule 0'], ['COLOR' => 'rule 3'], ['COLOR' => 'rule 4']], []], + $rules->documentDeclarations(true, $document) + ); + $this->assertSame( + [[['COLOR' => 'rule 0'], ['COLOR' => 'rule 2'], ['COLOR' => 'rule 1'], ['COLOR' => 'rule 3']], []], + $rules->documentDeclarations(false, $document) + ); + } + /** * The open elements are only asked for when a rule is filed under the element */ diff --git a/tests/Mpdf/Css/SelectorCompilerTest.php b/tests/Mpdf/Css/SelectorCompilerTest.php index 4ca571858..2867bacef 100644 --- a/tests/Mpdf/Css/SelectorCompilerTest.php +++ b/tests/Mpdf/Css/SelectorCompilerTest.php @@ -112,6 +112,7 @@ public function selectors() $tag = function ($tag) { return ['tag' => $tag, 'ids' => [], 'classes' => [], 'pseudos' => []]; }; + $any = ['compounds' => [['tag' => null, 'ids' => [], 'classes' => [], 'attributes' => [], 'pseudos' => []]], 'combinators' => [], 'specificity' => [0, 0, 0], 'never' => false]; return [ 'type' => ['p', [$tag('P')], []], @@ -151,7 +152,7 @@ public function selectors() '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, 'never' => false]]]]]], []], + '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], 'never' => false]]]]]], []], 'root' => [':root', [['tag' => null, 'pseudos' => [['root']]]], []], 'html' => ['HTML > body', [$tag('HTML'), $tag('BODY')], ['>']], 'link' => ['a:link', [['tag' => 'A', 'pseudos' => [['link']]]], []], @@ -165,6 +166,23 @@ public function selectors() [['tag' => 'P', 'pseudos' => [['lang', ['fr', 'de-at', 'en']]]]], [], ], + 'universal' => ['*', [$tag(null)], []], + 'universal with a class' => ['*.note', [['tag' => null, 'classes' => ['NOTE']]], []], + 'universal with an id' => ['*#main', [['tag' => null, 'ids' => ['MAIN']]], []], + 'universal with an attribute' => ['*[title]', [['tag' => null, 'attributes' => [['TITLE', '', '', false]]]], []], + 'universal as the subject of a descendant' => ['div *', [$tag('DIV'), $tag(null)], [' ']], + 'universal as an ancestor' => ['* p', [$tag(null), $tag('P')], [' ']], + 'universal as the subject of a child' => ['div>*', [$tag('DIV'), $tag(null)], ['>']], + 'universal as a sibling' => ['* + p', [$tag(null), $tag('P')], ['+']], + 'universal on both sides' => ['* ~ *', [$tag(null), $tag(null)], ['~']], + 'universal in is and not' => [ + ':is(*) + p:not(*)', + [ + ['tag' => null, 'pseudos' => [['is', [$any]]]], + ['tag' => 'P', 'pseudos' => [['not', [$any]]]], + ], + ['+'], + ], 'attribute in a chain' => [ 'div[data-role="card"] > a[href]', [['tag' => 'DIV', 'attributes' => [['DATA-ROLE', '=', 'card', false]]], ['tag' => 'A', 'attributes' => [['HREF', '', '', false]]]], @@ -362,6 +380,10 @@ public function specificities() 'id' => ['#i', [1, 0, 0]], 'every kind' => ['div#i.a.b p:first-child', [1, 3, 2]], 'pseudo-class on the universal selector' => ['*:nth-of-type(2)', [0, 1, 0]], + 'class on the universal selector' => ['*.a', [0, 1, 0]], + 'universal selectors around a type' => ['* > p + *', [0, 0, 1]], + 'not of the universal selector' => ['p:not(*)', [0, 0, 1]], + 'is of the universal selector and a class' => [':is(*, .a)', [0, 1, 0]], 'two ids' => ['#a #b', [2, 0, 0]], 'attribute' => ['[data-x]', [0, 1, 0]], 'attribute on a type' => ['a[href^="http"]', [0, 1, 1]], @@ -420,20 +442,6 @@ public function selectorsThatNeverMatch() ]; } - /** - * Whether a selector names the universal selector, which waits for #530 - */ - public function testMarksASelectorThatNamesTheUniversalSelector() - { - $this->assertTrue($this->compiler->compile('*')['universal']); - $this->assertTrue($this->compiler->compile('div > *')['universal']); - $this->assertTrue($this->compiler->compile('* + p')['universal']); - $this->assertFalse($this->compiler->compile('div > :first-child')['universal']); - $this->assertTrue($this->compiler->compile('p:not(*)')['universal']); - $this->assertTrue($this->compiler->compile(':is(h1, *) + p')['universal']); - $this->assertFalse($this->compiler->compile(':is(h1, h2) + p')['universal']); - } - /** * An an+b argument is read into a and b * diff --git a/tests/Mpdf/Css/SelectorMatcherTest.php b/tests/Mpdf/Css/SelectorMatcherTest.php index e93a57e51..5980ef426 100644 --- a/tests/Mpdf/Css/SelectorMatcherTest.php +++ b/tests/Mpdf/Css/SelectorMatcherTest.php @@ -134,6 +134,25 @@ public function selectors() 'is hover' => ['li:is(:hover)', false], 'link on an element with no href' => ['li:link', false], 'not link on an element with no href' => ['li:not(:link)', true], + 'the universal selector' => ['*', true], + 'the universal selector with its class' => ['*.x', true], + 'the universal selector with a class it does not have' => ['*.y', false], + 'the universal selector as its parent' => ['* > li', true], + 'the universal selector as a child of its parent' => ['ul > *', true], + 'the universal selector as a child of an ancestor that is not its parent' => ['div > *.x', false], + 'the universal selector as an ancestor' => ['#main * li', true], + 'the universal selector as html, an ancestor of body' => ['* body li', true], + 'the universal selector as a descendant of body' => ['body * li', true], + 'the universal selector as the root' => ['* > div > ul > li', true], + 'the universal selector as html, the parent of body' => ['* > * > div > ul > li', true], + 'the universal selector as a parent of html' => ['* > * > * > div > ul > li', false], + 'the universal selector as the sibling before it' => ['* + li', true], + 'the universal selector as a sibling before its first sibling' => ['li:first-child + *', true], + 'the universal selector before the first child' => ['* + h1', false], + 'the universal selector as a sibling of an ancestor' => ['* ~ div li', true], + 'the universal selector in is' => [':is(*) > li', true], + 'the universal selector in not' => ['li:not(*)', false], + 'the universal selector in not on an ancestor' => ['div:not(*) li', false], ]; } diff --git a/tests/Mpdf/LogicalSelectorTest.php b/tests/Mpdf/LogicalSelectorTest.php index 736c91da5..cc4c56760 100644 --- a/tests/Mpdf/LogicalSelectorTest.php +++ b/tests/Mpdf/LogicalSelectorTest.php @@ -131,10 +131,10 @@ public function selectors() '

plain

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

one

two

', - ['one' => self::BLACK, 'two' => self::BLACK], + ['one' => self::BLACK, 'two' => self::RED], ], ]; } diff --git a/tests/Mpdf/StructuralSelectorTest.php b/tests/Mpdf/StructuralSelectorTest.php index 0c32fa4c6..9d50eadec 100644 --- a/tests/Mpdf/StructuralSelectorTest.php +++ b/tests/Mpdf/StructuralSelectorTest.php @@ -243,10 +243,10 @@ public function selectors() '

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], + 'the universal selector on either side of a combinator' => [ + 'div > * { color: #f00; } * + p { color: #00f; }', + '

child

next

first in a section

', + ['child' => self::RED, 'next' => self::BLUE, 'first in a section' => self::BLACK], ], 'a pseudo-class that looks inside the element is still dropped' => [ 'li:has(> b) { color: #f00; }', diff --git a/tests/Mpdf/Tag/TrBordersTest.php b/tests/Mpdf/Tag/TrBordersTest.php index 50e31a21e..cb629d2c4 100644 --- a/tests/Mpdf/Tag/TrBordersTest.php +++ b/tests/Mpdf/Tag/TrBordersTest.php @@ -84,9 +84,121 @@ public function testARowBorderOnTheRightIsDrawn() $this->assertSame(1, $this->redSegments($this->render('border-right: 2px solid #ff0000'))); } + /** + * Td::open() read the row's top and bottom borders only when the row also set its left one, so a row that named + * only its top or bottom drew neither + * + * @dataProvider topAndBottom + * + * @param string $side + */ + public function testARowBorderOnTheTopOrBottomAloneIsDrawn($side) + { + $this->assertGreaterThan(0, $this->redSegments($this->render('border-' . $side . ': 2px solid #ff0000'))); + } + + /** + * The sides Td::open() draws for a row + * + * @return array[] + */ + public function topAndBottom() + { + return ['top' => ['top'], 'bottom' => ['bottom']]; + } + public function testARowWithNoBorderOfItsOwnDrawsNone() { $this->assertSame(0, $this->redSegments($this->render(''))); } + /** + * The cells' blue borders on the side a row covers are drawn, or not, and the row's red border is drawn, or not + * + * @dataProvider rowBorders + * + * @param string $side bottom, top, left or right + * @param string $css Rules for the row + * @param bool $cellBorder Whether the cells' borders are drawn + * @param bool $rowBorder Whether the row's border is drawn + */ + public function testARowBorderReplacesTheCellsOnlyWhenItIsDrawnOrHidden($side, $css, $cellBorder, $rowBorder) + { + foreach ([\Mpdf\CssMode::STANDARD, \Mpdf\CssMode::LEGACY] as $mode) { + $pdf = $this->renderStylesheet('td { border-' . $side . ': 0.5mm solid #00f; } ' . $css, $mode); + + $this->assertSame($cellBorder, strpos($pdf, '0.000 0.000 1.000 RG') !== false, $mode . ': the cells\' border'); + $this->assertSame($rowBorder, strpos($pdf, '1.000 0.000 0.000 RG') !== false, $mode . ': the row\'s border'); + } + } + + /** + * Each side a row covers, with rules for the rows, whether the cells' borders are drawn, and whether the row's is + * + * @return array[] + */ + public function rowBorders() + { + $rules = [ + 'no border' => ['', true, false], + 'a colour alone' => ['tr { border-color: #f00; }', true, false], + 'no width' => ['tr { border: 0 solid #f00; }', true, false], + 'no style' => ['tr { border: none; }', true, false], + 'a style and a colour but no width' => ['tr { border-style: solid; border-width: 0; border-color: #f00; }', true, false], + 'hidden' => ['tr { border: hidden; }', false, false], + 'drawn' => ['tr { border: 0.5mm solid #f00; }', false, true], + ]; + + $data = []; + foreach (['bottom', 'top', 'left', 'right'] as $side) { + foreach ($rules as $name => $rule) { + $data[$side . ': ' . $name] = array_merge([$side], $rule); + } + } + + return $data; + } + + /** + * A reset reaching every element leaves the borders a later rule gives the cells + * + * @dataProvider resets + * + * @param string $reset + */ + public function testAResetLeavesTheCellsBorders($reset) + { + $this->assertStringContainsString('0.000 0.000 1.000 RG', $this->renderStylesheet($reset . ' td { border-bottom: 0.5mm solid #00f; }', \Mpdf\CssMode::STANDARD)); + } + + /** + * Resets as frameworks write them + * + * @return array[] + */ + public function resets() + { + return [ + 'a border colour' => ['* { border-color: #dee2e6; }'], + 'no width, a style and a colour' => ['*, ::before, ::after { border-width: 0; border-style: solid; border-color: #e5e7eb; }'], + 'the border shorthand' => ['* { border: 0 solid; }'], + ]; + } + + /** + * @param string $css + * @param string $mode A CssMode value + * + * @return string An uncompressed document holding a table of one-cell rows with collapsed borders, under a + * stylesheet + */ + private function renderStylesheet($css, $mode) + { + $mpdf = new Mpdf(['mode' => 'c', 'cssMode' => $mode]); + $mpdf->SetCompression(false); + $mpdf->WriteHTML('
a
b
'); + + return $mpdf->Output('', 'S'); + } + } diff --git a/tests/Mpdf/UniversalSelectorTest.php b/tests/Mpdf/UniversalSelectorTest.php new file mode 100644 index 000000000..df431a7d1 --- /dev/null +++ b/tests/Mpdf/UniversalSelectorTest.php @@ -0,0 +1,473 @@ +, and , are only reached by a rule that + * names their tag, or an id or class they have. In the legacy mode rules using it are dropped, as they were before. + */ +class UniversalSelectorTest extends TestCase +{ + + use DrawnStyles; + + const RED = '1.000 0.000 0.000 rg'; + const GREEN = '0.000 0.502 0.000 rg'; + const BLUE = '0.000 0.000 1.000 rg'; + const BLACK = '0.000 g'; + + /** @var string[] The contexts document() writes the cases in */ + private static $contexts = ['block', 'inline', 'table cell', 'list item', 'header', 'footer', 'positioned block', 'kept block', 'forced page break']; + + /** + * Each piece of text is drawn in the colour the rules leave it in, in every context, and where the context puts + * it. The document is closed, so its header and footer are drawn on the page + * + * @dataProvider casesInContexts + * + * @param string $context + * @param string $css With {T} for the subjects' tag and {O} for another tag + * @param array[] $groups As cases() gives them + */ + public function testMatchesInEveryContext($context, $css, array $groups) + { + $mpdf = $this->drawDocument($this->document($context, $css, $groups), ['cssMode' => CssMode::STANDARD]); + $mpdf->OutputBinaryData(); + + $expected = $this->expectedColours($groups); + $this->assertDrawnInColours($expected, $this->keyedByText($mpdf, $mpdf->drawnColours)); + $this->assertDrawnInContext($context, $mpdf, array_keys($expected)); + } + + /** + * 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. A rule + * that could match the element each group is written in is kept to its children with .g >, so the children do + * not inherit the colour from it + * + * @return array[] Each [css, groups]. A group is [the class of the element it is written in, its children], and a + * child is [T or O for its tag, its text, the colour it should be drawn in, its attributes] + */ + private function cases() + { + $r = self::RED; + $g = self::GREEN; + $b = self::BLUE; + $k = self::BLACK; + + return [ + 'alone' => [ + '* { color: #f00; }', + [['g', [['T', 'subject', $r], ['O', 'another tag', $r]]]], + ], + 'loses to a type selector written before it' => [ + '{T} { color: #00f; } * { color: #f00; }', + [['g', [['T', 'named by the type', $b], ['O', 'not named', $r]]]], + ], + 'loses to a class written before it' => [ + '.c { color: #008000; } * { color: #f00; }', + [['g', [['T', 'with the class', $g, 'class="c"'], ['T', 'without the class', $r]]]], + ], + 'with a class' => [ + '*.c { color: #f00; }', + [['g', [['T', 'with the class', $r, 'class="c"'], ['T', 'without the class', $k], ['O', 'another tag with it', $r, 'class="c"']]]], + ], + 'with an id' => [ + '*#i { color: #f00; }', + [['g', [['T', 'with the id', $r, 'id="i"'], ['T', 'without the id', $k]]]], + ], + 'with an attribute' => [ + '*[title] { color: #f00; }', + [['g', [['T', 'with a title', $r, 'title="t"'], ['O', 'without a title', $k]]]], + ], + 'with a pseudo-class' => [ + '.g > *:first-child { color: #f00; }', + [['g', [['T', 'first child', $r], ['T', 'second child', $k]]]], + ], + 'as the subject of a descendant combinator' => [ + '.in * { color: #f00; }', + [['in', [['T', 'inside', $r], ['O', 'another tag inside', $r]]], ['out', [['T', 'outside', $k]]]], + ], + 'as an ancestor' => [ + '* {T} { color: #f00; }', + [['g', [['T', 'a descendant of any', $r], ['O', 'another tag', $k]]]], + ], + 'as the subject of a child combinator' => [ + '.in > * { color: #f00; }', + [['in', [['T', 'a child of in', $r]]], ['out', [['T', 'a child of out', $k]]]], + ], + 'as the parent' => [ + '* > {T} { color: #f00; }', + [['g', [['T', 'a child of any', $r], ['O', 'another tag', $k]]]], + ], + 'as the sibling before' => [ + '.g > * + {T} { color: #f00; }', + [['g', [['T', 'first', $k], ['T', 'after a sibling', $r], ['O', 'another tag after a sibling', $k]]]], + ], + 'as the sibling after' => [ + '.g > {O} + * { color: #f00; }', + [['g', [['O', 'another tag', $k], ['T', 'after the other tag', $r], ['T', 'after the subject tag', $k]]]], + ], + 'on both sides of a general sibling combinator' => [ + '.g > * ~ * { color: #f00; }', + [['g', [['T', 'the first', $k], ['O', 'the second', $r], ['T', 'the third', $r]]]], + ], + 'in is' => [ + '.g > :is(*) + * { color: #f00; }', + [['g', [['T', 'the first', $k], ['O', 'the second', $r]]]], + ], + 'in where, which weighs nothing' => [ + '{T} { color: #f00; } :where(*) { color: #00f; }', + [['g', [['T', 'named by the type', $r], ['O', 'not named', $b]]]], + ], + 'in not, which then matches nothing' => [ + '{T}:not(*) { color: #f00; } :not(*) { color: #f00; }', + [['g', [['T', 'subject', $k], ['O', 'another tag', $k]]]], + ], + 'with a class in not' => [ + '{T}:not(*.c) { color: #f00; }', + [['g', [['T', 'without the class', $r], ['T', 'with the class', $k, 'class="c"']]]], + ], + ]; + } + + /** + * @param string $context + * + * @return string[] The tag of the subjects in a context, another tag for their siblings, and the start, with %s + * for its class, and the 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 string + */ + private function document($context, $css, array $groups) + { + list($subject, $other, $open, $close) = $this->tags($context); + + $html = ''; + foreach ($groups as $group) { + $html .= sprintf($open, $group[0]); + foreach ($group[1] as $child) { + $tag = $child[0] === 'T' ? $subject : $other; + $html .= '<' . $tag . (isset($child[3]) ? ' ' . $child[3] : '') . '>' . $child[1] . ' '; + } + $html .= $close; + } + + return '' . $this->inContext($context, $html); + } + + /** + * @param array[] $groups As cases() gives them + * + * @return array The text of each child and the colour it should be drawn in + */ + private function expectedColours(array $groups) + { + $expected = []; + foreach ($groups as $group) { + foreach ($group[1] as $child) { + $expected[$child[1]] = $child[2]; + } + } + + return $expected; + } + + /** + * In the legacy CSS mode rules using the universal selector are dropped: every case in every context is drawn as + * it is with those rules taken out + * + * @dataProvider casesInContexts + * + * @param string $context + * @param string $css + * @param array[] $groups + */ + public function testTheLegacyModeDropsThem($context, $css, array $groups) + { + $without = preg_replace('/[^{}]*\*[^{}]*\{[^}]*\}/', '', $css); + $this->assertNotSame($css, $without); + + $dropped = $this->drawDocument($this->document($context, $without, $groups), ['cssMode' => CssMode::LEGACY]); + $dropped->OutputBinaryData(); + $written = $this->drawDocument($this->document($context, $css, $groups), ['cssMode' => CssMode::LEGACY]); + $written->OutputBinaryData(); + + $this->assertSame($dropped->drawnText, $written->drawnText); + $this->assertSame($dropped->drawnColours, $written->drawnColours); + $this->assertSame($dropped->drawnBoxes, $written->drawnBoxes); + } + + /** + * The universal selector reaches html and body: text written straight into the body takes its colour, and body's + * own rules outweigh it wherever they are written. html is body's parent, so rules that need body to have a + * parent, or html to be there, reach it, and text takes their colour from one or the other. Rules that need body to + * have a sibling do not reach it + * + * @dataProvider bodyRules + * + * @param string $css + * @param string $expected The colour of the text written straight into the body + */ + public function testReachesBody($css, $expected) + { + $colours = $this->drawnColours('before

paragraph

after', ['cssMode' => CssMode::STANDARD]); + + $this->assertSame($expected, $colours['before']); + $this->assertSame($expected, $colours['after']); + } + + /** + * Rules, and the colour of the text written straight into the body + * + * @return array[] + */ + public function bodyRules() + { + return [ + 'the universal selector' => ['* { color: #f00; }', self::RED], + 'a body rule written after it' => ['* { color: #f00; } body { color: #00f; }', self::BLUE], + 'a body rule written before it' => ['body { color: #00f; } * { color: #f00; }', self::BLUE], + 'the later of two' => ['* { color: #00f; } * { color: #f00; }', self::RED], + 'an important universal rule over a body rule' => ['* { color: #f00 !important; } body { color: #00f; }', self::RED], + 'an important body rule over an important universal rule' => ['body { color: #00f !important; } * { color: #f00 !important; }', self::BLUE], + 'not another tag' => [':not(p) { color: #f00; }', self::RED], + 'not body, which html is' => ['*:not(body) { color: #f00; }', self::RED], + 'a child of anything, as body is of html' => ['* > * { color: #f00; }', self::RED], + 'a descendant of anything, as body is of html' => ['* * { color: #f00; }', self::RED], + 'a child of anything but html' => [':not(html) > * { color: #f00; }', self::BLACK], + 'a sibling of anything' => ['* + *, * ~ * { color: #f00; }', self::BLACK], + 'with a class it does not have' => ['*.c { color: #f00; }', self::BLACK], + ]; + } + + /** + * The universal selector reaches html, so its font size is the one rem is read against + */ + public function testSetsTheSizeRemIsReadAgainst() + { + $mpdf = $this->drawDocument('

rem

half

', ['cssMode' => CssMode::STANDARD]); + $sizes = $this->keyedByText($mpdf, $mpdf->drawnFontSize); + + $this->assertEqualsWithDelta(20, $sizes['rem'], 0.001); + $this->assertEqualsWithDelta(10, $sizes['half'], 0.001); + } + + /** + * A universal rule's background reaches body, which paints the page + */ + public function testPaintsThePageFromAUniversalBackground() + { + $html = '

text

'; + $this->assertNotEmpty($this->drawDocument($html, ['cssMode' => CssMode::STANDARD])->bodyBackgroundColor); + + $mpdf = $this->drawDocument($html, ['cssMode' => CssMode::LEGACY]); + $this->assertEmpty($mpdf->bodyBackgroundColor); + } + + /** + * A reset that zeroes every margin and padding reaches the default margins of paragraphs, headings, lists and + * blockquotes, a list's indent and a cell's padding, as in a browser: the text is drawn where it is drawn with the + * same declarations written on each element. In the legacy mode it is dropped, and the text is drawn where it is + * with no stylesheet + */ + public function testAResetReachesTheDefaultMarginsAndPadding() + { + $html = 'HeadingFirst paragraph

Second paragraph

' + . 'BulletNumber' + . 'TermDescriptionQuote' + . 'CellLast paragraph

'; + $reset = 'margin: 0; padding: 0'; + + $plain = $this->drawDocument(sprintf($html, ''), ['cssMode' => CssMode::STANDARD]); + $inline = $this->drawDocument(sprintf($html, ' style="' . $reset . '"'), ['cssMode' => CssMode::STANDARD]); + $document = '' . sprintf($html, ''); + $universal = $this->drawDocument($document, ['cssMode' => CssMode::STANDARD]); + $legacy = $this->drawDocument($document, ['cssMode' => CssMode::LEGACY]); + + $this->assertNotEquals($plain->drawnBoxes, $inline->drawnBoxes, 'The declarations should move the text'); + $this->assertSame($inline->drawnText, $universal->drawnText); + $this->assertEquals($inline->drawnBoxes, $universal->drawnBoxes); + $this->assertEquals($inline->drawnY, $universal->drawnY); + $this->assertEquals($plain->drawnBoxes, $legacy->drawnBoxes); + $this->assertEquals($plain->drawnY, $legacy->drawnY); + } + + /** + * Rules that do not name mPDF's own tags leave a barcode, a dot tab and a text circle as they are drawn without + * them. The page is otherwise the same, so its content is compared whole + * + * @dataProvider rulesNotNamingMpdfTags + * + * @param string $css + */ + public function testLeavesMpdfOwnTagsAlone($css) + { + $this->assertSame($this->ownTagsPage(''), $this->ownTagsPage($css)); + } + + /** + * Rules that reach every child of the paragraph the tags are written in, which holds nothing else + * + * @return array[] + */ + public function rulesNotNamingMpdfTags() + { + $everything = '{ display: none; margin: 5mm; padding: 5mm; border: 1mm solid #f00; color: #f00; background-color: #ff0;' + . ' vertical-align: top; font-size: 20pt; font-weight: bold; outdent: 20mm; visibility: hidden; }'; + + return [ + 'the universal selector' => ['p > * ' . $everything], + 'the universal selector with a pseudo-class' => ['*:empty ' . $everything], + 'a pseudo-class alone' => [':empty ' . $everything], + 'an attribute alone' => ['[code], [r], [outdent] ' . $everything], + 'not another tag' => ['p > :not(b) ' . $everything], + 'a sibling' => ['p > * + *, p > * ~ * ' . $everything], + ]; + } + + /** + * A rule that names one of mPDF's own tags by its tag, or by an id or class it has, still reaches it + * + * @dataProvider rulesNamingMpdfTags + * + * @param string $css + */ + public function testStillAppliesRulesThatNameMpdfOwnTags($css) + { + $this->assertNotSame($this->ownTagsPage(''), $this->ownTagsPage($css)); + } + + /** + * Rules that name one of mPDF's own tags + * + * @return array[] + */ + public function rulesNamingMpdfTags() + { + return [ + 'the text circle by its tag' => ['textcircle { display: none; }'], + 'the dot tab by its tag' => ['p > dottab { outdent: 20mm; }'], + 'the barcode by its class' => ['*.code { display: none; }'], + 'the barcode by its id' => ['#code { display: none; }'], + ]; + } + + /** + * @param string $css + * + * @return string[] The content of each page of a paragraph holding a barcode, a dot tab and a text circle, written + * in the standard mode under a stylesheet + */ + private function ownTagsPage($css) + { + $mpdf = new Mpdf(['mode' => 'c', 'cssMode' => CssMode::STANDARD]); + $mpdf->WriteHTML( + '

' + . '

' + ); + + return $mpdf->pages; + } + + /** + * The spans mPDF wraps a run of another script in are not the document's elements, and the universal selector + * does not reach them any more than a type selector does + */ + public function testLeavesTheSpansAroundARunOfAnotherScriptAlone() + { + $html = '

שלום bold שלום

'; + $config = ['mode' => '', 'autoScriptToLang' => true, 'autoLangToFont' => true, 'cssMode' => CssMode::STANDARD]; + + $colours = $this->drawnColours('' . $html, $config); + + $this->assertSame(self::RED, $colours['bold']); + foreach ($colours as $text => $colour) { + if ($text !== 'bold') { + $this->assertSame(self::BLUE, $colour, sprintf('"%s" is drawn as a child of the paragraph', $text)); + } + } + } + + /** + * The universal selector weighs nothing: every rule naming a tag, class, id, attribute or pseudo-class outweighs + * it, and between universal rules the later one wins + * + * @dataProvider precedence + * + * @param string $css + * @param string $expected The colour of the text "subject" + */ + public function testPrecedence($css, $expected) + { + $html = '

subject

'; + + $this->assertSame($expected, $this->drawnColours('' . $html, ['cssMode' => CssMode::STANDARD])['subject']); + } + + /** + * Competing rules, and the colour they leave the subject in + * + * @return array[] + */ + public function precedence() + { + return [ + 'a type written before' => ['p { color: #008000; } * { color: #f00; }', self::GREEN], + 'a class written before' => ['.c { color: #008000; } * { color: #f00; }', self::GREEN], + 'an id written before' => ['#i { color: #008000; } * { color: #f00; }', self::GREEN], + 'an attribute written before' => ['[title] { color: #008000; } * { color: #f00; }', self::GREEN], + 'a pseudo-class written before' => [':first-child { color: #008000; } * { color: #f00; }', self::GREEN], + 'a class with the universal selector against a type' => ['*.c { color: #008000; } p { color: #f00; }', self::GREEN], + 'a universal chain against a type' => ['* > * { color: #f00; } p { color: #008000; }', self::GREEN], + 'a universal chain against a class' => ['* div > * { color: #f00; } .c { color: #008000; }', self::GREEN], + 'a universal chain weighs as its type: the later wins' => ['p { color: #f00; } div > * { color: #008000; }', self::GREEN], + 'a universal chain with two types against one' => ['body div > * { color: #008000; } p { color: #f00; }', self::GREEN], + 'where against the universal selector: the later wins' => ['* { color: #f00; } :where(p) { color: #008000; }', self::GREEN], + 'the universal selector against where: the later wins' => [':where(p) { color: #f00; } * { color: #008000; }', self::GREEN], + 'two universal rules: the later wins' => ['* { color: #f00; } * { color: #008000; }', self::GREEN], + ]; + } +} diff --git a/tests/Snapshots/UniversalSelectorSnapshotTest.php b/tests/Snapshots/UniversalSelectorSnapshotTest.php new file mode 100644 index 000000000..a0edfece5 --- /dev/null +++ b/tests/Snapshots/UniversalSelectorSnapshotTest.php @@ -0,0 +1,160 @@ + + + +

The universal selector

+ +

*

+

Text no other rule colours is navy: * reaches every element, and every other rule on this page outweighs it. The captions stay grey and the headings black, from their own rules.

+
+

A paragraph

+
  • A list item
+
+ +

*.hl

+

The paragraph and the span with class hl are red and bold. The paragraph without it stays navy.

+
+

With the class

+

Without the class, a span with it

+
+ +

*#pick

+

Only the paragraph with id pick has a yellow background.

+
+

With the id

+

Without the id

+
+ +

*[title]

+

The paragraph and the item with a title are green and bold; the others stay navy.

+
+

With a title

+

Without a title

+
  • An item with a title
  • An item without
+
+ +

div.inside *

+

Everything inside the box is red: the paragraph, the span in it and the list item. The paragraph after the box stays navy.

+
+

A paragraph with a span

+
  • A list item
+
+

After the box

+ +

div.deep * em

+

The emphasis inside the paragraph is red and bold. The one written straight into the box has no element between it and the box, and stays navy.

+
+ Straight in the box +

In a paragraph, emphasised

+
+ +

div.children > *

+

The box's two children have a red bar on their left. The span inside the paragraph does not.

+
+

A child with a span in it

+

Another child

+
+ +

div.after * + p

+

The paragraphs after another element are red and bold. The first paragraph stays navy.

+
+

The first paragraph

+

After a paragraph

+

A heading

+

After the heading

+
+ +

div.later h4 ~ *

+

Everything after the heading is red. The paragraph before it and the heading stay navy.

+
+

Before the heading

+

The heading

+

After the heading

+

After that

+
+ +

div.is > :is(*):first-child

+

The box's first child is red. The second stays navy.

+
+

The first child

+

The second child

+
+ +

div.never p:not(*)

+

Every element is an element, so :not(*) matches nothing: the paragraph stays navy.

+
+

A paragraph

+
+ +

table.first tr > *:first-child

+

The first cell of each row is yellow, whether it is a header or a data cell.

+ + + +
HeadHead 2
R1 C1R1 C2
+ +

ul.rest > * + *

+

Every item but the first is red and bold.

+
+
  • First
  • Second
  • Third
+
+ mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/UniversalStylesheetSnapshotTest.php b/tests/Snapshots/UniversalStylesheetSnapshotTest.php new file mode 100644 index 000000000..5a7cab694 --- /dev/null +++ b/tests/Snapshots/UniversalStylesheetSnapshotTest.php @@ -0,0 +1,110 @@ + + + +

A framework's reset, with *

+ +

Cards

+

The reset leaves no space between the paragraphs of a card; .stack > * + * puts 2 mm back between the cards and between the parts of each, and none above the first. The first card's text is blue, from .card > *, and its heading black, the heavier .card > h3 winning. The muted card's text is grey, .muted * being as heavy as .card > * and written after it, and the paragraph marked hidden is not drawn. The featured card's text is bold, from #featured *. The cards' borders are light grey, from * { border-color }.

+
+
+

First card

+

A paragraph with no margin of its own.

+

A second paragraph, 2 mm below it.

+
+
+

Muted card

+

Grey text, from .muted *.

+ +
  • A list item, grey
  • Another
+
+ +
+ +

Table

+

The cells keep the padding of .table td, .table th over the reset, and the light grey rule under each row takes its colour from * { border-color }, which reaches the rows too without taking the cells' rules away. The first cell of each row, a th or a td, is grey, from .table tr > *:first-child.

+ + + + +
ItemAmount
First10.00
Second20.00
+ +

mPDF's own tags

+

No rule names the barcode, the dot tab or the text circle, so the reset and .label * + * leave them as they are drawn without a stylesheet. The label's lines are 1 mm apart, the barcode's line included.

+
+

Ship to: A. Person

+

+

Reference4711

+

+
+ + +

Index

+

The reset reaches the index's lines too: its letters have no space above them.

+ + mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/snapshots/universal-selector.pdf b/tests/data/snapshots/universal-selector.pdf new file mode 100644 index 000000000..f6702fdf5 Binary files /dev/null and b/tests/data/snapshots/universal-selector.pdf differ diff --git a/tests/data/snapshots/universal-stylesheet.pdf b/tests/data/snapshots/universal-stylesheet.pdf new file mode 100644 index 000000000..740909443 Binary files /dev/null and b/tests/data/snapshots/universal-stylesheet.pdf differ