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..3c5afda0f 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) + InheritedProperties::of($properties, 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..da063cee4 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,36 @@ 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 = []; + foreach (['TEXT-DECORATION-STYLE', 'TEXT-DECORATION-COLOR', 'TEXT-DECORATION-THICKNESS'] as $longhand) { + $parts[$longhand] = CssWideKeywords::initialValue($longhand); + } + foreach ($this->splitComponents($v) as $component) { + 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; + } 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'; + $this->properties = array_merge($this->properties, $parts); + } + /** * Process page size CSS properties. * diff --git a/src/Css/TextAlign.php b/src/Css/TextAlign.php new file mode 100644 index 000000000..f02d787b1 --- /dev/null +++ b/src/Css/TextAlign.php @@ -0,0 +1,42 @@ + '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. 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]; + + /** + * 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 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 + * @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': + $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, 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); + 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..a6aaedb38 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,89 @@ 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) + { + return TextDecorations::stroke( + isset($decoration['style']) ? $decoration['style'] : 'solid', + $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 +19299,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 +19574,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 +20083,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 +20126,17 @@ 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 = null; + foreach (TextDecorations::PARAMETERS as $bit => $parameter) { + 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); + } + } + } // 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 000000000..43404c33d Binary files /dev/null and b/tests/data/snapshots/text-decoration-align.pdf differ