diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index a916ec78e..43b0cc2c7 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -56,6 +56,11 @@ Read this section before upgrading from upstream mPDF. Each entry says what chan * **`rem` is read against the font size of `html`, in standard mode**, as in a browser. It was read against the font size of `body`, and inside a table against the table's. Now `1rem` is the default font size, from the `default_font_size` configuration or `SetDefaultFontSize()`, unless an `html` or `:root` rule sizes `html`: with `html { font-size: 62.5% }`, `1.6rem` is the default size again. A `body { font-size }` rule no longer changes it. To keep the old sizes, give them in `em` or points, or set `'cssMode' => \Mpdf\CssMode::LEGACY`. [#529] [#642] * **A border, `box-shadow` or `text-shadow` that names no colour is drawn in the element's colour**, which is `currentColor`, as in a browser. A border was drawn black and a shadow grey (`#888888`). `div { color: #c00; border: 1px solid; box-shadow: 2mm 2mm }` now draws a red border and a red shadow. This applies to the `border` shorthands and to a border built from `border-style` or `border-width` alone. Name the colour, as in `border: 1px solid #000`, or set `'cssMode' => \Mpdf\CssMode::LEGACY`, to keep black borders and grey shadows. [#540] [#651] * `currentColor` written in `background-color`, a gradient stop, `box-shadow`, `text-shadow`, `topntail` or `thead-underline` is read as the element's colour too, and `color: currentColor` as the colour it inherits. Before, the background was not painted, the gradient lost the stop, the shadows were grey, the table lines black, and `color: currentColor` on a block drew black. Legacy mode ignores `currentColor` there as before. +* **The CSS-wide keywords `inherit`, `initial`, `unset`, `revert` and `revert-layer` are applied**, as in a browser. mPDF read most of them as a value it did not know: `color: inherit` on a block or a cell drew black, `font-size: inherit` the default size, and `border: inherit` no border. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to read them as before. [#546] [#652] + * `inherit` takes the parent element's value, for properties that are not inherited too, such as `border`, `padding` and `background`. A cell takes those from its row. + * `initial` gives the property's initial value, such as `currentColor` for a border's colour, and the document's default font and size for `font-family` and `font-size`. `unset` is `inherit` for an inherited property and `initial` for any other. + * `revert` and `revert-layer` give the value of the built-in defaults and the default stylesheet, such as a heading's size, a list's margin or a cell's padding, and are `unset` where those give none. An HTML attribute such as `cellpadding` counts as an author rule, so `revert` goes past it too. + * A shorthand passes the keyword to each of its longhands, so `border: inherit` takes every side's width, style and colour, and `font: initial` resets the size, family, style, weight, line height and variants. New features ------------ @@ -161,6 +166,8 @@ Bugfixes * `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] +* A border colour mPDF cannot read, such as `border-color: initial` in the legacy CSS mode, raised "Trying to access array offset" warnings when the border was drawn. [#546] [#652] +* An image with a `vertical-align` mPDF does not read, such as a length or, in the legacy CSS mode, `inherit`, raised undefined-key warnings. It now sits as if none were given. [#546] [#652] * `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] @@ -426,6 +433,7 @@ These changes do not change output. * **Branch alias.** The Composer branch alias maps `dev-gravitypdf` to `8.x-dev`. [#3] * **Open elements.** `WriteHTML()` keeps a stack of the elements open in the HTML it reads, so that CSS rules can be matched against an element's parents, ancestors and earlier siblings rather than only the blocks mPDF lays out. Each element carries its position among its siblings and a record of the siblings before it. `Mpdf::getOpenElements()` reads it. [#533] * **Compiled selectors.** A rule whose selector the legacy parser cannot read is compiled into its compound selectors, the combinators between them and its specificity, and filed under its rightmost compound for the selector matcher. A selector list is split at the commas outside parentheses, brackets and strings, so `:is(h1, h2)` and `[title="a,b"]` stay whole. [#538] [#619] +* **Computed values on the open elements.** In the standard CSS mode, each element on the stack of open elements carries the properties merged for it, with the CSS-wide keywords resolved, in the `computed` slot of its frame, and the document's frame carries those of ``. `inherit` reads a parent's from there. [#546] [#652] * **nth-child in tables from the open elements.** `tr`, `td` and `th:nth-child()` and `:first-child` take a row's or cell's position among its siblings from the stack of open elements, as the selector matcher does, instead of from mPDF's row and column counters. An element a browser moves out of a table, such as a `` or `` written between two cells, is not counted, and with `allow_html_optional_endtags` off a cell, row or row group with no end tag is closed by the next one, as mPDF lays it out. [#538] [#629] [mpdf/mpdf#7]: https://github.com/mpdf/mpdf/issues/7 @@ -840,6 +848,7 @@ These changes do not change output. [#545]: https://github.com/GravityPDF/mpdf/issues/545 [#539]: https://github.com/GravityPDF/mpdf/issues/539 [#540]: https://github.com/GravityPDF/mpdf/issues/540 +[#546]: https://github.com/GravityPDF/mpdf/issues/546 [#548]: https://github.com/GravityPDF/mpdf/issues/548 [#549]: https://github.com/GravityPDF/mpdf/pull/549 [#552]: https://github.com/GravityPDF/mpdf/issues/552 @@ -900,4 +909,5 @@ These changes do not change output. [#647]: https://github.com/GravityPDF/mpdf/pull/647 [#648]: https://github.com/GravityPDF/mpdf/pull/648 [#651]: https://github.com/GravityPDF/mpdf/pull/651 +[#652]: https://github.com/GravityPDF/mpdf/pull/652 [#674]: https://github.com/GravityPDF/mpdf/pull/674 diff --git a/src/Color/ColorConverter.php b/src/Color/ColorConverter.php index 2184b4a35..a4b8f39e4 100644 --- a/src/Color/ColorConverter.php +++ b/src/Color/ColorConverter.php @@ -2,6 +2,7 @@ namespace Mpdf\Color; +use Mpdf\CssMode; use Mpdf\Mpdf; class ColorConverter @@ -45,7 +46,8 @@ public function convert($color, array &$PDFAXwarnings = []) { $color = strtolower(trim($color)); - if ($color === 'transparent' || $color === 'inherit') { + // The standard CSS mode resolves inherit before a colour is converted + if ($color === 'transparent' || ($color === 'inherit' && $this->mpdf->cssMode !== CssMode::STANDARD)) { return false; } diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 87fc3dd3d..fe62f8fd1 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -41,6 +41,16 @@ class CssMerger 'THEAD-UNDERLINE', ]; + /** + * The parts of a border side, in the order its shorthand writes them + */ + const BORDER_PARTS = ['WIDTH', 'STYLE', 'COLOR']; + + /** + * Matches a border side's shorthand or one of its parts, capturing the side's shorthand and the part + */ + const BORDER_SIDE_PROPERTY = '/^(BORDER-(?:TOP|RIGHT|BOTTOM|LEFT))(?:-(WIDTH|STYLE|COLOR))?$/'; + /** * @var \Mpdf\Mpdf */ @@ -238,14 +248,20 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) $dominance = $tag === 'TD' || $tag === 'TH' ? 9 : false; $this->mergeInheritedBlockProperties($inherit, $tag); + $inherited = $this->cssProperties; + // The built-in defaults and the default stylesheet are merged on their own first, for revert to read + $this->cssProperties = []; $this->mergeDefaultCss($tag); list($defaultRules, $importantDefaultRules) = $this->cssManager->getDefaultRules()->matchingDeclarations($tag, $id, $classes, $path); $this->mergeEach($defaultRules, $dominance); + $defaults = $this->cssProperties; + $this->cssProperties = array_merge($inherited, $defaults); $this->mergePresentationalHints($tag, $attr); $this->mergeTableSpecificCss($tag, $attr); + $html = null; if ($tag === 'BODY') { // Merged outside the document's elements, and written to by Mpdf::SetDefaultBodyCSS() and the like $important = $this->cssManager->getImportantCss(); @@ -253,8 +269,11 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) $body = isset($this->cssManager->CSS['BODY']) ? $this->cssManager->CSS['BODY'] : []; $this->setMergedCss($body, false); $rules = []; - $importantRules = array_merge(isset($important['BODY']) ? [$important['BODY']] : [], $this->mergeDocumentRules()); + list($importantBody, $html) = $this->mergeDocumentRules(); + $importantRules = array_merge(isset($important['BODY']) ? [$important['BODY']] : [], $importantBody); $importantDefaultRules = isset($importantDefault['BODY']) ? [$importantDefault['BODY']] : []; + // html is body's parent, which a keyword on body reads + $inherited = array_merge($inherited, $html); } else { list($rules, $importantRules) = $this->cssManager->getRules()->matchingDeclarations($tag, $id, $classes, $path); } @@ -262,7 +281,13 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) list($inline, $importantInline) = isset($attr['STYLE']) ? $this->inlineStyleParser->parseByImportance($attr['STYLE']) : [[], []]; $this->mergeEach(array_merge([$inline], $importantRules, [$importantInline], $importantDefaultRules), $dominance); + + // Before currentColor, which takes the colour a keyword resolves to + $this->resolveWideKeywords($inherited, $defaults, $path, $inherit !== 'INLINE' && $inherit !== '', $html); $this->resolveCurrentColor($inherit); + if ($this->sideEffects) { + $this->mpdf->recordStyledElementComputed($this->cssProperties); + } return $this->cssProperties; } @@ -317,6 +342,82 @@ private function resolveCurrentColor($inherit) } } + /** + * Resolves the CSS-wide keywords the cascade left in the element's properties: + * - inherit: for an inherited property, the value the element starts from, which is its parent's. A block, a table + * and a table part are not handed every inherited value, so failing that they take the value their parent's frame + * on the stack of open elements holds. Failing both, the property is dropped, and the element keeps the state it + * is drawn in, as an inline element does. For any other property, the value its parent's frame holds, or failing + * that the initial value + * - initial: the initial value CssWideKeywords gives, or the document's default font and size + * - unset: inherit for an inherited property, and initial for any other + * - revert and revert-layer: the value of the built-in defaults and the default stylesheet, or failing that unset + * + * A keyword in one part of a border side (its width, style or colour) is resolved into that side's shorthand too. + * + * @param array $inherited The properties the element starts from, before the defaults + * @param array $defaults The properties the built-in defaults and the default stylesheet give the element + * @param callable $path Gives the open elements from the document down to the element, or null for none + * @param bool $fromParent Whether an inherited property the element does not start from is read from its parent's + * frame: for a block, a table and a table part + * @param array|null $parentProperties The properties of a parent that has no frame, as html has none for body + * @return void + */ + private function resolveWideKeywords(array $inherited, array $defaults, callable $path, $fromParent, $parentProperties = null) + { + $parent = null; + $sides = []; + foreach ($this->cssProperties as $property => $value) { + $keyword = CssWideKeywords::keywordOf($value); + if ($keyword === null) { + continue; + } + + if (preg_match(self::BORDER_SIDE_PROPERTY, $property, $m)) { + $sides[$m[1]] = true; + } + + if ($keyword === 'revert' || $keyword === 'revert-layer') { + $reverted = $this->declaredValue($defaults, $property); + if ($reverted !== null) { + $this->cssProperties[$property] = $reverted; + continue; + } + $keyword = 'unset'; + } + + $isInherited = CssWideKeywords::isInherited($property); + if ($keyword === 'unset') { + $keyword = $isInherited ? 'inherit' : 'initial'; + } + + $resolved = null; + if ($keyword === 'inherit' && $isInherited && isset($inherited[$property])) { + $resolved = $inherited[$property]; + } elseif ($keyword === 'inherit' && (!$isInherited || $fromParent)) { + if ($parent === null) { + $parent = $parentProperties !== null ? $parentProperties : $this->parentComputed($path); + } + $resolved = $this->declaredValue($parent, $property); + if ($resolved === null && !$isInherited) { + $resolved = $this->initialValue($property); + } + } elseif ($keyword === 'initial') { + $resolved = $this->initialValue($property); + } + + if ($resolved === null) { + unset($this->cssProperties[$property]); + } else { + $this->cssProperties[$property] = $resolved; + } + } + + foreach (array_keys($sides) as $side) { + $this->resolveBorderSide($side); + } + } + /** * The colour currentColor stands for in the element's merged properties * @@ -360,6 +461,83 @@ private function inheritedColor($inherit) return $colorarray ? $this->colorConverter->colAtoString($colorarray) : null; } + /** + * Writes a border side's resolved width, style and colour into its shorthand, which the drawing code reads. The + * shorthand, resolved on its own, is the base: a keyword in it replaced the whole side, and a part declared after + * it is still among the element's properties + * + * @param string $key BORDER-TOP, BORDER-RIGHT, BORDER-BOTTOM or BORDER-LEFT + * @return void + */ + private function resolveBorderSide($key) + { + $parts = []; + foreach (self::BORDER_PARTS as $part) { + if (isset($this->cssProperties[$key . '-' . $part])) { + $parts[$key . '-' . $part] = $this->cssProperties[$key . '-' . $part]; + } + } + + $this->mergeBorderProperties($parts); + } + + /** + * The value a set of properties gives one of them, where a part of a border side left out of it is read from the + * side's shorthand + * + * @param array $properties + * @param string $property + * @return string|null Null when the properties do not give it + */ + private function declaredValue(array $properties, $property) + { + if (isset($properties[$property])) { + return $properties[$property]; + } + + if (!preg_match(self::BORDER_SIDE_PROPERTY, $property, $m) || !isset($m[2], $properties[$m[1]])) { + return null; + } + + // A shorthand of anything but a width, a style and a colour gives no part + $border = preg_split('/\s+/', trim($properties[$m[1]])); + + return count($border) === 3 ? $border[array_search($m[2], self::BORDER_PARTS, true)] : null; + } + + /** + * @param callable $path Gives the open elements from the document down to the element, or null for none + * @return array The properties merged for the element's parent, or none if it has no parent or they were not kept + */ + private function parentComputed(callable $path) + { + $elements = $path(); + if ($elements === null || count($elements) < 2) { + return []; + } + + $parent = $elements[count($elements) - 2]; + + return $parent['computed'] !== null ? $parent['computed'] : []; + } + + /** + * @param string $property Uppercased + * @return string|null The property's initial value, or null to leave the property unset + */ + private function initialValue($property) + { + if ($property === 'FONT-FAMILY') { + return $this->mpdf->original_default_font; + } + + if ($property === 'FONT-SIZE') { + return $this->mpdf->original_default_font_size . 'pt'; + } + + return CssWideKeywords::initialValue($property); + } + /** * Merges html's rules into body's CSS, and body's own rules again over them. mPDF has no html element: the * document's frame stands for body, and html is matched as its parent, so html's declarations, its !important @@ -369,7 +547,8 @@ private function inheritedColor($inherit) * html's font size, read against the default font size, is the root's, which rem and body's own size are read * against * - * @return array[] The !important declarations of body's rules, which apply after body's style + * @return array[] The !important declarations of body's rules, which apply after body's style, and the properties + * html's rules give */ private function mergeDocumentRules() { @@ -378,6 +557,7 @@ private function mergeDocumentRules() $initial = $this->mpdf->initial_font_size; $this->mpdf->root_font_size = $initial; + $htmlProperties = []; list($html, $importantHtml) = $rules->documentDeclarations(true, $path); foreach (array_merge($html, $importantHtml) as $properties) { if (isset($properties['FONT-SIZE'])) { @@ -388,12 +568,13 @@ private function mergeDocumentRules() } } $this->setMergedCss($properties, false); + $htmlProperties = array_merge($htmlProperties, $properties); } list($body, $importantBody) = $rules->documentDeclarations(false, $path); $this->mergeEach($body, false); - return $importantBody; + return [$importantBody, $htmlProperties]; } /** diff --git a/src/Css/CssWideKeywords.php b/src/Css/CssWideKeywords.php new file mode 100644 index 000000000..0c14443c0 --- /dev/null +++ b/src/Css/CssWideKeywords.php @@ -0,0 +1,177 @@ + The keywords, as keys + */ + private static $keywords = ['inherit' => true, 'initial' => true, 'unset' => true, 'revert' => true, 'revert-layer' => true]; + + /** + * @var array The inherited properties mPDF reads that are not among InheritedProperties::names(), + * as no channel hands them on + */ + private static $otherInherited = [ + 'BORDER-COLLAPSE' => true, + 'BORDER-SPACING-H' => true, + 'BORDER-SPACING-V' => true, + 'CAPTION-SIDE' => true, + 'EMPTY-CELLS' => true, + 'IMAGE-ORIENTATION' => true, + 'IMAGE-RENDERING' => true, + 'VISIBILITY' => true, + 'WHITE-SPACE' => true, + ]; + + /** + * @var array|null Every inherited property mPDF reads, as keys, built on first use + */ + private static $inherited; + + /** + * @var array The initial value of each property mPDF reads, written as mPDF reads it. A property + * not listed is left unset, which mPDF reads as its initial value: auto for the sizes and offsets, none for float, + * clear and transform, and nothing for mPDF's own properties. FONT-FAMILY and FONT-SIZE take the document's default + * font and size, which CssMerger supplies + */ + private static $initial = [ + 'BACKGROUND-CLIP' => 'border-box', + 'BACKGROUND-COLOR' => 'transparent', + 'BACKGROUND-IMAGE' => '', + 'BACKGROUND-ORIGIN' => 'padding-box', + 'BACKGROUND-POSITION' => '0% 0%', + 'BACKGROUND-REPEAT' => 'repeat', + 'BACKGROUND-SIZE' => 'auto', + 'BORDER-BOTTOM' => 'medium none currentcolor', + 'BORDER-BOTTOM-COLOR' => 'currentcolor', + 'BORDER-BOTTOM-LEFT-RADIUS-H' => '0', + 'BORDER-BOTTOM-LEFT-RADIUS-V' => '0', + 'BORDER-BOTTOM-RIGHT-RADIUS-H' => '0', + 'BORDER-BOTTOM-RIGHT-RADIUS-V' => '0', + 'BORDER-BOTTOM-STYLE' => 'none', + 'BORDER-BOTTOM-WIDTH' => 'medium', + 'BORDER-COLLAPSE' => 'separate', + 'BORDER-LEFT' => 'medium none currentcolor', + 'BORDER-LEFT-COLOR' => 'currentcolor', + 'BORDER-LEFT-STYLE' => 'none', + 'BORDER-LEFT-WIDTH' => 'medium', + 'BORDER-RIGHT' => 'medium none currentcolor', + 'BORDER-RIGHT-COLOR' => 'currentcolor', + 'BORDER-RIGHT-STYLE' => 'none', + 'BORDER-RIGHT-WIDTH' => 'medium', + 'BORDER-SPACING-H' => '0', + 'BORDER-SPACING-V' => '0', + 'BORDER-TOP' => 'medium none currentcolor', + 'BORDER-TOP-COLOR' => 'currentcolor', + 'BORDER-TOP-LEFT-RADIUS-H' => '0', + 'BORDER-TOP-LEFT-RADIUS-V' => '0', + 'BORDER-TOP-RIGHT-RADIUS-H' => '0', + 'BORDER-TOP-RIGHT-RADIUS-V' => '0', + 'BORDER-TOP-STYLE' => 'none', + 'BORDER-TOP-WIDTH' => 'medium', + 'BOX-DECORATION-BREAK' => 'slice', + 'BOX-SHADOW' => 'none', + 'CAPTION-SIDE' => 'top', + 'COLOR' => '#000000', + 'DIRECTION' => 'ltr', + 'DISPLAY' => 'inline', + 'EMPTY-CELLS' => 'show', + 'FONT-FEATURE-SETTINGS' => 'normal', + 'FONT-KERNING' => 'auto', + 'FONT-LANGUAGE-OVERRIDE' => 'normal', + 'FONT-STYLE' => 'normal', + 'FONT-VARIANT-ALTERNATES' => 'normal', + 'FONT-VARIANT-CAPS' => 'normal', + 'FONT-VARIANT-LIGATURES' => 'normal', + 'FONT-VARIANT-NUMERIC' => 'normal', + 'FONT-VARIANT-POSITION' => 'normal', + 'FONT-WEIGHT' => 'normal', + 'HYPHENS' => 'manual', + 'IMAGE-ORIENTATION' => '0', + 'IMAGE-RENDERING' => 'auto', + 'LETTER-SPACING' => 'normal', + 'LINE-HEIGHT' => 'normal', + 'LINE-STACKING-SHIFT' => 'consider-shifts', + 'LINE-STACKING-STRATEGY' => 'inline-line-height', + 'LIST-STYLE-IMAGE' => 'none', + 'LIST-STYLE-POSITION' => 'outside', + 'LIST-STYLE-TYPE' => 'disc', + 'MARGIN-BOTTOM' => '0', + 'MARGIN-LEFT' => '0', + 'MARGIN-RIGHT' => '0', + 'MARGIN-TOP' => '0', + 'OPACITY' => '1', + 'OVERFLOW' => 'visible', + 'PADDING-BOTTOM' => '0', + 'PADDING-LEFT' => '0', + 'PADDING-RIGHT' => '0', + 'PADDING-TOP' => '0', + 'PAGE-BREAK-AFTER' => 'auto', + 'PAGE-BREAK-BEFORE' => 'auto', + 'PAGE-BREAK-INSIDE' => 'auto', + 'POSITION' => 'static', + 'TEXT-ALIGN' => 'start', + 'TEXT-DECORATION' => 'none', + 'TEXT-INDENT' => '0', + 'TEXT-OUTLINE' => 'none', + 'TEXT-SHADOW' => 'none', + 'TEXT-TRANSFORM' => 'none', + 'VERTICAL-ALIGN' => 'baseline', + 'VISIBILITY' => 'visible', + 'WHITE-SPACE' => 'normal', + 'WORD-SPACING' => 'normal', + 'Z-INDEX' => 'auto', + ]; + + /** + * @param mixed $value A declared value + * + * @return string|null The CSS-wide keyword the value is, lowercased, or null if it is not one + */ + public static function keywordOf($value) + { + if (!is_string($value)) { + return null; + } + + $value = strtolower(trim($value)); + + return isset(self::$keywords[$value]) ? $value : null; + } + + /** + * @param string $property Uppercased + * + * @return bool Whether the property is inherited + */ + public static function isInherited($property) + { + if (self::$inherited === null) { + self::$inherited = array_fill_keys(InheritedProperties::names(), true) + self::$otherInherited; + } + + return isset(self::$inherited[$property]); + } + + /** + * @param string $property Uppercased + * + * @return string|null The property's initial value, as mPDF reads it, or null to leave the property unset + */ + public static function initialValue($property) + { + return isset(self::$initial[$property]) ? self::$initial[$property] : null; + } +} diff --git a/src/Css/NormalizeProperties.php b/src/Css/NormalizeProperties.php index d89cee0fb..a0fb8f76d 100644 --- a/src/Css/NormalizeProperties.php +++ b/src/Css/NormalizeProperties.php @@ -46,7 +46,8 @@ class NormalizeProperties private $colorConverter; /** - * The keywords every property takes, which a shorthand takes only as its whole value + * The keywords every property takes, which a shorthand takes only as its whole value, as the legacy CSS mode reads + * them. The standard mode reads them through CssWideKeywords */ const CSS_WIDE_KEYWORDS = ['inherit', 'initial', 'unset', 'revert']; @@ -93,6 +94,57 @@ class NormalizeProperties 'BORDER-TOP-COLOR' => true, 'BORDER-RIGHT-COLOR' => true, 'BORDER-BOTTOM-COLOR' => true, 'BORDER-LEFT-COLOR' => true, ]; + /** + * The longhands mPDF reads for each shorthand, and for each property it reads under another name, which a CSS-wide + * keyword given as the whole value is passed on to in the standard CSS mode + * + * @var string[][] + */ + private static $keywordLonghands = [ + 'FONT' => [ + 'FONT-FAMILY', 'FONT-SIZE', 'LINE-HEIGHT', 'FONT-STYLE', 'FONT-WEIGHT', 'FONT-VARIANT-LIGATURES', + 'FONT-VARIANT-CAPS', 'FONT-VARIANT-NUMERIC', 'FONT-VARIANT-ALTERNATES', 'FONT-VARIANT-POSITION', + ], + 'FONT-VARIANT' => [ + 'FONT-VARIANT-LIGATURES', 'FONT-VARIANT-CAPS', 'FONT-VARIANT-NUMERIC', 'FONT-VARIANT-ALTERNATES', + 'FONT-VARIANT-POSITION', + ], + 'MARGIN' => ['MARGIN-TOP', 'MARGIN-RIGHT', 'MARGIN-BOTTOM', 'MARGIN-LEFT'], + 'PADDING' => ['PADDING-TOP', 'PADDING-RIGHT', 'PADDING-BOTTOM', 'PADDING-LEFT'], + 'BORDER' => [ + 'BORDER-TOP', 'BORDER-TOP-WIDTH', 'BORDER-TOP-STYLE', 'BORDER-TOP-COLOR', + 'BORDER-RIGHT', 'BORDER-RIGHT-WIDTH', 'BORDER-RIGHT-STYLE', 'BORDER-RIGHT-COLOR', + 'BORDER-BOTTOM', 'BORDER-BOTTOM-WIDTH', 'BORDER-BOTTOM-STYLE', 'BORDER-BOTTOM-COLOR', + 'BORDER-LEFT', 'BORDER-LEFT-WIDTH', 'BORDER-LEFT-STYLE', 'BORDER-LEFT-COLOR', + ], + 'BORDER-TOP' => ['BORDER-TOP', 'BORDER-TOP-WIDTH', 'BORDER-TOP-STYLE', 'BORDER-TOP-COLOR'], + 'BORDER-RIGHT' => ['BORDER-RIGHT', 'BORDER-RIGHT-WIDTH', 'BORDER-RIGHT-STYLE', 'BORDER-RIGHT-COLOR'], + 'BORDER-BOTTOM' => ['BORDER-BOTTOM', 'BORDER-BOTTOM-WIDTH', 'BORDER-BOTTOM-STYLE', 'BORDER-BOTTOM-COLOR'], + 'BORDER-LEFT' => ['BORDER-LEFT', 'BORDER-LEFT-WIDTH', 'BORDER-LEFT-STYLE', 'BORDER-LEFT-COLOR'], + 'BORDER-WIDTH' => ['BORDER-TOP-WIDTH', 'BORDER-RIGHT-WIDTH', 'BORDER-BOTTOM-WIDTH', 'BORDER-LEFT-WIDTH'], + 'BORDER-STYLE' => ['BORDER-TOP-STYLE', 'BORDER-RIGHT-STYLE', 'BORDER-BOTTOM-STYLE', 'BORDER-LEFT-STYLE'], + 'BORDER-COLOR' => ['BORDER-TOP-COLOR', 'BORDER-RIGHT-COLOR', 'BORDER-BOTTOM-COLOR', 'BORDER-LEFT-COLOR'], + 'BORDER-SPACING' => ['BORDER-SPACING-H', 'BORDER-SPACING-V'], + 'BORDER-RADIUS' => [ + 'BORDER-TOP-LEFT-RADIUS-H', 'BORDER-TOP-LEFT-RADIUS-V', 'BORDER-TOP-RIGHT-RADIUS-H', 'BORDER-TOP-RIGHT-RADIUS-V', + 'BORDER-BOTTOM-RIGHT-RADIUS-H', 'BORDER-BOTTOM-RIGHT-RADIUS-V', 'BORDER-BOTTOM-LEFT-RADIUS-H', + 'BORDER-BOTTOM-LEFT-RADIUS-V', + ], + 'BORDER-TOP-LEFT-RADIUS' => ['BORDER-TOP-LEFT-RADIUS-H', 'BORDER-TOP-LEFT-RADIUS-V'], + 'BORDER-TOP-RIGHT-RADIUS' => ['BORDER-TOP-RIGHT-RADIUS-H', 'BORDER-TOP-RIGHT-RADIUS-V'], + 'BORDER-BOTTOM-RIGHT-RADIUS' => ['BORDER-BOTTOM-RIGHT-RADIUS-H', 'BORDER-BOTTOM-RIGHT-RADIUS-V'], + 'BORDER-BOTTOM-LEFT-RADIUS' => ['BORDER-BOTTOM-LEFT-RADIUS-H', 'BORDER-BOTTOM-LEFT-RADIUS-V'], + 'BACKGROUND' => [ + 'BACKGROUND-COLOR', 'BACKGROUND-IMAGE', 'BACKGROUND-REPEAT', 'BACKGROUND-POSITION', 'BACKGROUND-SIZE', + 'BACKGROUND-ORIGIN', 'BACKGROUND-CLIP', + ], + 'LIST-STYLE' => ['LIST-STYLE-TYPE', 'LIST-STYLE-IMAGE', 'LIST-STYLE-POSITION'], + 'TEXT-OUTLINE' => ['TEXT-OUTLINE', 'TEXT-OUTLINE-WIDTH', 'TEXT-OUTLINE-COLOR'], + 'BREAK-BEFORE' => ['PAGE-BREAK-BEFORE'], + 'BREAK-AFTER' => ['PAGE-BREAK-AFTER'], + 'BREAK-INSIDE' => ['PAGE-BREAK-INSIDE'], + ]; + public function __construct(Mpdf $mpdf, SizeConverter $sizeConverter, ColorConverter $colorConverter) { $this->mpdf = $mpdf; @@ -118,8 +170,27 @@ public function normalize($prop) } $this->properties = []; + $standard = $this->mpdf->cssMode === CssMode::STANDARD; foreach ($prop as $k => $v) { + // The size and sheet-size of an @page rule take no CSS-wide keyword + if ($standard && $k !== 'SIZE' && $k !== 'SHEET-SIZE') { + $keyword = CssWideKeywords::keywordOf($v); + if ($keyword !== null) { + // Resolved by CssMerger once the element's CSS is merged + foreach (isset(self::$keywordLonghands[$k]) ? self::$keywordLonghands[$k] : [$k] as $longhand) { + $this->properties[$longhand] = $keyword; + } + continue; + } + + // A shorthand takes a keyword only as its whole value, so a browser drops `margin: 0 inherit` + if (isset(self::$keywordLonghands[$k]) && preg_match('/(?:inherit|initial|unset|revert)/i', $v) + && array_filter($this->splitComponents($v), [CssWideKeywords::class, 'keywordOf'])) { + continue; + } + } + if ($k !== 'BACKGROUND-IMAGE' && $k !== 'BACKGROUND' && $k !== 'ODD-HEADER-NAME' && $k !== 'EVEN-HEADER-NAME' && $k !== 'ODD-FOOTER-NAME' && $k !== 'EVEN-FOOTER-NAME' && $k !== 'HEADER' && $k !== 'FOOTER' && $k !== 'LIST-STYLE' && $k !== 'LIST-STYLE-IMAGE') { $v = strtolower($v); } @@ -243,6 +314,7 @@ public function canParse($property, $value) } } elseif (isset(self::$colorProperties[$property])) { return in_array($value, ['transparent', 'currentcolor', 'inherit', 'initial', 'unset'], true) + || ($this->mpdf->cssMode === CssMode::STANDARD && CssWideKeywords::keywordOf($value) !== null) || $this->colorConverter->isColor($value); } @@ -515,7 +587,8 @@ protected function parseCssBackground($s) } } - // A CSS-wide keyword gives the initial value, no background, until the keywords are resolved + // A CSS-wide keyword gives the initial value, no background. The standard CSS mode passes it to the longhands + // before this, for CssMerger to resolve if (count($layers) === 1 && count($layers[0]) === 1 && in_array(strtolower($layers[0][0]), self::CSS_WIDE_KEYWORDS, true)) { return []; } @@ -901,8 +974,8 @@ protected function normalizeBorderString($bd) * Parse border property parts (width, style, color). * * Each part may come in any position and at most once. A part left out takes its initial value. A CSS-wide - * keyword gives the initial value of all three until the keywords are resolved. The initial colour is currentColor - * in standard mode, and black in legacy mode. + * keyword gives the initial value of all three. The standard CSS mode passes it to the longhands before this, for + * CssMerger to resolve. The initial colour is currentColor in standard mode, and black in legacy mode. * * @param string[] $prop Components of the border property value * @return array|false Array containing 'w' (width), 's' (style), 'c' (color), or false when a component is none of them diff --git a/src/Mpdf.php b/src/Mpdf.php index ff117512b..accdc911f 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -14591,6 +14591,9 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c if ($zproperties) { $properties = Arrays::uniqueRecursiveMerge($properties, $zproperties); } + if ($this->cssMode === CssMode::STANDARD) { + $this->setDocumentComputed($properties); + } if (isset($properties['DIRECTION']) && $properties['DIRECTION']) { $this->cssManager->CSS['BODY']['DIRECTION'] = $properties['DIRECTION']; @@ -15215,7 +15218,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c $token = $i; // The
standing in for a positioned block has had the block's rules, through its style $outerElement = $this->styledElement; - $this->styledElement = !$standIn && $this->isDocumentElement($tag, $attr) ? ['path' => $this->openElements, 'tag' => $tag, 'attr' => $attr] : null; + $this->styledElement = !$standIn && $this->isDocumentElement($tag, $attr) ? ['path' => $this->openElements, 'tag' => $tag, 'attr' => $attr, 'computed' => null] : null; $this->tag->OpenTag($tag, $attr, $a, $i); // mPDF 6 // Unless it put back a page-break-inside: avoid block, which rewinds the parser to that block's start // tag and the open elements to how they were there @@ -15223,7 +15226,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c if ($standIn) { $standIn = false; } else { - $this->startElement($tag, $attr, $selfClosing); + $this->startElement($tag, $attr, $selfClosing, $this->styledElement === null ? null : $this->styledElement['computed']); } } // Held on to, the copy of the stack would make recording each closed child copy its parent's record @@ -15411,9 +15414,12 @@ function WriteFixedPosHTML($html, $x, $y, $w, $h, $overflow = 'visible', $boundi $this->blk[1]['attr'] = $attr; $this->Reset(); $outerElement = $this->styledElement; - $this->styledElement = $this->fixedPosBlockElements === null ? null : ['path' => $this->fixedPosBlockElements, 'tag' => null, 'attr' => []]; + $this->styledElement = $this->fixedPosBlockElements === null ? null : ['path' => $this->fixedPosBlockElements, 'tag' => null, 'attr' => [], 'computed' => null]; $p = $this->cssManager->MergeCSS('BLOCK', $tag, $attr); $this->styledElement = $outerElement; + if ($this->cssMode === CssMode::STANDARD) { + $this->setFixedPosBlockComputed($p); + } $this->fixedPosBlockCascadeCSS = $this->blk[1]['cascadeCSS']; if (isset($p['ROTATE'])) { $rotate = Rotation::angle($p['ROTATE'], [90, -90, 180]); @@ -17906,6 +17912,10 @@ function _setBorderLine($b, $k = 1) { $this->SetLineWidth($b['w'] / $k); $this->SetDColor($b['c']); + // A colour mPDF cannot read, such as a CSS-wide keyword in the legacy CSS mode, converts to nothing + if (!isset($b['c'][0])) { + return; + } if ($b['c'][0] == 5) { // RGBa $this->SetAlpha(ord($b['c'][4]) / 100, 'Normal', false, 'S'); // mPDF 5.7.2 } elseif ($b['c'][0] == 6) { // CMYKa diff --git a/src/Tag/Img.php b/src/Tag/Img.php index 797e65c59..8231b7f5a 100644 --- a/src/Tag/Img.php +++ b/src/Tag/Img.php @@ -129,8 +129,10 @@ public function open($attr, &$ahtml, &$ihtml) $objattr['border_right'] = $this->mpdf->border_details($properties['BORDER-RIGHT']); } - if (isset($properties['VERTICAL-ALIGN'])) { - $objattr['vertical-align'] = $this->getAlign($properties['VERTICAL-ALIGN']); + // A value mPDF does not read, such as a length or a CSS-wide keyword in the legacy CSS mode, leaves the default + $align = isset($properties['VERTICAL-ALIGN']) ? $this->getAlign($properties['VERTICAL-ALIGN']) : ''; + if ($align !== '') { + $objattr['vertical-align'] = $align; } $percent = []; $w = $this->length($properties, $attr, 'WIDTH', 0, $percent, 'w'); diff --git a/src/TracksOpenElements.php b/src/TracksOpenElements.php index e91e213e6..29f1b541c 100644 --- a/src/TracksOpenElements.php +++ b/src/TracksOpenElements.php @@ -24,6 +24,9 @@ trait TracksOpenElements /** @var string The language the or tag gives the document, which the document's frame carries */ private $documentLang = ''; + /** @var array|null The properties merged for , which the document's frame carries as its computed values */ + private $documentComputed; + /** @var array[]|null The open elements as they stood inside the positioned block being written, for its content */ private $fixedPosBlockElements; @@ -43,10 +46,11 @@ trait TracksOpenElements private $openElements; /** - * @var array|null The element whose CSS is being merged: the open elements it sits in (path) and its tag and - * attributes (attr), or, for an element that is already open, the open elements down to it and a null tag. Null - * while no element of the document is being styled. WriteHTML() sets it while a start tag's handler runs, reopenBlock() while a block is opened again, and - * WriteFixedPosHTML() while a positioned block's own CSS is merged. See getStyledElementPath() + * @var array|null The element whose CSS is being merged: the open elements it sits in (path), its tag and + * attributes (attr), and the properties merged for it (computed), or, for an element that is already open, the + * open elements down to it and a null tag. Null while no element of the document is being styled. WriteHTML() sets + * it while a start tag's handler runs, reopenBlock() while a block is opened again, and WriteFixedPosHTML() while a + * positioned block's own CSS is merged. See getStyledElementPath() */ private $styledElement; @@ -130,7 +134,11 @@ trait TracksOpenElements * found. Null when that is not known, as for an element still open at the end of a WriteHTML() call that * leaves it open, and in the legacy CSS mode, which reads nothing ahead * - empty: whether it has no children and no text, white space included. Null when that is not known - * - computed: its computed values, not filled in yet + * - computed: under the standard CSS mode, the properties CssMerger merged for it, with the CSS-wide keywords + * resolved, keyed by the uppercased longhands the normalised declarations name. The document's frame carries + * those of . Null for an element whose CSS was not merged, such as the tbody a table's rows are put in + * when the HTML leaves it out, and under the legacy mode. `inherit` on a property that is not inherited reads + * the parent's * * @return array[] */ @@ -231,6 +239,47 @@ private function withChildFrame(array $path, $tag, array $attr) return $path; } + /** + * Keeps the properties merged for the element whose CSS is being merged, for its frame on the stack. CssMerger + * calls it under the standard CSS mode, once it has resolved the CSS-wide keywords. An element already open, such + * as a block a forced page break opens again, keeps the frame it has + * + * @param array $properties + */ + public function recordStyledElementComputed(array $properties) + { + if ($this->styledElement !== null && $this->styledElement['tag'] !== null) { + $this->styledElement['computed'] = $properties; + } + } + + /** + * Keeps the properties merged for , which the document's frame carries as its computed values, in the + * stack being written and in each stack a header, footer or new document starts from. WriteHTML() calls it under + * the standard CSS mode when it merges them + * + * @param array $properties + */ + private function setDocumentComputed(array $properties) + { + $this->documentComputed = $properties; + $this->openElements[0]['computed'] = $properties; + } + + /** + * Keeps the properties merged for the positioned block being written, for the frame its content is written in. + * Its start tag in the flow only previews its CSS, so WriteFixedPosHTML() calls it under the standard CSS mode + * once it merges them + * + * @param array $properties + */ + private function setFixedPosBlockComputed(array $properties) + { + if ($this->fixedPosBlockElements !== null) { + $this->fixedPosBlockElements[count($this->fixedPosBlockElements) - 1]['computed'] = $properties; + } + } + /** * Makes the stack a document starts from: a single frame standing for the document, with nothing written into it * yet. The constructor starts from one, and so does a WriteHTML() call that starts a new document, such as the one @@ -241,7 +290,10 @@ private function withChildFrame(array $path, $tag, array $attr) */ private function newOpenElementStack() { - return [$this->newElementFrame('', [], $this->documentLang, 1, 1, '')]; + $document = $this->newElementFrame('', [], $this->documentLang, 1, 1, ''); + $document['computed'] = $this->documentComputed; + + return [$document]; } /** @@ -287,8 +339,9 @@ private function newElementFrame($tag, array $attr, $lang, $nthChild, $nthOfType * @param string $tag * @param string[] $attr * @param bool $selfClosing Whether the start tag ends with a slash + * @param array|null $computed The properties merged for it, as recordStyledElementComputed() kept them */ - private function startElement($tag, array $attr, $selfClosing) + private function startElement($tag, array $attr, $selfClosing, $computed = null) { if ($tag === '' || isset(self::$substitutionTags[$tag])) { return; @@ -301,6 +354,7 @@ private function startElement($tag, array $attr, $selfClosing) } $frame = $this->newChildFrame($this->openElements[$parent], $tag, $attr); + $frame['computed'] = $computed; if ($selfClosing || isset(self::$voidTags[$tag])) { $this->recordClosedElement($frame); @@ -497,7 +551,7 @@ private function reopenBlock(array $block, $depth) $arr = []; $i = 0; $outerElement = $this->styledElement; - $this->styledElement = $depth === null ? null : ['path' => array_slice($this->openElements, 0, $depth + 1), 'tag' => null, 'attr' => []]; + $this->styledElement = $depth === null ? null : ['path' => array_slice($this->openElements, 0, $depth + 1), 'tag' => null, 'attr' => [], 'computed' => null]; $this->tag->OpenTag($block['tag'], $block['attr'], $arr, $i); $this->styledElement = $outerElement; } diff --git a/tests/Mpdf/Color/ColorConverterTest.php b/tests/Mpdf/Color/ColorConverterTest.php index a238c884c..95724910d 100644 --- a/tests/Mpdf/Color/ColorConverterTest.php +++ b/tests/Mpdf/Color/ColorConverterTest.php @@ -3,6 +3,7 @@ namespace Mpdf\Color; use Mockery; +use Mpdf\CssMode; use Mpdf\Mpdf; class ColorConverterTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase @@ -197,6 +198,19 @@ public function colorValuesProvider() ]; } + /** + * The legacy CSS mode reads inherit as transparent. The standard mode resolves it before a colour is converted, so + * there it is not a colour at all + */ + public function testInheritIsTransparentOnlyInTheLegacyMode() + { + $this->mpdf->cssMode = CssMode::LEGACY; + $this->assertFalse($this->converter->convert('inherit')); + + $this->mpdf->cssMode = CssMode::STANDARD; + $this->assertSame('', $this->converter->convert('inherit')); + } + public function testConvertUnknownSpotColor() { $this->expectException(\Mpdf\MpdfException::class); diff --git a/tests/Mpdf/Css/NormalizePropertiesTest.php b/tests/Mpdf/Css/NormalizePropertiesTest.php index 0cbb26f6c..afb1ae2a4 100644 --- a/tests/Mpdf/Css/NormalizePropertiesTest.php +++ b/tests/Mpdf/Css/NormalizePropertiesTest.php @@ -246,7 +246,6 @@ public function providerBorderString() ['solid rgb(204 0 0) 0.5mm', '0.5mm solid rgb(204,0,0)'], ['transparent 1px solid', '1px solid transparent'], ['SOLID 2MM #C00', '2mm solid #c00'], - ['inherit', 'medium none currentcolor'], ]; } @@ -465,10 +464,6 @@ public function providerBackground() 'none', ['BACKGROUND-COLOR' => 'transparent', 'BACKGROUND-IMAGE' => ''], ], - 'a keyword on its own' => [ - 'inherit', - ['BACKGROUND-COLOR' => 'transparent', 'BACKGROUND-IMAGE' => ''], - ], 'a -webkit- gradient keeps its prefix for its legacy angles' => [ '-webkit-linear-gradient(left, red, blue)', ['BACKGROUND-COLOR' => 'transparent', 'BACKGROUND-IMAGE' => '-webkit-linear-gradient(left, red, blue)'], @@ -794,6 +789,8 @@ public function declarationsProvider() 'transparent' => ['BACKGROUND-COLOR', 'transparent', true], 'currentColor' => ['BORDER-TOP-COLOR', 'currentColor', true], 'inherit' => ['COLOR', 'inherit', true], + 'revert' => ['COLOR', 'revert', true], + 'revert-layer' => ['BACKGROUND-COLOR', 'revert-layer', true], 'a colour with an inline !important' => ['COLOR', '#00f !important', true], 'a word that is not a colour' => ['COLOR', 'bogus', false], 'none as a background colour' => ['BACKGROUND-COLOR', 'none', false], @@ -822,6 +819,141 @@ public function testNormalizeDropsThePlusSignOfALength() $this->assertSame('1.5', $result['LINE-HEIGHT']); } + /** + * In the standard CSS mode a CSS-wide keyword, in any case, is passed on to a property mPDF reads as it is, for + * CssMerger to resolve. text-outline is read as itself and its two parts + * + * @dataProvider keywordProvider + * + * @param string $property + * @param string $value + * @param string[] $longhands + */ + public function testAKeywordIsPassedOnToEachLonghand($property, $value, array $longhands) + { + $this->assertSame(array_fill_keys($longhands, strtolower($value)), $this->normalizeProperties->normalize([$property => $value])); + } + + /** + * A property that is not a shorthand of the ones fullShorthandProvider() gives, with a CSS-wide keyword, and the + * properties it is passed on to + * + * @return array[] + */ + public function keywordProvider() + { + return [ + 'font-family' => ['FONT-FAMILY', 'inherit', ['FONT-FAMILY']], + 'background-image' => ['BACKGROUND-IMAGE', 'initial', ['BACKGROUND-IMAGE']], + 'text-outline' => ['TEXT-OUTLINE', 'unset', ['TEXT-OUTLINE', 'TEXT-OUTLINE-WIDTH', 'TEXT-OUTLINE-COLOR']], + 'a longhand' => ['COLOR', 'INITIAL', ['COLOR']], + ]; + } + + /** + * In the standard CSS mode a keyword, in any case, is passed on to each longhand a full value of the shorthand + * writes, so the keyword and the value it resolves to set the same properties. A break-* property is read as its + * page-break-* property + * + * @dataProvider fullShorthandProvider + * + * @param string $property + * @param string $value A value that names every part of the shorthand + * @param string $keyword + */ + public function testAKeywordReachesTheLonghandsAValueWrites($property, $value, $keyword) + { + $written = array_fill_keys(array_keys($this->normalizeProperties->normalize([$property => $value])), strtolower($keyword)); + $passedOn = $this->normalizeProperties->normalize([$property => $keyword]); + ksort($written); + ksort($passedOn); + + $this->assertSame($written, $passedOn); + } + + /** + * Each shorthand the keywords are passed on through, with a value that names every part, and a keyword + * + * @return string[][] + */ + public function fullShorthandProvider() + { + return [ + 'font' => ['FONT', 'italic bold small-caps 12pt/1.5 serif', 'initial'], + 'font-variant' => ['FONT-VARIANT', 'normal', 'unset'], + 'margin' => ['MARGIN', '1mm 2mm 3mm 4mm', 'revert'], + 'padding' => ['PADDING', '1mm', 'Inherit'], + 'border' => ['BORDER', '1mm solid red', 'inherit'], + 'border-top' => ['BORDER-TOP', '1mm solid red', 'inherit'], + 'border-right' => ['BORDER-RIGHT', '1mm solid red', 'initial'], + 'border-bottom' => ['BORDER-BOTTOM', '1mm solid red', 'unset'], + 'border-left' => ['BORDER-LEFT', '1mm solid red', 'revert'], + 'border-width' => ['BORDER-WIDTH', '1mm', 'initial'], + 'border-style' => ['BORDER-STYLE', 'solid', 'revert-layer'], + 'border-color' => ['BORDER-COLOR', 'red', 'unset'], + 'border-spacing' => ['BORDER-SPACING', '1mm 2mm', 'inherit'], + 'border-radius' => ['BORDER-RADIUS', '1mm 2mm 3mm 4mm / 1mm', 'initial'], + 'a corner' => ['BORDER-TOP-LEFT-RADIUS', '1mm 2mm', 'inherit'], + 'background' => ['BACKGROUND', 'red url(a.png) no-repeat left top / cover padding-box content-box', 'inherit'], + 'list-style' => ['LIST-STYLE', 'square inside url(a.png)', 'revert-layer'], + 'break-before' => ['BREAK-BEFORE', 'page', 'inherit'], + ]; + } + + /** + * In the standard CSS mode a shorthand with a keyword among other parts is dropped, as a browser drops it, and a + * keyword in an @page rule's size is not passed on + * + * @dataProvider keywordAmongPartsProvider + * + * @param string $property + * @param string $value + */ + public function testAShorthandWithAKeywordAmongItsPartsIsDropped($property, $value) + { + $this->assertSame([], $this->normalizeProperties->normalize([$property => $value])); + } + + /** + * Shorthands with a CSS-wide keyword among other parts, and an @page size that is a keyword + * + * @return string[][] + */ + public function keywordAmongPartsProvider() + { + return [ + 'border' => ['BORDER', 'inherit solid'], + 'margin' => ['MARGIN', '0 inherit'], + 'padding' => ['PADDING', 'initial 2mm'], + 'background' => ['BACKGROUND', 'red unset'], + 'font' => ['FONT', 'revert 12pt serif'], + 'an @page size' => ['SIZE', 'inherit'], + ]; + } + + /** + * A family name that is a keyword in quotes is a family name + */ + public function testAQuotedKeywordIsAFamilyName() + { + $this->assertArrayHasKey('FONT-SIZE', $this->normalizeProperties->normalize(['FONT' => '12pt "inherit", serif'])); + } + + /** + * The legacy CSS mode reads a border or background shorthand with a CSS-wide keyword as its initial value, and + * leaves a keyword out of the properties it cannot read, such as font-family. As it resets no longhand the + * shorthand does not name, background sets only its colour and image + */ + public function testTheLegacyModeReadsAKeywordShorthandAsItsInitialValue() + { + $this->mpdf->cssMode = CssMode::LEGACY; + + $this->assertSame('medium none #000000', $this->normalizeProperties->normalize(['BORDER-TOP' => 'inherit'])['BORDER-TOP']); + $this->assertSame(['BACKGROUND-COLOR' => 'transparent', 'BACKGROUND-IMAGE' => ''], $this->normalizeProperties->normalize(['BACKGROUND' => 'inherit'])); + $this->assertSame([], $this->normalizeProperties->normalize(['FONT-FAMILY' => 'inherit'])); + $this->assertFalse($this->normalizeProperties->canParse('COLOR', 'revert')); + } + /** * font-variant: normal resets font-variant-position with the other longhands */ diff --git a/tests/Mpdf/CssWideKeywordContextTest.php b/tests/Mpdf/CssWideKeywordContextTest.php new file mode 100644 index 000000000..93ca2b0f0 --- /dev/null +++ b/tests/Mpdf/CssWideKeywordContextTest.php @@ -0,0 +1,251 @@ + ['#008000', '#ff0000', '#000000'], + 'font-size' => ['20pt', '8pt', '11pt'], + 'background-color' => ['#00ff00', '#0000ff', 'transparent'], + 'border' => ['1mm solid #00ff00', '0.5mm dashed #0000ff', 'none'], + 'padding' => ['6mm', '2mm', '0'], + ]; + + /** + * The values the built-in defaults give the subjects, by tag and property + */ + const DEFAULTS = [ + 'td' => ['padding' => '0.1em'], + ]; + + /** + * The keyword draws what the value it resolves to draws, in each context. A subject beside it with no keyword + * keeps its tag's rule + * + * @dataProvider keywordsInContexts + * + * @param string $mode + * @param string $context + * @param string $property + * @param string $keyword + * @param string|null $resolved The value the keyword should draw as, or null for as if it were not declared + */ + public function testTheKeywordDrawsAsTheValueItResolvesTo($mode, $context, $property, $keyword, $resolved) + { + $this->assertSame( + $this->drawnPages($this->document($context, $property, $resolved), ['cssMode' => $mode]), + $this->drawnPages($this->document($context, $property, $keyword), ['cssMode' => $mode]) + ); + } + + /** + * Under the standard mode, a keyword draws something other than its tag's rule, so each case above tests the + * keyword and not a property the context never draws + * + * @dataProvider standardKeywordsInContexts + * + * @param string $mode + * @param string $context + * @param string $property + * @param string $keyword + */ + public function testTheKeywordOverridesItsTagsRule($mode, $context, $property, $keyword) + { + $this->assertNotSame( + $this->drawnPages($this->document($context, $property, null), ['cssMode' => $mode]), + $this->drawnPages($this->document($context, $property, $keyword), ['cssMode' => $mode]) + ); + } + + /** + * Each context draws its subject where the context puts it, for every property: in the header, the footer or the + * positioned block, on the second page after a forced page break or once the kept block has been put back and laid + * out again, and in the flow of the first page otherwise + * + * @dataProvider modes + * + * @param string $mode + */ + public function testEachContextDrawsItsSubject($mode) + { + foreach (array_keys($this->contexts()) as $context) { + foreach (array_keys(self::VALUES) as $property) { + $mpdf = $this->drawDocument($this->document($context, $property, 'inherit'), ['cssMode' => $mode]); + $mpdf->OutputBinaryData(); + + $this->assertDrawnInContext($context, $mpdf, ['subject']); + } + } + } + + /** + * @return string[][] Each CSS mode + */ + public function modes() + { + return [CssMode::STANDARD => [CssMode::STANDARD], CssMode::LEGACY => [CssMode::LEGACY]]; + } + + /** + * Every keyword, for each property, in every context, under each mode + * + * @return array[] Each [mode, context, property, keyword, the value it should draw as] + */ + public function keywordsInContexts() + { + $rows = []; + foreach (array_keys($this->contexts()) as $context) { + foreach (array_keys(self::VALUES) as $property) { + // mPDF draws no padding on an inline element + if ($context === 'inline' && $property === 'padding') { + continue; + } + + foreach (['inherit', 'initial', 'unset', 'revert', 'revert-layer'] as $keyword) { + $rows[$context . ', ' . $property . ': ' . $keyword] = [ + $context, + $property, + $keyword, + $this->standardValue($context, $property, $keyword), + $this->legacyValue($property, $keyword), + ]; + } + } + } + + return $this->bothModes($rows); + } + + /** + * The standard cases of keywordsInContexts() + * + * @return array[] + */ + public function standardKeywordsInContexts() + { + return array_filter($this->keywordsInContexts(), function ($case) { + return $case[0] === CssMode::STANDARD; + }); + } + + /** + * What a keyword resolves to under the standard mode: inherit takes the ancestor's value, which the cases set on + * the subject's parent, and on the table as well as the row for a cell; initial the initial value; unset the one or + * the other as the property is inherited or not; and revert the built-in default, or unset where there is none + * + * @param string $context + * @param string $property + * @param string $keyword + * + * @return string + */ + private function standardValue($context, $property, $keyword) + { + list($ancestor, , $initial) = self::VALUES[$property]; + $inherited = in_array($property, ['color', 'font-size'], true); + $tag = $this->contexts()[$context][0]; + + $defaults = self::DEFAULTS; + if ($keyword === 'revert' || $keyword === 'revert-layer') { + if (isset($defaults[$tag][$property])) { + return $defaults[$tag][$property]; + } + $keyword = 'unset'; + } + + if ($keyword === 'unset') { + $keyword = $inherited ? 'inherit' : 'initial'; + } + + return $keyword === 'inherit' ? $ancestor : $initial; + } + + /** + * What a keyword draws as under the legacy mode, as measured: a colour keyword mPDF reads sets no colour, so the + * text keeps the colour in force, as currentColor does, which the legacy mode ignores in color; one it does not + * read (revert) is dropped. A background colour drawn as none covers a row's, as currentColor does, where + * transparent lets it show through. A font size or padding keyword sets none. A border keyword draws + * no border, except revert-layer, which is dropped + * + * @param string $property + * @param string $keyword + * + * @return string|null + */ + private function legacyValue($property, $keyword) + { + $dropped = ['color' => ['revert', 'revert-layer'], 'background-color' => ['revert', 'revert-layer'], 'border' => ['revert-layer']]; + if (isset($dropped[$property]) && in_array($keyword, $dropped[$property], true)) { + return null; + } + + $values = ['color' => 'currentcolor', 'background-color' => 'currentcolor', 'font-size' => 'auto', 'border' => 'none', 'padding' => '0']; + + return $values[$property]; + } + + /** + * The contexts the subjects are written in. {S} stands for the subjects, one with the class k and one without, + * and the class a marks the ancestor the value is inherited from: for a cell, both its row and its table + * + * @return array[] Each [the subjects' tag, the document] + */ + private function contexts() + { + return [ + 'block' => ['p', '
{S}
'], + 'inline' => ['em', '

text {S}

'], + 'table cell' => ['td', '{S}
'], + 'nested table cell' => ['td', '
outer{S}
'], + 'list item' => ['li', '
    {S}
'], + 'header' => ['p', '
{S}

body

'], + 'footer' => ['p', '
{S}

body

'], + 'positioned block' => ['p', '
{S}
'], + 'kept block' => ['p', str_repeat('

filler

', 27) . '
kept
{S}
'], + 'forced page break' => ['p', '
before
{S}
'], + ]; + } + + /** + * A document in a context whose subject with the class k takes a value, or a keyword, over the rule for its tag + * + * @param string $context + * @param string $property + * @param string|null $value Null for no declaration + * + * @return string + */ + private function document($context, $property, $value) + { + list($tag, $html) = $this->contexts()[$context]; + list($ancestor, $rule) = self::VALUES[$property]; + + $css = '.a { ' . $property . ': ' . $ancestor . '; } .a ' . $tag . ' { ' . $property . ': ' . $rule . '; }'; + if ($value !== null) { + $css .= ' .a .k { ' . $property . ': ' . $value . '; }'; + } + $subjects = '<' . $tag . ' class="k">subject<' . $tag . '>near miss'; + + return '' . str_replace('{S}', $subjects, $html); + } +} diff --git a/tests/Mpdf/CssWideKeywordTest.php b/tests/Mpdf/CssWideKeywordTest.php new file mode 100644 index 000000000..80acafe9f --- /dev/null +++ b/tests/Mpdf/CssWideKeywordTest.php @@ -0,0 +1,421 @@ +drawDocument('' . $html, ['cssMode' => $mode]); + + $this->assertSame($expected, [$mpdf->drawnColours[0], round($mpdf->drawnFontSize[0], 6), $mpdf->drawnFontStyles[0]]); + } + + /** + * The cases of GravityPDF/mpdf#546, with what the standard mode draws, as a browser does, and what the legacy mode + * draws, as measured + * + * @return array[] + */ + public function measuredCases() + { + $block = '

text

'; + $rows = [ + 'color: inherit' => ['.a { color: #0f0; } p { color: red; } p.b { color: inherit; }', $block, [self::LIME, 11.0, ''], [self::DEFAULT_BLACK, 11.0, '']], + 'color: unset' => ['.a { color: #0f0; } p { color: red; } p.b { color: unset; }', $block, [self::LIME, 11.0, ''], [self::DEFAULT_BLACK, 11.0, '']], + 'color: revert' => ['.a { color: #0f0; } p { color: red; } p.b { color: revert; }', $block, [self::LIME, 11.0, ''], [self::RED, 11.0, '']], + 'font-size: inherit' => ['.a { font-size: 20pt; } p { font-size: 8pt; } p.b { font-size: inherit; }', $block, [self::DEFAULT_BLACK, 20.0, ''], [self::DEFAULT_BLACK, 11.0, '']], + 'font-weight: inherit' => ['.a { font-weight: bold; } p { font-weight: normal; } p.b { font-weight: inherit; }', $block, [self::DEFAULT_BLACK, 11.0, 'B'], [self::DEFAULT_BLACK, 11.0, '']], + 'color: inherit on a cell' => [ + 'table { color: #0f0; } td { color: red; } td.b { color: inherit; }', + '
text
', + [self::LIME, 11.0, ''], + [self::DEFAULT_BLACK, 11.0, ''], + ], + ]; + + return $this->bothModes($rows); + } + + /** + * border: inherit on a paragraph in a bordered block draws a second border in the standard mode, and none in the + * legacy mode + * + * @dataProvider modes + * + * @param string $mode + * @param int $sides How many border sides are drawn in red + */ + public function testBorderInheritDrawsTheParentsBorder($mode, $sides) + { + $pages = $this->drawnPages('

text

', ['cssMode' => $mode]); + + $this->assertSame($sides, substr_count($pages[0][0], '1.000 0.000 0.000 RG')); + } + + /** + * The initial colour of a border is currentColor, so in the standard mode initial draws a side in the element's + * colour + */ + public function testTheInitialBorderColourIsTheElementsColour() + { + $css = '.a { border: 1mm solid #00ff00; } .a p { color: #ff0000; border: 1mm solid #0000ff; } .a .k { {V} }'; + $html = '

subject

near miss

'; + + $this->assertSame( + $this->drawnPages('' . $html), + $this->drawnPages('' . $html) + ); + } + + /** + * @return array[] Each CSS mode, with how many red border sides the case above draws in it + */ + public function modes() + { + return [CssMode::STANDARD => [CssMode::STANDARD, 8], CssMode::LEGACY => [CssMode::LEGACY, 4]]; + } + + /** + * The keyword draws as the declarations it resolves to + * + * @dataProvider shorthands + * @dataProvider cellsRowsAndTables + * @dataProvider builtInDefaults + * @dataProvider borderParts + * @dataProvider chains + * + * @param string $mode + * @param string $css With {V} for the subject's declarations + * @param string $html + * @param string $declarations The subject's declarations, with the keyword + * @param string $resolved What they should draw as + */ + public function testTheKeywordDrawsAsWhatItResolvesTo($mode, $css, $html, $declarations, $resolved) + { + $this->assertSame( + $this->drawnPages('' . $html, ['cssMode' => $mode]), + $this->drawnPages('' . $html, ['cssMode' => $mode]) + ); + } + + /** + * Each shorthand with each keyword. In the standard mode the keyword reaches each of its longhands. In the legacy + * mode a border or background shorthand draws as its initial value, except with revert-layer, which it drops, and + * font and list-style drop every keyword. border-color reads inherit as transparent and the other keywords as a + * colour it cannot read. A legacy column given as one value holds for every keyword + * + * @return array[] + */ + public function shorthands() + { + $block = '

subject

near miss

'; + $list = '
  • subject
  • near miss
'; + + $shorthands = [ + 'font' => [ + '.a { font: italic bold 20pt courier; } .a p { font: 8pt helvetica; } .a .k { {V} }', + $block, + ['inherit' => 'font: italic bold 20pt courier', 'initial' => 'font: 11pt ctimes', 'unset' => 'font: italic bold 20pt courier', 'revert' => 'font: italic bold 20pt courier'], + '', + ], + 'background' => [ + '.a { background: #00ff00; } .a p { background: #0000ff; } .a .k { {V} }', + $block, + ['inherit' => 'background: #00ff00', 'initial' => 'background: none', 'unset' => 'background: none', 'revert' => 'background: none'], + ['inherit' => 'background: none', 'initial' => 'background: none', 'unset' => 'background: none', 'revert' => 'background: none', 'revert-layer' => ''], + ], + 'margin' => [ + '.a { margin: 7mm; } .a p { margin: 2mm; } .a .k { {V} }', + $block, + ['inherit' => 'margin: 7mm', 'initial' => 'margin: 0', 'unset' => 'margin: 0', 'revert' => 'margin: 1.12em 0'], + 'margin: 0', + ], + 'border-width' => [ + '.a { border: 2mm solid #00ff00; } .a p { border: 0.5mm solid #0000ff; } .a .k { {V} }', + $block, + ['inherit' => 'border-width: 2mm', 'initial' => 'border-width: medium', 'unset' => 'border-width: medium', 'revert' => 'border-width: medium'], + 'border-width: 0', + ], + 'border-style' => [ + '.a { border: 1mm dotted #00ff00; } .a p { border: 1mm dashed #0000ff; } .a .k { {V} }', + $block, + ['inherit' => 'border-style: dotted', 'initial' => 'border-style: none', 'unset' => 'border-style: none', 'revert' => 'border-style: none'], + 'border-style: none', + ], + 'border-color' => [ + '.a { border: 1mm solid #00ff00; } .a p { border: 1mm solid #0000ff; } .a .k { {V} }', + $block, + ['inherit' => 'border-color: #00ff00', 'initial' => 'border-color: #000000', 'unset' => 'border-color: #000000', 'revert' => 'border-color: #000000'], + ['inherit' => 'border-color: transparent', 'initial' => 'border-color: unreadable', 'unset' => 'border-color: unreadable', 'revert' => 'border-color: unreadable', 'revert-layer' => 'border-color: unreadable'], + ], + 'list-style' => [ + '.a { list-style: square inside; } .a li { list-style: circle outside; } .a .k { {V} }', + $list, + ['inherit' => 'list-style: square inside', 'initial' => 'list-style: disc outside', 'unset' => 'list-style: square inside', 'revert' => 'list-style: square inside'], + '', + ], + ]; + + $rows = []; + foreach ($shorthands as $shorthand => $case) { + list($css, $html, $standard, $legacy) = $case; + $standard['revert-layer'] = $standard['revert']; + foreach (['inherit', 'initial', 'unset', 'revert', 'revert-layer'] as $keyword) { + $declaration = $shorthand . ': ' . $keyword; + $rows[$declaration] = [$css, $html, $declaration, $standard[$keyword], is_array($legacy) ? $legacy[$keyword] : $legacy]; + } + } + + return $this->bothModes($rows); + } + + /** + * inherit on a cell takes a property that is not inherited from its row, not its table, and an inherited one + * from its row where the row sets it, and otherwise from its table. A row takes it from its row group, or from + * none where the HTML leaves the tbody out + * + * @return array[] + */ + public function cellsRowsAndTables() + { + $row = '
subjectnear miss
'; + $rows = '
subject
near miss
'; + $implied = '
subject
near miss
'; + + return $this->bothModes([ + 'a cell\'s background from its row' => [ + '.t { background-color: #ffff00; } .r { background-color: #00ff00; } td { background-color: #0000ff; } td.k { {V} }', + $row, + 'background-color: inherit', + 'background-color: #00ff00', + 'background-color: currentcolor', + ], + 'a cell\'s border from its row, which has none' => [ + '.t { border: 1mm solid #00ff00; } td { border: 0.5mm dashed #0000ff; } td.k { {V} }', + $row, + 'border: inherit', + 'border: none', + 'border: none', + ], + 'a cell\'s padding from its row, which has none' => [ + '.t { padding: 5mm; } td { padding: 2mm; } td.k { {V} }', + $row, + 'padding: inherit', + 'padding: 0', + 'padding: 0', + ], + 'a cell\'s colour from its table' => [ + '.t { color: #008000; } td { color: #ff0000; } td.k { {V} }', + $row, + 'color: inherit', + 'color: #008000', + 'color: currentcolor', + ], + 'a cell\'s colour from its row, over its table\'s' => [ + '.t { color: #0000ff; } .r { color: #008000; } td { color: #ff0000; } td.k { {V} }', + $row, + 'color: inherit', + 'color: #008000', + 'color: currentcolor', + ], + 'a cell\'s font size from its table' => [ + '.t { font-size: 16pt; } td { font-size: 8pt; } td.k { {V} }', + $row, + 'font-size: inherit', + 'font-size: 16pt', + 'font-size: auto', + ], + 'a row\'s background from its row group' => [ + '.t { background-color: #ffff00; } .g { background-color: #00ff00; } tr { background-color: #0000ff; } tr.k { {V} }', + $rows, + 'background-color: inherit', + 'background-color: #00ff00', + 'background-color: currentcolor', + ], + 'a row\'s background in a tbody the HTML leaves out' => [ + '.t { background-color: #ffff00; } tr { background-color: #0000ff; } tr.k { {V} }', + $implied, + 'background-color: inherit', + 'background-color: transparent', + 'background-color: currentcolor', + ], + ]); + } + + /** + * revert gives an element the value the built-in defaults and the default stylesheet give it, over any author + * rule, and a presentational attribute counts as an author rule. With none, it is unset + * + * @return array[] + */ + public function builtInDefaults() + { + return $this->bothModes([ + 'a list\'s margin' => [ + 'ul { margin: 5mm; } ul.k { {V} }', + '
  • subject
  • near miss
', + 'margin: revert', + 'margin: 0.83em 0', + 'margin: 0', + ], + 'a nested list\'s margin, from the default stylesheet' => [ + 'ul { margin: 5mm; } ul.k { {V} }', + '
  • outer
    • subject
', + 'margin: revert', + 'margin: 0', + 'margin: 0', + ], + 'a heading\'s size' => [ + 'h1 { font-size: 9pt; } h1.k { {V} }', + '

subject

near miss

', + 'font-size: revert', + 'font-size: 2em', + 'font-size: auto', + ], + 'a header cell\'s weight' => [ + 'th { font-weight: normal; } th.k { {V} }', + '
subjectnear miss
', + 'font-weight: revert', + 'font-weight: bold', + 'font-weight: normal', + ], + 'a cell\'s padding, over its table\'s cellpadding' => [ + 'td { padding: 5mm; } td.k { {V} }', + '
subjectnear miss
', + 'padding: revert', + 'padding: 0.1em', + 'padding: 0', + ], + 'a paragraph\'s colour, which has no default' => [ + 'div { color: #008000; } p { color: #ff0000; } p.k { {V} }', + '

subject

near miss

', + 'color: revert', + 'color: #008000', + '', + ], + ]); + } + + /** + * A keyword in a border side's width, style or colour resolves that part of the side, and a part declared after a + * border keyword still applies over it + * + * @return array[] + */ + public function borderParts() + { + $block = '

subject

near miss

'; + $css = '.a { border: 1mm solid #00ff00; } .a p { border: 0.5mm dashed #0000ff; } .a .k { {V} }'; + + return $this->bothModes([ + 'one side\'s colour' => [$css, $block, 'border-top-color: inherit', 'border-top-color: #00ff00', 'border-top-color: transparent'], + 'one side\'s width' => [$css, $block, 'border-left-width: inherit', 'border-left-width: 1mm', 'border-left-width: 0'], + 'one side\'s style' => [$css, $block, 'border-bottom-style: initial', 'border-bottom-style: none', 'border-bottom-style: none'], + 'one side' => [$css, $block, 'border-right: initial', 'border-right: none', 'border-right: none'], + 'a part after the keyword' => [ + $css, + $block, + 'border: inherit; border-top-color: #ff0000', + 'border: 1mm solid #00ff00; border-top-color: #ff0000', + 'border: none; border-top-color: #ff0000', + ], + ]); + } + + /** + * inherit reaches through an element that inherits the value itself, and at the top of the document takes the + * value body has, and on body the value html has. A block takes an inherited value mPDF does not hand it from its + * parent + * + * @return array[] + */ + public function chains() + { + return $this->bothModes([ + 'a background through a block' => [ + '.a { background-color: #00ff00; } .a div { background-color: inherit; } .a p { background-color: #0000ff; } .a .k { {V} }', + '

subject

near miss

', + 'background-color: inherit', + 'background-color: #00ff00', + 'background-color: currentcolor', + ], + 'a colour from body' => [ + 'body { color: #008000; } p { color: #ff0000; } p.k { {V} }', + '

subject

near miss

', + 'color: inherit', + 'color: #008000', + 'color: currentcolor', + ], + 'a background from body' => [ + 'body { background-color: #ffff00; } p { background-color: #0000ff; } p.k { {V} }', + '

subject

near miss

', + 'background-color: inherit', + 'background-color: #ffff00', + 'background-color: currentcolor', + ], + 'a nested list\'s markers, through the item it is in' => [ + '.a { list-style: square; } .a ul { {V} }', + '
  • outer
    • subject
', + 'list-style: inherit', + 'list-style: square', + '', + ], + 'a text shadow, which mPDF does not pass from block to block' => [ + '.a { text-shadow: 0.5mm 0.5mm #ff0000; } .a p { {V} }', + '

subject

', + 'text-shadow: inherit', + 'text-shadow: 0.5mm 0.5mm #ff0000', + '', + ], + 'a colour from html, on body' => [ + 'html { color: #008000; } body { color: #ff0000; } body { {V} }', + '

subject

', + 'color: inherit', + 'color: #008000', + 'color: currentcolor', + ], + 'a font size from html, on body' => [ + 'html { font-size: 14pt; } body { font-size: 8pt; } body { {V} }', + '

subject

', + 'font-size: unset', + 'font-size: 14pt', + 'font-size: auto', + ], + 'a font size from body' => [ + 'body { font-size: 14pt; } p { font-size: 8pt; } p.k { {V} }', + '

subject

near miss

', + 'font-size: inherit', + 'font-size: 14pt', + 'font-size: auto', + ], + ]); + } +} diff --git a/tests/Mpdf/DrawnStyles.php b/tests/Mpdf/DrawnStyles.php index 608975795..b7e1c0503 100644 --- a/tests/Mpdf/DrawnStyles.php +++ b/tests/Mpdf/DrawnStyles.php @@ -114,6 +114,59 @@ private function assertDrawnInContext($context, TextRecordingMpdf $mpdf, array $ } } + /** + * Everything a document draws, for comparing two documents that should draw the same: the content stream of + * each page, and each piece of text with its colour, font size, font style and box. The sizes and boxes are + * rounded, as a table keeps its font size in millimetres, which comes back a few units in the last place off + * + * @param string $html + * @param array $config Merged over the core-font mode + * + * @return array + */ + private function drawnPages($html, array $config = []) + { + $mpdf = $this->drawDocument($html, $config); + $mpdf->SetCompression(false); + + preg_match_all('/\d+ 0 obj\s*<<\/Length \d+>>\s*stream\n(.*?)\nendstream/s', $mpdf->Output('', 'S'), $streams); + + $round = function ($number) { + return round($number, 6); + }; + + return [ + $streams[1], + $mpdf->drawnText, + $mpdf->drawnColours, + array_map($round, $mpdf->drawnFontSize), + $mpdf->drawnFontStyles, + array_map(function ($box) use ($round) { + return array_map($round, $box); + }, $mpdf->drawnBoxes), + ]; + } + + /** + * Splits cases into one for each CSS mode + * + * @param array[] $rows Each case's arguments, ending with what it expects in the standard mode and in the legacy mode + * + * @return array[] Each case's arguments, led by the mode and ending with what it expects in that mode + */ + private function bothModes(array $rows) + { + $cases = []; + foreach ($rows as $name => $row) { + $legacy = array_pop($row); + $standard = array_pop($row); + $cases[$name . ' (standard)'] = array_merge([CssMode::STANDARD], $row, [$standard]); + $cases[$name . ' (legacy)'] = array_merge([CssMode::LEGACY], $row, [$legacy]); + } + + return $cases; + } + /** * 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/OpenElementStackTest.php b/tests/Mpdf/OpenElementStackTest.php index 81c38adf5..a0742fe8a 100644 --- a/tests/Mpdf/OpenElementStackTest.php +++ b/tests/Mpdf/OpenElementStackTest.php @@ -172,7 +172,6 @@ public function testAFrameDescribesItsElement() $this->assertSame(['A', 'B'], $div['classes']); $this->assertSame('de', $div['lang']); $this->assertSame('Box', $div['attr']['TITLE']); - $this->assertNull($div['computed']); $p = $this->frameAt($mpdf, 'text', 2); $this->assertSame('de', $p['lang']); @@ -180,6 +179,31 @@ public function testAFrameDescribesItsElement() $this->assertSame(['C'], $p['classes']); } + /** + * In the standard CSS mode a frame carries the properties merged for its element, with the CSS-wide keywords + * resolved, and the document's frame carries those of body. A tbody the HTML leaves out is never styled. The + * legacy mode keeps none + */ + public function testAFrameCarriesThePropertiesMergedForItsElement() + { + $html = '' + . '

text

cell
'; + + $mpdf = $this->write($html); + $frames = $this->framesAt($mpdf, 'text'); + $this->assertSame('#ff0', $frames[0]['computed']['BACKGROUND-COLOR']); + $this->assertSame('2mm', $frames[1]['computed']['PADDING-LEFT']); + $this->assertSame('2mm', $frames[2]['computed']['PADDING-LEFT']); + $this->assertNull($this->frameAt($mpdf, 'cell', 3)['computed']); + $this->assertSame('TBODY', $this->frameAt($mpdf, 'cell', 3)['tag']); + + $mpdf = $this->mpdfRecording(['cssMode' => CssMode::LEGACY]); + $mpdf->WriteHTML($html, HTMLParserMode::DEFAULT_MODE, true, false); + foreach ($this->framesAt($mpdf, 'text') as $frame) { + $this->assertNull($frame['computed']); + } + } + /** * Tables nested in cells keep their own rows and cells, and the outer cell is back on top after them */ diff --git a/tests/Snapshots/CssWideKeywordsInvoiceLegacySnapshotTest.php b/tests/Snapshots/CssWideKeywordsInvoiceLegacySnapshotTest.php new file mode 100644 index 000000000..19bb3adeb --- /dev/null +++ b/tests/Snapshots/CssWideKeywordsInvoiceLegacySnapshotTest.php @@ -0,0 +1,21 @@ +cssMode(); + } + + /** + * The invoice, each part under a caption saying what the standard and the legacy mode draw + */ + public function generatePdf() + { + ob_start(); + ?> + + +

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

+ +

The heading: after a reset makes every h1 small, .invoice h1 reverts its size and margin. Standard: the title is large, with its built-in space below it. Legacy: it is small, and set right against the text below it.

+

The notice: its link takes the notice's red, bold text and drops the underline, and the reference inside it takes the notice's border. In both modes "pay online" is red, bold and not underlined: the legacy mode ignores a keyword on an inline element, which keeps the state of the text around it. Standard: "INV-0042" has a solid red border like the notice's. Legacy: it has no border.

+

The sender: strong is color: initial. Standard: "Blue Liquid Designs" is black. Legacy: it is dark blue like the text around it.

+
+

Invoice INV-0042

+

From Blue Liquid Designs, 1 Example Street.

+

Payment is due in 14 days: pay online quoting INV-0042.

+
+ +

The lines: the notes cell is color: unset, the total row's cells take the row's background, and the terms table nested in a cell reverts its cells' padding. Standard: the note is green, the table's colour; the total row is pale blue; the terms cells have thin padding. Legacy: the note is black; the total row's cells have no background at all, so the table's white shows; the terms cells have no padding.

+ + + + + +
ItemNoteAmount
DesignTwo rounds of changes1,200.00
Hosting +
12 monthsrenews in March
+
300.00
Total1,500.00
+ +

The steps: a nested list takes its parent list's square markers, and the current step takes the list's dark blue over the grey of the items. Standard: every marker is a square, and "Approve the design" is dark blue. Legacy: the nested items have the default markers of a nested list, and "Approve the design" is black.

+
    +
  • Send the brief
  • +
  • Approve the design +
    • Colours
    • Type
    +
  • +
  • Launch
  • +
+ mpdf = $this->createMpdf(['cssMode' => $this->cssMode()]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/CssWideKeywordsInvoiceStandardSnapshotTest.php b/tests/Snapshots/CssWideKeywordsInvoiceStandardSnapshotTest.php new file mode 100644 index 000000000..4970b78e2 --- /dev/null +++ b/tests/Snapshots/CssWideKeywordsInvoiceStandardSnapshotTest.php @@ -0,0 +1,21 @@ + + + +

The CSS-wide keywords

+ +

color: inherit, unset and revert on a paragraph in a green box: all three lines are green.

+

inherit

unset

revert, with no built-in colour for a paragraph

+ +

color: initial on an emphasis in a green box: "initial" is black.

+
Green text initial and green again
+ +

font-size and font-weight: inherit: the line is as large and bold as the box's text.

+

inherit

+ +

font: initial: the line is in the document's default font and size, 9pt, and not bold. It stays red, as font does not set the colour.

+

font: initial

+ +

border: inherit: the line has the box's solid green border, not a dashed red one.

+

border: inherit

+ +

border-top-color: inherit: the line's dashed border is green along the top and red on the other sides.

+

border-top-color: inherit

+ +

padding: inherit: the line's text sits 1.5mm inside its dashed red border.

+

padding: inherit

+ +

background: inherit: the line's background is the box's pale green, not white.

+

background: inherit

+ +

border and background-color: unset, which are not inherited, so take their initial values: no border, and the box's pale green shows through.

+

unset

+ +

list-style: inherit on the items of a list with square markers inside: both items have square markers inside the box.

+
  • first item
  • second item
+ + + +

margin: revert on the second list, after a rule that sets every list's margin to 0: the second list has the built-in space above and below it, the first none.

+
  • A list with no margin
+
  • A list with its built-in margin
+

Text after the lists.

+ +

font-size: revert on the second heading, after a rule that makes headings small: the second heading is its built-in size.

+

A small heading

+

A heading at its built-in size

+ +

A table with green text on pale yellow, whose row is pale green. The first cell's colour is inherited from the table: green. The second cell's background is inherited from its row: pale green, not the table's yellow. The third cell's padding reverts to the thin built-in padding. The header cell's weight reverts to bold.

+ + + + + +
color: inheritbackground-color: inheritpadding: revertfont-weight: revert
+ mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/snapshots/css-wide-keywords-invoice-legacy.pdf b/tests/data/snapshots/css-wide-keywords-invoice-legacy.pdf new file mode 100644 index 000000000..db8aa3cff Binary files /dev/null and b/tests/data/snapshots/css-wide-keywords-invoice-legacy.pdf differ diff --git a/tests/data/snapshots/css-wide-keywords-invoice-standard.pdf b/tests/data/snapshots/css-wide-keywords-invoice-standard.pdf new file mode 100644 index 000000000..143828648 Binary files /dev/null and b/tests/data/snapshots/css-wide-keywords-invoice-standard.pdf differ diff --git a/tests/data/snapshots/css-wide-keywords.pdf b/tests/data/snapshots/css-wide-keywords.pdf new file mode 100644 index 000000000..c61572606 Binary files /dev/null and b/tests/data/snapshots/css-wide-keywords.pdf differ