diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index a1853f3db..7061d1944 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -37,6 +37,10 @@ Read this section before upgrading from upstream mPDF. Each entry says what chan * A property declared twice in one block is read at its last place, so `border-top-color: green; border: 1px solid blue; border-top-color: red` draws a red top. * **Dependencies changed.** `ext-json` is now required and `symfony/polyfill-intl-normalizer` is a new dependency. `myclabs/deep-copy` is no longer used. [#13] [#329] * **`outline-width` and `outline-color` no longer stroke the text.** mPDF read them as `text-outline-width` and `text-outline-color`, but in CSS they belong to the line around the box, which mPDF does not draw. `outline-width` on its own also raised an undefined-key warning. Write `text-outline-width` and `text-outline-color`, or `text-outline`, to keep stroking the text. [#578] +* **CSS rules apply by specificity, then in the order they are written**, as in a browser. mPDF applied them in a fixed order of selector groups, so `div p` beat `#id`, `p.c` beat `#i`, `body p` beat `.lead`, the rule from the nearest ancestor beat a heavier descendant rule, and classes applied in alphabetical order. Now the more specific selector wins each of those, and of `.a { } .b { } .a { }` the second `.a` wins. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to parse and apply CSS as mPDF v7 did. [#535] [#631] + * Each element takes, in order: the values it inherits; the built-in defaults and the rules of the default stylesheet (`defaultCssFile`), which any author rule beats; HTML attributes such as `
`, `width` and `vspace`, as author rules of no specificity; the stylesheets; then `style=""`. + * Class and id names still match whatever their case. + * An author's `a { }` rule now reaches the links of the table of contents and the index too. Style `a.mpdf_toc_a` and `a.mpdf_index_link` to keep them plain. New features ------------ @@ -120,6 +124,7 @@ Performance * **Image metadata.** JPEG and PNG metadata is read from the file's structure instead of by scanning the whole file, and PNG chunks are read from a chunk index. [#47] [#48] * **GD memory.** GD asks for less memory when it re-encodes an image. [#49] * **nth-child rules in tables.** Each row and cell looks up the nth-child rules the stylesheet uses by their keys, instead of running a regex over every rule. A 2,000-cell table under 1,000 rules is written in about 540 ms instead of 745 ms. Class combinations are built only up to the most classes one compound selector names, not one whole selector. [#526] [#580] +* **Selector matching.** A chain of descendant combinators stops trying ancestors once none left can match, instead of trying every combination of them, and a general sibling combinator skips a tag the parent has no child of and looks from the first sibling. `.x div div div div div p` is matched on a path 50 deep in well under a millisecond instead of 4 seconds, and `h2 ~ p` against 5,000 paragraphs after an `

` in 20 ms instead of 5 seconds. [#535] [#631] Bugfixes -------- @@ -132,6 +137,7 @@ Bugfixes * `@page { size: landscape }`, or a page box wider than it is tall, left the first page portrait, and `size: portrait` left a landscape document landscape. The first page now turns. A page box and percentage margins are measured on the turned sheet, so `size: 250mm 150mm` on A4 is no longer cut to 210 mm wide. [#552] [#562] * `page-break-before: auto` or `avoid` on a block inside another block closed the outer block and opened it again, so its border was drawn around each part. `page-break-after: auto` or `avoid` on a table started a new page. [#552] [#569] * `page-break-before` on a table was ignored. On a top-level table, `always`, `left` and `right` now start it on a new page, inside the blocks around it. [#552] [#622] +* A table with a `font-size` and a row with `text-rotate` threw a `TypeError` on PHP 8, and raised a warning before it. A cell whose font size was already in force saved its text with an empty size. [#632] [#631] * `background-size: cover` scaled by the wrong ratio when the image came out shorter than the area. [mpdf/mpdf#833] [#22] * Only double-quoted attributes were read. Single-quoted, unquoted and bare attributes are now read as well. [mpdf/mpdf#2030] [#24] * A shadow colour written without spaces, such as `rgba(255,0,0,0.5)`, fell back to grey. Whitespace in shadows is now parsed as CSS writes it too. [#25] @@ -777,6 +783,7 @@ These changes do not change output. [#527]: https://github.com/GravityPDF/mpdf/issues/527 [#528]: https://github.com/GravityPDF/mpdf/issues/528 [#533]: https://github.com/GravityPDF/mpdf/issues/533 +[#535]: https://github.com/GravityPDF/mpdf/issues/535 [#538]: https://github.com/GravityPDF/mpdf/issues/538 [#549]: https://github.com/GravityPDF/mpdf/pull/549 [#552]: https://github.com/GravityPDF/mpdf/issues/552 @@ -820,3 +827,5 @@ These changes do not change output. [#627]: https://github.com/GravityPDF/mpdf/pull/627 [#628]: https://github.com/GravityPDF/mpdf/pull/628 [#629]: https://github.com/GravityPDF/mpdf/pull/629 +[#631]: https://github.com/GravityPDF/mpdf/pull/631 +[#632]: https://github.com/GravityPDF/mpdf/issues/632 diff --git a/src/Config/ConfigVariables.php b/src/Config/ConfigVariables.php index e60298d85..82f32cf1f 100644 --- a/src/Config/ConfigVariables.php +++ b/src/Config/ConfigVariables.php @@ -2,6 +2,7 @@ namespace Mpdf\Config; +use Mpdf\CssMode; use Mpdf\Css\DefaultCss; use Mpdf\Language\LanguageToFont; use Mpdf\Language\LanguageToFontRegistry; @@ -269,6 +270,9 @@ public function __construct() // screen, print, or any other CSS @media type (except "all") 'CSSselectMedia' => 'print', + // How CSS is parsed and applied: CssMode::STANDARD as a browser does, CssMode::LEGACY as mPDF v7 did + 'cssMode' => CssMode::STANDARD, + // PAGE HEADERS & FOOTERS 'forcePortraitHeaders' => false, diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 8e5959dcc..867c079d6 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -3,6 +3,7 @@ namespace Mpdf\Css; use Mpdf\Color\ColorConverter; +use Mpdf\CssMode; use Mpdf\CssManager; use Mpdf\Exception\InvalidArgumentException; use Mpdf\Mpdf; @@ -112,6 +113,10 @@ public function merge($inherit, $tag, $attr) $attr = is_array($attr) ? $attr : []; + if ($this->mpdf->cssMode === CssMode::STANDARD) { + return $this->mergeInCascadeOrder($inherit, $tag, $attr); + } + $classes = []; if (isset($attr['CLASS'])) { // filter out classes that don't have any CSS applied, which reduces likelyhood of O(2^N) memory issue @@ -140,18 +145,104 @@ public function merge($inherit, $tag, $attr) $this->mergeTableCascadingCss($inherit, $tag, $attr, $classes); $this->mergeBlockCascadingCss($inherit, $tag, $attr, $classes); + $this->mergeInheritedBlockProperties($inherit, $tag); $this->mergeInlineAttributes($tag, $attr); $this->mergeDefaultCss($tag); $this->mergeTableSpecificCss($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); + // The rules only the matcher reads, such as div > p, h1 + p and li:first-child, go after the descendant rules + $this->mergeMatchingRules($this->cssManager->getRules(), $tag, $attr['ID'], $this->classesOf($attr), [$this->mpdf, 'getStyledElementPath']); + $this->mergeInlineStyle($tag, $attr); + + return $this->cssProperties; + } + + /** + * Merges an element's CSS as standard mode orders it, in layers, each over the one before: the inherited + * values, the built-in defaults and the default stylesheet's rules, the presentational attributes as author rules + * of zero specificity, the author rules, and the inline style. The rules of each stylesheet apply by specificity, + * then in the order they were written. + * + * @param string $inherit Inheritance context (BLOCK, INLINE, TABLE, TOPTABLE) + * @param string $tag HTML tag name + * @param array $attr HTML attributes, as the tag handler was given them + * @return array Merged CSS properties array + */ + private function mergeInCascadeOrder($inherit, $tag, array $attr) + { + // Found once, when a rule set first has a rule filed under the element. previewBlockCss() looks at an element + // that is not being written, so it is put in the innermost open element, and nothing is recorded + $mpdf = $this->mpdf; + $sideEffects = $this->sideEffects; + $elements = false; + $path = function () use ($mpdf, $sideEffects, $tag, $attr, &$elements) { + if ($elements === false) { + $elements = $sideEffects ? $mpdf->getStyledElementPath() : $mpdf->getOpenElementPathFor($tag, $attr); + } + + return $elements; + }; + + if (isset($attr['LANG'])) { + $attr['LANG'] = strtolower($attr['LANG']); + } + + $id = isset($attr['ID']) ? $attr['ID'] : ''; + $classes = $this->classesOf($attr); + + $this->mergeInheritedBlockProperties($inherit, $tag); + + $this->mergeDefaultCss($tag); + $this->mergeMatchingRules($this->cssManager->getDefaultRules(), $tag, $id, $classes, $path); + + $this->mergeInlineAttributes($tag, $attr); + $this->mergeTableSpecificCss($tag, $attr); + + if ($tag === 'BODY') { + // Merged outside the document's elements, and written to by Mpdf::SetDefaultBodyCSS() and the like + $body = isset($this->cssManager->CSS['BODY']) ? $this->cssManager->CSS['BODY'] : []; + $this->setMergedCss($body, false); + } else { + $this->mergeMatchingRules($this->cssManager->getRules(), $tag, $id, $classes, $path); + } + $this->mergeInlineStyle($tag, $attr); return $this->cssProperties; } + /** + * Merges the declarations of the rules in a rule set that an element matches, in the order they apply: by + * specificity, then by their position in the stylesheets. Each rule that reaches a table cell draws its borders + * over those of its neighbours + * + * @param RuleSet $rules + * @param string $tag HTML tag name, 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 + * @return void + */ + private function mergeMatchingRules(RuleSet $rules, $tag, $id, array $classes, callable $path) + { + $dominance = $tag === 'TD' || $tag === 'TH' ? 9 : false; + + foreach ($rules->matchingDeclarations($tag, $id, $classes, $path) as $properties) { + $this->setMergedCss($properties, false, $dominance); + } + } + + /** + * @param array $attr HTML attributes, with CLASS uppercased + * @return string[] The element's classes + */ + private function classesOf(array $attr) + { + return isset($attr['CLASS']) ? preg_split('/\s+/', $attr['CLASS'], -1, PREG_SPLIT_NO_EMPTY) : []; + } + /** * Preview block-level CSS without creating the block. * @@ -231,7 +322,8 @@ protected function mergeTableCascadingCss($inherit, $tag, $attr, $classes) /** * Merge block cascading CSS. * - * Handles inheritance and cascading of CSS properties for block elements. + * Lifts the descendant rules that go through a block element into its level of the block stack, for the elements + * inside it. * * @param string $inherit Inheritance type (TOPTABLE, TABLE, BLOCK) * @param string $tag HTML tag name @@ -283,6 +375,23 @@ protected function mergeBlockCascadingCss($inherit, $tag, $attr, $classes) $this->mpdf->blk[$this->mpdf->blklvl] = $currentBlock; $this->mpdf->markStyledElementAsLevel(); } + } + + /** + * Merge the properties a block inherits from the block it is opened in. + * + * @param string $inherit Inheritance type (TOPTABLE, TABLE, BLOCK) + * @param string $tag HTML tag name + * @return void + */ + protected function mergeInheritedBlockProperties($inherit, $tag) + { + if ($inherit !== 'BLOCK') { + return; + } + + $previousBlockLevel = $this->getBlockLevel(); + $previousBlock = isset($this->mpdf->blk[$previousBlockLevel]) ? $this->mpdf->blk[$previousBlockLevel] : []; // Block properties which are inherited if (!empty($previousBlock['margin_collapse'])) { @@ -836,27 +945,6 @@ 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/CssParser.php b/src/Css/CssParser.php index 56b117e96..0044752a6 100644 --- a/src/Css/CssParser.php +++ b/src/Css/CssParser.php @@ -4,6 +4,7 @@ use Mpdf\Utils\Arrays; use Mpdf\Utils\Path; +use Mpdf\CssMode; use Mpdf\Mpdf; use Mpdf\Cache; use Mpdf\SizeConverter; @@ -222,7 +223,8 @@ public function getCascadeCss() /** * The rules of the last CSS parsed for the matcher: those whose selector the legacy parser cannot read, and the - * descendant and :lang() rules it stores, which the matcher applies where the legacy engine cannot match them + * descendant and :lang() rules it stores, which the matcher applies where the legacy engine cannot match them. + * In standard mode, every rule it read * * @return array[] Each [compiled selector, declarations, whether the legacy parser stores it too], in the order * they were written @@ -313,9 +315,22 @@ private function processCssSelector($written, $classProperties) return; } + $tag = $level === 1 ? $this->selectorParser->parseSimpleSelector($tags) : null; + $simple = $tag !== null && $this->isLegacySelector([$tag]); + + if ($this->mpdf->cssMode === CssMode::STANDARD) { + $this->compileRule($written, $classProperties); + + // Simple rules are still stored by key, for what reads CssManager::$CSS directly: BODY, and SVG's classes + if ($simple) { + $this->storeSimpleRule($tag, $classProperties); + } + + return; + } + if ($level === 1) { - $tag = $this->selectorParser->parseSimpleSelector($tags); - if (!$this->isLegacySelector($tag === null ? [] : [$tag])) { + if (!$simple) { $this->compileRule($written, $classProperties); return; } @@ -325,13 +340,7 @@ private function processCssSelector($written, $classProperties) $this->compileRule($written, $classProperties, true); } - if (isset($this->css[$tag])) { - $this->css[$tag] = Arrays::uniqueRecursiveMerge($this->css[$tag], $classProperties); - } else { - $this->css[$tag] = $classProperties; - } - - $this->indexStoredKey($tag); + $this->storeSimpleRule($tag, $classProperties); return; } @@ -355,6 +364,24 @@ private function processCssSelector($written, $classProperties) $this->compileRule($written, $classProperties, true); } + /** + * Stores a rule whose selector is one compound the legacy parser reads, under its key + * + * @param string $key A key SelectorParser::parseSimpleSelector() made, e.g. P or CLASS>>A + * @param array $classProperties + * @return void + */ + private function storeSimpleRule($key, array $classProperties) + { + if (isset($this->css[$key])) { + $this->css[$key] = Arrays::uniqueRecursiveMerge($this->css[$key], $classProperties); + } else { + $this->css[$key] = $classProperties; + } + + $this->indexStoredKey($key); + } + /** * Record what the merger needs to know about the key of one compound selector of a stored rule: how many classes * it names, and its nth-child formula. @@ -410,8 +437,7 @@ private function compileRule($selector, array $classProperties, $legacy = false) $compiled = $this->selectorCompiler->compile($selector); - // The universal selector changes which elements existing documents style, so it waits for #530 and the - // standard cascade option + // The universal selector changes which elements existing documents style, so it waits for #530 if ($compiled !== null && !$compiled['universal']) { $this->compiledRules[] = [$compiled, $classProperties, $legacy]; } diff --git a/src/Css/SelectorMatcher.php b/src/Css/SelectorMatcher.php index 88d63ff9b..1c1945e1a 100644 --- a/src/Css/SelectorMatcher.php +++ b/src/Css/SelectorMatcher.php @@ -13,6 +13,15 @@ class SelectorMatcher { + /** The selector matches */ + const MATCHES = 0; + + /** It fails for this element, and may match another */ + const FAILS_HERE = 1; + + /** It fails for this element and for any other whose ancestors are all among this one's */ + const FAILS_FOR_ANCESTORS = 2; + /** * Whether a selector matches the last element of a path * @@ -28,13 +37,18 @@ public function matches(array $selector, array $path, $legacyView = false) { $depth = count($path) - 1; - return $this->matchesFrom($selector, count($selector['compounds']) - 1, $path, $depth - 1, $path[$depth]['nthChild'] - 1, $legacyView); + return $this->matchesFrom($selector, count($selector['compounds']) - 1, $path, $depth - 1, $path[$depth]['nthChild'] - 1, $legacyView) === self::MATCHES; } /** * 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 + * A descendant combinator tries each ancestor in turn, nearest first, and a general sibling combinator each earlier + * sibling, from the first, so a compound further left that fails on one is tried again on the next. + * + * A failure says how far it reaches, so that the tries stop once none left can match: when the compounds to the + * left of a descendant combinator have failed at every ancestor, they fail at every ancestor of those ancestors + * too, and at their siblings. Without this, a chain of descendant combinators is tried along every combination of + * ancestors on a deep path. * * @param array $selector A selector SelectorCompiler::compile() compiled * @param int $compound Which of its compounds to match, from the left @@ -44,44 +58,63 @@ public function matches(array $selector, array $path, $legacyView = false) * has closed, it is the open element at the next depth * @param bool $legacyView Whether to match as the legacy engine does, as matches() takes it * - * @return bool + * @return int self::MATCHES, or how far the failure reaches: self::FAILS_HERE or self::FAILS_FOR_ANCESTORS */ private function matchesFrom(array $selector, $compound, array $path, $parent, $index, $legacyView) { if (!$this->matchesCompound($selector['compounds'][$compound], $path, $parent, $index, $legacyView)) { - return false; + return self::FAILS_HERE; } if ($compound === 0) { - return true; + return self::MATCHES; } $compound--; switch ($selector['combinators'][$compound]) { case '>': - return $parent >= 0 && $this->matchesFrom($selector, $compound, $path, $parent - 1, $path[$parent]['nthChild'] - 1, $legacyView); + if ($parent < 0) { + return self::FAILS_FOR_ANCESTORS; + } + + return $this->matchesFrom($selector, $compound, $path, $parent - 1, $path[$parent]['nthChild'] - 1, $legacyView); case ' ': for ($depth = $parent; $depth >= 0; $depth--) { - if ((!$legacyView || $path[$depth]['level']) - && $this->matchesFrom($selector, $compound, $path, $depth - 1, $path[$depth]['nthChild'] - 1, $legacyView)) { - return true; + if ($legacyView && !$path[$depth]['level']) { + continue; + } + + $result = $this->matchesFrom($selector, $compound, $path, $depth - 1, $path[$depth]['nthChild'] - 1, $legacyView); + if ($result !== self::FAILS_HERE) { + return $result; } } - return false; + return self::FAILS_FOR_ANCESTORS; case '+': - return $parent >= 0 && $index > 0 && $this->matchesFrom($selector, $compound, $path, $parent, $index - 1, $legacyView); + if ($parent < 0 || $index === 0) { + return self::FAILS_HERE; + } + + return $this->matchesFrom($selector, $compound, $path, $parent, $index - 1, $legacyView); default: - for ($sibling = $index - 1; $parent >= 0 && $sibling >= 0; $sibling--) { - if ($this->matchesFrom($selector, $compound, $path, $parent, $sibling, $legacyView)) { - return true; + // None of the siblings before it has the tag, if the parent has had no child with it + $tag = $selector['compounds'][$compound]['tag']; + if ($parent < 0 || ($tag !== null && !isset($path[$parent]['childTypes'][$tag]))) { + return self::FAILS_HERE; + } + + for ($sibling = 0; $sibling < $index; $sibling++) { + $result = $this->matchesFrom($selector, $compound, $path, $parent, $sibling, $legacyView); + if ($result !== self::FAILS_HERE) { + return $result; } } - return false; + return self::FAILS_HERE; } } @@ -152,7 +185,7 @@ private function matchesPseudoClass(array $pseudo, array $element, array $path, { if ($pseudo[0] === 'is' || $pseudo[0] === 'not') { foreach ($pseudo[1] as $selector) { - if ($this->matchesFrom($selector, count($selector['compounds']) - 1, $path, $parent, $index, $legacyView)) { + if ($this->matchesFrom($selector, count($selector['compounds']) - 1, $path, $parent, $index, $legacyView) === self::MATCHES) { return $pseudo[0] === 'is'; } } diff --git a/src/CssManager.php b/src/CssManager.php index 7ce392545..ebc3870bb 100644 --- a/src/CssManager.php +++ b/src/CssManager.php @@ -92,6 +92,11 @@ class CssManager */ private $rules; + /** + * @var RuleSet The rules of the default stylesheet, compiled for the matcher + */ + private $defaultRules; + /** * CssManager constructor. * @@ -107,6 +112,7 @@ public function __construct(CssParser $cssParser, CssMerger $cssMerger) $this->cssMerger = $cssMerger; $this->cssMerger->setCssManager($this); $this->rules = new RuleSet(); + $this->defaultRules = new RuleSet(); } /** @@ -120,6 +126,30 @@ public function __construct(CssParser $cssParser, CssMerger $cssMerger) * @return string HTML with CSS content removed */ public function readCss($html) + { + return $this->read($html, $this->rules); + } + + /** + * Read the default stylesheet, Mpdf::$defaultCssFile. Its rules count as the built-in stylesheet's, which standard + * mode applies with the built-in defaults, below every rule of the document's own stylesheets + * + * @param string $css The stylesheet, without a ', $this->defaultRules); + } + + /** + * Parse the CSS in HTML content into the stores readCss() describes, and file its compiled rules in a rule set + * + * @param string $html + * @param RuleSet $rules + * @return string HTML with CSS content removed + */ + private function read($html, RuleSet $rules) { if (!is_array($this->cascadeCSS)) { $this->cascadeCSS = []; @@ -131,7 +161,7 @@ public function readCss($html) $this->cascadeCSS = Arrays::uniqueRecursiveMerge($this->cascadeCSS, $this->cssParser->getCascadeCss()); foreach ($this->cssParser->getCompiledRules() as $rule) { - $this->rules->add($rule[0], $rule[1], $rule[2]); + $rules->add($rule[0], $rule[1], $rule[2]); } return $html; @@ -147,6 +177,16 @@ public function getRules() return $this->rules; } + /** + * The rules of the default stylesheet, compiled for the matcher + * + * @return RuleSet + */ + public function getDefaultRules() + { + return $this->defaultRules; + } + /** * Parse inline CSS style attribute. * diff --git a/src/CssMode.php b/src/CssMode.php new file mode 100644 index 000000000..90caa092f --- /dev/null +++ b/src/CssMode.php @@ -0,0 +1,21 @@ +initConfig($originalConfig); + if (!in_array($this->cssMode, [CssMode::STANDARD, CssMode::LEGACY], true)) { + throw new \Mpdf\MpdfException(sprintf('cssMode (%s) is not valid. (Use: %s or %s)', $this->cssMode, CssMode::STANDARD, CssMode::LEGACY)); + } + if ($this->isPdfx4() && version_compare($this->pdf_version, '1.6', '<')) { $this->pdf_version = '1.6'; } @@ -1590,7 +1599,7 @@ public function __construct(array $config = [], $container = null) if (file_exists($this->defaultCssFile)) { $css = file_get_contents($this->defaultCssFile); - $this->cssManager->ReadCSS(''); + $this->cssManager->readDefaultCss($css); } else { throw new \Mpdf\MpdfException(sprintf('Unable to read default CSS file "%s"', $this->defaultCssFile)); } @@ -5167,13 +5176,14 @@ function SetFont($family, $style = '', $size = 0, $write = true, $forcewrite = f function SetFontSize($size, $write = true) { - // Set font size in points + // Set font size in points. Recorded even when it is already in force: Reset() empties it after setting the + // size, and the text saved for a table cell carries it + $this->currentfontsize = $size; if ($this->FontSizePt == $size) { return; } $this->FontSizePt = $size; $this->FontSize = $size / Mpdf::SCALE; - $this->currentfontsize = $size; if ($write) { $fontout = (sprintf('BT /F%d %.3F Tf ET', $this->CurrentFont['i'], $this->FontSizePt)); // Edited mPDF 3.0 diff --git a/src/TracksOpenElements.php b/src/TracksOpenElements.php index 12641fbe6..433093c41 100644 --- a/src/TracksOpenElements.php +++ b/src/TracksOpenElements.php @@ -142,20 +142,11 @@ public function getStyledElementPath() 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++; + if ($this->styledElement['tag'] === null) { + return $this->styledElement['path']; } - $path[] = $this->newChildFrame($path[$parent], $tag, $this->styledElement['attr']); - return $path; + return $this->withChildFrame($this->styledElement['path'], $this->styledElement['tag'], $this->styledElement['attr']); } /** @@ -179,6 +170,43 @@ public function getStyledElementNthChild() return self::impliesTbody($this->styledElement['tag'], $parent) ? 1 : count($parent['children']) + 1; } + /** + * The path an element would have if it were opened now, in the innermost open element, after the children that + * element has so far. Nothing is recorded, so CssMerger::previewBlockCss() can match the selectors against it + * without changing the stack + * + * @param string $tag Uppercased + * @param string[] $attr + * + * @return array[] The open elements from the document down to it, with the element's own frame last + */ + public function getOpenElementPathFor($tag, array $attr) + { + return $this->withChildFrame($this->openElements, $tag, $attr); + } + + /** + * A path with a frame added for an element opened in its last element, after the children that element has so + * far, and put under the tbody the stack gives a row written straight into a table + * + * @param array[] $path Open elements, outermost first + * @param string $tag + * @param string[] $attr + * + * @return array[] + */ + private function withChildFrame(array $path, $tag, array $attr) + { + $parent = count($path) - 1; + if (self::impliesTbody($tag, $path[$parent])) { + $path[] = $this->newChildFrame($path[$parent], 'TBODY', []); + $parent++; + } + $path[] = $this->newChildFrame($path[$parent], $tag, $attr); + + return $path; + } + /** * Notes that the element whose CSS is being merged opens a level of the descendant rules the legacy parser * stores, as a block, a table and a table part do. CssMerger calls it when it lifts those rules at the element. diff --git a/tests/Mpdf/CascadeContextTest.php b/tests/Mpdf/CascadeContextTest.php new file mode 100644 index 000000000..04cbd19f0 --- /dev/null +++ b/tests/Mpdf/CascadeContextTest.php @@ -0,0 +1,226 @@ + $subject) { + $expected[$text] = $subject[1]; + } + } + + $this->assertDrawnInColours($expected, $this->drawnColours($this->document($context, $css, $groups), ['cssMode' => CssMode::STANDARD])); + } + + /** + * Every case in every context + * + * @return array[] + */ + public function casesInContexts() + { + $data = []; + foreach (['block', 'inline', 'table cell', 'header', 'footer', 'positioned block', 'kept block', 'forced page break'] as $context) { + foreach ($this->cases() as $name => $case) { + $data[$context . ': ' . $name] = [$context, $case[0], $case[1]]; + } + } + + return $data; + } + + /** + * Rules that compete, with the elements they are matched against + * + * @return array[] Each [css, groups]: a group is [the attributes of the subjects' ancestors, outermost first, and + * for each subject keyed by its text, its attributes and the colour it should be drawn in] + */ + private function cases() + { + return [ + 'an id beats a tag with a class' => [ + '#i { color: #008000; } {T}.c { color: #f00; }', + [[[], ['with the id' => ['id="i" class="c"', self::GREEN], 'without it' => ['class="c"', self::RED]]]], + ], + 'an id beats a descendant rule' => [ + '#i { color: #008000; } {A} {T} { color: #f00; }', + [[[''], ['with the id' => ['id="i"', self::GREEN], 'without it' => ['', self::RED]]]], + ], + 'a class beats a rule naming body' => [ + 'body {T} { color: #f00; } .c { color: #008000; }', + [[[], ['with the class' => ['class="c"', self::GREEN], 'without it' => ['', self::RED]]]], + ], + 'two classes beat a tag with a class' => [ + '{T}.c { color: #f00; } .c.d { color: #008000; }', + [[[], ['with both' => ['class="c d"', self::GREEN], 'with one' => ['class="c"', self::RED]]]], + ], + 'a class ancestor beats two nearer tag ancestors' => [ + '.c {T} { color: #008000; } {A} {A} {T} { color: #f00; }', + [ + [['class="c"', ''], ['under the class' => ['', self::GREEN]]], + [['', ''], ['under plain ancestors' => ['', self::RED]]], + ], + ], + 'an id ancestor beats two nearer tag ancestors' => [ + '#o {T} { color: #008000; } {A} {A} {T} { color: #f00; }', + [ + [['id="o"', ''], ['under the id' => ['', self::GREEN]]], + [['', ''], ['under plain ancestors' => ['', self::RED]]], + ], + ], + 'classes apply in the order they are written' => [ + '.b { color: #008000; } .a { color: #f00; }', + [[[], ['a then b' => ['class="a b"', self::RED], 'b then a' => ['class="b a"', self::RED], 'b alone' => ['class="b"', self::GREEN]]]], + ], + 'a selector written twice takes each of its places' => [ + '.a { color: #f00; } .b { color: #008000; } .a { color: #00f; }', + [[[], ['a and b' => ['class="a b"', self::BLUE], 'b alone' => ['class="b"', self::GREEN]]]], + ], + 'a structural pseudo-class counts as a class' => [ + '{T}:first-child { color: #008000; } .c { color: #f00; }', + [[[''], ['first child' => ['class="c"', self::GREEN], 'second child' => ['class="c"', self::RED]]]], + ], + 'an id beats an attribute selector' => [ + '[data-x] { color: #f00; } #i { color: #008000; }', + [[[], ['with the id' => ['id="i" data-x="1"', self::GREEN], 'attribute alone' => ['data-x="1"', self::RED]]]], + ], + ':where() counts nothing' => [ + ':where(#o) {T} { color: #f00; } {A}.k {T} { color: #008000; }', + [ + [['id="o" class="k"'], ['under both' => ['', self::GREEN]]], + [['id="o"'], ['under the id alone' => ['', self::RED]]], + ], + ], + 'the inline style beats an id' => [ + '#i { color: #f00; }', + [[[], ['styled inline' => ['id="i" style="color: #008000"', self::GREEN], 'id alone' => ['id="i"', self::RED]]]], + ], + ]; + } + + /** + * @param string $context + * + * @return string[] The tag of the subjects in a context, and of their ancestors + */ + private function tags($context) + { + if ($context === 'inline') { + return ['em', 'span']; + } + + return [$context === 'table cell' ? 'td' : 'p', 'div']; + } + + /** + * A document with each group of subjects, in their ancestors, 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 {A} for their ancestors' + * @param array[] $groups As cases() gives them + * + * @return string + */ + private function document($context, $css, array $groups) + { + list($subject, $ancestor) = $this->tags($context); + + $style = ''; + $html = ''; + foreach ($groups as $group) { + $inner = ''; + foreach ($group[1] as $text => $attributes) { + $inner .= '<' . $subject . ' ' . $attributes[0] . '>' . $text . ' '; + } + + if ($context === 'table cell') { + $inner = '' . $inner . '
'; + } + + foreach (array_reverse($group[0]) as $attributes) { + $inner = '<' . $ancestor . ' ' . $attributes . '>' . $inner . ''; + } + + $html .= $context === 'inline' ? '

' . $inner . '

' : $inner; + } + + switch ($context) { + case 'header': + return $style . '' . $html . '

body

'; + + case 'footer': + return $style . '' . $html . '

body

'; + + case 'positioned block': + return $style . '
' . $html . '
'; + + case 'kept block': + // Too little of the first page is left for the block, which is laid out again on the second + return $style . str_repeat('

filler

', 44) . '

kept

' . $html . '
'; + + case 'forced page break': + return $style . '

before the break

' . $html . '
'; + + default: + return $style . $html; + } + } + + /** + * The kept-block context does lay its block out twice: it starts on the first page, runs over, and is laid out + * again from the top of the second + */ + public function testTheKeptBlockMovesToTheNextPage() + { + $mpdf = $this->drawDocument($this->document('kept block', '.c { color: #f00; }', [[[''], ['kept subject' => ['class="c"'], 'second' => [''], 'third' => ['']]]]), ['cssMode' => CssMode::STANDARD]); + $pages = $this->keyedByText($mpdf, array_map(function ($box) { + return $box[0]; + }, $mpdf->drawnBoxes)); + + $this->assertSame(2, $pages['kept']); + $this->assertSame(2, $pages['kept subject']); + } + + /** + * A rule naming the block a forced page break closes and opens again reaches what follows the break inside it, + * over a lighter rule, and not what is outside it + */ + public function testARuleThroughABlockOpenedAgainAfterAForcedPageBreak() + { + $colours = $this->drawnColours('' + . '

before

after the break

outside

', ['cssMode' => CssMode::STANDARD]); + + $this->assertSame(self::GREEN, $colours['before']); + $this->assertSame(self::GREEN, $colours['after the break']); + $this->assertSame(self::RED, $colours['outside']); + } +} diff --git a/tests/Mpdf/CascadeOrderTest.php b/tests/Mpdf/CascadeOrderTest.php new file mode 100644 index 000000000..07ef29ed3 --- /dev/null +++ b/tests/Mpdf/CascadeOrderTest.php @@ -0,0 +1,369 @@ + $expected Pieces of text and the colour each should be drawn in + */ + public function testAppliesTheRulesInTheCascadesOrder($cascade, $css, $html, array $expected) + { + $this->assertDrawnInColours($expected, $this->drawnColours('' . $html, ['cssMode' => $cascade])); + } + + /** + * Each row of precedence() once for each cascade, with that cascade's colours + * + * @return array[] + */ + public function precedence() + { + $cases = []; + foreach ($this->precedenceTable() as $name => $row) { + list($css, $html, $legacy, $standard) = $row; + $cases[$name . ' (legacy)'] = [CssMode::LEGACY, $css, $html, $legacy]; + $cases[$name . ' (standard)'] = [CssMode::STANDARD, $css, $html, $standard]; + } + + return $cases; + } + + /** + * Rules that compete for an element's colour, a document for them, and the colours its text is drawn in by the + * legacy cascade, as measured, and by the standard cascade, as the Selectors and Cascade specifications give them + * + * @return array[] Each [css, html, legacy colours, standard colours] + */ + private function precedenceTable() + { + return [ + 'an id beats a tag with a class' => [ + '#i { color: #008000; } p.c { color: #f00; }', + '

id

no id

', + ['id' => self::RED, 'no id' => self::RED], + ['id' => self::GREEN, 'no id' => self::RED], + ], + 'an id beats a descendant rule' => [ + '#i { color: #008000; } div p { color: #f00; }', + '

id

no id

', + ['id' => self::RED, 'no id' => self::RED], + ['id' => self::GREEN, 'no id' => self::RED], + ], + 'a class beats a descendant rule of tags' => [ + '.c { color: #008000; } div p { color: #f00; }', + '

class

no class

', + ['class' => self::RED, 'no class' => self::RED], + ['class' => self::GREEN, 'no class' => self::RED], + ], + 'a class beats body p, as CMS stylesheets write it' => [ + 'body p { color: #f00; } .c { color: #008000; }', + '

class

no class

', + ['class' => self::RED, 'no class' => self::RED], + ['class' => self::GREEN, 'no class' => self::RED], + ], + 'two classes beat a tag with a class' => [ + 'p.c { color: #f00; } .c.d { color: #008000; }', + '

both

one

', + ['both' => self::RED, 'one' => self::RED], + ['both' => self::GREEN, 'one' => self::RED], + ], + 'a heavier descendant rule beats one lifted from a nearer ancestor' => [ + '.c p { color: #008000; } div div p { color: #f00; }', + '

classed ancestor

plain ancestors

', + ['classed ancestor' => self::RED, 'plain ancestors' => self::RED], + ['classed ancestor' => self::GREEN, 'plain ancestors' => self::RED], + ], + 'an id ancestor beats nearer tag ancestors' => [ + '#o p { color: #008000; } div div p { color: #f00; }', + '

id ancestor

plain ancestors

', + ['id ancestor' => self::RED, 'plain ancestors' => self::RED], + ['id ancestor' => self::GREEN, 'plain ancestors' => self::RED], + ], + 'classes apply in the order they are written, not alphabetically' => [ + '.b { color: #008000; } .a { color: #f00; }', + '

a b

b a

b only

', + ['a b' => self::GREEN, 'b a' => self::GREEN, 'b only' => self::GREEN], + ['a b' => self::RED, 'b a' => self::RED, 'b only' => self::GREEN], + ], + 'a selector written twice takes each of its places' => [ + '.a { color: #f00; } .b { color: #008000; } .a { color: #00f; }', + '

a b

b only

', + ['a b' => self::GREEN, 'b only' => self::GREEN], + ['a b' => self::BLUE, 'b only' => self::GREEN], + ], + 'a later stylesheet wins at the same specificity' => [ + '.b { color: #008000; } ', ['cssMode' => CssMode::STANDARD]); + $mpdf->WriteHTML('

both

'); + + $this->assertSame(self::RED, $mpdf->drawnColours[0]); + } + + /** + * The default stylesheet counts as the built-in stylesheet under the standard cascade, so an author's rule beats + * it whatever the specificity: `ul { margin-bottom }` reaches a nested list over the default + * `ul ul { margin-bottom: 0 }`. The legacy cascade applies the descendant rule last + * + * @dataProvider cascades + * + * @param string $cascade + * @param bool $authorWins + */ + public function testAnAuthorRuleBeatsTheDefaultStylesheet($cascade, $authorWins) + { + $mpdf = $this->drawDocument('
  • outer
    • inner
  • next
', ['cssMode' => $cascade]); + $y = $this->keyedByText($mpdf, $mpdf->drawnY); + + $this->assertEqualsWithDelta($authorWins ? 20 : 0, $y['next'] - $y['inner'] - ($y['inner'] - $y['outer']), 0.5); + } + + /** + * Each cascade, and whether an author's rule beats a more specific rule of the default stylesheet under it + * + * @return array[] + */ + public function cascades() + { + return [ + CssMode::LEGACY => [CssMode::LEGACY, false], + CssMode::STANDARD => [CssMode::STANDARD, true], + ]; + } + + /** + * A presentational attribute is an author rule of no specificity under the standard cascade: it beats the built-in + * defaults, as `
` over the grey of an hr, and loses to any stylesheet rule. The legacy cascade applies the + * attributes before the defaults + * + * @dataProvider attributes + * + * @param string $cascade + * @param string $html + * @param string $expected The stroke colour the rule is drawn in + */ + public function testAPresentationalAttributeComesAfterTheDefaults($cascade, $html, $expected) + { + $page = $this->pages($this->render($html, ['cssMode' => $cascade]))[0]; + + $this->assertStringContainsString($expected, $page); + } + + /** + * Each cascade with an hr coloured by its attribute, with and without a stylesheet rule for it + * + * @return array[] + */ + public function attributes() + { + $grey = '0.533 0.533 0.533 RG'; + $red = '1.000 0.000 0.000 RG'; + $blue = '0.000 0.000 1.000 RG'; + + return [ + 'legacy: the default beats the attribute' => [CssMode::LEGACY, '
', $grey], + 'legacy: a rule beats the attribute' => [CssMode::LEGACY, '
', $blue], + 'standard: the attribute beats the default' => [CssMode::STANDARD, '
', $red], + 'standard: a rule beats the attribute' => [CssMode::STANDARD, '
', $blue], + ]; + } + + /** + * `` beats the default margin of an image under the standard cascade, and moves what follows down + */ + public function testVspaceBeatsTheDefaultMarginOfAnImage() + { + $html = '

after

'; + $legacy = $this->drawDocument($html, ['cssMode' => CssMode::LEGACY]); + $standard = $this->drawDocument($html, ['cssMode' => CssMode::STANDARD]); + + $this->assertEqualsWithDelta(2 * 30 * 25.4 / 96, $standard->drawnY[0] - $legacy->drawnY[0], 0.1); + } + + /** + * What Mpdf::SetDefaultBodyCSS() writes styles the document under the standard cascade too + */ + public function testTheBodyTakesWhatTheApiWritesForIt() + { + $mpdf = new TextRecordingMpdf(['mode' => 'c', 'cssMode' => CssMode::STANDARD]); + $mpdf->SetDefaultBodyCSS('color', '#008000'); + $mpdf->WriteHTML('

body text

'); + + $this->assertSame(self::GREEN, $mpdf->drawnColours[0]); + } + + /** + * A stylesheet rule for body sets the document's font size under the standard cascade, and a rule naming body as + * a parent reaches its children + */ + public function testBodyRulesStyleTheDocument() + { + $mpdf = $this->drawDocument('

plain

classed

', ['cssMode' => CssMode::STANDARD]); + $sizes = $this->keyedByText($mpdf, $mpdf->drawnFontSize); + $colours = $this->keyedByText($mpdf, $mpdf->drawnColours); + + $this->assertEquals(20, $sizes['plain']); + $this->assertSame(self::BLACK, $colours['plain']); + $this->assertSame(self::GREEN, $colours['classed']); + } + + /** + * The default stylesheet still applies under the standard cascade where no author rule competes: its `ul ul` + * takes the margins off a nested list + */ + public function testTheDefaultStylesheetApplies() + { + $html = '
  • outer
    • inner
  • next
'; + $legacy = $this->drawDocument($html, ['cssMode' => CssMode::LEGACY]); + $standard = $this->drawDocument($html, ['cssMode' => CssMode::STANDARD]); + + $this->assertSame($legacy->drawnY, $standard->drawnY); + } + + /** + * A class rule still styles the text of an SVG under the standard cascade, whose classes are looked up in + * CssManager::$CSS + */ + public function testSvgTextFindsItsClassRules() + { + $svg = 'Hi'; + $html = ''; + + $this->assertStringContainsString('0.000 1.000 0.000 rg', $this->render($html, ['cssMode' => CssMode::STANDARD, 'svgClasses' => true])); + } + + /** + * @page rules set the page's margins under the standard cascade, as they do under the legacy one + */ + public function testPageRulesStillApply() + { + $mpdf = $this->drawDocument('

text

', ['cssMode' => CssMode::STANDARD]); + + $this->assertEqualsWithDelta(50, $mpdf->drawnBoxes[0][1], 0.01); + } + + /** + * A document whose rules never compete for a property is drawn the same by both cascades: its tables' collapsed + * borders take the cell rules' dominance, and its lists, headings, links and inline styles the same values + * + * @dataProvider uncontestedDocuments + * + * @param string $html + */ + public function testADocumentWhoseRulesDoNotCompeteIsDrawnAlike($html) + { + $legacy = $this->pages($this->render($html, ['cssMode' => CssMode::LEGACY])); + $standard = $this->pages($this->render($html, ['cssMode' => CssMode::STANDARD])); + + $this->assertSame($legacy, $standard); + } + + /** + * Documents in which no two rules set the same property of an element + * + * @return array[] + */ + public function uncontestedDocuments() + { + return [ + 'collapsed borders of cells, rows and the table' => ['' + . '
ab
cd
'], + 'a border from a cell rule against one as wide from the border attribute, which the rule wins' => ['' + . '
abc
'], + 'separate borders and padding' => ['' + . '
h
a
'], + 'headings, lists and links' => ['' + . '

Title

  1. one
  2. two link
'], + 'descendant rules and inline styles' => ['' + . '

boxed

inline

'], + ]; + } + + /** + * Any value but legacy and standard is refused when the document is made + */ + public function testRefusesAnUnknownCascade() + { + $this->expectException(MpdfException::class); + $this->expectExceptionMessage('cssMode (browser) is not valid. (Use: standard or legacy)'); + + new Mpdf(['cssMode' => 'browser']); + } +} diff --git a/tests/Mpdf/Css/CssMergerTest.php b/tests/Mpdf/Css/CssMergerTest.php index 9047d32b9..e12fc64fb 100644 --- a/tests/Mpdf/Css/CssMergerTest.php +++ b/tests/Mpdf/Css/CssMergerTest.php @@ -20,13 +20,10 @@ protected function set_up() { parent::set_up(); - $this->mpdf = new Mpdf(); + // The merges below read the legacy cascade's keys and trees + $this->mpdf = new Mpdf(['cssMode' => \Mpdf\CssMode::LEGACY]); - // Get CssManager (private property of Mpdf, but we can reflect it) - $reflection = new \ReflectionClass($this->mpdf); - $property = $reflection->getProperty('cssManager'); - $property->setAccessible(true); - $this->cssManager = $property->getValue($this->mpdf); + $this->cssManager = $this->cssManagerOf($this->mpdf); // Get CssMerger (private property of CssManager) $reflection = new \ReflectionClass($this->cssManager); @@ -37,6 +34,22 @@ protected function set_up() $this->mpdf->AddPage(); } + /** + * A document's CssManager, which it keeps private + * + * @param Mpdf $mpdf + * + * @return CssManager + */ + private function cssManagerOf(Mpdf $mpdf) + { + $reflection = new \ReflectionClass($mpdf); + $property = $reflection->getProperty('cssManager'); + $property->setAccessible(true); + + return $property->getValue($mpdf); + } + protected function tear_down() { unset($this->mpdf, $this->cssManager, $this->cssMerger); @@ -567,4 +580,28 @@ public function testMergeWithComplexSelectors() $this->assertEquals('green', $result['COLOR']); } + + /** + * Under the standard cascade, previewBlockCss() matches the stylesheet against the open elements with the block + * in the innermost one, as a merge there would, and leaves the open elements and the block stack as they were + */ + public function testPreviewsABlockUnderTheStandardCascadeWithoutSideEffects() + { + $mpdf = new Mpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + $mpdf->WriteHTML('
', \Mpdf\HTMLParserMode::DEFAULT_MODE, true, false); + + $cssManager = $this->cssManagerOf($mpdf); + + $elements = $mpdf->getOpenElements(); + $blocks = $mpdf->blk; + $level = $mpdf->blklvl; + + $preview = $cssManager->previewBlockCss('P', ['CLASS' => 'LEAD']); + + $this->assertSame('red', $preview['COLOR']); + $this->assertSame('5mm', $preview['MARGIN-TOP']); + $this->assertSame($elements, $mpdf->getOpenElements()); + $this->assertSame($blocks, $mpdf->blk); + $this->assertSame($level, $mpdf->blklvl); + } } diff --git a/tests/Mpdf/Css/CssParserTest.php b/tests/Mpdf/Css/CssParserTest.php index bb258534c..b62f9665a 100644 --- a/tests/Mpdf/Css/CssParserTest.php +++ b/tests/Mpdf/Css/CssParserTest.php @@ -24,7 +24,8 @@ protected function set_up() parent::set_up(); $logger = new NullLogger(); - $this->mpdf = new Mpdf(); + // The legacy cascade stores rules by key and in the descendant tree as well as compiling them + $this->mpdf = new Mpdf(['cssMode' => \Mpdf\CssMode::LEGACY]); $this->mpdf->setLogger($logger); $assetFetcher = $this->getMockBuilder(AssetFetcher::class) @@ -339,4 +340,25 @@ public function testStartsANewListOfCompiledRulesForEachParse() $this->assertCount(1, $rules); $this->assertSame(['+'], $rules[0][0]['combinators']); } + + /** + * Under the standard cascade every rule is compiled, in the order it is written, the matcher applying all of + * them. The simple ones the legacy parser reads are still stored by key, for what reads CssManager::$CSS directly, + * but no descendant rule goes into the legacy tree + */ + public function testCompilesEveryRuleUnderTheStandardCascade() + { + $this->mpdf->cssMode = \Mpdf\CssMode::STANDARD; + $this->parser->parse(''); + + $rules = $this->parser->getCompiledRules(); + $this->assertCount(4, $rules); + $this->assertSame( + [['COLOR' => 'red'], ['COLOR' => 'green'], ['COLOR' => 'green'], ['COLOR' => 'blue']], + array_column($rules, 1) + ); + $this->assertSame([false, false, false, false], array_column($rules, 2)); + $this->assertSame(['P', 'CLASS>>A', '@PAGE'], array_keys($this->parser->getCss())); + $this->assertSame([], $this->parser->getCascadeCss()); + } } diff --git a/tests/Mpdf/Css/SelectorMatcherTest.php b/tests/Mpdf/Css/SelectorMatcherTest.php index 9d448194f..4d74409a6 100644 --- a/tests/Mpdf/Css/SelectorMatcherTest.php +++ b/tests/Mpdf/Css/SelectorMatcherTest.php @@ -280,6 +280,92 @@ public function legacyLanguages() ]; } + /** + * A failure left of a combinator stops the tries only where no other ancestor or sibling could match: a child + * combinator that fails at the nearest ancestor is tried at the next one, and an adjacent or general sibling + * combinator that fails at the first sibling at the next one + * + * @dataProvider retriedSelectors + * + * @param string $selector + * @param bool $expected Whether it matches the paragraph in `
` that follows an + * `

`, an `

`, an `

` and a `
    ` + */ + public function testTriesTheNextAncestorOrSiblingAfterAFailure($selector, $expected) + { + $path = [ + $this->frame('', 1, 1, [], []), + $this->frame('SECTION', 1, 1, [], []), + $this->frame('ARTICLE', 1, 1, [], []), + $this->frame('DIV', 1, 1, [], []), + $this->frame('ARTICLE', 1, 1, [], [['H2'], ['H1'], ['H2'], ['UL']]), + $this->frame('P', 5, 1, [], []), + ]; + + $this->assertSame($expected, $this->matcher->matches($this->compiler->compile($selector), $path)); + } + + /** + * A selector whose left part fails where it is first tried, and whether it matches in the end + * + * @return array[] + */ + public function retriedSelectors() + { + return [ + 'a child of a further ancestor' => ['section > article p', true], + 'a child of no ancestor' => ['div > section p', false], + 'the second of two siblings is adjacent to the one it needs' => ['h1 + h2 ~ p', true], + 'no sibling is adjacent to the one it needs' => ['ul + h2 ~ p', false], + 'the second of two siblings follows the one it needs' => ['h1 ~ h2 ~ p', true], + 'no sibling follows the one it needs' => ['ul ~ h1 ~ p', false], + 'a sibling tag the parent has no child of' => ['h3 ~ p', false], + 'an ancestor chain that fails everywhere' => ['p article section div p', false], + ]; + } + + /** + * A chain of descendant combinators whose leftmost compound names the nearest ancestor fails without trying every + * combination of ancestors. On a path 50 deep, `.x div div div div div p` has nearly two million of them + */ + public function testGivesUpOnADescendantChainOnceNoAncestorCanMatch() + { + $path = [$this->frame('', 1, 1, [], [])]; + for ($depth = 1; $depth < 50; $depth++) { + $path[] = $this->frame('DIV', 1, 1, $depth === 49 ? ['CLASS' => 'X'] : [], []); + } + $path[] = $this->frame('P', 1, 1, [], []); + + $start = microtime(true); + $this->assertFalse($this->matcher->matches($this->compiler->compile('.x div div div div div p'), $path)); + $this->assertTrue($this->matcher->matches($this->compiler->compile('div div div div div.x p'), $path)); + $this->assertLessThan(1, microtime(true) - $start); + } + + /** + * A general sibling combinator finds a sibling at the start of a long run of them, and skips a tag its parent has + * no child of, without walking back through every sibling for each element. Matching each of 5,000 paragraphs + * after an `

    ` walks 12.5 million siblings that way + */ + public function testFindsAnEarlierSiblingWithoutWalkingThroughEachOne() + { + $children = [['H2']]; + for ($i = 0; $i < 5000; $i++) { + $children[] = ['P']; + } + $parent = $this->frame('', 1, 1, [], $children); + $heading = $this->compiler->compile('h2 ~ p'); + $missing = $this->compiler->compile('h3 ~ p'); + + $start = microtime(true); + for ($i = 1; $i <= 5000; $i++) { + $path = [$parent, $this->frame('P', $i + 1, $i, [], [])]; + $this->assertTrue($this->matcher->matches($heading, $path)); + $this->assertFalse($this->matcher->matches($missing, $path)); + } + $this->assertLessThan(1, microtime(true) - $start); + } + /** * 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 diff --git a/tests/Mpdf/CssManagerTest.php b/tests/Mpdf/CssManagerTest.php index ecc771b3f..13ea9f102 100644 --- a/tests/Mpdf/CssManagerTest.php +++ b/tests/Mpdf/CssManagerTest.php @@ -19,7 +19,8 @@ protected function set_up() $this->tempDir = sys_get_temp_dir() . '/mpdf_test_' . uniqid(); mkdir($this->tempDir); - $this->mpdf = new Mpdf(['tempDir' => $this->tempDir]); + // The merges below read the legacy cascade's keys and trees + $this->mpdf = new Mpdf(['tempDir' => $this->tempDir, 'cssMode' => CssMode::LEGACY]); $this->mpdf->setBasePath($this->tempDir); // Use reflection to access private cssManager property @@ -903,4 +904,35 @@ public function testKeepsTheCompiledRulesOfEveryStylesheetRead() $this->assertSame(['COLOR' => 'red'], $rules->rule($candidates[0])[1]); $this->assertSame(['COLOR' => 'green'], $rules->rule($candidates[1])[1]); } + + /** + * The default stylesheet's rules are kept apart from the document's, and a document's stylesheet never adds to them + */ + public function testKeepsTheDefaultStylesheetsRulesApart() + { + $default = $this->cssManager->getDefaultRules()->candidates('UL', '', []); + + $this->cssManager->readDefaultCss('ul > ul { margin-top: 0; }'); + $this->cssManager->readCss(''); + + $defaultRules = array_values(array_diff($this->cssManager->getDefaultRules()->candidates('UL', '', []), $default)); + $documentRules = $this->cssManager->getRules()->candidates('UL', '', []); + $this->assertCount(1, $defaultRules); + $this->assertCount(1, $documentRules); + $this->assertSame(['MARGIN-TOP' => '0'], $this->cssManager->getDefaultRules()->rule($defaultRules[0])[1]); + $this->assertSame(['COLOR' => 'red'], $this->cssManager->getRules()->rule($documentRules[0])[1]); + $this->assertSame([], $this->cssManager->getDefaultRules()->candidates('LI', '', [])); + } + + /** + * The default stylesheet is read into the same stores a document's stylesheet is, for what reads them directly + */ + public function testReadsTheDefaultStylesheetIntoTheSharedStores() + { + $this->cssManager->readDefaultCss('.note { color: red; } div p { margin: 0; } @page { margin-left: 1cm; }'); + + $this->assertSame('red', $this->cssManager->CSS['CLASS>>NOTE']['COLOR']); + $this->assertArrayHasKey('@PAGE', $this->cssManager->CSS); + $this->assertArrayHasKey('P', $this->cssManager->cascadeCSS['DIV']); + } } diff --git a/tests/Mpdf/DescendantLangSelectorTest.php b/tests/Mpdf/DescendantLangSelectorTest.php index c5ba3cf0d..7c27d9c75 100644 --- a/tests/Mpdf/DescendantLangSelectorTest.php +++ b/tests/Mpdf/DescendantLangSelectorTest.php @@ -9,7 +9,7 @@ * in block content and in tables. * * A regional language such as fr-CA falls back to the rule for its short code, fr, when no rule names it in full, - * as the simple lang rules do. + * as the simple lang rules do. That is how the legacy cascade matches them, and these run under it. */ class DescendantLangSelectorTest extends TestCase { @@ -31,7 +31,7 @@ class DescendantLangSelectorTest extends TestCase */ public function testTheRuleColoursTheText($css, $html, $expected) { - $colours = $this->textColours('' . $html); + $colours = $this->textColours('' . $html, ['cssMode' => CssMode::LEGACY]); $this->assertArrayHasKey('text', $colours); $this->assertSame($expected, $colours['text']); diff --git a/tests/Mpdf/DrawnStyles.php b/tests/Mpdf/DrawnStyles.php index bc117adc3..18b94c879 100644 --- a/tests/Mpdf/DrawnStyles.php +++ b/tests/Mpdf/DrawnStyles.php @@ -40,6 +40,20 @@ private function drawnColours($html, array $config = []) return $this->keyedByText($mpdf, $mpdf->drawnColours); } + /** + * Each piece of text named is drawn, in the colour given for it + * + * @param array $expected Pieces of text and the operator that should set each one's colour + * @param array $colours What was drawn, as drawnColours() gives it + */ + private function assertDrawnInColours(array $expected, array $colours) + { + 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)); + } + } + /** * 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 diff --git a/tests/Mpdf/InlineAncestorSelectorTest.php b/tests/Mpdf/InlineAncestorSelectorTest.php index 7abfeedab..b3315c4ff 100644 --- a/tests/Mpdf/InlineAncestorSelectorTest.php +++ b/tests/Mpdf/InlineAncestorSelectorTest.php @@ -117,7 +117,7 @@ public function selectors() */ public function testLeavesToTheLegacyEngineWhatItMatches($css, $html, $expected) { - $colours = $this->drawnColours('' . $html); + $colours = $this->drawnColours('' . $html, ['cssMode' => CssMode::LEGACY]); $this->assertSame($expected, $colours['text']); } @@ -166,7 +166,8 @@ public function legacyMatches() public function testAppliesRulesMatchedThroughAnInlineAncestorAfterTheLegacyOnes() { $colours = $this->drawnColours( - '

      text

      ' + '

      text

      ', + ['cssMode' => CssMode::LEGACY] ); $this->assertSame(self::BLUE, $colours['text']); diff --git a/tests/Mpdf/PageStreams.php b/tests/Mpdf/PageStreams.php index a70ef2640..2daf8fd58 100644 --- a/tests/Mpdf/PageStreams.php +++ b/tests/Mpdf/PageStreams.php @@ -263,12 +263,13 @@ private function clipAndPlacement($stream) * * @param string $html * @param int $page Counted from 0 + * @param array $config * * @return array[] */ - private function imagePlacements($html, $page = 0) + private function imagePlacements($html, $page = 0, $config = []) { - $pages = $this->pages($this->render($html)); + $pages = $this->pages($this->render($html, $config)); $this->assertArrayHasKey($page, $pages); preg_match_all('/([-\d.]+) 0 0 ([-\d.]+) ([-\d.]+) [-\d.]+ cm \/I\d+ Do/', $pages[$page], $matches, PREG_SET_ORDER); @@ -281,12 +282,13 @@ private function imagePlacements($html, $page = 0) * The width of the only image on the first page, in millimetres * * @param string $html + * @param array $config * * @return float */ - private function drawnWidth($html) + private function drawnWidth($html, $config = []) { - $placements = $this->imagePlacements($html); + $placements = $this->imagePlacements($html, 0, $config); $this->assertCount(1, $placements); return $placements[0]['w']; @@ -296,12 +298,13 @@ private function drawnWidth($html) * The fill colour each piece of text on the first page is drawn in, keyed by that text * * @param string $html + * @param array $config * * @return string[] */ - private function textColours($html) + private function textColours($html, $config = []) { - $pages = $this->pages($this->render($html)); + $pages = $this->pages($this->render($html, $config)); preg_match_all('/q ([\d. ]+ (?:rg|g)) .*?\((.*?)\) Tj/', $pages[0], $drawn, PREG_SET_ORDER); $colours = []; diff --git a/tests/Mpdf/PositionedBlockSelectorStandardCascadeTest.php b/tests/Mpdf/PositionedBlockSelectorStandardCascadeTest.php new file mode 100644 index 000000000..73479720d --- /dev/null +++ b/tests/Mpdf/PositionedBlockSelectorStandardCascadeTest.php @@ -0,0 +1,20 @@ + CssMode::STANDARD]; + } +} diff --git a/tests/Mpdf/PositionedBlockSelectorTest.php b/tests/Mpdf/PositionedBlockSelectorTest.php index c0f002f1a..59be3c153 100644 --- a/tests/Mpdf/PositionedBlockSelectorTest.php +++ b/tests/Mpdf/PositionedBlockSelectorTest.php @@ -20,6 +20,17 @@ class PositionedBlockSelectorTest extends TestCase const BLUE = '0.000 0.000 1.000 rg'; const BLACK = '0.000 g'; + /** + * The configuration the documents are written with: the legacy cascade. A subclass runs the tests under the + * standard one + * + * @return array + */ + protected function config() + { + return ['cssMode' => CssMode::LEGACY]; + } + /** * A descendant rule naming the block's class reaches a paragraph and a table cell inside it */ @@ -31,7 +42,7 @@ public function testClassDescendantRulesReachTheContentOfAnAbsoluteBlock() .box { color: #0000ff; }

      flow p

      flow td
      -

      abs p

      abs td
      abs span
      '); +

      abs p

      abs td
      abs span
      ', $this->config()); $this->assertSame(self::GREEN, $colours['flow p']); $this->assertSame(self::RED, $colours['flow td']); @@ -46,7 +57,7 @@ public function testClassDescendantRulesReachTheContentOfAnAbsoluteBlock() public function testIdDescendantRulesReachTheContentOfAFixedBlock() { $colours = $this->textColours(' -

      fixed p

      '); +

      fixed p

      ', $this->config()); $this->assertSame(self::RED, $colours['fixed p']); } @@ -57,7 +68,7 @@ public function testIdDescendantRulesReachTheContentOfAFixedBlock() public function testTheContentIsAChildOfTheBlockItself() { $colours = $this->textColours(' -

      direct p

      nested p

      '); +

      direct p

      nested p

      ', $this->config()); $this->assertSame(self::BLACK, $colours['direct p']); $this->assertSame(self::RED, $colours['nested p']); @@ -70,7 +81,7 @@ public function testTheRulesOfOneBlockDoNotReachTheNext() { $colours = $this->textColours('

      boxed p

      -

      plain p

      '); +

      plain p

      ', $this->config()); $this->assertSame(self::GREEN, $colours['boxed p']); $this->assertSame(self::BLACK, $colours['plain p']); diff --git a/tests/Mpdf/RotatedCellFontSizeTest.php b/tests/Mpdf/RotatedCellFontSizeTest.php new file mode 100644 index 000000000..1e0664d6e --- /dev/null +++ b/tests/Mpdf/RotatedCellFontSizeTest.php @@ -0,0 +1,42 @@ +pages($this->render($html))[0], $sizes); + + $this->assertSame(['9.000'], $sizes[1]); + } + + /** + * Tables that set a font size, with a rotated cell + * + * @return array[] + */ + public function tables() + { + return [ + 'the size in the table\'s style' => ['
      Rotated
      '], + 'the size in a rule for the table' => ['
      Plain
      Rotated
      '], + ]; + } +} diff --git a/tests/Mpdf/StructuralSelectorTest.php b/tests/Mpdf/StructuralSelectorTest.php index de717a4eb..6d463d949 100644 --- a/tests/Mpdf/StructuralSelectorTest.php +++ b/tests/Mpdf/StructuralSelectorTest.php @@ -268,7 +268,7 @@ public function selectors() */ public function testAppliesMatchedRulesBySpecificityThenPosition($css, $html, $expected) { - $colours = $this->drawnColours('' . $html); + $colours = $this->drawnColours('' . $html, ['cssMode' => CssMode::LEGACY]); $this->assertSame($expected, $colours['text']); } diff --git a/tests/Mpdf/StyledElementPathTest.php b/tests/Mpdf/StyledElementPathTest.php index f9a6e2903..8223dc418 100644 --- a/tests/Mpdf/StyledElementPathTest.php +++ b/tests/Mpdf/StyledElementPathTest.php @@ -6,7 +6,8 @@ /** * The open elements the CSS merger matches compiled rules against, for each element it styles: the element opened - * by a start tag, a block opened again after a forced page break, a positioned block and the content written in it + * by a start tag, a block opened again after a forced page break, a positioned block and the content written in it. + * These run under the legacy cascade, which asks for the path only when a compiled rule is filed under the element */ class StyledElementPathTest extends TestCase { @@ -22,7 +23,7 @@ class StyledElementPathTest extends TestCase */ public function testGivesThePathToTheElementBeingStyled($html, array $expected) { - $mpdf = new StyledPathRecordingMpdf(['mode' => 'c']); + $mpdf = new StyledPathRecordingMpdf(['mode' => 'c', 'cssMode' => CssMode::LEGACY]); $mpdf->WriteHTML($html); $this->assertSame($expected, $mpdf->styledPaths); @@ -38,7 +39,7 @@ public function testGivesThePathToTheElementBeingStyled($html, array $expected) */ public function testGivesThePositionTheElementHasOnItsPath($html) { - $mpdf = new StyledPathRecordingMpdf(['mode' => 'c']); + $mpdf = new StyledPathRecordingMpdf(['mode' => 'c', 'cssMode' => CssMode::LEGACY]); $mpdf->WriteHTML($html . '' . '
      ab
      c
      d
      '); @@ -85,7 +86,7 @@ public function documents() */ public function testLetsGoOfTheElementOnceItsStartTagIsRead() { - $mpdf = new StyledPathRecordingMpdf(['mode' => 'c']); + $mpdf = new StyledPathRecordingMpdf(['mode' => 'c', 'cssMode' => CssMode::LEGACY]); $mpdf->WriteHTML('

      a

      b

      '); $this->assertNull($mpdf->getStyledElementPath()); @@ -96,7 +97,7 @@ public function testLetsGoOfTheElementOnceItsStartTagIsRead() */ public function testGivesNoPathForASpanWrappedAroundARunOfAnotherScript() { - $mpdf = new StyledPathRecordingMpdf(['mode' => '', 'autoScriptToLang' => true]); + $mpdf = new StyledPathRecordingMpdf(['mode' => '', 'autoScriptToLang' => true, 'cssMode' => CssMode::LEGACY]); $mpdf->WriteHTML('

      abc שלום def

      '); $this->assertSame([null, '>P>SPAN'], $mpdf->styledPaths); diff --git a/tests/Mpdf/TableCellDescendantSelectorStandardCascadeTest.php b/tests/Mpdf/TableCellDescendantSelectorStandardCascadeTest.php new file mode 100644 index 000000000..34e121328 --- /dev/null +++ b/tests/Mpdf/TableCellDescendantSelectorStandardCascadeTest.php @@ -0,0 +1,20 @@ + CssMode::STANDARD]; + } +} diff --git a/tests/Mpdf/TableCellDescendantSelectorTest.php b/tests/Mpdf/TableCellDescendantSelectorTest.php index 0aa9f7899..826034917 100644 --- a/tests/Mpdf/TableCellDescendantSelectorTest.php +++ b/tests/Mpdf/TableCellDescendantSelectorTest.php @@ -12,6 +12,17 @@ class TableCellDescendantSelectorTest extends \Yoast\PHPUnitPolyfills\TestCases\ const RED = '1.000 0.000 0.000 rg'; + /** + * The configuration the documents are written with: the legacy cascade. A subclass runs the tests under the + * standard one + * + * @return array + */ + protected function config() + { + return ['cssMode' => CssMode::LEGACY]; + } + /** * A table of one cell holding the given content, in a div, under the given stylesheet * @@ -60,7 +71,7 @@ public static function matchingRules() */ public function testARuleReachesAPictureInTheCell($selector, $content) { - $this->assertEqualsWithDelta(20, $this->drawnWidth($this->table($selector . ' { max-width: 20mm; }', $content)), 0.01); + $this->assertEqualsWithDelta(20, $this->drawnWidth($this->table($selector . ' { max-width: 20mm; }', $content), $this->config()), 0.01); } /** @@ -68,7 +79,7 @@ public function testARuleReachesAPictureInTheCell($selector, $content) */ public function testARuleForAnotherCellDoesNotMatch() { - $width = $this->drawnWidth($this->table('td.other img { max-width: 20mm; }', self::image())); + $width = $this->drawnWidth($this->table('td.other img { max-width: 20mm; }', self::image()), $this->config()); $this->assertEqualsWithDelta(292 * 25.4 / 96, $width, 0.01); } @@ -94,7 +105,7 @@ public static function textRules() */ public function testARuleReachesTextInTheCell($selector, $content) { - $colours = $this->textColours($this->table($selector . ' { color: #ff0000; }', $content)); + $colours = $this->textColours($this->table($selector . ' { color: #ff0000; }', $content), $this->config()); $this->assertSame(self::RED, $colours['red']); } diff --git a/tests/Snapshots/DescendantLangSelectorSnapshotTest.php b/tests/Snapshots/DescendantLangSelectorSnapshotTest.php index ce036484e..456728ef3 100644 --- a/tests/Snapshots/DescendantLangSelectorSnapshotTest.php +++ b/tests/Snapshots/DescendantLangSelectorSnapshotTest.php @@ -4,7 +4,8 @@ /** * Descendant rules whose last part names a language, such as `div :lang(fr)`, `div [lang=fr]` and `div p:lang(fr)`, - * in block content, inline content and tables, and a regional language falling back to the rule for its short code. + * in block content, inline content and tables, and a regional language falling back to the rule for its short code, + * under the legacy cascade. * * @group snapshot */ @@ -107,7 +108,8 @@ public function generatePdf() mpdf = $this->createMpdf(); + // A regional language named in full winning over its short code, whatever the order, is the legacy cascade's + $this->mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::LEGACY]); $this->mpdf->WriteHTML($html); } diff --git a/tests/Snapshots/FrameworkCascadeLegacySnapshotTest.php b/tests/Snapshots/FrameworkCascadeLegacySnapshotTest.php new file mode 100644 index 000000000..15e72f501 --- /dev/null +++ b/tests/Snapshots/FrameworkCascadeLegacySnapshotTest.php @@ -0,0 +1,21 @@ +cascade(); + } + + /** + * The components, each under a caption saying what the standard and the legacy cascade draw + */ + public function generatePdf() + { + ob_start(); + ?> + + +

      A framework's stylesheet

      +

      Written with cssMode set to cascade(); ?>.

      + +

      Navbar

      +

      #brand against .navbar > .container a. Standard: "Brand" is yellow and bold, the id beating the child rule. Legacy: "Brand" is grey like the other links, though still bold, because a rule only the matcher reads is applied after the id.

      + + +

      Card

      +

      h5.card-title against .card h5, as heavy and written later. Standard: the title is blue. Legacy: it is black, the descendant rule coming last.

      +

      .lead against article p. Standard: the lead paragraph is large and grey, a class outweighing two tags. Legacy: it is the size and colour of the paragraph after it.

      +
      +
      Card title
      +

      A lead paragraph, set larger than the text after it.

      The body text of the card.

      +
      + +

      Sidebar navigation

      +

      #sidebar a against .nav .nav-link. Standard: the links are purple and bold, an id ancestor outweighing two classes. Legacy: they are blue and bold, the rule lifted from the nearer .nav coming last.

      + + +

      List group

      +

      .list-group-item.active against .list-group .list-group-item, as heavy and written later. Standard: the active item is white on blue. Legacy: it is white on light grey, the descendant rule's background coming last, so its text all but disappears.

      +
        +
      • An item
      • +
      • The active item
      • +
      • Another item
      • +
      + +

      Table

      +

      td.text-end against .table td, as heavy and written later. Standard: the amounts are right-aligned. Legacy: they are left-aligned, the descendant rule coming last. "Hosting", the third row counting the header, is striped grey in both.

      + + + + + +
      ItemAmount
      Consulting1,200.00
      Hosting300.00
      Support150.00
      + +

      Utilities and components

      +

      .muted, written after .title. Standard: "Muted title" is light grey and bold. Legacy: it is blue and bold, classes applying in alphabetical order.

      +

      Muted title

      + mpdf = $this->createMpdf(['cssMode' => $this->cascade()]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/FrameworkCascadeStandardSnapshotTest.php b/tests/Snapshots/FrameworkCascadeStandardSnapshotTest.php new file mode 100644 index 000000000..fd8eb5b5f --- /dev/null +++ b/tests/Snapshots/FrameworkCascadeStandardSnapshotTest.php @@ -0,0 +1,21 @@ + + + + +

      The standard cascade

      + +

      #id beats p.class: the line is green.

      +

      An id against a tag with a class

      + +

      #id beats div.class div p: the line is green.

      +

      An id against a descendant rule

      + +

      .class.class beats body p.class: the line is green.

      +

      Two classes against body and a tag with a class

      + +

      .class p beats div div div p, although the divs are nearer: the line is green.

      +

      A class ancestor against three tag ancestors

      + +

      .c5a is written after .c5b, so it wins whichever order the classes are in: both lines are green.

      +

      Classes a then b

      Classes b then a

      + +

      .c6a, then .c6b, then .c6a again: the second .c6a comes last, and the line is green.

      +

      A selector written twice

      + +

      A rule in a later style block beats one as heavy in an earlier block: the line is green.

      +

      A rule in a later stylesheet

      + +

      :where() counts nothing, so div.class p beats :where(#id) p: the line is green.

      +

      :where() against a class

      + +

      The inline style beats #id: the line is green.

      +

      Inline style against an id

      + +

      ul.class beats the default stylesheet's ul ul, which counts as the built-in stylesheet's: the nested list keeps the 4mm gap below it, so "Outer two" is well below "Inner two".

      +
      +
      • Outer one
        • Inner one
        • Inner two
      • Outer two
      +
      + +

      <hr color> beats the grey of the built-in defaults: the rule is green.

      +

      + +

      td#id beats table.class tr td: the middle cell has a thick green border, drawn over its neighbours' thin red ones.

      +
      LeftMiddleRight
      + mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/snapshots/framework-cascade-legacy.pdf b/tests/data/snapshots/framework-cascade-legacy.pdf new file mode 100644 index 000000000..6e54d92e3 Binary files /dev/null and b/tests/data/snapshots/framework-cascade-legacy.pdf differ diff --git a/tests/data/snapshots/framework-cascade-standard.pdf b/tests/data/snapshots/framework-cascade-standard.pdf new file mode 100644 index 000000000..805ab9454 Binary files /dev/null and b/tests/data/snapshots/framework-cascade-standard.pdf differ diff --git a/tests/data/snapshots/page-break-avoid-content.pdf b/tests/data/snapshots/page-break-avoid-content.pdf index 1b0b528fb..7497dc2a8 100644 Binary files a/tests/data/snapshots/page-break-avoid-content.pdf and b/tests/data/snapshots/page-break-avoid-content.pdf differ diff --git a/tests/data/snapshots/page-break-avoid-floats.pdf b/tests/data/snapshots/page-break-avoid-floats.pdf index e2adc0af1..7da0d3ee5 100644 Binary files a/tests/data/snapshots/page-break-avoid-floats.pdf and b/tests/data/snapshots/page-break-avoid-floats.pdf differ diff --git a/tests/data/snapshots/positioned-html.pdf b/tests/data/snapshots/positioned-html.pdf index 80b27020e..2343ab40c 100644 Binary files a/tests/data/snapshots/positioned-html.pdf and b/tests/data/snapshots/positioned-html.pdf differ diff --git a/tests/data/snapshots/rtl.pdf b/tests/data/snapshots/rtl.pdf index 40fe553b9..724d684aa 100644 Binary files a/tests/data/snapshots/rtl.pdf and b/tests/data/snapshots/rtl.pdf differ diff --git a/tests/data/snapshots/standard-cascade.pdf b/tests/data/snapshots/standard-cascade.pdf new file mode 100644 index 000000000..8f561bf9b Binary files /dev/null and b/tests/data/snapshots/standard-cascade.pdf differ diff --git a/tests/data/snapshots/table.pdf b/tests/data/snapshots/table.pdf index 69a1fe048..93b183e04 100644 Binary files a/tests/data/snapshots/table.pdf and b/tests/data/snapshots/table.pdf differ diff --git a/tests/data/snapshots/toc-and-index.pdf b/tests/data/snapshots/toc-and-index.pdf index 1e8ad6242..d1762981c 100644 Binary files a/tests/data/snapshots/toc-and-index.pdf and b/tests/data/snapshots/toc-and-index.pdf differ