From 577de55a434c33edfa0754a5a28a39b35beccca6 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Tue, 6 Oct 2026 15:12:21 +1100 Subject: [PATCH] Draw a text outline with no colour in the colour of its text (#650) text-outline-width without a text-outline-color read a colour that was never set and raised an undefined-key warning. In standard mode the outline now takes the colour of each piece of text, as text-outline-color is currentColor by default; the text-outline shorthand with a width alone draws it too, and currentColor written in text-outline-color is passed on as the keyword. Legacy mode keeps the stroke colour it drew in before, without the warning. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG-GRAVITYPDF.md | 3 + src/Css/CssMerger.php | 14 ++-- src/Css/NormalizeProperties.php | 12 ++-- src/Mpdf.php | 25 +++++-- tests/Mpdf/CurrentColorTest.php | 118 +++++++++++++++++++++++++++++++- 5 files changed, 155 insertions(+), 17 deletions(-) diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index 96a8e3ffb..42de2fd63 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -262,6 +262,7 @@ Bugfixes * `text-transform: none` on an inline element, such as a `` in an uppercase paragraph, did not undo the transform it inherits. Under the standard `cssMode` only; set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep the transform. [#552] [#539] [#648] * A block inside an inline element, such as `…
…
…
` or a card written as `
…
`, lost the inline element's colour, font and other inherited text properties, and so did the inline element's text after the block. A link lost its colour and underline on the block's text and did not cover it. In a table cell the block kept the style, but the text after the inline element did too. A block now inherits the text properties of the inline elements it opens in, and the text after it is drawn in their style until they end. Text before the inline element, on the line before the block, is no longer drawn in the inline element's colour or covered by its link. Under the standard `cssMode` only; set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep the old output. [#541] [#653] * Styling a table row or row group, such as `tr.total { font-weight: bold }` or ``, did not change the text of its cells, only their background. A `tr`, `thead`, `tbody` or `tfoot` now hands every inherited property to its cells, over the table's and under the cell's own, and a `tbody` rule reaches the rows written straight into a table. A row's `font-size: 80%` is taken of its row group's size, and a cell's of its row's. A row's `currentColor` is its row group's colour, and a cell's is its row's. A `th` stays bold, and is centred only when it inherits no `text-align` from its row, row group or table, as HTML's rendering rules have it. Under the standard `cssMode` only; set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep the old output. [#542] [#655] +* `text-outline-width` with no `text-outline-color` raised an undefined-key warning (`outline-COLOR`) and drew the outline black. The warning is gone in either `cssMode`. In standard mode the outline is now drawn in the colour of the text, as `text-outline-color` is `currentColor` by default, and `text-outline` with a width alone, such as `text-outline: 0.2mm`, which was ignored, outlines the text in its colour too. A text outline in `currentColor`, written or by default, is passed on as the keyword, so a `` of another colour inside the outlined text is outlined in its own colour. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep black outlines and to ignore that shorthand. [#552] [#650] [#681] ### Images and SVG @@ -950,3 +951,5 @@ These changes do not change output. [#679]: https://github.com/GravityPDF/mpdf/pull/679 [#649]: https://github.com/GravityPDF/mpdf/issues/649 [#680]: https://github.com/GravityPDF/mpdf/pull/680 +[#650]: https://github.com/GravityPDF/mpdf/issues/650 +[#681]: https://github.com/GravityPDF/mpdf/pull/681 diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 6dbacccaf..105ad1853 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -25,9 +25,14 @@ class CssMerger 'BORDER-RIGHT-COLOR', 'BORDER-BOTTOM-COLOR', 'BORDER-LEFT-COLOR', - 'TEXT-OUTLINE-COLOR', ]; + /** + * The properties that resolve currentColor in legacy mode only. Standard mode keeps the keyword in them because + * they are inherited: each descendant draws it in its own colour, as the default of an outline with no colour + */ + const LEGACY_CURRENT_COLOR_PROPERTIES = ['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 @@ -417,11 +422,10 @@ private function mergeBlockExtras($tag) */ private function resolveCurrentColor($inherit, array $inherited = []) { - $properties = self::CURRENT_COLOR_PROPERTIES; - - if ($this->mpdf->cssMode === CssMode::STANDARD) { - $properties = array_merge($properties, self::STANDARD_CURRENT_COLOR_PROPERTIES); + $standard = $this->mpdf->cssMode === CssMode::STANDARD; + $properties = array_merge(self::CURRENT_COLOR_PROPERTIES, $standard ? self::STANDARD_CURRENT_COLOR_PROPERTIES : self::LEGACY_CURRENT_COLOR_PROPERTIES); + if ($standard) { foreach (['BOX-SHADOW', 'TEXT-SHADOW'] as $property) { if (isset($this->cssProperties[$property])) { $this->cssProperties[$property] = ShadowParser::withColor($this->cssProperties[$property], 'currentcolor'); diff --git a/src/Css/NormalizeProperties.php b/src/Css/NormalizeProperties.php index a0fb8f76d..e7263db21 100644 --- a/src/Css/NormalizeProperties.php +++ b/src/Css/NormalizeProperties.php @@ -1174,7 +1174,8 @@ protected function processBorderRadiusProperty($property, $value) /** * Process text outline CSS properties. * - * Handles TEXT-OUTLINE shorthand. + * Handles TEXT-OUTLINE shorthand. In standard mode a width alone sets the colour to its initial value, currentColor; + * legacy mode ignores it. * * @param string $v Property value * @return void @@ -1182,15 +1183,16 @@ protected function processBorderRadiusProperty($property, $value) protected function processTextOutlineProperty($v) { $prop = preg_split('/\s+/', trim($v)); + $count = count($prop); if (strtolower(trim($v)) === 'none') { $this->properties['TEXT-OUTLINE'] = 'none'; - } elseif (count($prop) === 2) { + } elseif ($count === 2 || $count === 3) { $this->properties['TEXT-OUTLINE-WIDTH'] = $prop[0]; - $this->properties['TEXT-OUTLINE-COLOR'] = $prop[1]; - } elseif (count($prop) === 3) { + $this->properties['TEXT-OUTLINE-COLOR'] = end($prop); + } elseif ($count === 1 && $this->resetsOmittedParts()) { $this->properties['TEXT-OUTLINE-WIDTH'] = $prop[0]; - $this->properties['TEXT-OUTLINE-COLOR'] = $prop[2]; + $this->properties['TEXT-OUTLINE-COLOR'] = 'currentcolor'; } } diff --git a/src/Mpdf.php b/src/Mpdf.php index bc5ed85a2..a06843e68 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -9880,11 +9880,25 @@ function _SetTextRendering($mode) $this->pageoutput[$this->page]['TextRendering'] = $tr; } - function SetTextOutline($params = []) + /** + * Sets the line width, stroke colour and rendering mode text is drawn with: stroked for a text outline, or else + * filled, with the width and stroke colour reset + * + * @param array $params The text parameters, which name the outline's width and colour + * @param array|string|null $textColor The colour of the text, which an outline with no colour takes in standard + * mode, as text-outline-color is currentColor by default. Null for black + * @return void + */ + function SetTextOutline($params = [], $textColor = null) { if (isset($params['outline-s']) && $params['outline-s']) { $this->SetLineWidth($params['outline-WIDTH']); - $this->SetDColor($params['outline-COLOR']); + if (isset($params['outline-COLOR'])) { + $this->SetDColor($params['outline-COLOR']); + } elseif ($this->cssMode === CssMode::STANDARD) { + $this->SetDColor($textColor ?: $this->colorConverter->convert(0, $this->PDFAXwarnings)); + } + // Legacy mode strokes an outline with no colour in the stroke colour already set, as mPDF v7 did $tr = ('2 Tr'); if ($this->page > 0 && ((isset($this->pageoutput[$this->page]['TextRendering']) && $this->pageoutput[$this->page]['TextRendering'] != $tr) || !isset($this->pageoutput[$this->page]['TextRendering']))) { $this->writer->write($tr); @@ -17333,7 +17347,7 @@ function printbuffer($arrayaux, $blockstate = 0, $is_table = false, $table_draft } if (isset($vetor[9]) and ! empty($vetor[9])) { // Text parameters - Outline + hyphens $this->textparam = $vetor[9]; - $this->SetTextOutline($this->textparam); + $this->SetTextOutline($this->textparam, isset($vetor[3]) ? $vetor[3] : null); // mPDF 5.7.3 inline text-decoration parameters if ($is_table && $this->shrin_k) { if (isset($this->textparam['text-baseline'])) { @@ -20009,7 +20023,10 @@ function setCSS($arrayaux, $type = '', $tag = '') break; case 'TEXT-OUTLINE-COLOR': - if (strtoupper($v) == 'INVERT') { + if (strtolower($v) === 'currentcolor') { + // Left unset, the outline is drawn in the colour of each piece of text + unset($this->textparam['outline-COLOR']); + } elseif (strtoupper($v) == 'INVERT') { if ($this->colorarray) { $cor = $this->colorarray; $this->textparam['outline-COLOR'] = $this->colorConverter->invert($cor); diff --git a/tests/Mpdf/CurrentColorTest.php b/tests/Mpdf/CurrentColorTest.php index 9c6ad15b7..715096a87 100644 --- a/tests/Mpdf/CurrentColorTest.php +++ b/tests/Mpdf/CurrentColorTest.php @@ -24,6 +24,18 @@ class CurrentColorTest extends TestCase /** A border's colour in legacy mode when it names none */ const BLACK = '0.000 0.000 0.000 rg'; + /** A text outline stroked in #0a0 */ + const GREEN_OUTLINE = '0.000 0.667 0.000 RG 2 Tr BT'; + + /** A text outline stroked in #c00 */ + const RED_OUTLINE = '0.800 0.000 0.000 RG 2 Tr BT'; + + /** A text outline stroked in #00f */ + const BLUE_OUTLINE = '0.000 0.000 1.000 RG 2 Tr BT'; + + /** A text outline stroked in black, as legacy mode strokes one that names no colour */ + const BLACK_OUTLINE = 'w 0.000 G 2 Tr BT'; + /** A shadow's colour in legacy mode when it names none: #888888 */ const GREY = '0.533 0.533 0.533 rg'; @@ -339,7 +351,8 @@ public function boxShadows() /** * 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 + * properties are merged, in a value with other parts and in a gradient, and not in an image's address. A text + * outline keeps the keyword, which is drawn in the colour of each piece of text */ public function testEveryColourPropertyResolvesCurrentColor() { @@ -351,7 +364,7 @@ public function testEveryColourPropertyResolvesCurrentColor() $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']); + $this->assertSame('currentcolor', strtolower($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); @@ -402,7 +415,7 @@ public function testATextOutlineInCurrentColorIsDrawnInTheTextsColour($mode) { $pdf = $this->uncompressed('

subject

', $mode); - $this->assertStringContainsString('0.000 0.667 0.000 RG 2 Tr BT', $pdf); + $this->assertStringContainsString(self::GREEN_OUTLINE, $pdf); } /** @@ -561,6 +574,105 @@ private function inModesAndContexts(array $contexts, array $cases) return $data; } + /** + * The issue's document: a text outline with no colour, and none for the text, is drawn in black with no warning + * (#650) + * + * @dataProvider modes + * + * @param string $mode + */ + public function testATextOutlineWithNoColourIsDrawnWithoutAWarning($mode) + { + $pdf = $this->uncompressed('

subject

', $mode); + + $this->assertStringContainsString(self::BLACK_OUTLINE, $pdf); + } + + /** + * In standard mode a text outline with no colour is drawn in the colour of the text, which it inherits here. + * Legacy mode keeps the black it drew before + * + * @dataProvider outlinesWithNoColourInContexts + * + * @param string $mode + * @param string $context + * @param string $style + * @param string $expected + */ + public function testATextOutlineWithNoColourTakesTheTextsColour($mode, $context, $style, $expected) + { + $pdf = $this->uncompressed($this->subject($context, $style, 'color: #0a0'), $mode); + + $this->assertStringContainsString($expected, $pdf); + } + + /** + * A text outline with no colour in each context, with the stroke each mode draws it in + * + * @return array[] + */ + public function outlinesWithNoColourInContexts() + { + return $this->inModesAndContexts(self::CONTEXTS, ['no colour' => ['text-outline-width: 0.2mm', self::GREEN_OUTLINE, self::BLACK_OUTLINE]]); + } + + /** + * In standard mode a text outline in currentColor, written or by default, strokes each piece of text in that + * piece's colour, as currentColor is inherited as the keyword + * + * @dataProvider outlinesInCurrentColor + * + * @param string $style + */ + public function testATextOutlineInCurrentColorFollowsEachPieceOfText($style) + { + $pdf = $this->uncompressed('

before inside after

', CssMode::STANDARD); + + $this->assertSame(2, substr_count($pdf, self::GREEN_OUTLINE)); + $this->assertSame(1, substr_count($pdf, self::BLUE_OUTLINE)); + } + + /** + * Text outlines in currentColor: with no colour, the shorthand with a width alone, which resets a colour given + * before it, and the colour written + * + * @return string[][] + */ + public function outlinesInCurrentColor() + { + return [ + 'no colour' => ['text-outline-width: 0.2mm'], + 'shorthand with a width alone' => ['text-outline: 0.2mm'], + 'shorthand after a colour' => ['text-outline-color: #c00; text-outline: 0.2mm'], + 'currentColor' => ['text-outline-width: 0.2mm; text-outline-color: currentColor'], + ]; + } + + /** + * Legacy mode ignores the text-outline shorthand with a width alone, as it did + */ + public function testLegacyModeIgnoresTheTextOutlineShorthandWithAWidthAlone() + { + $pdf = $this->uncompressed('

subject

', CssMode::LEGACY); + + $this->assertStringNotContainsString('2 Tr BT', $pdf); + } + + /** + * An outline colour inherited from a parent is kept by a child that sets only a width + * + * @dataProvider modes + * + * @param string $mode + */ + public function testAnInheritedTextOutlineColourIsKept($mode) + { + $pdf = $this->uncompressed('

before inside

', $mode); + + $this->assertStringContainsString(self::RED_OUTLINE, $pdf); + } + /** * Both modes *