diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index ba0c89c13..d3e8fa21e 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -205,7 +205,7 @@ Bugfixes * `rotate: 90deg` on a positioned block turned it on PHP 5.6 to 7.4 but not on PHP 8, where `"90deg" == 90` is false. The angle is now read as tables read it: whole degrees, with or without `deg`, so `270deg` turns the block as `-90` does. A positioned block also takes `180deg` as it takes `180`. [#579] * `background-size` lengths such as `60mm` or `100px` drew the image 2.83 times too small, because they were used as points. [#574] * A stylesheet `url()` did not load its image when the path had spaces or parentheses, when there was whitespace inside the parentheses, or when it was an SVG data URI that is not base64, such as Bootstrap's `form-select` arrow. [#573] -* `linear-gradient(to bottom, …)` and `to top` were drawn upside down, and so was the vertical half of `to bottom right` and the other corners. Angles ran counter-clockwise from pointing right, so `90deg` drew bottom to top. A gradient now runs as CSS Images gives: `0deg` points up and angles turn clockwise, `turn` is read, and a corner keyword leaves the other two corners halfway along. `-moz-`, `-webkit-` and `-o-` gradients keep their legacy angles, and their side keyword, such as `left`, names the side the gradient starts from. [#577] +* `linear-gradient(to bottom, …)` and `to top` were drawn upside down, and so was the vertical half of `to bottom right` and the other corners. Angles ran counter-clockwise from pointing right, so `90deg` drew bottom to top. A gradient now runs as CSS Images gives: `0deg` points up and angles turn clockwise, `turn` is read, and a corner keyword leaves the other two corners halfway along. `-moz-`, `-webkit-` and `-o-` gradients keep their legacy angles, and their side keyword, such as `left`, names the side the gradient starts from. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep the directions mPDF v7 drew, prefixed or not. [#577] [#639] * Characters mPDF moved into a substitute font (`useSubstitutions`, and the fonts for supplementary planes) were wrapped in a `` that stylesheet rules reached, so `span { border: 1px solid }` drew a box around them, and the span counted among its siblings for `:nth-child()`. In the standard CSS mode no rule reaches that span and it is not counted, as a browser has no such element. The legacy mode styles it as before. [#634] ### Images and SVG @@ -848,6 +848,7 @@ These changes do not change output. [#631]: https://github.com/GravityPDF/mpdf/pull/631 [#632]: https://github.com/GravityPDF/mpdf/issues/632 [#633]: https://github.com/GravityPDF/mpdf/pull/633 -[#635]: https://github.com/GravityPDF/mpdf/pull/635 [#634]: https://github.com/GravityPDF/mpdf/pull/634 +[#635]: https://github.com/GravityPDF/mpdf/pull/635 +[#639]: https://github.com/GravityPDF/mpdf/pull/639 [#638]: https://github.com/GravityPDF/mpdf/pull/638 diff --git a/src/Gradient.php b/src/Gradient.php index e7f90c65a..a826d1023 100644 --- a/src/Gradient.php +++ b/src/Gradient.php @@ -623,17 +623,20 @@ public function Gradient($x, $y, $w, $h, $type, $stops = [], $colorspace = 'RGB' /** * Reads the arguments of a linear gradient into the start point or angle Gradient() works from, where y runs up the - * box and angles run counter-clockwise from pointing right + * box and angles run counter-clockwise from pointing right. In legacy CSS mode the direction is read as mPDF v7 + * read it, whatever the prefix * * @param string[] $m The match whose second element holds the arguments * @param bool $repeat Whether the gradient repeats - * @param bool $legacy Whether the function is prefixed, where a keyword names the start side and angles already - * run counter-clockwise from pointing right + * @param bool $prefixed Whether the function is prefixed, where a keyword names the start side and angles already + * run counter-clockwise from pointing right * * @return array */ - private function parseMozLinearGradient($m, $repeat, $legacy) + private function parseMozLinearGradient($m, $repeat, $prefixed) { + $legacy = $this->mpdf->cssMode === CssMode::LEGACY; + $g = []; $g['type'] = self::TYPE_LINEAR; $g['colorspace'] = 'RGB'; @@ -657,7 +660,9 @@ private function parseMozLinearGradient($m, $repeat, $legacy) } // Is first part $bgr[0] a valid point/angle? $first = preg_split('/\s+/', trim($bgr[0])); - if (preg_match('/(left|center|right|bottom|top|deg|grad|rad|turn)/i', $bgr[0]) && !preg_match('/(<#|rgb|rgba|hsl|hsla)/i', $bgr[0])) { + // mPDF v7 did not read turn, so a first stop whose colour name holds it, such as a spot colour, stays a stop + $units = $legacy ? 'deg|grad|rad' : 'deg|grad|rad|turn'; + if (preg_match('/(left|center|right|bottom|top|' . $units . ')/i', $bgr[0]) && !preg_match('/(<#|rgb|rgba|hsl|hsla)/i', $bgr[0])) { $startStops = 1; } elseif (trim($first[count($first) - 1]) === '0') { $startStops = 1; @@ -672,45 +677,10 @@ private function parseMozLinearGradient($m, $repeat, $legacy) // first part a valid point/angle? if ($startStops === 1) { // default values - // [ || ,] = [<% em px left center right bottom top> || ,] - if (preg_match('/([\-]*[0-9\.]+)(deg|grad|rad|turn)/i', $bgr[0], $m)) { - $perTurn = ['deg' => 360, 'grad' => 400, 'rad' => 2 * M_PI, 'turn' => 1]; - $angle = (float) $m[1] * 360 / $perTurn[strtolower($m[2])]; - } elseif (trim($first[count($first) - 1]) === '0') { - $angle = 0; - } - - if (isset($angle)) { - // CSS Images angles point up at 0 and run clockwise - if (!$legacy) { - $angle = 90 - $angle; - } - $angle = fmod($angle, 360); - if ($angle < 0) { - $angle += 360; - } - } - - // "to left" names the side the gradient ends at, and a legacy "left" the side it starts from - $to = strtolower($first[0]) === 'to'; - - if (stripos($bgr[0], 'left') !== false) { - $startx = $to ? 1 : 0; - } elseif (stripos($bgr[0], 'right') !== false) { - $startx = $to ? 0 : 1; - } - - if (stripos($bgr[0], 'top') !== false) { - $starty = $to ? 0 : 1; - } elseif (stripos($bgr[0], 'bottom') !== false) { - $starty = $to ? 1 : 0; - } - - // Corner to corner across the box, so the other two corners take the colour halfway along, as CSS Images - // gives for "to top right" - if ($to && isset($startx, $starty)) { - $endx = 1 - $startx; - $endy = 1 - $starty; + if ($legacy) { + list($angle, $startx, $starty, $endx, $endy) = $this->readLegacyDirection($bgr[0], $first); + } else { + list($angle, $startx, $starty, $endx, $endy) = $this->readDirection($bgr[0], $first, $prefixed); } // Check for %? ?% or %% @@ -796,6 +766,110 @@ private function parseMozLinearGradient($m, $repeat, $legacy) return $g; } + /** + * Reads the angle or side keywords a linear gradient starts with, as CSS Images gives them for an unprefixed + * function: "to" names the side the gradient ends at, and angles point up at 0 and run clockwise + * + * @param string $direction The first argument + * @param string[] $words The first argument split at whitespace + * @param bool $prefixed Whether the function is prefixed, where a keyword names the start side and angles already + * run counter-clockwise from pointing right + * + * @return array The angle, start x, start y, end x and end y, each null where the argument does not set it + */ + private function readDirection($direction, array $words, $prefixed) + { + $angle = $startx = $starty = $endx = $endy = null; + + // [ || ,] = [<% em px left center right bottom top> || ,] + if (preg_match('/([\-]*[0-9\.]+)(deg|grad|rad|turn)/i', $direction, $m)) { + $perTurn = ['deg' => 360, 'grad' => 400, 'rad' => 2 * M_PI, 'turn' => 1]; + $angle = (float) $m[1] * 360 / $perTurn[strtolower($m[2])]; + } elseif (trim($words[count($words) - 1]) === '0') { + $angle = 0; + } + + if (isset($angle)) { + // CSS Images angles point up at 0 and run clockwise + if (!$prefixed) { + $angle = 90 - $angle; + } + $angle = fmod($angle, 360); + if ($angle < 0) { + $angle += 360; + } + } + + // "to left" names the side the gradient ends at, and a prefixed "left" the side it starts from + $to = strtolower($words[0]) === 'to'; + + if (stripos($direction, 'left') !== false) { + $startx = $to ? 1 : 0; + } elseif (stripos($direction, 'right') !== false) { + $startx = $to ? 0 : 1; + } + + if (stripos($direction, 'top') !== false) { + $starty = $to ? 0 : 1; + } elseif (stripos($direction, 'bottom') !== false) { + $starty = $to ? 1 : 0; + } + + // Corner to corner across the box, so the other two corners take the colour halfway along, as CSS Images + // gives for "to top right" + if ($to && isset($startx, $starty)) { + $endx = 1 - $startx; + $endy = 1 - $starty; + } + + return [$angle, $startx, $starty, $endx, $endy]; + } + + /** + * Reads the angle or side keywords a linear gradient starts with as mPDF v7 did, prefixed or not and with or + * without "to": "left" and "right" name the side the gradient ends at, "top" and "bottom" the side it starts + * from, and angles run counter-clockwise from pointing right in whole degrees + * + * @param string $direction The first argument + * @param string[] $words The first argument split at whitespace + * + * @return array The angle, start x, start y, end x and end y, each null where the argument does not set it + */ + private function readLegacyDirection($direction, array $words) + { + $angle = $startx = $starty = null; + + if (preg_match('/([\-]*[0-9\.]+)(deg|grad|rad)/i', $direction, $m)) { + $angle = (float) $m[1]; + if (strtolower($m[2]) === 'grad') { + $angle *= (360 / 400); + } elseif (strtolower($m[2]) === 'rad') { + $angle = rad2deg($angle); + } + $angle = fmod($angle, 360); + if ($angle < 0) { + $angle += 360; + } + $angle = (int) $angle % 360; + } elseif (trim($words[count($words) - 1]) === '0') { + $angle = 0; + } + + if (stripos($direction, 'left') !== false) { + $startx = 1; + } elseif (stripos($direction, 'right') !== false) { + $startx = 0; + } + + if (stripos($direction, 'top') !== false) { + $starty = 1; + } elseif (stripos($direction, 'bottom') !== false) { + $starty = 0; + } + + return [$angle, $startx, $starty, null, null]; + } + private function parseMozRadialGradient($m, $repeat) { $g = []; diff --git a/tests/Mpdf/LinearGradientDirectionTest.php b/tests/Mpdf/LinearGradientDirectionTest.php index fdf633815..33c1323fb 100644 --- a/tests/Mpdf/LinearGradientDirectionTest.php +++ b/tests/Mpdf/LinearGradientDirectionTest.php @@ -4,7 +4,8 @@ /** * The axis a linear-gradient() background is drawn along. Gradient() registers it as [x0, y0, x1, y1] across the box, - * with y running up, so "to bottom" starts at y = 1 and ends at y = 0. + * with y running up, so "to bottom" starts at y = 1 and ends at y = 0. Standard CSS mode reads the direction as CSS + * Images gives it, and legacy CSS mode as mPDF v7 did. */ class LinearGradientDirectionTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase { @@ -13,13 +14,15 @@ class LinearGradientDirectionTest extends \Yoast\PHPUnitPolyfills\TestCases\Test * The axis drawn for a block with the given background * * @param string $background + * @param string $cssMode + * @param string $property The property the gradient is given in * * @return float[] */ - private function axis($background) + private function axis($background, $cssMode = CssMode::STANDARD, $property = 'background') { - $mpdf = new Mpdf(); - $mpdf->WriteHTML('
'); + $mpdf = new Mpdf(['cssMode' => $cssMode]); + $mpdf->WriteHTML('
'); $mpdf->Output('', 'S'); $this->assertCount(1, $mpdf->gradients); @@ -87,6 +90,121 @@ public function axisProvider() ]; } + /** + * In legacy CSS mode a side keyword or an angle runs the axis as mPDF v7 drew it, prefixed or not + * + * @dataProvider legacyAxisProvider + * + * @param string $background + * @param float[] $expected + */ + public function testLegacyAxis($background, $expected) + { + $this->assertEqualsWithDelta($expected, $this->axis($background, CssMode::LEGACY), 0.0001); + } + + /** + * Backgrounds with a side or corner keyword or a right angle, with the axis mPDF v7 gave each + * + * @return array + */ + public function legacyAxisProvider() + { + $down = [0.5, 1, 0.5, 0]; + $up = [0.5, 0, 0.5, 1]; + $right = [0, 0.5, 1, 0.5]; + $left = [1, 0.5, 0, 0.5]; + + return [ + 'no direction' => ['linear-gradient(red, blue)', $down], + 'to bottom runs up' => ['linear-gradient(to bottom, red, blue)', $up], + 'to top runs down' => ['linear-gradient(to top, red, blue)', $down], + 'to right' => ['linear-gradient(to right, red, blue)', $right], + 'to left' => ['linear-gradient(to left, red, blue)', $left], + 'repeating, to bottom runs up' => ['repeating-linear-gradient(to bottom, red, blue 5mm)', $up], + 'left runs to the left' => ['linear-gradient(left, red, blue)', $left], + 'top runs down' => ['linear-gradient(top, red, blue)', $down], + + // A corner sets only the start, and the angle is squared to the box + 'to bottom right' => ['linear-gradient(to bottom right, red, blue)', [0, 0, 1, 0.5]], + 'to top left' => ['linear-gradient(to top left, red, blue)', [1, 0.5, 0, 0]], + + '0deg points right' => ['linear-gradient(0deg, red, blue)', [0, 0, 1, 0]], + 'unitless 0 points right' => ['linear-gradient(0, red, blue)', [0, 0, 1, 0]], + '90deg points up' => ['linear-gradient(90deg, red, blue)', [0, 0, 0, 1]], + '180deg points left' => ['linear-gradient(180deg, red, blue)', [1, 0, 0, 0]], + '270deg points down' => ['linear-gradient(270deg, red, blue)', [0, 1, 0, 0]], + '-90deg points down' => ['linear-gradient(-90deg, red, blue)', [0, 1, 0, 0]], + '450deg points up' => ['linear-gradient(450deg, red, blue)', [0, 0, 0, 1]], + '100grad points up' => ['linear-gradient(100grad, red, blue)', [0, 0, 0, 1]], + 'turn is not read' => ['linear-gradient(0.5turn, red, blue)', [0, 0, 1, 0]], + + '-moz- left runs to the left' => ['-moz-linear-gradient(left, red, blue)', $left], + '-webkit- left runs to the left' => ['-webkit-linear-gradient(left, red, blue)', $left], + '-o- right runs to the right' => ['-o-linear-gradient(right, red, blue)', $right], + '-moz- 90deg points up' => ['-moz-linear-gradient(90deg, red, blue)', [0, 0, 0, 1]], + '-webkit- 0deg points right' => ['-webkit-linear-gradient(0deg, red, blue)', [0, 0, 1, 0]], + ]; + } + + /** + * In legacy CSS mode "left" runs the gradient to the left, prefixed or not, in the background shorthand and in + * background-image + * + * @dataProvider prefixProvider + * + * @param string $property + * @param string $prefix + */ + public function testLegacyLeftRunsToTheLeftWhateverThePrefix($property, $prefix) + { + $this->assertEqualsWithDelta( + [1, 0.5, 0, 0.5], + $this->axis($prefix . 'linear-gradient(left, red, blue)', CssMode::LEGACY, $property), + 0.0001 + ); + } + + /** + * Each property a gradient is given in, with no prefix and with each prefix the gradient parser tells apart + * + * @return array + */ + public function prefixProvider() + { + $cases = []; + foreach (['background', 'background-image'] as $property) { + foreach (['', '-moz-', '-webkit-', '-o-'] as $prefix) { + $cases[$property . ', ' . ($prefix ?: 'no prefix')] = [$property, $prefix]; + } + } + + return $cases; + } + + /** + * In legacy CSS mode a fractional angle is cut to whole degrees, as mPDF v7 cut it, without the deprecation PHP 8.1 + * raises for an implicit conversion from float to int or any other error + */ + public function testLegacyFractionalAngleIsCutToWholeDegreesWithoutDeprecation() + { + $errors = []; + set_error_handler(function ($errno, $errstr) use (&$errors) { + $errors[] = $errstr; + + return true; + }); + + try { + $fractional = $this->axis('linear-gradient(22.9deg, red, blue)', CssMode::LEGACY); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $errors); + $this->assertEqualsWithDelta($this->axis('linear-gradient(22deg, red, blue)', CssMode::LEGACY), $fractional, 0.0001); + } + /** * An angle between the sides runs the axis into the right quarter * @@ -95,10 +213,11 @@ public function axisProvider() * @param string $background * @param int $dx The sign the axis runs across the box * @param int $dy The sign the axis runs up the box + * @param string $cssMode */ - public function testDiagonalAxis($background, $dx, $dy) + public function testDiagonalAxis($background, $dx, $dy, $cssMode = CssMode::STANDARD) { - list($x0, $y0, $x1, $y1) = $this->axis($background); + list($x0, $y0, $x1, $y1) = $this->axis($background, $cssMode); $this->assertSame($dx, $this->sign($x1 - $x0), 'across'); $this->assertSame($dy, $this->sign($y1 - $y0), 'up'); @@ -132,6 +251,11 @@ public function diagonalProvider() '135deg' => ['linear-gradient(135deg, red, blue)', 1, -1], '22.5deg' => ['linear-gradient(22.5deg, red, blue)', 1, 1], '-moz- left top' => ['-moz-linear-gradient(left top, red, blue)', 1, -1], + + 'legacy 45deg' => ['linear-gradient(45deg, red, blue)', 1, 1, CssMode::LEGACY], + 'legacy 135deg' => ['linear-gradient(135deg, red, blue)', -1, 1, CssMode::LEGACY], + 'legacy 22.5deg' => ['linear-gradient(22.5deg, red, blue)', 1, 1, CssMode::LEGACY], + 'legacy -moz- left top' => ['-moz-linear-gradient(left top, red, blue)', -1, -1, CssMode::LEGACY], ]; } diff --git a/tests/Snapshots/LinearGradientDirectionLegacySnapshotTest.php b/tests/Snapshots/LinearGradientDirectionLegacySnapshotTest.php new file mode 100644 index 000000000..4c7826740 --- /dev/null +++ b/tests/Snapshots/LinearGradientDirectionLegacySnapshotTest.php @@ -0,0 +1,80 @@ + [ + ['linear-gradient(to bottom, red, blue)', 'to bottom: red at the bottom, blue at the top.'], + ['linear-gradient(to top, red, blue)', 'to top: red at the top, blue at the bottom.'], + ['linear-gradient(to right, red, blue)', 'to right: red at the left, blue at the right.'], + ['linear-gradient(to left, red, blue)', 'to left: red at the right, blue at the left.'], + ['linear-gradient(to bottom right, red, blue)', 'to bottom right: red at the left, blue at the right, running slightly up from the bottom left corner.'], + ['linear-gradient(to top left, red, blue)', 'to top left: red at the right, blue at the left, running slightly down to the bottom left corner.'], + ], + 'Angles' => [ + ['linear-gradient(0deg, red, blue)', '0deg: red at the left, blue at the right.'], + ['linear-gradient(90deg, red, blue)', '90deg: red at the bottom, blue at the top.'], + ['linear-gradient(180deg, red, blue)', '180deg: red at the right, blue at the left.'], + ['linear-gradient(270deg, red, blue)', '270deg: red at the top, blue at the bottom.'], + ['linear-gradient(45deg, red, blue)', '45deg: red at the left, blue at the right, running slightly up from the bottom left corner.'], + ['linear-gradient(135deg, red, blue)', '135deg: red at the right, blue at the left, running slightly up from the bottom right corner.'], + ['linear-gradient(0.25turn, red, blue)', '0.25turn: turn is not read, so red at the left, blue at the right.'], + ], + 'Prefixed functions' => [ + ['-moz-linear-gradient(left, red, blue)', '-moz- left: red at the right, blue at the left.'], + ['-webkit-linear-gradient(top, red, blue)', '-webkit- top: red at the top, blue at the bottom.'], + ['-webkit-linear-gradient(90deg, red, blue)', '-webkit- 90deg: red at the bottom, blue at the top.'], + ], + ]; + } + +} diff --git a/tests/Snapshots/LinearGradientDirectionSnapshotTest.php b/tests/Snapshots/LinearGradientDirectionSnapshotTest.php index b1f1024b5..bde8deeb0 100644 --- a/tests/Snapshots/LinearGradientDirectionSnapshotTest.php +++ b/tests/Snapshots/LinearGradientDirectionSnapshotTest.php @@ -2,10 +2,12 @@ namespace Snapshots; +use Mpdf\CssMode; + /** * Linear gradient backgrounds run the way CSS Images gives: "to" names the side the gradient ends at, and angles point - * up at 0deg and run clockwise. Prefixed functions keep the legacy reading, where a keyword names the side the - * gradient starts from and angles run counter-clockwise from pointing right. + * up at 0deg and run clockwise. Prefixed functions keep the reading of their old syntax, where a keyword names the + * side the gradient starts from and angles run counter-clockwise from pointing right. * * @group snapshot */ @@ -23,11 +25,33 @@ public function getId() } /** - * A red to blue gradient box for each direction, under a caption saying where the red should be + * The CSS mode the document is drawn in + * + * @return string */ - public function generatePdf() + protected function cssMode() + { + return CssMode::STANDARD; + } + + /** + * The document's title + * + * @return string + */ + protected function title() { - $cases = [ + return 'linear-gradient() directions'; + } + + /** + * The gradients, each with a caption saying where the red and blue should be, grouped under headings + * + * @return array + */ + protected function cases() + { + return [ 'Keywords' => [ ['linear-gradient(to bottom, red, blue)', 'to bottom: red at the top, blue at the bottom.'], ['linear-gradient(to top, red, blue)', 'to top: red at the bottom, blue at the top.'], @@ -51,7 +75,13 @@ public function generatePdf() ['-webkit-linear-gradient(90deg, red, blue)', '-webkit- 90deg: red at the bottom, blue at the top.'], ], ]; + } + /** + * A red to blue gradient box for each direction, under a caption saying where the red should be + */ + public function generatePdf() + { $html = ' -

linear-gradient() directions

'; +

' . $this->title() . '

'; - foreach ($cases as $heading => $gradients) { + foreach ($this->cases() as $heading => $gradients) { $html .= '

' . $heading . '

'; foreach ($gradients as $case) { $html .= '

' . $case[1] . '

'; } } - $this->mpdf = $this->createMpdf(); + $this->mpdf = $this->createMpdf(['cssMode' => $this->cssMode()]); $this->mpdf->WriteHTML($html); } diff --git a/tests/data/snapshots/linear-gradient-direction-legacy.pdf b/tests/data/snapshots/linear-gradient-direction-legacy.pdf new file mode 100644 index 000000000..aacf21efb Binary files /dev/null and b/tests/data/snapshots/linear-gradient-direction-legacy.pdf differ