diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index d2c0a85c1..ba0c89c13 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -42,6 +42,10 @@ Read this section before upgrading from upstream mPDF. Each entry says what chan * 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. * In legacy mode, a rule whose selector mPDF v7 could not read is dropped: child and sibling combinators, structural pseudo-classes outside tables, attribute selectors other than `[lang]`, `:lang()` through an ancestor, descendant rules through inline ancestors, and `:not()`, `:is()` and `:where()`. The `font`, `border` and `background` shorthands leave the longhands they do not name, as mPDF v7 did. Fixes to how values and selectors are read apply in both modes. [#633] +* **`!important` is honoured**, as in a browser, in standard mode. mPDF read a declaration marked `!important` as any other, so `p { color: green !important } #i { color: red }` drew `

` red, and so did `style="color: red"` on a `

`. Now an important declaration beats every declaration that is not, the inline style included. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to read the flag as nothing, as mPDF v7 did. [mpdf/mpdf#1010] [mpdf/mpdf#1707] [#532] [#638] + * Important declarations apply after the inline style: those of the stylesheets by specificity and then order, then those of `style=""`, then those of the default stylesheet (`defaultCssFile`), as a browser's own important declarations beat a page's. + * A shorthand marked `!important` makes every longhand it sets important, so `border: 1mm solid green !important` beats a `border-top-color` of a more specific rule or of a later declaration in the same block. + * `@page` rules, `body` rules and the class rules SVG text looks up take an important declaration over a later one of the same selector that is not. An important declaration of a `body` rule beats ``. New features ------------ @@ -155,6 +159,7 @@ Bugfixes * Descendant rules naming a table, row or cell, such as `td img` or `td span`, matched nothing inside the cell. [#223] [#507] * A descendant rule with a part mPDF cannot parse was cut short at that part and applied to the element before it. Such rules are now dropped. [#519] * An inline `style` with `!important` drew no border for a `border` shorthand, dropped the bottom padding of a two-value `padding`, drew text at 0pt for a `font-size`, and ignored an image's `height` with a warning. The flag is now removed, as it is from a stylesheet. [mpdf/mpdf#1010] [mpdf/mpdf#1707] [#523] [#561] +* A declaration whose flag had a space after the bang, such as `margin: 5mm ! important`, kept the flag in its value: that margin set the right and left margins to `!` and the bottom one to `important`, a `font-size` was lost, and a `border` drew nothing. The flag is now found whatever its spacing and case, in stylesheets and inline styles, in either `cssMode`. [#532] [#638] * Text styled with the `font` shorthand or `` came out as empty boxes when the family was not installed or had a space in its name, such as `font: 12pt Roboto` or `font: 16px "DejaVu Sans Mono"`. It was drawn in the first registered font, which is the emoji font in a full install. The family is now read as `font-family` reads it: a name is kept whole, the list is tried in order, and when mPDF knows none of it the text keeps the family it inherits. `font-family` itself also reads an unquoted name with spaces whole. [#552] [#560] * A descendant rule ending in `:lang()` or `[lang]`, such as `div :lang(fr)`, `div p:lang(fr)` or `table [lang=fr]`, never applied. It now applies in block content and in tables, and a regional language such as `fr-CA` falls back to the rule for `fr`, as the simple lang rules do. [#525] [#559] * A rule such as `td:nth-child(2):not(.x)` or `td:nth-child(2 of .x)` was applied to the second cell of every row, as if it were `td:nth-child(2)`. A rule with anything after an nth-child argument, or with an argument that is not a formula, is now dropped. [mpdf/mpdf#83] [#522] [#564] @@ -792,6 +797,7 @@ These changes do not change output. [#526]: https://github.com/GravityPDF/mpdf/issues/526 [#527]: https://github.com/GravityPDF/mpdf/issues/527 [#528]: https://github.com/GravityPDF/mpdf/issues/528 +[#532]: https://github.com/GravityPDF/mpdf/issues/532 [#533]: https://github.com/GravityPDF/mpdf/issues/533 [#535]: https://github.com/GravityPDF/mpdf/issues/535 [#536]: https://github.com/GravityPDF/mpdf/issues/536 @@ -844,3 +850,4 @@ These changes do not change output. [#633]: https://github.com/GravityPDF/mpdf/pull/633 [#635]: https://github.com/GravityPDF/mpdf/pull/635 [#634]: https://github.com/GravityPDF/mpdf/pull/634 +[#638]: https://github.com/GravityPDF/mpdf/pull/638 diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 04b49d37d..cb4a45e91 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -160,8 +160,10 @@ public function merge($inherit, $tag, $attr) /** * 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. + * of zero specificity, the author rules, and the inline style; then the !important declarations of the author + * rules, of the inline style, and of the default stylesheet's rules. The rules of each stylesheet apply by + * specificity, then in the order they were written. Each rule that reaches a table cell draws its borders over + * those of its neighbours. * * @param string $inherit Inheritance context (BLOCK, INLINE, TABLE, TOPTABLE) * @param string $tag HTML tag name @@ -189,45 +191,45 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) $id = isset($attr['ID']) ? $attr['ID'] : ''; $classes = $this->classesOf($attr); + $dominance = $tag === 'TD' || $tag === 'TH' ? 9 : false; $this->mergeInheritedBlockProperties($inherit, $tag); $this->mergeDefaultCss($tag); - $this->mergeMatchingRules($this->cssManager->getDefaultRules(), $tag, $id, $classes, $path); + list($defaultRules, $importantDefaultRules) = $this->cssManager->getDefaultRules()->matchingDeclarations($tag, $id, $classes, $path); + $this->mergeEach($defaultRules, $dominance); $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); + $important = $this->cssManager->getImportantCss(); + $importantDefault = $this->cssManager->getDefaultImportantCss(); + $rules = [isset($this->cssManager->CSS['BODY']) ? $this->cssManager->CSS['BODY'] : []]; + $importantRules = isset($important['BODY']) ? [$important['BODY']] : []; + $importantDefaultRules = isset($importantDefault['BODY']) ? [$importantDefault['BODY']] : []; } else { - $this->mergeMatchingRules($this->cssManager->getRules(), $tag, $id, $classes, $path); + list($rules, $importantRules) = $this->cssManager->getRules()->matchingDeclarations($tag, $id, $classes, $path); } + $this->mergeEach($rules, $dominance); - $this->mergeInlineStyle($tag, $attr); + list($inline, $importantInline) = isset($attr['STYLE']) ? $this->inlineStyleParser->parseByImportance($attr['STYLE']) : [[], []]; + $this->mergeEach(array_merge([$inline], $importantRules, [$importantInline], $importantDefaultRules), $dominance); 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 + * Merges sets of properties, each over those before it * - * @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 + * @param array[] $sets + * @param int|false $dominance The border dominance each set's borders take in a table cell, or false outside one * @return void */ - private function mergeMatchingRules(RuleSet $rules, $tag, $id, array $classes, callable $path) + private function mergeEach(array $sets, $dominance) { - $dominance = $tag === 'TD' || $tag === 'TH' ? 9 : false; - - foreach ($rules->matchingDeclarations($tag, $id, $classes, $path) as $properties) { + foreach ($sets as $properties) { $this->setMergedCss($properties, false, $dominance); } } diff --git a/src/Css/CssParser.php b/src/Css/CssParser.php index d8d20f2a9..a4e6a323a 100644 --- a/src/Css/CssParser.php +++ b/src/Css/CssParser.php @@ -88,6 +88,12 @@ class CssParser */ private $css = []; + /** + * @var array[] The properties the !important declarations of the rules in $css set, under the same keys. Only + * standard mode keeps them apart + */ + private $importantCss = []; + /** * CSS for cascaded selectors. * @@ -127,8 +133,8 @@ class CssParser private $nthChildFormulas = ['TR' => [], 'TD' => [], 'TH' => []]; /** - * @var array[] The rules of the last CSS parsed for the matcher, each compiled: [compiled selector, declarations], - * in the order they were written + * @var array[] The rules of the last CSS parsed for the matcher, each compiled: [compiled selector, declarations, + * !important declarations], in the order they were written */ private $compiledRules = []; @@ -160,6 +166,7 @@ public function __construct( public function parse($html) { $this->css = []; + $this->importantCss = []; $this->cascadeCSS = []; $this->compiledRules = []; @@ -215,6 +222,17 @@ public function getCss() return $this->css; } + /** + * The properties the !important declarations of the last CSS parsed set, under the keys getCss() uses. Legacy mode + * reads those declarations as any other, into getCss(), and leaves this empty + * + * @return array[] + */ + public function getImportantCss() + { + return $this->importantCss; + } + /** * @return array */ @@ -226,7 +244,7 @@ public function getCascadeCss() /** * The rules of the last CSS parsed for the matcher, in standard mode. Legacy mode compiles none * - * @return array[] Each [compiled selector, declarations], in the order they were written + * @return array[] Each [compiled selector, declarations, !important declarations], in the order they were written */ public function getCompiledRules() { @@ -299,14 +317,14 @@ private function processCssString($css) */ private function processRule($selectors, $declarations) { - $classProperties = $this->parseCssProperties($declarations); + list($classProperties, $important) = $this->parseCssPropertiesByImportance($declarations); if (strpbrk($selectors, "\t\n\r\f\v") !== false) { $selectors = preg_replace(self::WHITESPACE, ' ', $selectors); } foreach ($this->selectorCompiler->splitList($selectors) as $selector) { - $this->processCssSelector($selector, $classProperties); + $this->processCssSelector($selector, $classProperties, $important); } } @@ -315,9 +333,10 @@ private function processRule($selectors, $declarations) * * @param string $written Selector string, as written * @param array $classProperties CSS properties + * @param array $important The properties its !important declarations set, which only standard mode reads apart * @return void */ - private function processCssSelector($written, $classProperties) + private function processCssSelector($written, $classProperties, array $important) { $selector = strtoupper($written); @@ -337,10 +356,8 @@ private function processCssSelector($written, $classProperties) $level = count($tags); if (trim($tags[0]) === '@PAGE') { $tag = $this->selectorParser->parsePageSelector($tags); - if ($tag && isset($this->css[$tag])) { - $this->css[$tag] = Arrays::uniqueRecursiveMerge($this->css[$tag], $classProperties); - } elseif ($tag) { - $this->css[$tag] = $classProperties; + if ($tag) { + $this->storeByKey($tag, $classProperties, $important); } return; @@ -352,11 +369,12 @@ private function processCssSelector($written, $classProperties) // Legacy mode applies simple rules from these keys, and standard mode keeps them for what reads // CssManager::$CSS directly: BODY, and SVG's classes if ($simple) { - $this->storeSimpleRule($tag, $classProperties); + $this->storeByKey($tag, $classProperties, $important); + $this->indexStoredKey($tag); } if ($this->mpdf->cssMode === CssMode::STANDARD) { - $this->compileRule($written, $classProperties); + $this->compileRule($written, $classProperties, $important); return; } @@ -382,21 +400,20 @@ private function processCssSelector($written, $classProperties) } /** - * Stores a rule whose selector is one compound the legacy parser reads, under its key + * Stores a rule under its key: an @page rule, or one whose selector is one compound the legacy parser reads * - * @param string $key A key SelectorParser::parseSimpleSelector() made, e.g. P or CLASS>>A + * @param string $key A key SelectorParser made, e.g. P, CLASS>>A or @PAGE * @param array $classProperties + * @param array $important The properties its !important declarations set * @return void */ - private function storeSimpleRule($key, array $classProperties) + private function storeByKey($key, array $classProperties, array $important) { - if (isset($this->css[$key])) { - $this->css[$key] = Arrays::uniqueRecursiveMerge($this->css[$key], $classProperties); - } else { - $this->css[$key] = $classProperties; - } + $this->css[$key] = Arrays::uniqueRecursiveMerge(isset($this->css[$key]) ? $this->css[$key] : [], $classProperties); - $this->indexStoredKey($key); + if ($important) { + $this->importantCss[$key] = Arrays::uniqueRecursiveMerge(isset($this->importantCss[$key]) ? $this->importantCss[$key] : [], $important); + } } /** @@ -443,11 +460,12 @@ private function isLegacySelector(array $levels) * * @param string $selector As written * @param array $classProperties + * @param array $important The properties its !important declarations set * @return void */ - private function compileRule($selector, array $classProperties) + private function compileRule($selector, array $classProperties, array $important) { - if (!$classProperties) { + if (!$classProperties && !$important) { return; } @@ -455,7 +473,7 @@ private function compileRule($selector, array $classProperties) // The universal selector changes which elements existing documents style, so it waits for #530 if ($compiled !== null && !$compiled['universal']) { - $this->compiledRules[] = [$compiled, $classProperties]; + $this->compiledRules[] = [$compiled, $classProperties, $important]; } } @@ -466,6 +484,32 @@ private function compileRule($selector, array $classProperties) * @return array Associative array of CSS properties */ public function parseCssProperties($rawStyles) + { + return $this->inlineStyleParser->parseDeclarations($this->declarationsOf($rawStyles)); + } + + /** + * The properties the normal declarations of a block set, and those its !important ones set. Legacy mode reads a + * declaration marked !important as any other + * + * @param string $rawStyles The declarations of a block, with its comments removed + * @return array[] [normal properties, important properties] + */ + private function parseCssPropertiesByImportance($rawStyles) + { + if ($this->mpdf->cssMode === CssMode::STANDARD) { + return $this->inlineStyleParser->parseDeclarationsByImportance($this->declarationsOf($rawStyles)); + } + + return [$this->parseCssProperties($rawStyles), []]; + } + + /** + * @param string $rawStyles The declarations of a block, with its comments removed + * @return string[][] Each [name, value], with the whitespace in the value read as spaces and its data URI images + * stored + */ + private function declarationsOf($rawStyles) { $declarations = $this->tokenizer->declarations($rawStyles); foreach ($declarations as &$declaration) { @@ -479,7 +523,7 @@ public function parseCssProperties($rawStyles) } unset($declaration); - return $this->inlineStyleParser->parseDeclarations($declarations); + return $declarations; } /** diff --git a/src/Css/InlineStyleParser.php b/src/Css/InlineStyleParser.php index 25d7b3447..3781f9300 100644 --- a/src/Css/InlineStyleParser.php +++ b/src/Css/InlineStyleParser.php @@ -24,7 +24,7 @@ public function __construct(NormalizeProperties $normalizeProperties) * Parse inline CSS style attribute. * * Parses a CSS string from an HTML style attribute and returns - * an array of CSS properties. + * an array of CSS properties. A declaration marked !important is read as any other. * * @param string $html CSS string from style attribute * @return array Parsed CSS properties @@ -35,17 +35,61 @@ public function parse($html) } /** - * The properties a list of declarations sets, whether from a style attribute or a stylesheet's block + * Parse an HTML style attribute into the properties its normal declarations set and those its !important ones set + * + * @param string $html CSS string from style attribute + * @return array[] [normal properties, important properties] + */ + public function parseByImportance($html) + { + return $this->parseDeclarationsByImportance($this->tokenizer->declarations(htmlspecialchars_decode($html))); + } + + /** + * The properties a list of declarations sets, whether from a style attribute or a stylesheet's block. A + * declaration marked !important is read as any other * * @param string[][] $declarations Each [name, value], as StylesheetTokenizer::declarations() gives them * @return array Parsed CSS properties */ public function parseDeclarations(array $declarations) { - $classproperties = []; + $values = $this->valuesByImportance($declarations, false); + + return $this->normalizeProperties->normalize($values[0]); + } + + /** + * The properties the normal declarations of a list set, and those its !important ones set. Each set is normalised + * on its own, so a shorthand marked !important expands into longhands that are all important + * + * @param string[][] $declarations Each [name, value], as StylesheetTokenizer::declarations() gives them + * @return array[] [normal properties, important properties] + */ + public function parseDeclarationsByImportance(array $declarations) + { + $values = $this->valuesByImportance($declarations, true); + + return [ + $this->normalizeProperties->normalize($values[0]), + $this->normalizeProperties->normalize($values[1]), + ]; + } + + /** + * The value each declaration of a list gives its property, without !important, before the values are normalised + * + * @param string[][] $declarations Each [name, value], as StylesheetTokenizer::declarations() gives them + * @param bool $split Whether the declarations marked !important are kept apart from the others + * @return array[] [values of the normal declarations, values of the important ones], each keyed by the uppercased + * property. Unless split, every declaration is in the first + */ + private function valuesByImportance(array $declarations, $split) + { + $values = [[], []]; foreach ($declarations as $declaration) { $property = strtoupper(trim($declaration[0], " \t\n\r\0\x0B\f")); - $value = trim(preg_replace('/\s*!important/i', '', $this->processUrlsInCss($declaration[1]))); + $value = trim(preg_replace('/\s*!\s*important\b/i', '', $this->processUrlsInCss($declaration[1]), -1, $important)); if (empty($property) || $value === '') { continue; @@ -62,11 +106,12 @@ public function parseDeclarations(array $declarations) } // A repeated property moves to its last place, so it is expanded after a shorthand written before it - unset($classproperties[$property]); - $classproperties[$property] = $value; + $set = $split && $important ? 1 : 0; + unset($values[$set][$property]); + $values[$set][$property] = $value; } - return $this->normalizeProperties->normalize($classproperties); + return $values; } /** diff --git a/src/Css/RuleSet.php b/src/Css/RuleSet.php index a67713c39..5dfebe526 100644 --- a/src/Css/RuleSet.php +++ b/src/Css/RuleSet.php @@ -12,7 +12,7 @@ class RuleSet /** * @var array[] Each rule, by its position in the stylesheets read: [compiled selector, declarations, the ancestors - * it requires as requiredAncestors() gives them] + * it requires as requiredAncestors() gives them, !important declarations] */ private $rules = []; @@ -40,11 +40,12 @@ public function __construct() * * @param array $selector A selector SelectorCompiler::compile() compiled * @param array $declarations Its normalised declarations + * @param array $important Its normalised !important declarations */ - public function add(array $selector, array $declarations) + public function add(array $selector, array $declarations, array $important = []) { $position = count($this->rules); - $this->rules[] = [$selector, $declarations, self::requiredAncestors($selector)]; + $this->rules[] = [$selector, $declarations, self::requiredAncestors($selector), $important]; self::file($this->index, $selector['compounds'][count($selector['compounds']) - 1], $position); } @@ -65,7 +66,8 @@ 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 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 $id Uppercased, or empty for none @@ -73,18 +75,18 @@ public function candidates($tag, $id, array $classes) * @param callable $path Gives the open elements from the document down to the element, or null for none. Only * called when a rule is filed under the element * - * @return array[] + * @return array[] [the normal declarations of each rule, the !important declarations of each rule with any] */ public function matchingDeclarations($tag, $id, array $classes, callable $path) { $candidates = $this->candidates($tag, $id, $classes); if (!$candidates) { - return []; + return [[], []]; } $path = call_user_func($path); if ($path === null || $path[count($path) - 1]['tag'] !== $tag) { - return []; + return [[], []]; } $ancestors = self::ancestorsOf($path); @@ -112,9 +114,12 @@ public function matchingDeclarations($tag, $id, array $classes, callable $path) return $a - $b; }); - $declarations = []; + $declarations = [[], []]; foreach ($matched as $position) { - $declarations[] = $this->rules[$position][1]; + $declarations[0][] = $this->rules[$position][1]; + if ($this->rules[$position][3]) { + $declarations[1][] = $this->rules[$position][3]; + } } return $declarations; @@ -123,7 +128,8 @@ public function matchingDeclarations($tag, $id, array $classes, callable $path) /** * @param int $position * - * @return array The rule at a position, as it is kept: [compiled selector, declarations, required ancestors] + * @return array The rule at a position, as it is kept: [compiled selector, declarations, required ancestors, + * !important declarations] */ public function rule($position) { diff --git a/src/CssManager.php b/src/CssManager.php index 02a17fff1..78b64cb95 100644 --- a/src/CssManager.php +++ b/src/CssManager.php @@ -97,6 +97,17 @@ class CssManager */ private $defaultRules; + /** + * @var array[] The properties the !important declarations of the document's stylesheets set, under the keys of + * $CSS. Only standard mode reads them apart + */ + private $importantCss = []; + + /** + * @var array[] The properties the !important declarations of the default stylesheet set, under the keys of $CSS + */ + private $defaultImportantCss = []; + /** * CssManager constructor. * @@ -127,7 +138,7 @@ public function __construct(CssParser $cssParser, CssMerger $cssMerger) */ public function readCss($html) { - return $this->read($html, $this->rules); + return $this->read($html, $this->rules, $this->importantCss); } /** @@ -139,17 +150,22 @@ public function readCss($html) */ public function readDefaultCss($css) { - $this->read('', $this->defaultRules); + $this->read('', $this->defaultRules, $this->defaultImportantCss); } /** * Parse the CSS in HTML content into the stores readCss() describes, and file its compiled rules in a rule set * + * The !important declarations of the rules stored by key are kept apart as well, and laid again over $CSS after + * each read, those of the default stylesheet last, so that what reads $CSS directly (@page, BODY, SVG's classes) + * finds them over any declaration that is not important. + * * @param string $html * @param RuleSet $rules + * @param array $important Where the !important declarations of the rules stored by key go * @return string HTML with CSS content removed */ - private function read($html, RuleSet $rules) + private function read($html, RuleSet $rules, array &$important) { if (!is_array($this->cascadeCSS)) { $this->cascadeCSS = []; @@ -160,13 +176,36 @@ private function read($html, RuleSet $rules) $this->CSS = Arrays::uniqueRecursiveMerge($this->CSS, $this->cssParser->getCss()); $this->cascadeCSS = Arrays::uniqueRecursiveMerge($this->cascadeCSS, $this->cssParser->getCascadeCss()); + $important = Arrays::uniqueRecursiveMerge($important, $this->cssParser->getImportantCss()); + $this->CSS = Arrays::uniqueRecursiveMerge($this->CSS, $this->importantCss, $this->defaultImportantCss); + foreach ($this->cssParser->getCompiledRules() as $rule) { - $rules->add($rule[0], $rule[1]); + $rules->add($rule[0], $rule[1], $rule[2]); } return $html; } + /** + * The properties the !important declarations of the document's rules stored by key in $CSS set, such as BODY's + * + * @return array[] Under the keys of $CSS + */ + public function getImportantCss() + { + return $this->importantCss; + } + + /** + * The properties the !important declarations of the default stylesheet's rules stored by key in $CSS set + * + * @return array[] Under the keys of $CSS + */ + public function getDefaultImportantCss() + { + return $this->defaultImportantCss; + } + /** * The rules compiled for the matcher, from every stylesheet read so far * diff --git a/src/Mpdf.php b/src/Mpdf.php index a7f08d4bf..523cbd879 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -14467,6 +14467,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c } $zproperties = []; + $bodyAttr = []; if ($mode === HTMLParserMode::DEFAULT_MODE || $mode === HTMLParserMode::HEADER_CSS) { $this->ReadMetaTags($html); @@ -14491,7 +14492,13 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c $html = $m[2]; // Changed to allow style="background: url('bg.jpg')" if (preg_match('/style=[\"](.*?)[\"]/ism', $m[1], $mm) || preg_match('/style=[\'](.*?)[\']/ism', $m[1], $mm)) { - $zproperties = $this->cssManager->readInlineCSS($mm[1]); + if ($this->cssMode === CssMode::STANDARD) { + // Merged as an element's style is, so the !important declarations of a body rule beat it + $bodyAttr['STYLE'] = $mm[1]; + $zproperties = []; + } else { + $zproperties = $this->cssManager->readInlineCSS($mm[1]); + } } if (preg_match('/dir=[\'\"]\s*rtl\s*[\'\"]/ism', $m[1])) { $zproperties['DIRECTION'] = 'rtl'; @@ -14507,7 +14514,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c } } } - $properties = $this->cssManager->MergeCSS('BLOCK', 'BODY', ''); + $properties = $this->cssManager->MergeCSS('BLOCK', 'BODY', $bodyAttr); if ($zproperties) { $properties = Arrays::uniqueRecursiveMerge($properties, $zproperties); } diff --git a/tests/Mpdf/Css/CssParserTest.php b/tests/Mpdf/Css/CssParserTest.php index 6143406df..ef17ed9e6 100644 --- a/tests/Mpdf/Css/CssParserTest.php +++ b/tests/Mpdf/Css/CssParserTest.php @@ -324,6 +324,40 @@ public function testCompilesEveryRuleUnderTheStandardCascade() $this->assertSame([], $this->parser->getCascadeCss()); } + /** + * Under the standard cascade each rule keeps its !important declarations apart from the others, compiled and + * under the key of a rule stored by key, @page rules included + */ + public function testKeepsImportantDeclarationsApartUnderTheStandardCascade() + { + $this->mpdf->cssMode = CssMode::STANDARD; + $this->parser->parse(''); + + $rules = $this->parser->getCompiledRules(); + $this->assertCount(3, $rules); + $this->assertSame(['MARGIN-TOP' => '1mm', 'MARGIN-RIGHT' => '1mm', 'MARGIN-BOTTOM' => '1mm', 'MARGIN-LEFT' => '1mm'], $rules[0][1]); + $this->assertSame(['COLOR' => 'red'], $rules[0][2]); + $this->assertSame([], $rules[1][1]); + $this->assertSame(['FONT-SIZE' => '9pt'], $rules[1][2]); + $this->assertSame(['COLOR' => 'blue'], $rules[2][2]); + + $this->assertSame(['MARGIN-TOP' => '1mm', 'MARGIN-RIGHT' => '1mm', 'MARGIN-BOTTOM' => '1mm', 'MARGIN-LEFT' => '1mm'], $this->parser->getCss()['P']); + $this->assertSame([], $this->parser->getCss()['@PAGE']); + $this->assertSame(['P' => ['COLOR' => 'blue'], '@PAGE' => ['MARGIN-LEFT' => '1cm']], $this->parser->getImportantCss()); + } + + /** + * Under the legacy cascade a declaration marked !important is stored with the others, and the one after it + * replaces it + */ + public function testReadsTheFlagAsNothingUnderTheLegacyCascade() + { + $this->parser->parse(''); + + $this->assertSame(['COLOR' => 'green', 'MARGIN-TOP' => '1mm'], $this->parser->getCss()['P']); + $this->assertSame([], $this->parser->getImportantCss()); + } + /** * Each at-rule is unwrapped, kept or left out whole, in either cssMode, and the rules around it are stored as * they are written diff --git a/tests/Mpdf/Css/InlineStyleParserTest.php b/tests/Mpdf/Css/InlineStyleParserTest.php index 7c72f6c0b..bc836baf6 100644 --- a/tests/Mpdf/Css/InlineStyleParserTest.php +++ b/tests/Mpdf/Css/InlineStyleParserTest.php @@ -240,6 +240,80 @@ public function importantDeclarations() ]; } + /** + * The declarations marked !important are parsed apart from the others, each set on its own, so a shorthand marked + * important gives important longhands, and a later declaration that is not important takes nothing from it + * + * @dataProvider declarationsByImportance + * + * @param string $style + * @param array $normal The properties of the declarations not marked + * @param array $important The properties of those marked !important + */ + public function testParseByImportanceKeepsTheImportantDeclarationsApart($style, array $normal, array $important) + { + $this->assertSame([$normal, $important], $this->inlineStyleParser->parseByImportance($style)); + } + + /** + * Style attributes with declarations marked !important in various ways, and the two sets of properties each gives + * + * @return array[] + */ + public function declarationsByImportance() + { + return [ + 'none marked' => ['color: #f00', ['COLOR' => '#f00'], []], + 'all marked' => ['color: #f00 !important', [], ['COLOR' => '#f00']], + 'the flag in capitals, spaced from the bang' => ['color: #f00 ! IMPORTANT', [], ['COLOR' => '#f00']], + 'the flag against the value, in mixed case' => ['color: #f00!Important', [], ['COLOR' => '#f00']], + 'a later declaration not marked' => [ + 'color: #0f0 !important; color: #f00', + ['COLOR' => '#f00'], + ['COLOR' => '#0f0'], + ], + 'two marked, the later winning' => ['color: #f00 !important; color: #0f0 !important', [], ['COLOR' => '#0f0']], + 'a marked shorthand and a later longhand' => [ + 'padding: 1mm 2mm !important; padding-top: 5mm', + ['PADDING-TOP' => '5mm'], + ['PADDING-TOP' => '1mm', 'PADDING-RIGHT' => '2mm', 'PADDING-BOTTOM' => '1mm', 'PADDING-LEFT' => '2mm'], + ], + 'a marked border shorthand' => [ + 'border-top-color: #00f; border: 1px solid #f00 !important', + ['BORDER-TOP-COLOR' => '#00f'], + [ + 'BORDER-TOP' => '1px solid #f00', + 'BORDER-TOP-WIDTH' => '1px', + 'BORDER-TOP-STYLE' => 'solid', + 'BORDER-TOP-COLOR' => '#f00', + 'BORDER-RIGHT' => '1px solid #f00', + 'BORDER-RIGHT-WIDTH' => '1px', + 'BORDER-RIGHT-STYLE' => 'solid', + 'BORDER-RIGHT-COLOR' => '#f00', + 'BORDER-BOTTOM' => '1px solid #f00', + 'BORDER-BOTTOM-WIDTH' => '1px', + 'BORDER-BOTTOM-STYLE' => 'solid', + 'BORDER-BOTTOM-COLOR' => '#f00', + 'BORDER-LEFT' => '1px solid #f00', + 'BORDER-LEFT-WIDTH' => '1px', + 'BORDER-LEFT-STYLE' => 'solid', + 'BORDER-LEFT-COLOR' => '#f00', + ], + ], + 'a marked value mPDF cannot read' => ['width: 50%; width: calc(100% - 1mm) !important', ['WIDTH' => '50%'], []], + ]; + } + + /** + * Parsed without keeping them apart, a declaration marked !important is read as any other: a later declaration + * of the property replaces it, as mPDF v7 read it + */ + public function testParseReadsTheFlagAsNothing() + { + $this->assertSame(['COLOR' => '#f00'], $this->inlineStyleParser->parse('color: #0f0 !important; color: #f00')); + $this->assertSame(['MARGIN-TOP' => '5mm', 'MARGIN-RIGHT' => '5mm', 'MARGIN-BOTTOM' => '5mm', 'MARGIN-LEFT' => '5mm'], $this->inlineStyleParser->parse('margin: 5mm ! important')); + } + /** * A declaration mPDF cannot read is dropped, so an earlier one of the same property in the attribute still applies */ diff --git a/tests/Mpdf/Css/RuleSetTest.php b/tests/Mpdf/Css/RuleSetTest.php index 52230f164..d543c8f53 100644 --- a/tests/Mpdf/Css/RuleSetTest.php +++ b/tests/Mpdf/Css/RuleSetTest.php @@ -129,6 +129,32 @@ public function testGivesTheMatchingDeclarationsBySpecificityThenPosition() $this->assertSame( [['COLOR' => 'rule 1'], ['COLOR' => 'rule 4'], ['COLOR' => 'rule 3'], ['COLOR' => 'rule 0'], ['COLOR' => 'rule 2'], ['COLOR' => 'rule 5']], + $rules->matchingDeclarations('P', '', [], $path)[0] + ); + } + + /** + * The !important declarations of the rules an element matches come apart from the others, in the same order, and + * only from the rules that have any + */ + public function testGivesTheImportantDeclarationsApartInTheSameOrder() + { + $rules = new RuleSet(); + $rules->add($this->compiler->compile('#main > p'), ['COLOR' => 'id'], ['FONT-SIZE' => 'id']); + $rules->add($this->compiler->compile('div > p'), ['COLOR' => 'first tag'], ['FONT-SIZE' => 'first tag']); + $rules->add($this->compiler->compile('p:first-child'), ['COLOR' => 'pseudo-class']); + $rules->add($this->compiler->compile('div > p'), [], ['FONT-SIZE' => 'second tag']); + $rules->add($this->compiler->compile('ul > p'), ['COLOR' => 'no match'], ['FONT-SIZE' => 'no match']); + + $path = function () { + return $this->path(); + }; + + $this->assertSame( + [ + [['COLOR' => 'first tag'], [], ['COLOR' => 'pseudo-class'], ['COLOR' => 'id']], + [['FONT-SIZE' => 'first tag'], ['FONT-SIZE' => 'second tag'], ['FONT-SIZE' => 'id']], + ], $rules->matchingDeclarations('P', '', [], $path) ); } @@ -145,13 +171,13 @@ public function testOnlyAsksForThePathWhenARuleIsFiledUnderTheElement() return null; }; - $this->assertSame([], $this->rules->matchingDeclarations('LI', '', [], $path)); + $this->assertSame([[], []], $this->rules->matchingDeclarations('LI', '', [], $path)); $this->assertTrue($asked, 'The rule for any element should ask'); $rules = new RuleSet(); $rules->add($this->compiler->compile('div > p'), ['COLOR' => 'red']); $asked = false; - $this->assertSame([], $rules->matchingDeclarations('LI', '', [], $path)); + $this->assertSame([[], []], $rules->matchingDeclarations('LI', '', [], $path)); $this->assertFalse($asked); } @@ -167,7 +193,7 @@ public function testMatchesNothingForThePathOfAnotherElement() return $this->path(); }; - $this->assertSame([], $rules->matchingDeclarations('SPAN', '', [], $path)); + $this->assertSame([[], []], $rules->matchingDeclarations('SPAN', '', [], $path)); } /** diff --git a/tests/Mpdf/CssManagerTest.php b/tests/Mpdf/CssManagerTest.php index 697888b20..b18279599 100644 --- a/tests/Mpdf/CssManagerTest.php +++ b/tests/Mpdf/CssManagerTest.php @@ -907,6 +907,39 @@ public function testKeepsTheCompiledRulesOfEveryStylesheetRead() $this->assertSame(['COLOR' => 'green'], $rules->rule($candidates[2])[1]); } + /** + * Under the standard cascade the !important declarations of rules stored by key are laid over CssManager::$CSS + * after every stylesheet read, so what reads it directly finds them over a later declaration that is not + * important, and those of the default stylesheet over the document's. Both are kept apart as well + */ + public function testLaysImportantDeclarationsOverTheStoredRules() + { + $this->mpdf->cssMode = CssMode::STANDARD; + $this->cssManager->readDefaultCss('body { color: gray !important; margin-top: 1mm; }'); + $this->cssManager->readCss(''); + $this->cssManager->readCss(''); + + $this->assertSame('gray', $this->cssManager->CSS['BODY']['COLOR']); + $this->assertSame('9pt', $this->cssManager->CSS['BODY']['FONT-SIZE']); + $this->assertSame('2mm', $this->cssManager->CSS['BODY']['MARGIN-TOP']); + $this->assertSame('5mm', $this->cssManager->CSS['@PAGE']['MARGIN-LEFT']); + $this->assertSame(['BODY' => ['COLOR' => 'red', 'FONT-SIZE' => '9pt'], '@PAGE' => ['MARGIN-LEFT' => '5mm']], $this->cssManager->getImportantCss()); + $this->assertSame(['BODY' => ['COLOR' => 'gray']], $this->cssManager->getDefaultImportantCss()); + } + + /** + * Under the legacy cascade a declaration marked !important is stored as any other, and a later one replaces it + */ + public function testStoresImportantDeclarationsAsAnyOtherUnderTheLegacyCascade() + { + $this->cssManager->readCss(''); + $this->cssManager->readCss(''); + + $this->assertSame('blue', $this->cssManager->CSS['BODY']['COLOR']); + $this->assertSame('9mm', $this->cssManager->CSS['@PAGE']['MARGIN-LEFT']); + $this->assertSame([], $this->cssManager->getImportantCss()); + } + /** * The default stylesheet's rules are kept apart from the document's, and a document's stylesheet never adds to them */ diff --git a/tests/Mpdf/ImportantContextTest.php b/tests/Mpdf/ImportantContextTest.php new file mode 100644 index 000000000..b52547cbe --- /dev/null +++ b/tests/Mpdf/ImportantContextTest.php @@ -0,0 +1,150 @@ + $subject) { + $expected[$text] = $subject[$cascade === CssMode::LEGACY ? 1 : 2]; + } + + $mpdf = $this->drawDocument($this->document($context, $css, $subjects), ['cssMode' => $cascade]); + $mpdf->OutputBinaryData(); + + $this->assertDrawnInColours($expected, $this->keyedByText($mpdf, $mpdf->drawnColours)); + $this->assertDrawnInContext($context, $mpdf, array_keys($expected)); + } + + /** + * Every case in every context, under each cascade + * + * @return array[] + */ + public function casesInContexts() + { + $data = []; + foreach ([CssMode::LEGACY, CssMode::STANDARD] as $cascade) { + foreach (['block', 'inline', 'table cell', 'header', 'footer', 'positioned block', 'kept block', 'forced page break'] as $context) { + foreach ($this->cases() as $name => $case) { + $data[$cascade . ', ' . $context . ': ' . $name] = [$cascade, $context, $case[0], $case[1]]; + } + } + } + + return $data; + } + + /** + * Rules with an important declaration, and the elements they are matched against. Every subject is inside an + * element of class w + * + * @return array[] Each [css, subjects]: each subject keyed by its text, with its attributes, the colour the legacy + * cascade draws it in, as measured, and the colour the standard cascade draws it in + */ + private function cases() + { + return [ + 'an important class beats an id' => [ + '.c { color: #008000 !important; } #i { color: #f00; }', + ['both' => ['id="i" class="c"', self::RED, self::GREEN], 'id only' => ['id="i"', self::RED, self::RED]], + ], + 'an important rule beats the inline style' => [ + '.c { color: #008000 !important; }', + ['both' => ['class="c" style="color: #f00"', self::RED, self::GREEN], 'inline only' => ['style="color: #f00"', self::RED, self::RED]], + ], + 'an important inline style beats an important rule' => [ + '#i { color: #f00 !important; }', + [ + 'important inline' => ['id="i" style="color: #008000 !important"', self::GREEN, self::GREEN], + 'plain inline' => ['id="i" style="color: #00f"', self::BLUE, self::RED], + ], + ], + 'the more specific of two important rules wins' => [ + '.c.d { color: #008000 !important; } {T}.c { color: #f00 !important; }', + ['both' => ['class="c d"', self::RED, self::GREEN], 'one class' => ['class="c"', self::RED, self::RED]], + ], + 'the later of two important rules as specific wins' => [ + '.b { color: #f00 !important; } .a { color: #008000 !important; } .b { color: #00f; }', + ['b then a' => ['class="b a"', self::BLUE, self::GREEN], 'b alone' => ['class="b"', self::BLUE, self::RED]], + ], + 'an important descendant rule beats an id' => [ + '.w {T}.c { color: #008000 !important; } #i { color: #f00; }', + ['both' => ['id="i" class="c"', self::GREEN, self::GREEN], 'id only' => ['id="i"', self::RED, self::RED]], + ], + 'an important rule in a matching @media' => [ + '@media print { .c { color: #008000 !important; } } @media screen { .c { color: #00f !important; } } #i { color: #f00; }', + ['both' => ['id="i" class="c"', self::RED, self::GREEN], 'id only' => ['id="i"', self::RED, self::RED]], + ], + 'the flag in capitals, spaced from the bang' => [ + '.c { color: #008000 ! IMPORTANT; } #i { color: #f00; }', + ['both' => ['id="i" class="c"', self::RED, self::GREEN], 'id only' => ['id="i"', self::RED, self::RED]], + ], + 'an important declaration beats a later one of its block' => [ + '.c { color: #008000 !important; color: #f00; }', + ['with the class' => ['class="c"', self::RED, self::GREEN], 'without it' => ['', self::BLACK, self::BLACK]], + ], + ]; + } + + /** + * A document with each subject inside an element of class w, in a context, under a stylesheet naming the tag the + * context gives them + * + * @param string $context + * @param string $css With {T} for the subjects' tag + * @param array[] $subjects As cases() gives them + * + * @return string + */ + private function document($context, $css, array $subjects) + { + $tag = $context === 'inline' ? 'em' : ($context === 'table cell' ? 'td' : 'p'); + + $html = ''; + foreach ($subjects as $text => $subject) { + $html .= '<' . $tag . ' ' . $subject[0] . '>' . $text . ' '; + } + + if ($context === 'inline') { + $html = '

' . $html . '

'; + } elseif ($context === 'table cell') { + $html = '' . $html . '
'; + } + + $html = '
' . $html . '
'; + + // A table cell and an inline element are the subjects' own tags, in the flow + return '' . ($context === 'table cell' ? $html : $this->inContext($context, $html)); + } +} diff --git a/tests/Mpdf/ImportantDeclarationTest.php b/tests/Mpdf/ImportantDeclarationTest.php new file mode 100644 index 000000000..c1969723f --- /dev/null +++ b/tests/Mpdf/ImportantDeclarationTest.php @@ -0,0 +1,405 @@ + $expected Pieces of text and the colour each should be drawn in + */ + public function testAppliesImportantDeclarationsInTheCascadesOrder($cascade, $css, $html, array $expected) + { + $this->assertDrawnInColours($expected, $this->drawnColours('' . $html, ['cssMode' => $cascade])); + } + + /** + * Each row of precedenceTable() 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 with at least one declaration marked !important, a document with an + * element each rule reaches and a near miss only one of them reaches, and the colours its text is drawn in by the + * legacy cascade, as measured, and by the standard cascade, as CSS Cascade gives them + * + * @return array[] Each [css, html, legacy colours, standard colours] + */ + private function precedenceTable() + { + return [ + 'an important class beats an id' => [ + '.c { color: #008000 !important; } #i { color: #f00; }', + '

both

id only

', + ['both' => self::RED, 'id only' => self::RED], + ['both' => self::GREEN, 'id only' => self::RED], + ], + 'an important tag beats an id' => [ + 'p { color: #008000 !important; } #i { color: #f00; }', + '

p with the id

div with the id
', + ['p with the id' => self::RED, 'div with the id' => self::RED], + ['p with the id' => self::GREEN, 'div with the id' => self::RED], + ], + 'an important rule beats the inline style' => [ + '.c { color: #008000 !important; }', + '

both

inline only

', + ['both' => self::RED, 'inline only' => self::RED], + ['both' => self::GREEN, 'inline only' => self::RED], + ], + 'an important inline style beats an important rule' => [ + '#i { color: #f00 !important; }', + '

important inline

plain inline

rule only

', + ['important inline' => self::GREEN, 'plain inline' => self::BLUE, 'rule only' => self::RED], + ['important inline' => self::GREEN, 'plain inline' => self::RED, 'rule only' => self::RED], + ], + 'an important declaration beats a later one of the block that is not' => [ + '.c { color: #008000 !important; color: #f00; }', + '

class

', + ['class' => self::RED], + ['class' => self::GREEN], + ], + 'an important inline declaration beats a later one of the style that is not' => [ + '', + '

inline

', + ['inline' => self::RED], + ['inline' => self::GREEN], + ], + 'the more specific of two important rules wins' => [ + '.c.d { color: #008000 !important; } p.c { color: #f00 !important; }', + '

both

one class

', + ['both' => self::RED, 'one class' => self::RED], + ['both' => self::GREEN, 'one class' => self::RED], + ], + 'the later of two important rules as specific wins' => [ + '.b { color: #f00 !important; } .a { color: #008000 !important; } .b { color: #00f; }', + '

b a

b only

', + ['b a' => self::BLUE, 'b only' => self::BLUE], + ['b a' => self::GREEN, 'b only' => self::RED], + ], + 'an important rule of a later stylesheet wins at the same specificity' => [ + '.a { color: #008000 !important; } ' . $html, ['cssMode' => $cascade, 'mode' => 'c']))[0]; + + foreach ($drawn as $operator) { + $this->assertStringContainsString($operator, $page); + } + foreach ($notDrawn as $operator) { + $this->assertStringNotContainsString($operator, $page); + } + } + + /** + * The border and background shorthands against longhands of a more specific rule, and of the same block after + * them, under each cascade + * + * @return array[] + */ + public function shorthands() + { + $green = '0.000 0.502 0.000 RG'; + $red = '1.000 0.000 0.000 RG'; + $greenFill = '0.000 0.502 0.000 rg'; + $redFill = '1.000 0.000 0.000 rg'; + + $border = '.c { border: 1mm solid #008000 !important; border-top-color: #f00; } #i { border-bottom-color: #f00; border-left: 1mm solid #f00; }'; + $background = '.c { background: #008000 !important; } #i { background-color: #f00; }'; + + return [ + 'border (legacy)' => [CssMode::LEGACY, $border, '
boxed
', [$green, $red], []], + 'border (standard)' => [CssMode::STANDARD, $border, '
boxed
', [$green], [$red]], + 'border of an inline element (standard)' => [CssMode::STANDARD, $border, '

boxed

', [$green], [$red]], + 'border of a table cell (standard)' => [CssMode::STANDARD, $border, '
boxed
', [$green], [$red]], + 'background (legacy)' => [CssMode::LEGACY, $background, '
filled
', [$redFill], [$greenFill]], + 'background (standard)' => [CssMode::STANDARD, $background, '
filled
', [$greenFill], [$redFill]], + 'background of a table cell (standard)' => [CssMode::STANDARD, $background, '
filled
', [$greenFill], [$redFill]], + ]; + } + + /** + * The font shorthand marked !important sets the size over a more specific rule's, and the weight it resets to + * normal: the text is drawn at 14pt in Times Roman. The legacy cascade applies the id's 10pt bold + * + * @dataProvider cascades + * + * @param string $cascade + * @param float $size The size the text is drawn at + * @param string $font The core font it is drawn in + */ + public function testAnImportantFontShorthandExpandsWithItsFlag($cascade, $size, $font) + { + $html = '

text

'; + + $this->assertEquals($size, $this->drawDocument($html, ['cssMode' => $cascade])->drawnFontSize[0]); + $this->assertStringContainsString('/BaseFont /' . $font, $this->render($html, ['cssMode' => $cascade, 'mode' => 'c'])); + } + + /** + * Each cascade with the font size and core font the text is drawn in + * + * @return array[] + */ + public function cascades() + { + return [ + CssMode::LEGACY => [CssMode::LEGACY, 10, 'Times-Bold'], + CssMode::STANDARD => [CssMode::STANDARD, 14, 'Times-Roman'], + ]; + } + + /** + * The important declarations of the default stylesheet beat the document's, of its stylesheets and its inline + * styles alike, as a user agent's do an author's. Its declarations that are not important lose to the document's. + * The legacy cascade reads the default stylesheet first and the flag as nothing, so the document wins + * + * @dataProvider defaultStylesheet + * + * @param string $cascade + * @param array $colours + * @param float $size The font size the body is drawn at + */ + public function testTheDefaultStylesheetsImportantDeclarationsComeLast($cascade, array $colours, $size) + { + $mpdf = $this->drawDocument( + '' + . '

default rule

document rule

plain default rule

', + ['cssMode' => $cascade, 'defaultCssFile' => __DIR__ . '/../data/css/important-default.css'] + ); + + $this->assertDrawnInColours($colours, $this->keyedByText($mpdf, $mpdf->drawnColours)); + $this->assertEquals($size, $mpdf->drawnFontSize[0]); + } + + /** + * Each cascade, the colours it draws each paragraph in, and the font size of the body + * + * @return array[] + */ + public function defaultStylesheet() + { + return [ + CssMode::LEGACY => [CssMode::LEGACY, ['default rule' => self::BLUE, 'document rule' => self::RED, 'plain default rule' => self::GREEN], 8], + CssMode::STANDARD => [CssMode::STANDARD, ['default rule' => self::GREEN, 'document rule' => self::RED, 'plain default rule' => self::GREEN], 9], + ]; + } + + /** + * An important declaration of a body rule beats the style attribute of the body, and an important one of the + * style attribute beats both + * + * @dataProvider bodies + * + * @param string $cascade + * @param string $css + * @param string $style The body's style attribute + * @param string $expected The colour the text is drawn in + */ + public function testTheBodysStyleMeetsImportantBodyRules($cascade, $css, $style, $expected) + { + $colours = $this->drawnColours('

text

', ['cssMode' => $cascade]); + + $this->assertSame($expected, $colours['text']); + } + + /** + * Each cascade with a body rule and a style attribute, one of them marked important + * + * @return array[] + */ + public function bodies() + { + $rule = 'body { color: #008000 !important; }'; + $style = 'color: #008000 !important'; + + return [ + 'an important rule (legacy)' => [CssMode::LEGACY, $rule, 'color: #f00', self::RED], + 'an important rule (standard)' => [CssMode::STANDARD, $rule, 'color: #f00', self::GREEN], + 'an important rule against an important style (legacy)' => [CssMode::LEGACY, 'body { color: #f00 !important; }', $style, self::GREEN], + 'an important rule against an important style (standard)' => [CssMode::STANDARD, 'body { color: #f00 !important; }', $style, self::GREEN], + 'a plain rule against a plain style (standard)' => [CssMode::STANDARD, 'body { color: #f00; }', 'color: #008000', self::GREEN], + ]; + } + + /** + * An important declaration of an @page rule beats a later one that is not, under the standard cascade + * + * @dataProvider pageMargins + * + * @param string $cascade + * @param float $left The left margin of the page + */ + public function testAnImportantPageRuleBeatsALaterOne($cascade, $left) + { + $mpdf = $this->drawDocument('

text

', ['cssMode' => $cascade]); + + $this->assertEqualsWithDelta($left, $mpdf->drawnBoxes[0][1], 0.01); + } + + /** + * Each cascade with the left margin it gives the page + * + * @return array[] + */ + public function pageMargins() + { + return [ + CssMode::LEGACY => [CssMode::LEGACY, 10], + CssMode::STANDARD => [CssMode::STANDARD, 50], + ]; + } + + /** + * An important declaration of a class rule beats a later one that is not in the text of an SVG, which looks its + * classes up in CssManager::$CSS + * + * @dataProvider svgFills + * + * @param string $cascade + * @param string $fill The fill operator the text is drawn with + */ + public function testAnImportantClassRuleFillsSvgText($cascade, $fill) + { + $svg = 'Hi'; + $html = ''; + + $this->assertStringContainsString($fill, $this->render($html, ['cssMode' => $cascade, 'svgClasses' => true])); + } + + /** + * Each cascade with the fill it gives the SVG's text + * + * @return array[] + */ + public function svgFills() + { + return [ + CssMode::LEGACY => [CssMode::LEGACY, '1.000 0.000 0.000 rg'], + CssMode::STANDARD => [CssMode::STANDARD, '0.000 1.000 0.000 rg'], + ]; + } + + /** + * An important cell rule sets the border dominance of the sides it gives a border, as every cell rule does, and an + * important inline style of a cell does too + * + * @dataProvider cellBorders + * + * @param string $cascade + * @param string $html A table left open in a cell given a left border + */ + public function testAnImportantCellBorderTakesACellRulesDominance($cascade, $html) + { + $mpdf = new Mpdf(['mode' => 'c', 'cssMode' => $cascade]); + $mpdf->WriteHTML($html, HTMLParserMode::DEFAULT_MODE, true, false); + + $manager = new \ReflectionProperty($mpdf, 'cssManager'); + if (PHP_VERSION_ID < 80100) { + $manager->setAccessible(true); + } + + $this->assertSame(9, $manager->getValue($mpdf)->getBorderDominance('L')); + $this->assertSame(0, $manager->getValue($mpdf)->getBorderDominance('R')); + } + + /** + * Each cascade with a cell whose left border comes from an important rule, and from an important inline style + * + * @return array[] + */ + public function cellBorders() + { + $rule = '
'; + $style = '
'; + + return [ + 'a rule (legacy)' => [CssMode::LEGACY, $rule], + 'a rule (standard)' => [CssMode::STANDARD, $rule], + 'an inline style (legacy)' => [CssMode::LEGACY, $style], + 'an inline style (standard)' => [CssMode::STANDARD, $style], + ]; + } +} diff --git a/tests/Snapshots/FrameworkImportantLegacySnapshotTest.php b/tests/Snapshots/FrameworkImportantLegacySnapshotTest.php new file mode 100644 index 000000000..37174e312 --- /dev/null +++ b/tests/Snapshots/FrameworkImportantLegacySnapshotTest.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(); + ?> + + +

Framework utilities marked !important

+

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

+ +

A utility against an id

+

.text-success and .fw-normal against #sidebar p. Standard: the first line is green and not bold, the utilities beating the id. Legacy: it is grey and bold like the second, the descendant rule coming last.

+ + +

A utility against the inline style

+

.text-success against style="color: grey". Standard: the first line is green. Legacy: it is grey like the second, the inline style coming last.

+

With the utility

+

Without it

+ +

An inline !important against a utility

+

style="color: green" against .text-muted, with and without !important. Both: the first line is green. Standard: the second line is grey, the utility beating the inline style that is not important. Legacy: it is green too.

+

Styled inline with !important

+

Styled inline without it

+ +

Two utilities

+

.text-success and .text-primary, both !important and as specific, .text-primary written later. Standard: the line is blue, the later rule winning. Legacy: it is green, classes applying in alphabetical order.

+

Both colour utilities

+ +

A spacing utility against a component

+

.m-0 against .card p. Standard: the first paragraph sits against the top of the card, with no margin. Legacy: it has the 4mm margin of the second, the descendant rule coming last.

+

With .m-0

Without it

+ +

Hidden by a utility

+

.d-none against #promo { display: block }. Standard: only "Visible alert" is drawn. Legacy: "Hidden alert" is drawn above it, the id coming last.

+
Hidden alert
+
Visible alert
+ +

Border and background utilities against an id

+

.border-0 and .bg-light (a background shorthand) against #notice. Standard: the notice has no border and a light grey background. Legacy: it has a thick dark red border and a pink background, the id coming last.

+
A notice
+ +

A utility in a table

+

.text-end against .table td. Standard: the amounts are right-aligned. Legacy: they are left-aligned, the descendant rule coming last.

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

A utility against a component inside an id

+

.bg-success against #checkout .btn. Standard: the first button is green. Legacy: it is purple like the second, the descendant rule coming last.

+

With .bg-success Without it

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

!important

+ +

.class !important beats #id: the line is green.

+

An important class against an id

+ +

.class !important beats the inline style: the line is green.

+

An important class against the inline style

+ +

An inline !important beats #id !important: the line is green.

+

An important inline style against an important id

+ +

An !important declaration beats one written after it in the same block: the line is green.

+

An important declaration against a later one

+ +

Of two !important rules, .class.class beats p.class: the line is green.

+

Two important rules of different specificity

+ +

Of two !important rules as specific, the one written later wins, and a plain rule after both does not: the line is green.

+

Two important rules as specific

+ +

An !important rule inside @media print beats #id, and one inside @media screen is left out: the line is green.

+

An important rule inside @media

+ +

The flag in capitals, spaced from the bang, still counts: the line is green.

+

! IMPORTANT

+ +

border: … !important expands into sides that are all important: a solid green border of even width on every side, no red, no dots.

+
An important border shorthand
+ +

background: … !important beats #id's background-color: a pale green background.

+
An important background shorthand
+ +

font: 13pt serif !important sets the size over #id's 7pt, and resets its bold: the line is large, serif and not bold.

+

An important font shorthand

+ +

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

+
LeftMiddleRight
+ +

The default stylesheet's p.ua { color: green !important } beats the document's p.ua !important and an inline !important: the line is green.

+

An important rule of the default stylesheet

+ mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD, 'defaultCssFile' => __DIR__ . '/../data/css/important-default.css']); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/css/important-default.css b/tests/data/css/important-default.css new file mode 100644 index 000000000..cc1dc2768 --- /dev/null +++ b/tests/data/css/important-default.css @@ -0,0 +1,13 @@ +/* A default stylesheet with !important declarations, which beat the document's own under the standard cascade */ + +body { + font-size: 9pt !important; +} + +p.ua { + color: #008000 !important; +} + +p.plain { + color: #f00; +} diff --git a/tests/data/snapshots/framework-important-legacy.pdf b/tests/data/snapshots/framework-important-legacy.pdf new file mode 100644 index 000000000..7d6ad2fed Binary files /dev/null and b/tests/data/snapshots/framework-important-legacy.pdf differ diff --git a/tests/data/snapshots/framework-important-standard.pdf b/tests/data/snapshots/framework-important-standard.pdf new file mode 100644 index 000000000..f1ebad610 Binary files /dev/null and b/tests/data/snapshots/framework-important-standard.pdf differ diff --git a/tests/data/snapshots/important-declarations.pdf b/tests/data/snapshots/important-declarations.pdf new file mode 100644 index 000000000..16f11d88c Binary files /dev/null and b/tests/data/snapshots/important-declarations.pdf differ