From d08960a1eb1add3fb171fbc34f1627427ae678a0 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 12 Oct 2026 07:48:03 +1100 Subject: [PATCH 1/2] Read the text-decoration longhands and text-align start, end and match-parent (#607) mPDF read only the lines of text-decoration and matched them by substring, so the style, colour and thickness of the shorthand, the longhands and text-underline-offset/-position were ignored, and every decoration was a solid line in the text colour. text-align: start and end matched none of the keywords mPDF knows, so end left ltr text at the left. Under the standard cssMode the shorthand now expands into its longhands (TEXT-DECORATION keeps the lines, so the propagation from #654 reads them as before), each decoration keeps its own style, colour, thickness and underline position, and the line is stroked solid, double, dotted, dashed or wavy. start and end are taken against the block's or cell's direction, and match-parent computes to the parent's alignment. Legacy mode is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-GRAVITYPDF.md | 6 + src/Css/ComputedValues.php | 5 + src/Css/CssMerger.php | 4 +- src/Css/CssWideKeywords.php | 5 + src/Css/InheritedProperties.php | 13 +- src/Css/NormalizeProperties.php | 38 ++ src/Css/TextAlign.php | 44 +++ src/Css/TextDecorations.php | 106 +++++- src/Mpdf.php | 132 ++++++- src/Tag/Td.php | 5 + tests/Mpdf/TextAlignLogicalTest.php | 136 +++++++ tests/Mpdf/TextDecorationLonghandsTest.php | 338 ++++++++++++++++++ .../TextDecorationAlignSnapshotTest.php | 101 ++++++ .../data/snapshots/text-decoration-align.pdf | Bin 0 -> 44138 bytes 14 files changed, 921 insertions(+), 12 deletions(-) create mode 100644 src/Css/TextAlign.php create mode 100644 tests/Mpdf/TextAlignLogicalTest.php create mode 100644 tests/Mpdf/TextDecorationLonghandsTest.php create mode 100644 tests/Snapshots/TextDecorationAlignSnapshotTest.php create mode 100644 tests/data/snapshots/text-decoration-align.pdf diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index a674b3d97..defbf8313 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -158,6 +158,11 @@ New features * A reset such as `* { margin: 0; padding: 0 }` now takes away the default margins of paragraphs, headings and lists, a list's indent and a cell's padding, as in a browser. It reaches the lines of the table of contents and the index as well, which are the document's elements, so their levels are no longer indented. Style `div.mpdf_toc_level_1` and the like to indent them again. * mPDF's own ``, `` and `` are not HTML elements. A rule reaches them only by naming their tag, or an id or class they carry, so `*`, and `:empty`, `[attr]` or `:not()` alone, leave them as they are: `:empty { display: none }` does not hide a barcode. * **Font weights as numbers, `bolder` and `lighter`, and `font-size: larger` and `smaller`.** In the standard CSS mode, `font-weight` takes a number from 1 to 1000, and `bolder` and `lighter` step from the parent's weight by the table in CSS Fonts. The weight is inherited as a number, so `bolder` inside `bolder` gives 900 and `lighter` inside 700 gives 400, and `h1 { font-weight: lighter }` is regular, one step down from its parent's 400. A weight above 500 is drawn with the family's bold face and any other with its regular face. `larger` and `smaller` multiply and divide the parent's size by 1.2. A form field reads them against the document's size, as it reads its other font sizes. The `font` shorthand's weight is read the same way. All of these were ignored before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to read only `normal` and `bold`, as before. [#545] [#647] +* **The `text-decoration` longhands, and `text-align: start`, `end` and `match-parent`, in standard mode.** They were ignored before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep ignoring them. [#552] [#607] + * `text-decoration-line`, `text-decoration-style`, `text-decoration-color` and `text-decoration-thickness` are read, and so are the style, colour and thickness parts of the `text-decoration` shorthand, which resets those it leaves out. `p { text-decoration: underline wavy #f00 }` draws a red wavy underline where it drew a straight one in the text colour. + * The styles are `solid`, `double`, `dotted`, `dashed` and `wavy`. A thickness is a length, or a percentage of the font size; `auto` and `from-font` keep the font's. + * `text-underline-offset` sets the top of the underline that far below the baseline, and `text-underline-position: under` puts it below the descenders. Both are inherited, and read where the underline is set. + * `text-align: start` and `end` align a block's or a cell's text to the left or right by its direction, so `end` aligns ltr text right and rtl text left. `match-parent` takes the parent's alignment, with `start` or `end` taken against the parent's direction. Performance ----------- @@ -964,4 +969,5 @@ These changes do not change output. [#588]: https://github.com/GravityPDF/mpdf/issues/588 [#691]: https://github.com/GravityPDF/mpdf/pull/691 [#584]: https://github.com/GravityPDF/mpdf/issues/584 +[#607]: https://github.com/GravityPDF/mpdf/issues/607 [#689]: https://github.com/GravityPDF/mpdf/pull/689 diff --git a/src/Css/ComputedValues.php b/src/Css/ComputedValues.php index 49242974e..05e1a396a 100644 --- a/src/Css/ComputedValues.php +++ b/src/Css/ComputedValues.php @@ -53,6 +53,7 @@ final class ComputedValues 'TEXT-INDENT' => true, 'TEXT-SHADOW' => true, 'TEXT-OUTLINE-WIDTH' => true, + 'TEXT-UNDERLINE-OFFSET' => true, 'PADDING-TOP' => true, 'PADDING-RIGHT' => true, 'PADDING-BOTTOM' => true, @@ -187,6 +188,10 @@ public function compute(array $properties, array $parent, $own = null, $medium = } } + if (isset($own['TEXT-ALIGN'], $properties['TEXT-ALIGN']) && strtolower($properties['TEXT-ALIGN']) === 'match-parent') { + $properties['TEXT-ALIGN'] = TextAlign::matchParent($parent); + } + if (isset($own['LINE-HEIGHT'], $properties['LINE-HEIGHT'])) { $properties['LINE-HEIGHT'] = $this->computeLineHeight($properties['LINE-HEIGHT'], $fontSize); } diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 105ad1853..2b92f965a 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -341,8 +341,8 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) $properties = array_merge($this->cssProperties, InheritedProperties::of($computed, InheritedProperties::names())); // An inline element, and a block in a table cell, is drawn over the state of what it is in, which already - // holds what it inherits - return $inherit === 'INLINE' || $inherit === '' ? array_intersect_key($properties, $own) : $properties; + // holds what it inherits. What places an underline is read where the underline is set, so it comes too + return $inherit === 'INLINE' || $inherit === '' ? array_intersect_key($properties, $own + array_flip(InheritedProperties::DECORATION)) : $properties; } /** diff --git a/src/Css/CssWideKeywords.php b/src/Css/CssWideKeywords.php index 0c14443c0..f34b822a8 100644 --- a/src/Css/CssWideKeywords.php +++ b/src/Css/CssWideKeywords.php @@ -124,10 +124,15 @@ final class CssWideKeywords 'POSITION' => 'static', 'TEXT-ALIGN' => 'start', 'TEXT-DECORATION' => 'none', + 'TEXT-DECORATION-COLOR' => 'currentcolor', + 'TEXT-DECORATION-STYLE' => 'solid', + 'TEXT-DECORATION-THICKNESS' => 'auto', 'TEXT-INDENT' => '0', 'TEXT-OUTLINE' => 'none', 'TEXT-SHADOW' => 'none', 'TEXT-TRANSFORM' => 'none', + 'TEXT-UNDERLINE-OFFSET' => 'auto', + 'TEXT-UNDERLINE-POSITION' => 'auto', 'VERTICAL-ALIGN' => 'baseline', 'VISIBILITY' => 'visible', 'WHITE-SPACE' => 'normal', diff --git a/src/Css/InheritedProperties.php b/src/Css/InheritedProperties.php index bfac198f8..663ac3f50 100644 --- a/src/Css/InheritedProperties.php +++ b/src/Css/InheritedProperties.php @@ -61,6 +61,15 @@ final class InheritedProperties 'LIST-STYLE-POSITION', ]; + /** + * Read by Mpdf::setCSS() where an element sets an underline, so an element hands them on through the computed + * values on its frame alone. Under CssMode::LEGACY they are not read + */ + const DECORATION = [ + 'TEXT-UNDERLINE-OFFSET', + 'TEXT-UNDERLINE-POSITION', + ]; + /** * What a positioned block handed its content under CssMode::LEGACY, in the order it was written. Text decorations * and the stacking order are not inherited, but the
that stands in for the block takes them too @@ -90,13 +99,13 @@ final class InheritedProperties ]; /** - * @return string[] Every inherited property mPDF supports: TEXT, then BLOCK + * @return string[] Every inherited property mPDF supports: TEXT, then BLOCK, then DECORATION */ public static function names() { static $names = null; if ($names === null) { - $names = array_merge(self::TEXT, self::BLOCK); + $names = array_merge(self::TEXT, self::BLOCK, self::DECORATION); } return $names; diff --git a/src/Css/NormalizeProperties.php b/src/Css/NormalizeProperties.php index e7263db21..35798199a 100644 --- a/src/Css/NormalizeProperties.php +++ b/src/Css/NormalizeProperties.php @@ -82,6 +82,7 @@ class NormalizeProperties 'BORDER-LEFT-WIDTH' => true, 'BORDER-SPACING' => true, 'BORDER-RADIUS' => true, 'BORDER-TOP-LEFT-RADIUS' => true, 'BORDER-TOP-RIGHT-RADIUS' => true, 'BORDER-BOTTOM-LEFT-RADIUS' => true, 'BORDER-BOTTOM-RIGHT-RADIUS' => true, + 'TEXT-DECORATION-THICKNESS' => true, 'TEXT-UNDERLINE-OFFSET' => true, ]; /** @@ -92,6 +93,7 @@ class NormalizeProperties private static $colorProperties = [ 'COLOR' => true, 'BACKGROUND-COLOR' => true, 'BORDER-TOP-COLOR' => true, 'BORDER-RIGHT-COLOR' => true, 'BORDER-BOTTOM-COLOR' => true, 'BORDER-LEFT-COLOR' => true, + 'TEXT-DECORATION-COLOR' => true, ]; /** @@ -140,6 +142,8 @@ class NormalizeProperties ], 'LIST-STYLE' => ['LIST-STYLE-TYPE', 'LIST-STYLE-IMAGE', 'LIST-STYLE-POSITION'], 'TEXT-OUTLINE' => ['TEXT-OUTLINE', 'TEXT-OUTLINE-WIDTH', 'TEXT-OUTLINE-COLOR'], + 'TEXT-DECORATION' => ['TEXT-DECORATION', 'TEXT-DECORATION-STYLE', 'TEXT-DECORATION-COLOR', 'TEXT-DECORATION-THICKNESS'], + 'TEXT-DECORATION-LINE' => ['TEXT-DECORATION'], 'BREAK-BEFORE' => ['PAGE-BREAK-BEFORE'], 'BREAK-AFTER' => ['PAGE-BREAK-AFTER'], 'BREAK-INSIDE' => ['PAGE-BREAK-INSIDE'], @@ -230,6 +234,11 @@ public function normalize($prop) $this->processBorderShorthandProperty($k, $v); } elseif ($k === 'TEXT-OUTLINE') { $this->processTextOutlineProperty($v); + } elseif ($standard && $k === 'TEXT-DECORATION') { + $this->processTextDecorationProperty($v); + } elseif ($standard && $k === 'TEXT-DECORATION-LINE') { + // TEXT-DECORATION holds the lines alone, so what reads it, and propagates it, reads this too + $this->properties['TEXT-DECORATION'] = $v; } elseif ($k === 'SIZE' || $k === 'SHEET-SIZE') { $this->processPageSizeProperty($k, $v); } elseif ($k === 'BREAK-BEFORE' || $k === 'BREAK-AFTER' || $k === 'BREAK-INSIDE') { @@ -1196,6 +1205,35 @@ protected function processTextOutlineProperty($v) } } + /** + * Expands the text-decoration shorthand into the lines, kept as TEXT-DECORATION, and the style, colour and + * thickness, each reset to its initial value where the shorthand leaves it out + * + * @param string $v Property value + * @return void + */ + private function processTextDecorationProperty($v) + { + $lines = []; + $parts = ['TEXT-DECORATION-STYLE' => 'solid', 'TEXT-DECORATION-COLOR' => 'currentcolor', 'TEXT-DECORATION-THICKNESS' => 'auto']; + foreach ($this->splitComponents($v) as $component) { + if (in_array($component, ['none', 'underline', 'overline', 'line-through', 'blink'], true)) { + $lines[] = $component; + } elseif (in_array($component, TextDecorations::STYLES, true)) { + $parts['TEXT-DECORATION-STYLE'] = $component; + } elseif ($component === 'auto' || $component === 'from-font' || $this->sizeConverter->isLength($component)) { + $parts['TEXT-DECORATION-THICKNESS'] = $component; + } else { + $parts['TEXT-DECORATION-COLOR'] = $component; + } + } + + $this->properties['TEXT-DECORATION'] = $lines ? implode(' ', $lines) : 'none'; + foreach ($parts as $longhand => $value) { + $this->properties[$longhand] = $value; + } + } + /** * Process page size CSS properties. * diff --git a/src/Css/TextAlign.php b/src/Css/TextAlign.php new file mode 100644 index 000000000..5a2cba922 --- /dev/null +++ b/src/Css/TextAlign.php @@ -0,0 +1,44 @@ + 'o-decoration', ]; + /** + * Each decoration's TextVars bit, and the keyword that sets it + */ + const LINES = [ + TextVars::FD_UNDERLINE => 'underline', + TextVars::FD_LINETHROUGH => 'line-through', + TextVars::FD_OVERLINE => 'overline', + ]; + + /** + * The keys of a decoration's text parameter that Mpdf::setCSS() sets only where the element's CSS asks for them, as + * keys, so an element's own decoration does not keep those of the one it replaces + */ + const DRAWN_AS = ['style' => true, 'thickness' => true, 'offset' => true, 'under' => true]; + + /** + * The values of text-decoration-style + */ + const STYLES = ['solid', 'double', 'dotted', 'dashed', 'wavy']; + + /** + * Which way from its position a decoration's second line goes, and a wave's first arch: away from the text for an + * underline and an overline, and either side of the line for a line-through. In PDF units, which run up the page + */ + const UNDER = -1; + const THROUGH = 0; + const OVER = 1; + + /** + * Where a cubic Bézier arch's control points go along a half wave, as a fraction of it, to follow a sine + */ + const SINE_CONTROL = 0.3642; + /** * Under CssMode::STANDARD, starts an element's text with the decorations of the element it is in, or with none when * the element is floated, positioned out of the flow or an inline block. Call it before Mpdf::setCSS() adds the @@ -111,4 +145,72 @@ private static function propagateInto(array $properties) && !in_array($position, ['absolute', 'fixed'], true) && !in_array($display, ['inline-block', 'inline-table'], true); } + + /** + * The PDF operators that stroke a decoration in a style other than solid, in the stroke colour and line width already + * set. All lengths are in PDF units. + * + * - double: two lines of the thickness, a thickness apart, the second away from the text + * - dotted: round dots the thickness across, a dot's width apart + * - dashed: dashes three thicknesses long, two apart + * - wavy: a sine three thicknesses high from crest to trough, of half wavelength three thicknesses, fitted to the + * width with whole half waves, each a cubic Bézier arch + * + * @param string $style One of STYLES other than solid + * @param float $x Where the decoration starts + * @param float $y Where its centre line runs + * @param float $width + * @param float $thickness + * @param int $side UNDER, THROUGH or OVER + * + * @return string + */ + public static function stroke($style, $x, $y, $width, $thickness, $side) + { + $line = function ($y) use ($x, $width) { + return sprintf('%.3F %.3F m %.3F %.3F l S', $x, $y, $x + $width, $y); + }; + + switch ($style) { + case 'double': + $first = $side === self::THROUGH ? $y + $thickness : $y; + $second = $side === self::THROUGH ? $y - $thickness : $y + 2 * $thickness * $side; + + return $line($first) . ' ' . $line($second); + + case 'dotted': + return sprintf('1 J [0 %.3F] 0 d ', 2 * $thickness) . $line($y) . ' [] 0 d'; + + case 'dashed': + return sprintf('[%.3F %.3F] 0 d ', 3 * $thickness, 2 * $thickness) . $line($y) . ' [] 0 d'; + + case 'wavy': + $half = 3 * $thickness; + $count = max(1, (int) round($width / $half)); + $half = $width / $count; + // A cubic's control points at height h raise its middle by 3h/4 + $height = 1.5 * $thickness * 4 / 3; + $crest = $side === self::THROUGH ? self::OVER : $side; + + $path = sprintf('1 j %.3F %.3F m', $x, $y); + for ($i = 0; $i < $count; $i++) { + $start = $x + $i * $half; + $control = $y + $crest * $height; + $path .= sprintf( + ' %.3F %.3F %.3F %.3F %.3F %.3F c', + $start + self::SINE_CONTROL * $half, + $control, + $start + (1 - self::SINE_CONTROL) * $half, + $control, + $start + $half, + $y + ); + $crest = -$crest; + } + + return $path . ' S'; + } + + return $line($y); + } } diff --git a/src/Mpdf.php b/src/Mpdf.php index b700f3353..636700361 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -13,6 +13,7 @@ use Mpdf\Css\RelativeFontValues; use Mpdf\Css\TextSpacing; use Mpdf\Color\IccProfile; +use Mpdf\Css\TextAlign; use Mpdf\Css\TextDecorations; use Mpdf\Css\TextVars; use Mpdf\Css\WidthConstraints; @@ -6188,7 +6189,20 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = $up = -100; } - $adjusty = (-$up / 1000 * $decorationfontsize) + $ut / 2; + $underline = isset($this->textparam['u-decoration']) ? $this->textparam['u-decoration'] : []; + if (isset($underline['thickness'])) { + $ut = $underline['thickness'] / $shrin_k; + } + if (isset($underline['offset']) || isset($underline['under'])) { + // An offset is from the alphabetic baseline, or from below the descenders, to the line's top + $top = 0; + if (isset($underline['under']) && isset($this->fonts[$decorationfontkey]['desc']['Descent'])) { + $top = -$this->fonts[$decorationfontkey]['desc']['Descent'] / 1000 * $decorationfontsize; + } + $adjusty = $top + (isset($underline['offset']) ? $underline['offset'] / $shrin_k : 0) + $ut / 2; + } else { + $adjusty = (-$up / 1000 * $decorationfontsize) + $ut / 2; + } $ubaseline = isset($this->textparam['u-decoration']['baseline']) ? $glyphYorigin - $this->textparam['u-decoration']['baseline'] / $shrin_k : $glyphYorigin; @@ -6196,7 +6210,7 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = $olw = $this->LineWidth; $sub .= ' ' . (sprintf(' %.3F w 0 j 0 J ', $ut * Mpdf::SCALE)); - $sub .= ' ' . $this->_dounderline($this->x + $dx, $this->y + $ubaseline + $adjusty, $txt, $OTLdata, $textvar); + $sub .= ' ' . $this->decorationStroke($underline, TextDecorations::UNDER, $ut, $this->x + $dx, $this->y + $ubaseline + $adjusty, $txt, $OTLdata, $textvar); $sub .= ' ' . (sprintf(' %.3F w 2 j 2 J ', $olw * Mpdf::SCALE)); if ($this->FillColor != $c) { @@ -6243,11 +6257,14 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = } $sbaseline = $glyphYorigin - $this->textparam['s-decoration']['baseline'] / $shrin_k; + if (isset($this->textparam['s-decoration']['thickness'])) { + $ut = $this->textparam['s-decoration']['thickness'] / $shrin_k; + } $olw = $this->LineWidth; $sub .=' ' . (sprintf(' %.3F w 0 j 0 J ', $ut * Mpdf::SCALE)); - $sub .=' ' . $this->_dounderline($this->x + $dx, $this->y + $sbaseline + $adjusty, $txt, $OTLdata, $textvar); + $sub .=' ' . $this->decorationStroke($this->textparam['s-decoration'], TextDecorations::THROUGH, $ut, $this->x + $dx, $this->y + $sbaseline + $adjusty, $txt, $OTLdata, $textvar); $sub .=' ' . (sprintf(' %.3F w 2 j 2 J ', $olw * Mpdf::SCALE)); if ($this->FillColor != $c) { @@ -6280,9 +6297,12 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = } $adjusty = (-$ch / 1000 * $decorationfontsize) * $this->baselineO; $obaseline = $glyphYorigin - $this->textparam['o-decoration']['baseline'] / $shrin_k; + if (isset($this->textparam['o-decoration']['thickness'])) { + $ut = $this->textparam['o-decoration']['thickness'] / $shrin_k; + } $olw = $this->LineWidth; $sub .=' ' . (sprintf(' %.3F w 0 j 0 J ', $ut * Mpdf::SCALE)); - $sub .=' ' . $this->_dounderline($this->x + $dx, $this->y + $obaseline + $adjusty, $txt, $OTLdata, $textvar); + $sub .=' ' . $this->decorationStroke($this->textparam['o-decoration'], TextDecorations::OVER, $ut, $this->x + $dx, $this->y + $obaseline + $adjusty, $txt, $OTLdata, $textvar); $sub .=' ' . (sprintf(' %.3F w 2 j 2 J ', $olw * Mpdf::SCALE)); if ($this->FillColor != $c) { $sub .= ' ' . $this->FillColor . ' '; @@ -11674,12 +11694,93 @@ function _endpage() function _dounderline($x, $y, $txt, $OTLdata = false, $textvar = 0) { // Now print line exactly where $y secifies - called from Text() and Cell() - adjust position there - // WORD SPACING - $w = ($this->GetStringWidth($txt, false, $OTLdata, $textvar) * Mpdf::SCALE) + ($this->charspacing * mb_strlen($txt, $this->mb_enc)) + ( $this->ws * mb_substr_count($txt, ' ', $this->mb_enc)); + $w = $this->decorationWidth($txt, $OTLdata, $textvar); // Draw a line return sprintf('%.3F %.3F m %.3F %.3F l S', $x * Mpdf::SCALE, ($this->h - $y) * Mpdf::SCALE, ($x * Mpdf::SCALE) + $w, ($this->h - $y) * Mpdf::SCALE); } + /** + * @param string $txt + * @param array|false $OTLdata + * @param int $textvar + * + * @return float The width a decoration under the text runs, word and letter spacing included, in PDF units + */ + private function decorationWidth($txt, $OTLdata, $textvar) + { + return ($this->GetStringWidth($txt, false, $OTLdata, $textvar) * Mpdf::SCALE) + ($this->charspacing * mb_strlen($txt, $this->mb_enc)) + ( $this->ws * mb_substr_count($txt, ' ', $this->mb_enc)); + } + + /** + * Strokes a decoration in the style its element set, or as a plain line + * + * @param array $decoration The decoration's text parameter + * @param int $side TextDecorations::UNDER, THROUGH or OVER + * @param float $thickness In mm + * @param float $x In mm + * @param float $y Where the line's centre runs, in mm down the page + * @param string $txt + * @param array|false $OTLdata + * @param int $textvar + * + * @return string + */ + private function decorationStroke(array $decoration, $side, $thickness, $x, $y, $txt, $OTLdata, $textvar) + { + if (!isset($decoration['style'])) { + return $this->_dounderline($x, $y, $txt, $OTLdata, $textvar); + } + + return TextDecorations::stroke( + $decoration['style'], + $x * Mpdf::SCALE, + ($this->h - $y) * Mpdf::SCALE, + $this->decorationWidth($txt, $OTLdata, $textvar), + $thickness * Mpdf::SCALE, + $side + ); + } + + /** + * How an element's own decorations are drawn where its CSS asks for other than mPDF's plain line in the text + * colour, at the font's thickness and position + * + * @param array $properties The element's merged CSS + * + * @return array Any of: style, one of TextDecorations::STYLES other than solid; color, the stroke colour operator; + * thickness and offset, in mm; under, set when the underline goes below the descenders + */ + private function decorationDrawing(array $properties) + { + $drawing = []; + + $style = isset($properties['TEXT-DECORATION-STYLE']) ? strtolower(trim($properties['TEXT-DECORATION-STYLE'])) : 'solid'; + if ($style !== 'solid' && in_array($style, TextDecorations::STYLES, true)) { + $drawing['style'] = $style; + } + + if (isset($properties['TEXT-DECORATION-COLOR']) && strtolower(trim($properties['TEXT-DECORATION-COLOR'])) !== 'currentcolor') { + $color = $this->colorConverter->convert($properties['TEXT-DECORATION-COLOR'], $this->PDFAXwarnings); + if ($color) { + $drawing['color'] = $this->SetDColor($color, true); + } + } + + // auto and from-font both take the font's own, which mPDF draws already. A percentage is of 1em + foreach (['thickness' => 'TEXT-DECORATION-THICKNESS', 'offset' => 'TEXT-UNDERLINE-OFFSET'] as $key => $property) { + if (isset($properties[$property]) && $this->sizeConverter->isLength($properties[$property])) { + $drawing[$key] = $this->sizeConverter->convert($properties[$property], $this->FontSize, $this->FontSize); + } + } + + // left and right place the underline of vertical text, which mPDF does not set + if (isset($properties['TEXT-UNDERLINE-POSITION']) && stripos($properties['TEXT-UNDERLINE-POSITION'], 'under') !== false) { + $drawing['under'] = true; + } + + return $drawing; + } + /* -- WATERMARK -- */ @@ -19202,6 +19303,7 @@ function setCSS($arrayaux, $type = '', $tag = '') $preceeding_fontsize = $this->FontSize; $spanbordset = false; $spanbgset = false; + $logicalAlign = null; // mPDF 6 $prevlevel = (($this->blklvl == 0) ? 0 : $this->blklvl - 1); @@ -19476,6 +19578,13 @@ function setCSS($arrayaux, $type = '', $tag = '') case 'JUSTIFY': $this->blk[$this->blklvl]['align'] = "J"; break; + case 'START': + case 'END': + // Resolved once the block's direction is read + if ($this->cssMode === CssMode::STANDARD) { + $logicalAlign = strtolower($v); + } + break; } break; @@ -19978,6 +20087,9 @@ function setCSS($arrayaux, $type = '', $tag = '') break; }//end of switch($k) }//end of foreach + if ($logicalAlign !== null) { + $this->blk[$this->blklvl]['align'] = TextAlign::physical($logicalAlign, isset($this->blk[$this->blklvl]['direction']) ? $this->blk[$this->blklvl]['direction'] : null); + } // mPDF 5.7.3 inline text-decoration parameters // Needs to be set at the end - after vertical-align = super/sub, so that textparam['text-baseline'] is set if (isset($arrayaux['TEXT-DECORATION']) && TextDecorations::seen($this)) { @@ -20018,6 +20130,14 @@ function setCSS($arrayaux, $type = '', $tag = '') $this->textparam['o-decoration']['fontsize'] = $this->FontSize; $this->textparam['o-decoration']['color'] = strtoupper($this->TextColor); // change 0 0 0 rg to 0 0 0 RG } + if ($this->cssMode === CssMode::STANDARD) { + $drawing = $this->decorationDrawing($arrayaux); + foreach (TextDecorations::PARAMETERS as $bit => $parameter) { + if (isset($this->textparam[$parameter]) && stristr($v, TextDecorations::LINES[$bit])) { + $this->textparam[$parameter] = array_merge(array_diff_key($this->textparam[$parameter], TextDecorations::DRAWN_AS), $drawing); + } + } + } // Under standard the element has no decoration of its own, and those it is in still propagate to it if ($this->cssMode === CssMode::LEGACY && stristr($v, 'NONE')) { $this->textvar = ($this->textvar & ~TextVars::FD_UNDERLINE); diff --git a/src/Tag/Td.php b/src/Tag/Td.php index c325325c9..f65b0200c 100644 --- a/src/Tag/Td.php +++ b/src/Tag/Td.php @@ -3,6 +3,7 @@ namespace Mpdf\Tag; use Mpdf\Css\Border; +use Mpdf\Css\TextAlign; use Mpdf\Css\TextVars; use Mpdf\CssMode; use Mpdf\Utils\Arrays; @@ -250,6 +251,10 @@ public function open($attr, &$ahtml, &$ihtml) $c['direction'] = strtolower($properties['DIRECTION']); } + if ($this->appliesStandardCascade() && isset($properties['TEXT-ALIGN']) && in_array(strtolower($properties['TEXT-ALIGN']), ['start', 'end'], true)) { + $c['a'] = TextAlign::physical($properties['TEXT-ALIGN'], $c['direction']); + } + if (!$c['a']) { if (isset($c['direction']) && $c['direction'] === 'rtl') { $c['a'] = 'R'; diff --git a/tests/Mpdf/TextAlignLogicalTest.php b/tests/Mpdf/TextAlignLogicalTest.php new file mode 100644 index 000000000..83dbb2610 --- /dev/null +++ b/tests/Mpdf/TextAlignLogicalTest.php @@ -0,0 +1,136 @@ +assertSame($this->leftEdge($like), $this->leftEdge($html)); + } + + /** + * Blocks and cells in each direction, with the physical alignment each should give + * + * @return array[] + */ + public function alignments() + { + $block = '
qq
'; + $cell = '
qq
'; + $rows = []; + foreach (['block' => $block, 'cell' => $cell] as $name => $html) { + $rows += [ + $name . ': start in ltr' => [strtr($html, ['{S}' => 'text-align: start']), strtr($html, ['{S}' => 'text-align: left'])], + $name . ': end in ltr' => [strtr($html, ['{S}' => 'text-align: end']), strtr($html, ['{S}' => 'text-align: right'])], + $name . ': start in rtl' => [strtr($html, ['{S}' => 'direction: rtl; text-align: start']), strtr($html, ['{S}' => 'direction: rtl; text-align: right'])], + $name . ': end in rtl' => [strtr($html, ['{S}' => 'direction: rtl; text-align: end']), strtr($html, ['{S}' => 'direction: rtl; text-align: left'])], + ]; + } + + return $rows + [ + 'end is taken against the direction of the element that inherits it' => [ + '

qq

', + '

qq

', + ], + 'match-parent takes the parent\'s end against the parent\'s direction' => [ + '

qq

', + '

qq

', + ], + 'match-parent takes the parent\'s start in rtl' => [ + '

qq

', + '

qq

', + ], + 'match-parent takes a physical alignment as it is' => [ + '

qq

', + '

qq

', + ], + 'match-parent hands its resolved value on' => [ + '

qq

', + '

qq

', + ], + 'a cell takes match-parent from its row' => [ + '
qq
', + '
qq
', + ], + 'a cell inherits end from its table' => [ + '
qq
', + '
qq
', + ], + ]; + } + + /** + * Legacy mode reads none of them, and sets the text where it always did + */ + public function testLegacyReadsNoLogicalAlignment() + { + $legacy = ['cssMode' => CssMode::LEGACY]; + + $this->assertSame( + $this->leftEdge('
qq
', $legacy), + $this->leftEdge('
qq
', $legacy) + ); + $this->assertSame( + $this->leftEdge('
qq
', $legacy), + $this->leftEdge('
qq
', $legacy) + ); + } + + /** + * The text of an end-aligned block is set against its right edge + */ + public function testEndSetsTheTextAgainstTheRightEdge() + { + $this->assertGreaterThan( + $this->leftEdge('
qq
') + 80, + $this->leftEdge('
qq
') + ); + } + + /** + * physical() gives right for end in ltr and start in rtl, and matchParent() resolves the parent's value + */ + public function testTheResolution() + { + $this->assertSame('L', TextAlign::physical('start', null)); + $this->assertSame('R', TextAlign::physical('END', 'ltr')); + $this->assertSame('R', TextAlign::physical('start', 'rtl')); + $this->assertSame('L', TextAlign::physical('end', 'RTL')); + + $this->assertSame('left', TextAlign::matchParent([])); + $this->assertSame('right', TextAlign::matchParent(['TEXT-ALIGN' => 'end'])); + $this->assertSame('left', TextAlign::matchParent(['TEXT-ALIGN' => 'end', 'DIRECTION' => 'rtl'])); + $this->assertSame('justify', TextAlign::matchParent(['TEXT-ALIGN' => 'justify', 'DIRECTION' => 'rtl'])); + } + + /** + * @param string $html Draws the text qq + * @param array $config + * + * @return float Where qq starts, in mm + */ + private function leftEdge($html, array $config = []) + { + $mpdf = $this->drawDocument($html, $config); + + return round($this->keyedByText($mpdf, $mpdf->drawnBoxes)['qq'][1], 3); + } +} diff --git a/tests/Mpdf/TextDecorationLonghandsTest.php b/tests/Mpdf/TextDecorationLonghandsTest.php new file mode 100644 index 000000000..83fd15b38 --- /dev/null +++ b/tests/Mpdf/TextDecorationLonghandsTest.php @@ -0,0 +1,338 @@ +qq

'; + + $this->assertSame(['underline', 'overline'], array_keys($this->decorationsOf('qq', $html))); + $this->assertSame([], $this->decorationsOf('qq', $html, CssMode::LEGACY)); + } + + /** + * The style, colour and thickness of the shorthand, in any order, are kept with the line they draw + */ + public function testTheShorthandSetsTheStyleColourAndThickness() + { + $underline = $this->decorationsOf('qq', '

qq

')['underline']; + + $this->assertSame('wavy', $underline['style']); + $this->assertSame('1.000 0.000 0.000 RG', $underline['color']); + $this->assertEqualsWithDelta(1.0, $underline['thickness'], 0.0001); + } + + /** + * Legacy mode draws the shorthand's lines as it always did: a plain line in the text colour + */ + public function testLegacyDrawsAPlainLineInTheTextColour() + { + $underline = $this->decorationsOf('qq', '

qq

', CssMode::LEGACY)['underline']; + + $this->assertArrayNotHasKey('style', $underline); + $this->assertArrayNotHasKey('thickness', $underline); + $this->assertSame('0.000 G', $underline['color']); + } + + /** + * A later longhand overrides the shorthand's part, and the shorthand resets a longhand set before it + */ + public function testTheShorthandAndItsLonghandsCascade() + { + $later = $this->decorationsOf('qq', '

qq

')['underline']; + $this->assertSame('dashed', $later['style']); + + $reset = $this->decorationsOf('qq', '

qq

')['underline']; + $this->assertArrayNotHasKey('style', $reset); + + $rule = $this->decorationsOf('qq', '

qq

')['underline']; + $this->assertSame('0.000 G', $rule['color'], 'The shorthand in the style attribute resets the colour to currentColor'); + } + + /** + * text-decoration-color on a nested span colours the span's own line, and the line a paragraph sets keeps the + * paragraph's decoration colour over a span in another colour + */ + public function testTheColourIsTheDecoratingElements() + { + $own = $this->decorationsOf('qq', '

aa qq

'); + $this->assertSame('0.000 0.000 1.000 RG', $own['underline']['color']); + + $html = '

aa qq

'; + $this->assertSame('1.000 0.000 0.000 RG', $this->decorationsOf('qq', $html)['underline']['color']); + } + + /** + * A descendant's own decoration of the same kind is drawn as it says, not in the style of the one around it + */ + public function testAnOwnDecorationDoesNotKeepTheStyleItReplaces() + { + $html = '

aa qq

'; + $underline = $this->decorationsOf('qq', $html)['underline']; + + $this->assertArrayNotHasKey('style', $underline); + $this->assertArrayNotHasKey('thickness', $underline); + $this->assertSame('wavy', $this->decorationsOf('aa', $html)['underline']['style']); + } + + /** + * Each style strokes the line in its own way, and solid as mPDF always has + * + * @dataProvider styles + * + * @param string $style + * @param string $pattern What the page stream draws after the line width is set + */ + public function testEachStyleIsStroked($style, $pattern) + { + $stream = $this->firstPage('

qq

'); + + $this->assertMatchesRegularExpression($pattern, $stream); + } + + /** + * The five styles, and what each draws + * + * @return array[] + */ + public function styles() + { + $line = '[\d.]+ [\d.]+ m [\d.]+ [\d.]+ l S'; + + return [ + 'solid' => ['solid', '/ 0 j 0 J ' . $line . ' 0\.567 w/'], + 'double' => ['double', '/ 0 j 0 J ' . $line . ' ' . $line . ' 0\.567 w/'], + 'dotted' => ['dotted', '/ 0 j 0 J 1 J \[0 1\.100\] 0 d ' . $line . ' \[\] 0 d /'], + 'dashed' => ['dashed', '/ 0 j 0 J \[1\.650 1\.100\] 0 d ' . $line . ' \[\] 0 d /'], + 'wavy' => ['wavy', '/ 0 j 0 J 1 j [\d.]+ [\d.]+ m( [\d.]+ [\d.]+ [\d.]+ [\d.]+ [\d.]+ [\d.]+ c)+ S /'], + ]; + } + + /** + * A double underline's second line lies two thicknesses below the first, and a double line-through's two lines lie + * either side of where the single line would be + */ + public function testADoubleLineIsTwoLinesAThicknessApart() + { + $under = $this->lines('

qq

'); + $this->assertCount(2, $under); + $this->assertEqualsWithDelta(2.0, $under[0][1] - $under[1][1], 0.002); + + $single = $this->lines('

qq

'); + $through = $this->lines('

qq

'); + $this->assertEqualsWithDelta($single[0][1] + 1, $through[0][1], 0.002); + $this->assertEqualsWithDelta($single[0][1] - 1, $through[1][1], 0.002); + } + + /** + * A wavy line runs the width of the text in whole half waves, alternating either side of the line + */ + public function testAWavyLineSpansTheText() + { + $solid = $this->lines('

qqqq

')[0]; + $stream = $this->firstPage('

qqqq

'); + + preg_match('/1 j ([\d.]+) ([\d.]+) m(( [\d.]+){6} c)+ S/', $stream, $wave); + preg_match_all('/([\d.]+) ([\d.]+) [\d.]+ [\d.]+ ([\d.]+) ([\d.]+) c/', $wave[0], $arches, PREG_SET_ORDER); + + $this->assertSame($solid[0], (float) $wave[1]); + $this->assertEqualsWithDelta($solid[2], (float) end($arches)[3], 0.002); + $this->assertLessThan((float) $wave[2], (float) $arches[0][2], 'The first arch dips below the line'); + $this->assertGreaterThan((float) $wave[2], (float) $arches[1][2], 'The second rises above it'); + } + + /** + * text-decoration-thickness sets the line width: a length, or a percentage of the font size; auto and from-font + * keep the font's own + */ + public function testTheThicknessSetsTheLineWidth() + { + $this->assertStringContainsString(' 2.000 w 0 j 0 J ', $this->firstPage('

qq

')); + $this->assertStringContainsString(' 2.200 w 0 j 0 J ', $this->firstPage('

qq

')); + $this->assertStringContainsString(' 1.000 w 0 j 0 J ', $this->firstPage('

qq

')); + $this->assertStringContainsString(' 1.000 w 0 j 0 J ', $this->firstPage('

qq

')); + + $font = $this->firstPage('

qq

'); + foreach (['auto', 'from-font'] as $keyword) { + $this->assertSame($font, $this->firstPage('

qq

'), $keyword); + } + } + + /** + * text-underline-offset puts the top of the underline that far below the baseline, and moves no other line + */ + public function testTheOffsetIsFromTheBaseline() + { + $zero = $this->underlineCentre('

gq

'); + $three = $this->underlineCentre('

gq

'); + $em = $this->underlineCentre('

gq

'); + $baseline = $this->baseline('

gq

'); + + $this->assertEqualsWithDelta($baseline - 0.5, $zero, 0.002); + $this->assertEqualsWithDelta($zero - 3, $three, 0.002); + $this->assertEqualsWithDelta($zero - 5.5, $em, 0.002); + + $through = '

gq

'; + $this->assertSame($this->lines($through), $this->lines('

gq

')); + } + + /** + * text-underline-position: under puts the underline below the descenders of a font that has them, and an offset is + * taken from there + */ + public function testUnderPutsTheLineBelowTheDescenders() + { + $font = $this->underlineCentre('

gq

', 'utf-8'); + $under = $this->underlineCentre('

gq

', 'utf-8'); + $offset = $this->underlineCentre('

gq

', 'utf-8'); + + $this->assertLessThan($font, $under); + $this->assertEqualsWithDelta($under - 1, $offset, 0.002); + } + + /** + * text-underline-offset and text-underline-position are inherited, and read where the underline is set + */ + public function testTheOffsetIsInherited() + { + $own = $this->decorationsOf('qq', '

qq

'); + $this->assertEqualsWithDelta(2.0, $own['underline']['offset'], 0.0001); + + $inline = $this->decorationsOf('qq', '

aa qq

'); + $this->assertEqualsWithDelta(2.0, $inline['underline']['offset'], 0.0001); + + $block = $this->decorationsOf('qq', '

qq

'); + $this->assertEqualsWithDelta(2.0, $block['underline']['offset'], 0.0001); + $this->assertTrue($block['underline']['under']); + + // An em is the font size of the element that declares it + $em = $this->decorationsOf('qq', '

qq

'); + $this->assertEqualsWithDelta(10 / Mpdf::SCALE, $em['underline']['offset'], 0.001); + + $cell = $this->decorationsOf('qq', '
qq
'); + $this->assertEqualsWithDelta(2.0, $cell['underline']['offset'], 0.0001); + } + + /** + * The longhands take the CSS-wide keywords + */ + public function testTheLonghandsTakeTheCssWideKeywords() + { + $html = '

qq

'; + $underline = $this->decorationsOf('qq', $html)['underline']; + $this->assertArrayNotHasKey('style', $underline); + $this->assertSame('0.000 G', $underline['color']); + + $html = '

qq

'; + $this->assertSame('0.000 0.000 1.000 RG', $this->decorationsOf('qq', $html)['underline']['color']); + } + + /** + * A legacy document is drawn as it was before the longhands were read + */ + public function testLegacyIgnoresTheLonghands() + { + $html = '

qq

'; + + $this->assertSame( + $this->firstPage('

qq

', 'c', CssMode::LEGACY), + $this->firstPage($html, 'c', CssMode::LEGACY) + ); + } + + /** + * stroke() falls back to a plain line for a style it does not know + */ + public function testAnUnknownStyleIsAPlainLine() + { + $this->assertSame('1.000 2.000 m 4.000 2.000 l S', TextDecorations::stroke('groove', 1, 2, 3, 1, TextDecorations::UNDER)); + } + + /** + * @param string $text A piece of text the document draws + * @param string $html + * @param string $mode + * + * @return array[] The decorations drawn over the text, keyed by name, as TextRecordingMpdf records them + */ + private function decorationsOf($text, $html, $mode = CssMode::STANDARD) + { + $mpdf = $this->drawDocument($html, ['cssMode' => $mode]); + + return $this->keyedByText($mpdf, $mpdf->drawnDecorations)[$text]; + } + + /** + * @param string $html + * @param string $mode mPDF's mode, such as c for the core fonts + * @param string $cssMode + * + * @return string The content stream of the document's first page + */ + private function firstPage($html, $mode = 'c', $cssMode = CssMode::STANDARD) + { + return $this->pages($this->render($html, ['mode' => $mode, 'cssMode' => $cssMode]))[0]; + } + + /** + * @param string $html + * @param string $mode + * + * @return float[][] Each straight line stroked on the first page: x and y where it starts, x where it ends, in PDF + * units + */ + private function lines($html, $mode = 'c') + { + preg_match_all(self::LINE, $this->firstPage($html, $mode), $matches, PREG_SET_ORDER); + + return array_map(function ($match) { + return [(float) $match[1], (float) $match[2], (float) $match[3]]; + }, $matches); + } + + /** + * @param string $html + * @param string $mode + * + * @return float Where the first line's centre runs, in PDF units up the page + */ + private function underlineCentre($html, $mode = 'c') + { + return $this->lines($html, $mode)[0][1]; + } + + /** + * @param string $html + * + * @return float Where the first text is set, in PDF units up the page + */ + private function baseline($html) + { + preg_match('/BT [\d.]+ ([\d.]+) Td/', $this->firstPage($html), $match); + + return (float) $match[1]; + } +} diff --git a/tests/Snapshots/TextDecorationAlignSnapshotTest.php b/tests/Snapshots/TextDecorationAlignSnapshotTest.php new file mode 100644 index 000000000..634ff3c10 --- /dev/null +++ b/tests/Snapshots/TextDecorationAlignSnapshotTest.php @@ -0,0 +1,101 @@ +Text
'; + $cell = '
Text
'; + + return [ + [sprintf($block, 'text-align: start'), 'Block, start, ltr: at the left.'], + [sprintf($block, 'text-align: end'), 'Block, end, ltr: at the right.'], + [sprintf($block, 'direction: rtl; text-align: start'), 'Block, start, rtl: at the right.'], + [sprintf($block, 'direction: rtl; text-align: end'), 'Block, end, rtl: at the left.'], + ['

Text

', 'match-parent in an rtl block, inside an ltr block with end: at the right.'], + [sprintf($cell, 'text-align: end'), 'Cell, end, ltr: at the right.'], + [sprintf($cell, 'direction: rtl; text-align: end'), 'Cell, end, rtl: at the left.'], + ]; + } + + /** + * Each decoration on a line of text with descenders, and each alignment in a bordered box, under its caption + */ + public function generatePdf() + { + $html = ' +

Text decorations and logical alignment

+

Decorations

'; + + foreach ($this->decorations() as $case) { + $html .= '

' . $case[1] . '

Sample text gjpq

'; + } + + $html .= '

Alignment

'; + foreach ($this->alignments() as $case) { + $html .= '

' . $case[1] . '

' . $case[0]; + } + + $this->mpdf = $this->createMpdf(['cssMode' => CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } +} diff --git a/tests/data/snapshots/text-decoration-align.pdf b/tests/data/snapshots/text-decoration-align.pdf new file mode 100644 index 0000000000000000000000000000000000000000..43404c33d22d1449d2e6e0925b5931c753c52328 GIT binary patch literal 44138 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

EU;QAux{d$*pHl4lPMSqu}K=J<-c9ubL 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 poXm!-5G@`T#P{<5P0@RuMNZC!K>F{{tEA$728h literal 0 HcmV?d00001 From b51f39629464730c1604fd2d09a258850fe4834c Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 12 Oct 2026 07:54:04 +1100 Subject: [PATCH 2/2] Share the decoration keywords and initial values, and stroke every style through one path Co-Authored-By: Claude Opus 5.5 --- src/Css/CssMerger.php | 2 +- src/Css/NormalizeProperties.php | 11 ++++++----- src/Css/TextAlign.php | 6 ++---- src/Css/TextDecorations.php | 18 +++++++++--------- src/Mpdf.php | 13 ++++++------- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index 2b92f965a..3c5afda0f 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -342,7 +342,7 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) // An inline element, and a block in a table cell, is drawn over the state of what it is in, which already // holds what it inherits. What places an underline is read where the underline is set, so it comes too - return $inherit === 'INLINE' || $inherit === '' ? array_intersect_key($properties, $own + array_flip(InheritedProperties::DECORATION)) : $properties; + return $inherit === 'INLINE' || $inherit === '' ? array_intersect_key($properties, $own) + InheritedProperties::of($properties, InheritedProperties::DECORATION) : $properties; } /** diff --git a/src/Css/NormalizeProperties.php b/src/Css/NormalizeProperties.php index 35798199a..da063cee4 100644 --- a/src/Css/NormalizeProperties.php +++ b/src/Css/NormalizeProperties.php @@ -1215,9 +1215,12 @@ protected function processTextOutlineProperty($v) private function processTextDecorationProperty($v) { $lines = []; - $parts = ['TEXT-DECORATION-STYLE' => 'solid', 'TEXT-DECORATION-COLOR' => 'currentcolor', 'TEXT-DECORATION-THICKNESS' => 'auto']; + $parts = []; + foreach (['TEXT-DECORATION-STYLE', 'TEXT-DECORATION-COLOR', 'TEXT-DECORATION-THICKNESS'] as $longhand) { + $parts[$longhand] = CssWideKeywords::initialValue($longhand); + } foreach ($this->splitComponents($v) as $component) { - if (in_array($component, ['none', 'underline', 'overline', 'line-through', 'blink'], true)) { + if ($component === 'none' || $component === 'blink' || in_array($component, TextDecorations::LINES, true)) { $lines[] = $component; } elseif (in_array($component, TextDecorations::STYLES, true)) { $parts['TEXT-DECORATION-STYLE'] = $component; @@ -1229,9 +1232,7 @@ private function processTextDecorationProperty($v) } $this->properties['TEXT-DECORATION'] = $lines ? implode(' ', $lines) : 'none'; - foreach ($parts as $longhand => $value) { - $this->properties[$longhand] = $value; - } + $this->properties = array_merge($this->properties, $parts); } /** diff --git a/src/Css/TextAlign.php b/src/Css/TextAlign.php index 5a2cba922..f02d787b1 100644 --- a/src/Css/TextAlign.php +++ b/src/Css/TextAlign.php @@ -33,12 +33,10 @@ public static function physical($value, $direction) public static function matchParent(array $parent) { $align = isset($parent['TEXT-ALIGN']) ? strtolower($parent['TEXT-ALIGN']) : 'start'; - if ($align !== 'start' && $align !== 'end' && $align !== 'match-parent') { + if (!in_array($align, ['start', 'end', 'match-parent'], true)) { return $parent['TEXT-ALIGN']; } - $direction = isset($parent['DIRECTION']) ? $parent['DIRECTION'] : 'ltr'; - - return self::physical($align === 'end' ? 'end' : 'start', $direction) === 'R' ? 'right' : 'left'; + return self::physical($align, isset($parent['DIRECTION']) ? $parent['DIRECTION'] : null) === 'R' ? 'right' : 'left'; } } diff --git a/src/Css/TextDecorations.php b/src/Css/TextDecorations.php index 7b07973bc..11a8def7b 100644 --- a/src/Css/TextDecorations.php +++ b/src/Css/TextDecorations.php @@ -43,8 +43,8 @@ final class TextDecorations ]; /** - * The keys of a decoration's text parameter that Mpdf::setCSS() sets only where the element's CSS asks for them, as - * keys, so an element's own decoration does not keep those of the one it replaces + * The keys of a decoration's text parameter that Mpdf::setCSS() sets only where the element's CSS asks for them. An + * element's own decoration drops them first, so it does not keep those of the decoration it replaces */ const DRAWN_AS = ['style' => true, 'thickness' => true, 'offset' => true, 'under' => true]; @@ -147,16 +147,17 @@ private static function propagateInto(array $properties) } /** - * The PDF operators that stroke a decoration in a style other than solid, in the stroke colour and line width already - * set. All lengths are in PDF units. + * The PDF operators that stroke a decoration, in the stroke colour and line width already set. All lengths are in + * PDF units. * + * - solid, or a style not listed: one line * - double: two lines of the thickness, a thickness apart, the second away from the text * - dotted: round dots the thickness across, a dot's width apart * - dashed: dashes three thicknesses long, two apart * - wavy: a sine three thicknesses high from crest to trough, of half wavelength three thicknesses, fitted to the * width with whole half waves, each a cubic Bézier arch * - * @param string $style One of STYLES other than solid + * @param string $style One of STYLES * @param float $x Where the decoration starts * @param float $y Where its centre line runs * @param float $width @@ -185,11 +186,10 @@ public static function stroke($style, $x, $y, $width, $thickness, $side) return sprintf('[%.3F %.3F] 0 d ', 3 * $thickness, 2 * $thickness) . $line($y) . ' [] 0 d'; case 'wavy': - $half = 3 * $thickness; - $count = max(1, (int) round($width / $half)); + $count = max(1, (int) round($width / (3 * $thickness))); $half = $width / $count; - // A cubic's control points at height h raise its middle by 3h/4 - $height = 1.5 * $thickness * 4 / 3; + // A cubic's control points at height h raise its middle by 3h/4, so 2 thicknesses give a crest of 1.5 + $height = 2 * $thickness; $crest = $side === self::THROUGH ? self::OVER : $side; $path = sprintf('1 j %.3F %.3F m', $x, $y); diff --git a/src/Mpdf.php b/src/Mpdf.php index 636700361..a6aaedb38 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -11727,12 +11727,8 @@ private function decorationWidth($txt, $OTLdata, $textvar) */ private function decorationStroke(array $decoration, $side, $thickness, $x, $y, $txt, $OTLdata, $textvar) { - if (!isset($decoration['style'])) { - return $this->_dounderline($x, $y, $txt, $OTLdata, $textvar); - } - return TextDecorations::stroke( - $decoration['style'], + isset($decoration['style']) ? $decoration['style'] : 'solid', $x * Mpdf::SCALE, ($this->h - $y) * Mpdf::SCALE, $this->decorationWidth($txt, $OTLdata, $textvar), @@ -20131,9 +20127,12 @@ function setCSS($arrayaux, $type = '', $tag = '') $this->textparam['o-decoration']['color'] = strtoupper($this->TextColor); // change 0 0 0 rg to 0 0 0 RG } if ($this->cssMode === CssMode::STANDARD) { - $drawing = $this->decorationDrawing($arrayaux); + $drawing = null; foreach (TextDecorations::PARAMETERS as $bit => $parameter) { - if (isset($this->textparam[$parameter]) && stristr($v, TextDecorations::LINES[$bit])) { + if (stristr($v, TextDecorations::LINES[$bit])) { + if ($drawing === null) { + $drawing = $this->decorationDrawing($arrayaux); + } $this->textparam[$parameter] = array_merge(array_diff_key($this->textparam[$parameter], TextDecorations::DRAWN_AS), $drawing); } }