diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index 3efcd2e31..4872ebc11 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -54,6 +54,8 @@ Read this section before upgrading from upstream mPDF. Each entry says what chan * A table's `border` gives each cell a 1px border of its own, which a rule for the table's border leaves alone. Before, the cells copied the table's border. * **`line-height` on an inline element sets the height of its line**, as in a browser. mPDF ignored it, so `` left its line as it was. Now each inline element's box is as tall as its line-height, and a line grows to hold the tallest box on it, text or image, in blocks and table cells alike. The block's own line height is the least a line can be, so a smaller inline line-height, or `0`, does not shrink it. A percentage in `vertical-align` is taken of the element's own line-height. `line-height` on a `

` or `

` inside a table cell, which mPDF lays out as inline content, can now make its lines taller than the cell's. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to ignore it as before. [#548] [#646] * **`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. New features ------------ @@ -206,6 +208,12 @@ Bugfixes * With several layers, mPDF still draws only the first, now over the colour given in the last. * A `border` or `background` shorthand with a part that is none of its parts, such as `border: 1px solid bogus`, is dropped, as a browser drops it, so the value it would have replaced still applies. `border: 1px solid bogus` used to draw a black border. [#552] [#583] * `tr:nth-child()` counted the rows of the whole table less its header and footer rows, so a second `` carried on the count of the first, and `tr:first-child` missed the first footer row when `` came after the body. Rows are now counted within their ``, `` or ``, or within the run of rows written straight into the table. [#528] [#582] +* `currentColor` in a border raised two "Uninitialized string offset" warnings for each side and drew it black, and in `text-outline` it raised an undefined-key warning and drew black. Both are now drawn in the element's colour, set or inherited. [#540] [#651] +* Colours that convert to nothing were drawn black or grey instead of drawing nothing. [#552] [#540] [#651] + * A `transparent` side of a border, as in Bootstrap's `border: 1px solid transparent`, was drawn black, with warnings on a block. It now takes its width and draws nothing, on blocks, inline elements, table cells and images. + * `border-style: hidden` on a block or an image drew a black hairline, with warnings. + * `color: transparent` drew black text. The text is now laid out and written in the invisible text rendering mode, so it takes its place and can be selected but is not seen. Its `text-shadow` is still drawn. + * A `box-shadow` or `text-shadow` in `transparent` was grey. It is no longer drawn. * `font: small-caps 14pt serif` drew the text in full-size capitals, as `text-transform: uppercase` does. It is now drawn in small capitals, as `font-variant: small-caps` draws it. The `font` shorthand also kept a `line-height` and a `font-variant` it inherited; like the style and weight, in standard mode they are now reset to `normal` when the shorthand does not name them, as in a browser. To keep a line height, give it in the shorthand, as in `font: 14pt/1.5 serif`, or declare `line-height` after it. A `font` shorthand with a negative line height, such as `font: 12pt/-1 serif`, is dropped whole. `font-variant: normal` now resets `font-variant-position` as well. [#552] [#581] * A percentage `width`, `min-width` or `max-width` on an image in a table cell was resolved against the block around the table, not the cell. In a 60mm cell, `max-width: 20%` came out as 36mm. [#223] [#505] * An `@page :left` or `@page :right` rule was ignored unless the style sheet also had a plain `@page` rule. With one, the margins of a `:right` rule were applied to left pages too. [#555] [#556] @@ -830,6 +838,7 @@ These changes do not change output. [#538]: https://github.com/GravityPDF/mpdf/issues/538 [#545]: https://github.com/GravityPDF/mpdf/issues/545 [#539]: https://github.com/GravityPDF/mpdf/issues/539 +[#540]: https://github.com/GravityPDF/mpdf/issues/540 [#548]: https://github.com/GravityPDF/mpdf/issues/548 [#549]: https://github.com/GravityPDF/mpdf/pull/549 [#552]: https://github.com/GravityPDF/mpdf/issues/552 @@ -889,3 +898,4 @@ These changes do not change output. [#644]: https://github.com/GravityPDF/mpdf/pull/644 [#647]: https://github.com/GravityPDF/mpdf/pull/647 [#648]: https://github.com/GravityPDF/mpdf/pull/648 +[#651]: https://github.com/GravityPDF/mpdf/pull/651 diff --git a/src/Css/Border.php b/src/Css/Border.php index fbc908d4f..bb4c2ba38 100644 --- a/src/Css/Border.php +++ b/src/Css/Border.php @@ -10,4 +10,18 @@ class Border const RIGHT = 4; const BOTTOM = 2; const LEFT = 1; + + /** + * Whether a side of a border, as Mpdf::border_details() reads it, draws anything. A hidden side has no width, and a + * transparent one has false for its colour, which a side packed with a table cell keeps as NUL bytes. A colour + * mPDF cannot read is drawn black, as it was + * + * @param array|null $side + * + * @return bool + */ + public static function drawsSide($side) + { + return !empty($side['s']) && !empty($side['w']) && isset($side['c']) && $side['c'] !== false && strpos($side['c'], "\0") !== 0; + } } diff --git a/src/Css/BorderMerger.php b/src/Css/BorderMerger.php index 28bf7d3d6..9c16c3911 100644 --- a/src/Css/BorderMerger.php +++ b/src/Css/BorderMerger.php @@ -2,6 +2,7 @@ namespace Mpdf\Css; +use Mpdf\CssMode; use Mpdf\Exception\InvalidArgumentException; class BorderMerger @@ -26,17 +27,30 @@ class BorderMerger 'L' => 0, ]; + /** + * The colour of a border that names none: currentColor in standard mode, and black in legacy mode + * + * @param string $cssMode One of the Mpdf\CssMode values + * @return string + */ + public static function initialColor($cssMode) + { + return $cssMode === CssMode::STANDARD ? 'currentcolor' : self::DEFAULTS['COLOR']; + } + /** * Merge borders into CSS properties. * * @param array $newProperties properties to merge from * @param array $cssProperties current CSS properties (passed by reference) + * @param string|null $initialColor The colour a side's shorthand is built with when no longhand gives one, in + * place of the default * @return void */ - public function mergeBorderProperties($newProperties, &$cssProperties) + public function mergeBorderProperties($newProperties, &$cssProperties, $initialColor = null) { foreach (['TOP', 'RIGHT', 'BOTTOM', 'LEFT'] as $side) { - $this->mergeSideBorder($side, $newProperties, $cssProperties); + $this->mergeSideBorder($side, $newProperties, $cssProperties, $initialColor); } } @@ -49,9 +63,11 @@ public function mergeBorderProperties($newProperties, &$cssProperties) * @param string $side Side to merge (TOP, RIGHT, BOTTOM, LEFT) * @param array $properties Source border properties * @param array $cssProperties Target CSS properties (passed by reference) + * @param string|null $initialColor The colour to build the shorthand with when no longhand gives one, in place of + * the default * @return void */ - protected function mergeSideBorder($side, $properties, &$cssProperties) + protected function mergeSideBorder($side, $properties, &$cssProperties, $initialColor = null) { // Merges $a['BORDER-TOP-STYLE'] to $cssProperties['BORDER-TOP'] etc. $borderKey = 'BORDER-' . $side; @@ -79,6 +95,9 @@ protected function mergeSideBorder($side, $properties, &$cssProperties) // Build new border from scratch with defaults if (!isset($borderParts)) { $borderParts = self::DEFAULTS; + if ($initialColor !== null) { + $borderParts['COLOR'] = $initialColor; + } } $borderParts[$el] = $value; diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 448db9b71..87fc3dd3d 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -13,6 +13,34 @@ class CssMerger { + /** + * The properties that resolve currentColor in both modes. Legacy mode raised warnings for it in each + */ + const CURRENT_COLOR_PROPERTIES = [ + 'BORDER-TOP', + 'BORDER-RIGHT', + 'BORDER-BOTTOM', + 'BORDER-LEFT', + 'BORDER-TOP-COLOR', + 'BORDER-RIGHT-COLOR', + 'BORDER-BOTTOM-COLOR', + 'BORDER-LEFT-COLOR', + 'TEXT-OUTLINE-COLOR', + ]; + + /** + * The other properties mPDF reads a colour from that can name currentColor, which legacy mode ignores in them. A + * background image names it in the stops of a gradient + */ + const STANDARD_CURRENT_COLOR_PROPERTIES = [ + 'BACKGROUND-COLOR', + 'BACKGROUND-IMAGE', + 'BOX-SHADOW', + 'TEXT-SHADOW', + 'TOPNTAIL', + 'THEAD-UNDERLINE', + ]; + /** * @var \Mpdf\Mpdf */ @@ -168,6 +196,7 @@ public function merge($inherit, $tag, $attr) $this->mergeTagSpecificSelectors($tag, $attr, $classes, $languageCode); $this->mergeDescendantSelectors($inherit, $tag, $attr, $classes, $languageCode); $this->mergeInlineStyle($tag, $attr); + $this->resolveCurrentColor($inherit); return $this->cssProperties; } @@ -233,10 +262,104 @@ 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); + $this->resolveCurrentColor($inherit); return $this->cssProperties; } + /** + * Replaces currentColor in the merged properties with the element's colour, which is its own color, or the one it + * inherits. In standard mode, color: currentColor is the colour the element inherits, and a shadow that names no + * colour takes currentColor, as a border does. Legacy mode resolves it only where it raised warnings + * + * @param string $inherit Inheritance context (BLOCK, INLINE, TABLE, TOPTABLE, or empty) + * @return void + */ + private function resolveCurrentColor($inherit) + { + $properties = self::CURRENT_COLOR_PROPERTIES; + + if ($this->mpdf->cssMode === CssMode::STANDARD) { + $properties = array_merge($properties, self::STANDARD_CURRENT_COLOR_PROPERTIES); + + foreach (['BOX-SHADOW', 'TEXT-SHADOW'] as $property) { + if (isset($this->cssProperties[$property])) { + $this->cssProperties[$property] = ShadowParser::withColor($this->cssProperties[$property], 'currentcolor'); + } + } + + if (isset($this->cssProperties['COLOR']) && strtolower($this->cssProperties['COLOR']) === 'currentcolor') { + $inherited = $this->inheritedColor($inherit); + if ($inherited === null) { + unset($this->cssProperties['COLOR']); + } else { + $this->cssProperties['COLOR'] = $inherited; + } + } + } + + $color = null; + foreach ($properties as $property) { + if (!isset($this->cssProperties[$property]) || stripos($this->cssProperties[$property], 'currentcolor') === false) { + continue; + } + + // The same word in a url() is part of the address + if ($property === 'BACKGROUND-IMAGE' && stripos($this->cssProperties[$property], 'gradient(') === false) { + continue; + } + + if ($color === null) { + $color = $this->elementColor($inherit); + } + + $this->cssProperties[$property] = str_ireplace('currentcolor', $color, $this->cssProperties[$property]); + } + } + + /** + * The colour currentColor stands for in the element's merged properties + * + * @param string $inherit Inheritance context + * @return string A colour with no spaces, which a border or shadow value keeps as one component + */ + private function elementColor($inherit) + { + $color = isset($this->cssProperties['COLOR']) ? $this->cssProperties['COLOR'] : ''; + if (!$this->colorConverter->isColor($color) && strtolower($color) !== 'transparent') { + $color = $this->inheritedColor($inherit); + } + + if ($color === null) { + $color = isset($this->cssManager->CSS['BODY']['COLOR']) ? $this->cssManager->CSS['BODY']['COLOR'] : '#000000'; + } + + return str_replace(' ', '', $color); + } + + /** + * The colour an element takes when it sets none, as mPDF passes it on in each context: a block takes the colour of + * the block it is opened in, a table and its parts the table's, and anything else the colour of the text around it + * + * @param string $inherit Inheritance context + * @return string|null Null for the document's default colour + */ + private function inheritedColor($inherit) + { + if ($inherit === 'TABLE' || $inherit === 'TOPTABLE') { + return isset($this->mpdf->base_table_properties['COLOR']) ? $this->mpdf->base_table_properties['COLOR'] : null; + } + + if ($inherit === 'BLOCK') { + $level = $this->getBlockLevel(); + $colorarray = isset($this->mpdf->blk[$level]['InlineProperties']['colorarray']) ? $this->mpdf->blk[$level]['InlineProperties']['colorarray'] : ''; + } else { + $colorarray = $this->mpdf->colorarray; + } + + return $colorarray ? $this->colorConverter->colAtoString($colorarray) : null; + } + /** * 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 @@ -1214,7 +1337,7 @@ protected function setMergedCss(&$property, $strictMode = true, $borderDominance */ protected function mergeBorderProperties($properties) { - $this->borderMerger->mergeBorderProperties($properties, $this->cssProperties); + $this->borderMerger->mergeBorderProperties($properties, $this->cssProperties, BorderMerger::initialColor($this->mpdf->cssMode)); } /** diff --git a/src/Css/InlinePropertyConverter.php b/src/Css/InlinePropertyConverter.php index e3bf35fdb..a502a11b3 100644 --- a/src/Css/InlinePropertyConverter.php +++ b/src/Css/InlinePropertyConverter.php @@ -91,6 +91,10 @@ public function convert($properties) $css['TEXT-OUTLINE-COLOR'] = $this->colorConverter->colAtoString($properties['textparam']['outline-COLOR']); } + if (!empty($properties['textparam']['transparent'])) { + $css['COLOR'] = 'transparent'; + } + if (!empty($properties['textparam']['outline-WIDTH'])) { $css['TEXT-OUTLINE-WIDTH'] = $properties['textparam']['outline-WIDTH'] . 'mm'; } diff --git a/src/Css/NormalizeProperties.php b/src/Css/NormalizeProperties.php index 2aebd1fd5..d89cee0fb 100644 --- a/src/Css/NormalizeProperties.php +++ b/src/Css/NormalizeProperties.php @@ -901,7 +901,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. + * 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. * * @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 @@ -936,7 +937,7 @@ protected function parseBorderParts($prop) $parts[$kind] = $part; } - return $parts + ['w' => 'medium', 's' => 'none', 'c' => '#000000']; + return $parts + ['w' => 'medium', 's' => 'none', 'c' => BorderMerger::initialColor($this->mpdf->cssMode)]; } /** diff --git a/src/Css/ShadowParser.php b/src/Css/ShadowParser.php index d236d4254..89024185b 100644 --- a/src/Css/ShadowParser.php +++ b/src/Css/ShadowParser.php @@ -52,6 +52,26 @@ public function normalizeShadowColors($value) return $value; } + /** + * Gives each shadow in a box-shadow or text-shadow value that names no colour the colour given. A component that + * starts with a letter or # and is not inset is a colour, and so is none, which leaves the value as it is. + * + * @param string $value Shadow property value + * @param string $color + * @return string + */ + public static function withColor($value, $color) + { + $shadows = preg_split('/,(?![^(]*\))/', $value); + foreach ($shadows as $i => $shadow) { + if (!preg_match('/(^|\s)(?!inset\b)[a-z#]/i', trim($shadow))) { + $shadows[$i] = rtrim($shadow) . ' ' . $color; + } + } + + return implode(',', $shadows); + } + /** * Parse box-shadow CSS property. * @@ -139,6 +159,11 @@ protected function parseSingleBoxShadow($s) ); } + if (isset($boxShadow['col']) && $boxShadow['col'] === false) { + // A transparent shadow draws nothing + return null; + } + if (empty($boxShadow['col'])) { $boxShadow['col'] = $this->colorConverter->convert('#888888', $this->mpdf->PDFAXwarnings); } @@ -243,6 +268,10 @@ protected function parseSingleTextShadow($s) ); } + if (isset($textShadow['col']) && $textShadow['col'] === false) { + return null; + } + if (empty($textShadow['col'])) { $textShadow['col'] = $this->colorConverter->convert( '#888888', diff --git a/src/Mpdf.php b/src/Mpdf.php index 815f36186..ff117512b 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -5857,7 +5857,7 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = $dashoff = 3.5; $dot = 2.5; - if ($tbw) { + if (Border::drawsSide($this->spanborddet['T'])) { $short = 0; if ($this->spanborddet['T']['style'] == 'dashed') { @@ -5896,7 +5896,7 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = $s .= ' Q '; } } - if ($bbw) { + if (Border::drawsSide($this->spanborddet['B'])) { $short = 0; if ($this->spanborddet['B']['style'] == 'dashed') { @@ -5936,7 +5936,7 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = } } - if ($lbw) { + if (Border::drawsSide($this->spanborddet['L'])) { $short = 0; if ($this->spanborddet['L']['style'] == 'dashed') { $s .= sprintf(' 0 j 0 J [%.3F %.3F] 0 d ', $lbw * $dashon * Mpdf::SCALE, $lbw * $dashoff * Mpdf::SCALE); @@ -5974,7 +5974,7 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = } } - if ($rbw) { + if (Border::drawsSide($this->spanborddet['R'])) { $short = 0; if ($this->spanborddet['R']['style'] == 'dashed') { @@ -6302,7 +6302,12 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = } } - $s .= $sub; + if (empty($this->textparam['transparent'])) { + $s .= $sub; + } else { + // The text is laid out and can be selected, but not seen + $s .= ' q 3 Tr ' . $sub . ' Q '; + } // COLOR if ($this->ColorFlag) { @@ -18086,7 +18091,7 @@ function PaintDivBB($divider = '', $blockstate = 0, $blvl = 0) $this->SetTColor($this->colorConverter->convert(0, $this->PDFAXwarnings)); } - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { if (!$brset && $tbd['style'] != 'dotted' && $tbd['style'] != 'dashed') { $this->writer->write('q'); $this->SetLineWidth(0); @@ -18183,7 +18188,7 @@ function PaintDivBB($divider = '', $blockstate = 0, $blvl = 0) if ($this->blk[$blvl]['border_bottom'] && $blockstate != 1 && $divider != 'pagebottom') { $tbd = $this->blk[$blvl]['border_bottom']; - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { if (!$brset && $tbd['style'] != 'dotted' && $tbd['style'] != 'dashed') { $this->writer->write('q'); $this->SetLineWidth(0); @@ -18247,7 +18252,7 @@ function PaintDivBB($divider = '', $blockstate = 0, $blvl = 0) if ($this->blk[$blvl]['border_left']) { $tbd = $this->blk[$blvl]['border_left']; - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { if (!$brset && $tbd['style'] != 'dotted' && $tbd['style'] != 'dashed') { $this->writer->write('q'); $this->SetLineWidth(0); @@ -18308,7 +18313,7 @@ function PaintDivBB($divider = '', $blockstate = 0, $blvl = 0) } if ($this->blk[$blvl]['border_right']) { $tbd = $this->blk[$blvl]['border_right']; - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { if (!$brset && $tbd['style'] != 'dotted' && $tbd['style'] != 'dashed') { $this->writer->write('q'); $this->SetLineWidth(0); @@ -18984,7 +18989,7 @@ function PaintDivLnBorder($state = 0, $blvl = 0, $h = 0) if ($this->blk[$blvl]['border_top'] && ($state == 1 || $state == 3)) { $tbd = $this->blk[$blvl]['border_top']; - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { $this->_setBorderLine($tbd); $this->y = $y0 + ($tbd['w'] / 2); if ($tbd['style'] == 'dotted' || $tbd['style'] == 'dashed') { @@ -19004,7 +19009,7 @@ function PaintDivLnBorder($state = 0, $blvl = 0, $h = 0) } if ($this->blk[$blvl]['border_left']) { $tbd = $this->blk[$blvl]['border_left']; - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { $this->_setBorderLine($tbd); if ($tbd['style'] == 'dotted' || $tbd['style'] == 'dashed') { $this->y = $y0 + ($tbd['w'] / 2); @@ -19025,7 +19030,7 @@ function PaintDivLnBorder($state = 0, $blvl = 0, $h = 0) } if ($this->blk[$blvl]['border_right']) { $tbd = $this->blk[$blvl]['border_right']; - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { $this->_setBorderLine($tbd); if ($tbd['style'] == 'dotted' || $tbd['style'] == 'dashed') { $this->y = $y0 + ($tbd['w'] / 2); @@ -19046,7 +19051,7 @@ function PaintDivLnBorder($state = 0, $blvl = 0, $h = 0) } if ($this->blk[$blvl]['border_bottom'] && $state > 1) { $tbd = $this->blk[$blvl]['border_bottom']; - if (isset($tbd['s']) && $tbd['s']) { + if (Border::drawsSide($tbd)) { $this->_setBorderLine($tbd); $this->y = $y0 + $h - ($tbd['w'] / 2); if ($tbd['style'] == 'dotted' || $tbd['style'] == 'dashed') { @@ -19097,7 +19102,7 @@ function PaintImgBorder($objattr, $is_table, $box = null) ]; foreach ($lines as $side => $line) { $tbd = $objattr['border_' . $side]; - if (empty($tbd['s'])) { + if (!Border::drawsSide($tbd)) { continue; } $this->_setBorderLine($tbd, $k); @@ -20032,6 +20037,9 @@ function setCSS($arrayaux, $type = '', $tag = '') if ($cor) { $this->colorarray = $cor; $this->SetTColor($cor); + unset($this->textparam['transparent']); + } elseif (strtolower(trim($v)) === 'transparent') { + $this->textparam['transparent'] = true; } break; }//end of switch($k) @@ -21919,6 +21927,9 @@ function _tableRect($x, $y, $w, $h, $bord = -1, $details = [], $buffer = false, if ($bord == -1) { $this->Rect($x, $y, $w, $h); } elseif ($this->simpleTables && ($cort == 'cell')) { + if (!Border::drawsSide($details['L'])) { + return; + } $this->SetLineWidth($details['L']['w']); if ($details['L']['c']) { $this->SetDColor($details['L']['c']); @@ -22159,7 +22170,7 @@ function _tableRect($x, $y, $w, $h, $bord = -1, $details = [], $buffer = false, } // Now draw line - if ($print) { + if ($print && Border::drawsSide($details[$side])) { /* -- TABLES-ADVANCED-BORDERS -- */ if ($details[$side]['style'] == 'double') { if (!isset($details[$side]['overlay']) || !$details[$side]['overlay'] || $bSeparate) { diff --git a/src/Tag/Tag.php b/src/Tag/Tag.php index bebdecf24..6cf84491c 100644 --- a/src/Tag/Tag.php +++ b/src/Tag/Tag.php @@ -241,11 +241,13 @@ protected function formFieldStyle(array $properties) } // The cascade folds border-top-width, -style and -color into BORDER-TOP, and keeps them. A part given only as a - // longhand brings BorderMerger's defaults for the others, which the field should not take. + // longhand brings BorderMerger's defaults for the others, which the field should not take. In standard mode the + // default colour is the field's own, and a shorthand always sets the colour longhand $border = array_combine(array_keys(BorderMerger::DEFAULTS), array_pad(preg_split('/\s+/', trim($properties['BORDER-TOP']), 3), 3, '')); $longhand = isset($properties['BORDER-TOP-WIDTH']) || isset($properties['BORDER-TOP-STYLE']) || isset($properties['BORDER-TOP-COLOR']); foreach ($border as $part => $value) { - if ($value === '' || ($longhand && !isset($properties['BORDER-TOP-' . $part]) && $value === BorderMerger::DEFAULTS[$part])) { + $default = $value === BorderMerger::DEFAULTS[$part] || ($part === 'COLOR' && $this->mpdf->cssMode === CssMode::STANDARD); + if ($value === '' || ($longhand && !isset($properties['BORDER-TOP-' . $part]) && $default)) { continue; } if ($part === 'WIDTH') { diff --git a/tests/Mpdf/ActiveFieldBorderTest.php b/tests/Mpdf/ActiveFieldBorderTest.php index 22899e376..835a76d12 100644 --- a/tests/Mpdf/ActiveFieldBorderTest.php +++ b/tests/Mpdf/ActiveFieldBorderTest.php @@ -155,6 +155,43 @@ public function testABorderLonghandKeepsTheRestOfTheDefault() $this->assertStringContainsString('/BC [ 0.6 0.6 0.72 ]', $this->widget($pdf, 'w')); } + /** + * A field with a colour of its own keeps the default border colour when only the border's width or style is given. + * A border shorthand with no colour draws in the field's colour in standard mode, where a border's colour is + * currentColor, and in black in legacy mode + * + * @dataProvider borderColoursInModes + * + * @param string $mode + * @param string $border + * @param string $expected The field's /BC + */ + public function testABorderWithNoColourTakesTheFieldsColourOnlyFromAShorthand($mode, $border, $expected) + { + $pdf = $this->render('
', ['useActiveForms' => true, 'cssMode' => $mode]); + + $this->assertStringContainsString('/BC [ ' . $expected . ' ]', $this->widget($pdf, 'f')); + } + + /** + * Borders naming no colour, in both modes + * + * @return string[][] + */ + public function borderColoursInModes() + { + return [ + 'standard, a width' => [CssMode::STANDARD, 'border-width: 2pt', '0.6 0.6 0.72'], + 'standard, a style' => [CssMode::STANDARD, 'border-style: dashed', '0.6 0.6 0.72'], + 'standard, a shorthand' => [CssMode::STANDARD, 'border: 2pt solid', '0.800 0.000 0.000'], + 'standard, a shorthand in currentColor' => [CssMode::STANDARD, 'border: 2pt solid currentColor', '0.800 0.000 0.000'], + 'legacy, a width' => [CssMode::LEGACY, 'border-width: 2pt', '0.6 0.6 0.72'], + 'legacy, a style' => [CssMode::LEGACY, 'border-style: dashed', '0.6 0.6 0.72'], + 'legacy, a shorthand' => [CssMode::LEGACY, 'border: 2pt solid', '0.000 0.000 0.000'], + 'legacy, a shorthand in currentColor' => [CssMode::LEGACY, 'border: 2pt solid currentColor', '0.800 0.000 0.000'], + ]; + } + /** * A PDF/A document restricted to CMYK writes the border colour in CMYK, in both /MK and the appearance */ diff --git a/tests/Mpdf/Css/BorderTest.php b/tests/Mpdf/Css/BorderTest.php new file mode 100644 index 000000000..9536b3ff2 --- /dev/null +++ b/tests/Mpdf/Css/BorderTest.php @@ -0,0 +1,39 @@ +assertSame($expected, Border::drawsSide($side)); + } + + /** + * @return array[] Sides of a border, and whether each draws anything + */ + public function providerDrawsSide() + { + $black = "\x03\x00\x00\x00"; + + return [ + 'solid, with a width and colour' => [['s' => 1, 'w' => 0.2, 'c' => $black, 'style' => 'solid'], true], + 'no style' => [['s' => 0, 'w' => 0.2, 'c' => $black, 'style' => ''], false], + 'hidden, with no width' => [['s' => 1, 'w' => 0, 'c' => $black, 'style' => 'hidden'], false], + 'transparent' => [['s' => 1, 'w' => 0.2, 'c' => false, 'style' => 'solid'], false], + 'transparent, packed with a table cell' => [['s' => 1, 'w' => 0.2, 'c' => "\0\0\0\0", 'style' => 'solid'], false], + 'no colour' => [['s' => 1, 'w' => 0.2, 'style' => 'solid'], false], + 'no side' => [null, false], + ]; + } +} diff --git a/tests/Mpdf/Css/NormalizePropertiesTest.php b/tests/Mpdf/Css/NormalizePropertiesTest.php index 4f9b5c01f..0cbb26f6c 100644 --- a/tests/Mpdf/Css/NormalizePropertiesTest.php +++ b/tests/Mpdf/Css/NormalizePropertiesTest.php @@ -5,6 +5,7 @@ use Mpdf\Color\ColorConverter; use Mpdf\Color\ColorModeConverter; use Mpdf\Color\ColorSpaceRestrictor; +use Mpdf\CssMode; use Mpdf\Mpdf; use Mpdf\SizeConverter; use Psr\Log\NullLogger; @@ -227,25 +228,58 @@ public function testBorderStringNormalization($input, $expected) public function providerBorderString() { return [ - ['solid', 'medium solid #000000'], + ['solid', 'medium solid currentcolor'], ['#ff0000', 'medium none #ff0000'], - ['2px', '2px none #000000'], - ['2px solid', '2px solid #000000'], + ['2px', '2px none currentcolor'], + ['2px solid', '2px solid currentcolor'], ['solid #ff0000', 'medium solid #ff0000'], ['2px #ff0000', '2px none #ff0000'], ['2px solid #ff0000', '2px solid #ff0000'], ['#ff0000 2px solid', '2px solid #ff0000'], - ['none', 'medium none #000000'], + ['none', 'medium none currentcolor'], ['solid #c00 2mm', '2mm solid #c00'], ['2mm #c00 solid', '2mm solid #c00'], ['red 2mm solid', '2mm solid red'], ['solid 2mm red', '2mm solid red'], ['#c00 solid', 'medium solid #c00'], - ['dashed thick', 'thick dashed #000000'], + ['dashed thick', 'thick dashed currentcolor'], ['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 #000000'], + ['inherit', 'medium none currentcolor'], + ]; + } + + /** + * In legacy mode a border with no colour is black, as it was in mPDF v7 + * + * @dataProvider providerBorderWithNoColour + * + * @param string $input + * @param string $expected + */ + public function testABorderWithNoColourIsBlackInLegacyMode($input, $expected) + { + $this->mpdf->cssMode = CssMode::LEGACY; + + $result = $this->normalizeProperties->normalize(['BORDER' => $input]); + + $this->assertSame($expected, $result['BORDER-TOP']); + } + + /** + * Border values that name no colour, and a CSS-wide keyword, with what legacy mode normalizes them to + * + * @return string[][] + */ + public function providerBorderWithNoColour() + { + return [ + 'a style' => ['solid', 'medium solid #000000'], + 'a width and a style' => ['2px solid', '2px solid #000000'], + 'none' => ['none', 'medium none #000000'], + 'inherit' => ['inherit', 'medium none #000000'], + 'a colour' => ['2px solid #c00', '2px solid #c00'], ]; } @@ -370,10 +404,10 @@ public function testBorderSetsTheLonghandsOfItsSides() ]); foreach (['TOP', 'RIGHT', 'BOTTOM'] as $side) { - $this->assertSame('medium dashed #000000', $result['BORDER-' . $side]); + $this->assertSame('medium dashed currentcolor', $result['BORDER-' . $side]); $this->assertSame('medium', $result['BORDER-' . $side . '-WIDTH']); $this->assertSame('dashed', $result['BORDER-' . $side . '-STYLE']); - $this->assertSame('#000000', $result['BORDER-' . $side . '-COLOR']); + $this->assertSame('currentcolor', $result['BORDER-' . $side . '-COLOR']); } $this->assertSame('1mm solid #00f', $result['BORDER-LEFT']); diff --git a/tests/Mpdf/Css/ShadowParserTest.php b/tests/Mpdf/Css/ShadowParserTest.php index 6b70db3c7..dee4b7c89 100644 --- a/tests/Mpdf/Css/ShadowParserTest.php +++ b/tests/Mpdf/Css/ShadowParserTest.php @@ -182,4 +182,48 @@ public function testATightColourFunctionIsPaintedOnThePage() $this->assertStringContainsString('1.000 0.000 0.000 rg', $mpdf->Output('', 'S')); } + + /** + * withColor() gives the colour to each shadow that names none, and leaves the others and none alone + * + * @dataProvider shadowsWithAndWithoutColours + * + * @param string $value + * @param string $expected + */ + public function testWithColorGivesTheColourToEachShadowThatNamesNone($value, $expected) + { + $this->assertSame($expected, ShadowParser::withColor($value, 'currentcolor')); + } + + /** + * Shadow values, and each with currentColor given to the shadows that name no colour + * + * @return string[][] + */ + public function shadowsWithAndWithoutColours() + { + return [ + 'offsets' => ['2px 2px', '2px 2px currentcolor'], + 'a blur and a spread' => ['2px 2px 3px -1px', '2px 2px 3px -1px currentcolor'], + 'inset' => ['inset 2px 2px', 'inset 2px 2px currentcolor'], + 'inset last' => ['2px 2px inset', '2px 2px inset currentcolor'], + 'a colour' => ['2px 2px #c00', '2px 2px #c00'], + 'a colour first' => ['red 2px 2px', 'red 2px 2px'], + 'a colour function' => ['2px 2px rgba(0,0,0,0.5)', '2px 2px rgba(0,0,0,0.5)'], + 'several' => ['2px 2px rgb(0, 0, 255), 3px 3px, inset 1px 1px', '2px 2px rgb(0, 0, 255), 3px 3px currentcolor, inset 1px 1px currentcolor'], + 'none' => ['none', 'none'], + ]; + } + + /** + * A shadow whose colour converts to nothing, such as transparent, draws nothing, where one with no colour is grey + */ + public function testATransparentShadowIsDropped() + { + $this->assertCount(1, $this->shadowParser->parseBoxShadow('2px 2px transparent, 3px 3px')); + $this->assertCount(0, $this->shadowParser->parseBoxShadow('2px 2px 1px transparent')); + $this->assertCount(0, $this->shadowParser->parseTextShadow('2px 2px transparent')); + $this->assertCount(1, $this->shadowParser->parseTextShadow('2px 2px')); + } } diff --git a/tests/Mpdf/CurrentColorTest.php b/tests/Mpdf/CurrentColorTest.php new file mode 100644 index 000000000..c86022e71 --- /dev/null +++ b/tests/Mpdf/CurrentColorTest.php @@ -0,0 +1,647 @@ +subject($context, $border, 'color: #0a0') : $this->subject($context, 'color: #0a0; ' . $border); + $mpdf = $this->paint($html, $mode); + + $this->assertSame(self::GREEN, $this->keyedByText($mpdf, $mpdf->drawnColours)['subject']); + $this->assertNotEmpty($mpdf->drawnBorders, 'The border is drawn'); + $this->assertSame([strtoupper($expected)], array_values(array_unique($mpdf->drawnBorders))); + $this->assertSubjectPlaced($context, $mpdf); + } + + /** + * Each border in each context, with the colour set on the element and inherited, in both modes + * + * @return array[] + */ + public function bordersInContexts() + { + $cases = [ + 'currentColor' => ['border: 0.4mm solid currentColor', self::GREEN, self::GREEN], + 'currentColor in capitals' => ['border: 0.4mm solid CURRENTCOLOR', self::GREEN, self::GREEN], + 'border-color: currentColor' => ['border: 0.4mm solid #c00; border-color: currentColor', self::GREEN, self::GREEN], + 'no colour' => ['border: 0.4mm solid', self::GREEN, self::BLACK], + 'no colour, from the longhands' => ['border-style: solid; border-width: 0.4mm', self::GREEN, self::BLACK], + 'a colour of its own' => ['border: 0.4mm solid #c00', self::RED, self::RED], + ]; + + $data = []; + foreach ($this->inModesAndContexts(self::CONTEXTS, $cases) as $name => $case) { + $data[$name . ', colour set'] = [$case[0], $case[1], $case[2], false, $case[3]]; + $data[$name . ', colour inherited'] = [$case[0], $case[1], $case[2], true, $case[3]]; + } + + return $data; + } + + /** + * An image's border in currentColor, or in standard mode with no colour, is drawn in the colour of the text around + * the image, which is the image's own + * + * @dataProvider imageBorders + * + * @param string $mode + * @param string $border + * @param string $expected + */ + public function testAnImagesBorderIsDrawnInTheColourItNames($mode, $border, $expected) + { + $mpdf = $this->paint('

before ' . $this->image($border) . ' after

', $mode); + + $this->assertSame([strtoupper($expected)], array_values(array_unique($mpdf->drawnBorders))); + } + + /** + * An image's borders, in both modes + * + * @return array[] + */ + public function imageBorders() + { + return [ + 'standard, currentColor' => [CssMode::STANDARD, 'border: 0.4mm solid currentColor', self::GREEN], + 'standard, no colour' => [CssMode::STANDARD, 'border: 0.4mm solid', self::GREEN], + 'standard, a colour of its own' => [CssMode::STANDARD, 'border: 0.4mm solid #c00', self::RED], + 'legacy, currentColor' => [CssMode::LEGACY, 'border: 0.4mm solid currentColor', self::GREEN], + 'legacy, no colour' => [CssMode::LEGACY, 'border: 0.4mm solid', self::BLACK], + 'legacy, a colour of its own' => [CssMode::LEGACY, 'border: 0.4mm solid #c00', self::RED], + ]; + } + + /** + * color: currentColor is the colour the element inherits. With none to inherit, the text keeps the default. Legacy + * mode ignores it as mPDF v7 did: an inline element keeps the colour around it, and anything else is black + * + * @dataProvider colorCurrentColorInContexts + * + * @param string $mode + * @param string $context + * @param string $parent The style of the element the subject inherits from + * @param string $expected + */ + public function testColorCurrentColorIsTheInheritedColour($mode, $context, $parent, $expected) + { + $mpdf = $this->paint($this->subject($context, 'color: currentColor', $parent), $mode); + + $this->assertSame($expected, $this->keyedByText($mpdf, $mpdf->drawnColours)['subject']); + } + + /** + * color: currentColor in each context, under a colour and under none, in both modes + * + * @return array[] + */ + public function colorCurrentColorInContexts() + { + $data = $this->inModesAndContexts(self::CONTEXTS, [ + 'under a colour' => ['color: #0a0', self::GREEN, '0.000 g'], + 'under none' => ['', '0.000 g', '0.000 g'], + ]); + + // An inline element keeps the colour around it in legacy mode too + $data['legacy, inline, under a colour'][3] = self::GREEN; + + return $data; + } + + /** + * A transparent side of a border draws nothing, and the others are drawn, with no warning + * + * @dataProvider contextsInModes + * + * @param string $mode + * @param string $context + */ + public function testATransparentSideDrawsNothing($mode, $context) + { + $mpdf = $this->paint($this->subject($context, 'border: 1mm solid transparent; border-bottom-color: #c00'), $mode); + + $this->assertSame([strtoupper(self::RED)], array_values(array_unique($mpdf->drawnBorders))); + $this->assertSubjectPlaced($context, $mpdf); + + $mpdf = $this->paint($this->subject($context, 'border: 1mm solid transparent'), $mode); + + $this->assertSame([], $mpdf->drawnBorders); + } + + /** + * A hidden border draws nothing, with no warning. A block drew a hairline for it + * + * @dataProvider contextsInModes + * + * @param string $mode + * @param string $context + */ + public function testAHiddenBorderDrawsNothing($mode, $context) + { + $mpdf = $this->paint($this->subject($context, 'border: 3mm hidden #36c'), $mode); + + $this->assertSame([], $mpdf->drawnBorders); + $this->assertSubjectPlaced($context, $mpdf); + } + + /** + * An image's transparent or hidden border draws nothing, and a side with a colour is drawn + * + * @dataProvider modes + * + * @param string $mode + */ + public function testAnImagesTransparentOrHiddenBorderDrawsNothing($mode) + { + $mpdf = $this->paint('

' . $this->image('border: 1mm solid transparent; border-left-color: #c00') . $this->image('border: 1mm hidden #c00') . '

', $mode); + + $this->assertSame([strtoupper(self::RED)], $mpdf->drawnBorders); + } + + /** + * Transparent text is laid out, and not seen. Text inside it that sets a colour is seen, and so is the text after + * it + * + * @dataProvider contextsInModes + * + * @param string $mode + * @param string $context + */ + public function testTransparentTextIsNotSeen($mode, $context) + { + $html = $this->subject($context, 'color: transparent', '', ' inner coloured') . '

next

'; + $mpdf = $this->paint($html, $mode); + + $visible = $this->keyedByText($mpdf, $mpdf->drawnVisible); + $this->assertFalse($visible['subject']); + $this->assertFalse($visible['inner']); + $this->assertTrue($visible['coloured']); + $this->assertTrue($visible['next']); + $this->assertSame(self::RED, $this->keyedByText($mpdf, $mpdf->drawnColours)['coloured']); + $this->assertSubjectPlaced($context, $mpdf); + } + + /** + * A block in a transparent block, and a cell of a transparent table, are not seen either + * + * @dataProvider modes + * + * @param string $mode + */ + public function testTransparentTextIsInherited($mode) + { + $mpdf = $this->paint('

in a block

after

' + . '
in a cell
', $mode); + + $this->assertSame(['in a block' => false, 'after' => true, 'in a cell' => false], $this->keyedByText($mpdf, $mpdf->drawnVisible)); + } + + /** + * Transparent text is written in the invisible text rendering mode, and its shadow is drawn + */ + public function testTransparentTextIsWrittenInvisiblyAndKeepsItsShadow() + { + $pdf = $this->uncompressed('

hidden

', CssMode::STANDARD); + + $this->assertMatchesRegularExpression('/q 0\.800 0\.000 0\.000 rg\s+1 0 0 1 [\d.]+ -[\d.]+ cm\s+BT [\d. ]+Td\s+\(hidden\) Tj ET Q\s+q 3 Tr BT [\d. ]+Td\s+\(hidden\) Tj ET Q/', $pdf); + } + + /** + * A text-shadow is drawn in the colour it names, and in standard mode in currentColor when it + * names that or none. Legacy mode draws both grey, as mPDF v7 did. A transparent one draws nothing + * + * @dataProvider textShadowsInContexts + * + * @param string $mode + * @param string $context + * @param string $shadow + * @param string[] $expected The colour of each shadow drawn + */ + public function testATextShadowIsDrawnInTheColourItNames($mode, $context, $shadow, array $expected) + { + $mpdf = $this->paint($this->subject($context, 'color: #0a0; text-shadow: ' . $shadow), $mode); + + $this->assertSame($expected, $this->keyedByText($mpdf, $mpdf->drawnTextShadows)['subject']); + } + + /** + * Text shadows in each context, in both modes + * + * @return array[] + */ + public function textShadowsInContexts() + { + return $this->inModesAndContexts(self::CONTEXTS, [ + 'currentColor' => ['0.5mm 0.5mm currentColor', [self::GREEN], [self::GREY]], + 'no colour' => ['0.5mm 0.5mm', [self::GREEN], [self::GREY]], + 'no colour, with a blur' => ['0.5mm 0.5mm 1mm', [self::GREEN], [self::GREY]], + 'two, one with no colour' => ['0.5mm 0.5mm #c00, 1mm 1mm', [self::GREEN, self::RED], [self::GREY, self::RED]], + 'transparent' => ['0.5mm 0.5mm transparent', [], []], + 'a colour of its own' => ['0.5mm 0.5mm #c00', [self::RED], [self::RED]], + ]); + } + + /** + * A block's box-shadow is painted in the colour it names, and in standard mode in currentColor when it names + * that or none. Legacy mode paints both grey, as mPDF v7 did. A transparent one paints nothing + * + * @dataProvider boxShadows + * + * @param string $mode + * @param string $context + * @param string $shadow + * @param string[] $expected + */ + public function testABoxShadowIsPaintedInTheColourItNames($mode, $context, $shadow, array $expected) + { + $mpdf = $this->paint($this->subject($context, 'color: #0a0; box-shadow: ' . $shadow), $mode); + + $this->assertSame($expected, array_values(array_unique($mpdf->drawnBoxShadows))); + } + + /** + * Box shadows on the blocks of each context that lays out a block, in both modes + * + * @return array[] + */ + public function boxShadows() + { + return $this->inModesAndContexts(['block', 'list item', 'positioned block', 'kept block', 'forced page break'], [ + 'currentColor' => ['1mm 1mm currentColor', [self::GREEN], [self::GREY]], + 'no colour' => ['1mm 1mm', [self::GREEN], [self::GREY]], + 'inset, no colour' => ['inset 1mm 1mm', [self::GREEN], [self::GREY]], + 'transparent' => ['1mm 1mm transparent', [], []], + 'a colour of its own' => ['1mm 1mm #c00', [self::RED], [self::RED]], + ]); + } + + /** + * In standard mode every property mPDF reads a colour from resolves currentColor to the element's colour when its + * properties are merged, in a value with other parts and in a gradient, and not in an image's address + */ + public function testEveryColourPropertyResolvesCurrentColor() + { + $properties = $this->merged('INLINE', 'SPAN', self::EVERY_CURRENT_COLOR, CssMode::STANDARD); + + $this->assertSame('#0a0', $properties['BACKGROUND-COLOR']); + $this->assertSame('1mm solid #0a0', $properties['BORDER-TOP']); + $this->assertSame('#0a0', $properties['BORDER-RIGHT-COLOR']); + $this->assertStringEndsWith(' #0a0', $properties['BORDER-RIGHT']); + $this->assertSame('1mm 1mm #0a0, inset 1mm 1mm rgb(0,0,255)', $properties['BOX-SHADOW']); + $this->assertSame('1mm 1mm #0a0', $properties['TEXT-SHADOW']); + $this->assertSame('#0a0', $properties['TEXT-OUTLINE-COLOR']); + + $gradient = $this->merged('BLOCK', 'DIV', self::GRADIENT, CssMode::STANDARD); + $url = $this->merged('BLOCK', 'DIV', 'color: #0a0; background-image: url(currentcolor.png)', CssMode::STANDARD); + + $this->assertSame('linear-gradient(to right, #0a0 20%, #c00)', $gradient['BACKGROUND-IMAGE']); + $this->assertStringContainsString('currentcolor.png', $url['BACKGROUND-IMAGE']); + + $table = $this->merged('TABLE', 'TABLE', self::TABLE_LINES, CssMode::STANDARD); + + $this->assertSame('0.5mm solid rgb(0,170,0)', $table['TOPNTAIL']); + $this->assertSame('0.5mm solid rgb(0,170,0)', $table['THEAD-UNDERLINE']); + } + + /** + * Legacy mode resolves currentColor only in a border and a text outline, which raised warnings for it. Everywhere + * else it leaves the keyword, which it ignores as mPDF v7 did, and color: currentColor stays as written + */ + public function testLegacyModeResolvesCurrentColorOnlyWhereItRaisedWarnings() + { + $properties = $this->merged('INLINE', 'SPAN', self::EVERY_CURRENT_COLOR, CssMode::LEGACY); + + $this->assertSame('1mm solid #0a0', $properties['BORDER-TOP']); + $this->assertSame('#0a0', $properties['BORDER-RIGHT-COLOR']); + $this->assertStringEndsWith(' #0a0', $properties['BORDER-RIGHT']); + $this->assertSame('#0a0', $properties['TEXT-OUTLINE-COLOR']); + $this->assertSame('currentcolor', $properties['BACKGROUND-COLOR']); + $this->assertSame('1mm 1mm currentcolor, inset 1mm 1mm rgb(0,0,255)', $properties['BOX-SHADOW']); + $this->assertSame('1mm 1mm currentcolor', $properties['TEXT-SHADOW']); + + $this->assertSame('linear-gradient(to right, CurrentColor 20%, #c00)', $this->merged('BLOCK', 'DIV', self::GRADIENT, CssMode::LEGACY)['BACKGROUND-IMAGE']); + + $table = $this->merged('TABLE', 'TABLE', self::TABLE_LINES, CssMode::LEGACY); + + $this->assertSame('0.5mm solid currentcolor', $table['TOPNTAIL']); + $this->assertSame('0.5mm solid currentcolor', $table['THEAD-UNDERLINE']); + + $this->assertSame('currentcolor', $this->merged('BLOCK', 'P', 'color: currentColor', CssMode::LEGACY)['COLOR']); + } + + /** + * A text outline in currentColor is drawn in the text's colour, with no warning + * + * @dataProvider modes + * + * @param string $mode + */ + public function testATextOutlineInCurrentColorIsDrawnInTheTextsColour($mode) + { + $pdf = $this->uncompressed('

subject

', $mode); + + $this->assertStringContainsString('0.000 0.667 0.000 RG 2 Tr BT', $pdf); + } + + /** + * A colour that names currentColor takes the colour the element has, not what its parent has + * + * @dataProvider modes + * + * @param string $mode + */ + public function testCurrentColorIsTheElementsOwnColourOverItsParents($mode) + { + $mpdf = $this->paint('
subject
' + . '

before inline

', $mode); + + $this->assertSame([strtoupper(self::GREEN)], array_values(array_unique($mpdf->drawnBorders))); + } + + /** + * currentColor is the colour the element ends up with, an !important one included, over a later or more + * specific rule's colour and the style attribute's + */ + public function testCurrentColorIsTheImportantColour() + { + $mpdf = $this->paint('' + . '
subject
', CssMode::STANDARD); + + $this->assertSame([strtoupper(self::GREEN)], array_values(array_unique($mpdf->drawnBorders))); + } + + /** + * With no colour anywhere, currentColor is the body's colour, and with none there, black + * + * @dataProvider modes + * + * @param string $mode + */ + public function testCurrentColorFallsBackToTheBodysColour($mode) + { + $mpdf = $this->paint('
subject
', $mode); + + $this->assertSame(self::GREEN, $this->keyedByText($mpdf, $mpdf->drawnColours)['subject']); + $this->assertSame([strtoupper(self::GREEN)], array_values(array_unique($mpdf->drawnBorders))); + + $mpdf = $this->paint('
subject
', $mode); + + $this->assertSame([strtoupper(self::BLACK)], array_values(array_unique($mpdf->drawnBorders))); + } + + /** + * Every context in both modes + * + * @return array[] + */ + public function contextsInModes() + { + $data = []; + foreach ($this->inModesAndContexts(self::CONTEXTS, ['' => ['', '', '']]) as $name => $case) { + $data[rtrim($name, ', ')] = [$case[0], $case[1]]; + } + + return $data; + } + + /** + * Each case in each context, in both modes + * + * @param string[] $contexts + * @param array[] $cases Each [value, what standard mode should give, what legacy mode should give], keyed by name + * + * @return array[] Each [mode, context, value, what the mode should give], keyed "mode, context, name" + */ + private function inModesAndContexts(array $contexts, array $cases) + { + $data = []; + foreach ([CssMode::STANDARD, CssMode::LEGACY] as $mode) { + foreach ($contexts as $context) { + foreach ($cases as $name => $case) { + $data[$mode . ', ' . $context . ', ' . $name] = [$mode, $context, $case[0], $mode === CssMode::STANDARD ? $case[1] : $case[2]]; + } + } + } + + return $data; + } + + /** + * Both modes + * + * @return string[][] + */ + public function modes() + { + return [CssMode::STANDARD => [CssMode::STANDARD], CssMode::LEGACY => [CssMode::LEGACY]]; + } + + /** + * Writes a document in core-font mode, recording what it paints, and fails on any warning or notice it raises + * + * @param string $html + * @param string $mode + * + * @return PaintRecordingMpdf + */ + private function paint($html, $mode) + { + $raised = []; + set_error_handler(static function ($errno, $message, $file, $line) use (&$raised) { + $raised[] = sprintf('%s in %s:%d', $message, basename($file), $line); + return true; + }); + + try { + $mpdf = new PaintRecordingMpdf(['mode' => 'c', 'cssMode' => $mode]); + $mpdf->WriteHTML($html); + $mpdf->Close(); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $raised); + + return $mpdf; + } + + /** + * A document written uncompressed, with no warning or notice raised + * + * @param string $html + * @param string $mode + * + * @return string The document + */ + private function uncompressed($html, $mode) + { + return $this->assertDrawsSilently(function (Mpdf $mpdf) use ($html) { + $mpdf->WriteHTML($html); + }, ['cssMode' => $mode, 'useKerning' => false]); + } + + /** + * An element with the text "subject" in a context, under a parent. The parent is the element mPDF passes a colour + * on from in that context: a block, a paragraph, the table, the inner table, or the list + * + * @param string $context One of CONTEXTS + * @param string $style The subject's style + * @param string $parent The parent's style + * @param string $content What follows the text in the subject + * + * @return string + */ + private function subject($context, $style, $parent = '', $content = '') + { + $subject = 'style="' . $style . '">subject' . $content; + + switch ($context) { + case 'inline': + return '

before after

'; + + case 'table cell': + return '
'; + + case 'collapsed table cell': + return '
'; + + case 'nested table cell': + return '
'; + + case 'list item': + return ''; + } + + $html = '
'; + + switch ($context) { + case 'header': + return '' . $html . '

body

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

body

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

filler

', 44) . '

kept

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

before the break

' . $html . '
'; + } + + return $html; + } + + /** + * The subject is drawn where its context puts it: on the second page after a forced page break or once a kept + * block has moved there, and on the first page elsewhere + * + * @param string $context + * @param PaintRecordingMpdf $mpdf + */ + private function assertSubjectPlaced($context, PaintRecordingMpdf $mpdf) + { + $page = in_array($context, ['kept block', 'forced page break'], true) ? 2 : 1; + + $this->assertSame($page, $this->keyedByText($mpdf, $mpdf->drawnBoxes)['subject'][0]); + } + + /** + * A small image with a style + * + * @param string $style + * + * @return string + */ + private function image($style) + { + return ''; + } + + /** + * An element's merged CSS properties, from its inline style + * + * @param string $inherit The context MergeCSS() is given + * @param string $tag + * @param string $style + * @param string $mode + * + * @return string[] + */ + private function merged($inherit, $tag, $style, $mode) + { + $mpdf = new Mpdf(['mode' => 'c', 'cssMode' => $mode]); + $property = new \ReflectionProperty($mpdf, 'cssManager'); + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + + return $property->getValue($mpdf)->MergeCSS($inherit, $tag, ['STYLE' => $style]); + } + +} diff --git a/tests/Mpdf/PaintRecordingMpdf.php b/tests/Mpdf/PaintRecordingMpdf.php new file mode 100644 index 000000000..0d202ecb7 --- /dev/null +++ b/tests/Mpdf/PaintRecordingMpdf.php @@ -0,0 +1,115 @@ +pages as $page) { + $this->drawnBorders = array_merge($this->drawnBorders, $this->strokedColours($page)); + } + } + + /** + * The stroke colour of each path a page's content strokes outside text, in order. Borders are the only paths the + * documents these tests write stroke; text outlines are stroked inside text objects + * + * @param string $content A page's content stream, uncompressed + * + * @return string[] Each colour as the operator that sets it, as SetDColor() writes it + */ + private function strokedColours($content) + { + $colours = []; + $colour = ''; + $saved = []; + $inText = false; + preg_match_all('/((?:-?[\d.]+ ){1,4})(G|RG|K)(?=\s)|(?<=\s|^)(q|Q|BT|ET|S|s|B\*?|b\*?)(?=\s|$)/', $content, $ops, PREG_SET_ORDER); + foreach ($ops as $op) { + if (!empty($op[2])) { + $colour = $op[1] . $op[2]; + continue; + } + switch ($op[3]) { + case 'q': + $saved[] = $colour; + break; + case 'Q': + $colour = array_pop($saved); + break; + case 'BT': + $inText = true; + break; + case 'ET': + $inText = false; + break; + default: + if (!$inText) { + $colours[] = $colour; + } + } + } + + return $colours; + } + + /** + * Records the colours of the block's box-shadows before painting it + * + * @param string $divider + * @param int $blockstate + * @param int $blvl + */ + function PaintDivBB($divider = '', $blockstate = 0, $blvl = 0) + { + $level = $blvl ? $blvl : $this->blklvl; + if (!empty($this->blk[$level]['box_shadow'])) { + foreach ($this->blk[$level]['box_shadow'] as $shadow) { + $this->drawnBoxShadows[] = $this->SetFColor($shadow['col'], true); + } + } + + return parent::PaintDivBB($divider, $blockstate, $blvl); + } + + /** + * Records the text-shadows and the visibility of each line of text, as TextRecordingMpdf records its text + */ + function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = 0, $link = '', $currentx = 0, $lcpaddingL = 0, $lcpaddingR = 0, $valign = 'M', $spanfill = 0, $exactWidth = false, $OTLdata = false, $textvar = 0, $lineBox = false) + { + if (is_string($txt) && trim($txt) !== '') { + $shadows = []; + foreach ($this->textshadow ? $this->textshadow : [] as $shadow) { + $shadows[] = $this->SetTColor($shadow['col'], true); + } + $this->drawnTextShadows[] = $shadows; + $this->drawnVisible[] = empty($this->textparam['transparent']); + } + + return parent::Cell($w, $h, $txt, $border, $ln, $align, $fill, $link, $currentx, $lcpaddingL, $lcpaddingR, $valign, $spanfill, $exactWidth, $OTLdata, $textvar, $lineBox); + } + +} diff --git a/tests/Snapshots/CurrentColorDocumentLegacySnapshotTest.php b/tests/Snapshots/CurrentColorDocumentLegacySnapshotTest.php new file mode 100644 index 000000000..750ce23bf --- /dev/null +++ b/tests/Snapshots/CurrentColorDocumentLegacySnapshotTest.php @@ -0,0 +1,21 @@ +mode(); + } + + /** + * The invoice, each part under a caption saying what the standard and the legacy mode draw + */ + public function generatePdf() + { + ob_start(); + ?> + + +

A framework-styled invoice

+

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

+ +

The masthead's border-bottom names no colour. Standard: it is blue, the masthead's colour. Legacy: it is black. The words "Skip to the invoice" are transparent: in both they leave a gap before the studio's name and are not seen.

+
Skip to the invoice Example Studio, invoice 2024-117
+ +

The link button's border is transparent, and the primary button's is dark blue: in both, the link button has no frame and takes the same room.

+

Pay now Download a copy

+ +

Items

+

The heading's text-shadow names no colour. Standard: it is blue like the heading. Legacy: it is grey.

+

The header's line is in currentColor, grey in both. The rows' lines name no colour. Standard: they are grey, the table's colour, and the total's lines, double above, are green. Legacy: they are black. The breakdown's dotted cells are in currentColor, a lighter grey in both.

+ + + + + +
ItemAmount
Design, with two rounds of review1,200.00
Hosting +
Server240.00
Backups60.00
+
300.00
Total1,500.00
+ +

The divider's border is hidden: no red line and no hairline in either.

+
Below a hidden border
+ +

The alert's border names no colour. Standard: it is dark red, the alert's colour. Legacy: it is black.

+
Payment is overdue by 14 days.
+ +

The badge's box-shadow names no colour. Standard: it is purple. Legacy: it is grey.

+
Priority customer
+ +

Next steps

+

Each step's left border names no colour. Standard: green for the step done, light grey for those to do. Legacy: all black.

+
    +
  1. Invoice sent
  2. +
  3. Payment received
  4. +
  5. Receipt issued
  6. +
+ mpdf = $this->createMpdf(['cssMode' => $this->mode()]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/CurrentColorDocumentStandardSnapshotTest.php b/tests/Snapshots/CurrentColorDocumentStandardSnapshotTest.php new file mode 100644 index 000000000..7e2d1856a --- /dev/null +++ b/tests/Snapshots/CurrentColorDocumentStandardSnapshotTest.php @@ -0,0 +1,21 @@ + + + +

currentColor, and colours that draw nothing

+ +

A border in currentColor is green, the colour of the text.

+
Green text in a green border
+ +

A border that names no colour is green too.

+
Green text in a green border
+ +

A dashed border with no colour on a block inside a blue one is blue.

+
Blue text in a blue dashed border
+ +

A span's border with no colour is red, around the red word only.

+

Black text with a red boxed word in it.

+ +

The cells of a purple table have purple borders that name no colour.

+
First cellSecond cell
+ +

The items of an amber list are underlined in amber by a border-bottom with no colour.

+
  • First item
  • Second item
+ +

An image in green text has a green frame that names no colour.

+

An image in green text.

+ +

A transparent border takes its width and draws nothing: only the red bottom side shows, and the text is set in 3mm from the left.

+
Text inside a transparent border
+ +

A hidden border draws nothing, not even a hairline.

+
Text inside a hidden border
+ +

Transparent text takes its place and is not seen: the brackets hold a gap.

+

Between the brackets [these words are hidden] there is only space.

+ +

A box-shadow with no colour is green, and a text-shadow with no colour is red.

+
A block with a green shadow
+

Red text with a red shadow

+ +

A transparent text-shadow draws nothing: only the blue shadow shows.

+

Black text with a blue shadow

+ +

A background in currentColor is peach, the block's colour, behind black text.

+
Black text on peach
+ mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/snapshots/current-color-document-legacy.pdf b/tests/data/snapshots/current-color-document-legacy.pdf new file mode 100644 index 000000000..37a0d2584 Binary files /dev/null and b/tests/data/snapshots/current-color-document-legacy.pdf differ diff --git a/tests/data/snapshots/current-color-document-standard.pdf b/tests/data/snapshots/current-color-document-standard.pdf new file mode 100644 index 000000000..169e0ee87 Binary files /dev/null and b/tests/data/snapshots/current-color-document-standard.pdf differ diff --git a/tests/data/snapshots/current-color.pdf b/tests/data/snapshots/current-color.pdf new file mode 100644 index 000000000..bf4d70a62 Binary files /dev/null and b/tests/data/snapshots/current-color.pdf differ