镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

A gradient colour stop before the start, such as red -10%, is read as +10%, and one past the end, such as blue 120%, loses its position #765

Open

Description

@gravitypdf-bot

A colour stop placed before the start of a gradient line is read as if its minus sign were not there: red -10% becomes red at 10%, and red -20mm red at 20mm. A percentage stop past the end, such as blue 120%, loses its position, so the stop falls back to the end of the line. Either way the colours across the box are wrong. CSS allows stop positions outside 0–100%.

$mpdf = new \Mpdf\Mpdf();
$mpdf->WriteHTML('
<div style="width: 100mm; height: 20mm; background: linear-gradient(to right, red -10%, blue 110%)"></div>
<div style="width: 100mm; height: 20mm; background: linear-gradient(to right, red, blue 120%)"></div>
<div style="width: 100mm; height: 20mm; background: linear-gradient(to right, red -50%, blue)"></div>
<div style="width: 100mm; height: 20mm; background: repeating-linear-gradient(to right, red -5mm, blue 5mm)"></div>
');
$mpdf->Output('stops.pdf', 'F');

Reproduced on fix/738-gradient-stop-positions (#754) at c5d0dd8 and on gravitypdf at 384d5b5, in both cssMode standard and legacy (the code is not gated).

Expected. CSS Images 3 §3.4.3 lets a stop sit anywhere on the gradient line, including before 0% and past 100%. The colours are worked out along the whole line, and the box shows only the part of the line inside it. A repeating gradient repeats its period, from the first stop to the last, in both directions. Browsers do this.

Actual. Here are the stops registered in $mpdf->gradients on #754, and the pixels sampled from the page rasterised at 100 DPI:

background Expected at 0 / 50% / 100% Stops registered Drawn at 0 / 50% / 100%
linear-gradient(to right, red -10%, blue 110%) 1/12, 1/2, 11/12 of the way to blue red 0, red 0.1, blue 1 red, 0.44, blue
linear-gradient(to right, red, blue 120%) red, 5/12, 5/6 red 0, blue 1 red, 1/2, blue
linear-gradient(to right, red -50%, blue) 1/3, 2/3, blue red 0, red 0.5, blue 1 red, red, blue
linear-gradient(to right, red -20mm, blue 120mm) 1/7, 1/2, 6/7 red 0, red 0.2, blue 1.2 red to 20%, then 3/8 at 50%, blue
radial-gradient(circle, red -10%, blue 110%) starts 1/12 of the way to blue at the centre red 0, red 0.1, blue 1 red out to 10% of the radius, blue at 100%
repeating-linear-gradient(to right, red -5mm, blue 5mm) sawtooth: halfway at 0, blue then a hard edge to red at 5mm, 15mm, … purple 0, purple 1 solid purple

The last row comes out solid purple because -5mm is read as 5mm. Both stops then sit at 5mm, the period is zero, and the gradient is replaced by the average of its colours. On gravitypdf the same row draws stripes 5% wide with a hard edge at each one.

Cause. src/Gradient.php on #754:

  • 1117–1123, readPercentage(): preg_match('/(\d*\.?\d+)%/', $word, $m) has no sign, so -10% matches 10%.
  • 1140–1142, getStop(): if ($fraction <= 1) { $stop['offset'] = $fraction; } leaves the offset unset when the stop is past 100%. The stop then counts as one with no position written, and the end-stop default at 507–508 puts it at 1.
  • 1143, getStop(): preg_match('/([0-9.]+(px|em|ex|pc|pt|cm|mm|in))/i', $el[1], $m) keeps only what comes after the -. The same pattern at 521, where Gradient() converts length stops to fractions, also drops the sign.
  • Even with the sign kept, nothing in Gradient() handles an offset below 0 or above 1. Lines 531–540 put a copy of the first stop at 0 and of the last at 1. BackgroundWriter::writeShaders() writes the offsets as /Bounds inside /Domain [0 1], so a stop at -0.1 or 1.2 makes the stitching function invalid or gets cut. A length stop past the end already reaches it (blue 120mm gives 1.2): the last segment is then encoded over [0.2 1] and runs to full blue at the edge.

On gravitypdf the same faults are at 1022–1026 (/(\d+)[%]/, and unset($stop['offset']) when it is over 1), 1027 and 527.

Fix.

  1. Read signed numbers. Use /(-?\d*\.?\d+)%/ in readPercentage(), and -? in front of [0-9.]+ in the length patterns at 521 and 1143. Then drop the <= 1 test in getStop().
  2. In Gradient(), once the offsets are fractions and clampStopOffsets() has run, cut a non-repeating gradient to [0, 1]. Drop every stop wholly outside that range. Replace the stops that straddle 0 and 1 with stops at exactly 0 and 1, their colour and opacity interpolated from the two stops on either side. This works for radial gradients too, which cannot take a negative radius. For linear gradients, stretching Coords to cover the first and last offsets would also work.
  3. For a repeating gradient, work out the period from the signed first and last offsets. Shift the stops by a whole number of periods so the first one sits at or before 0, and tile from there. Do not put a copy of the first stop at 0.

Tests. After OutputBinaryData(), read the stops in $mpdf->gradients:

  • linear-gradient(to right, red -10%, blue 110%) starts at 0 with 1/12 blue and ends at 1 with 11/12 blue.
  • red, blue 120% ends 5/6 of the way to blue.
  • red -20mm, blue 120mm across 100mm starts at 1/7 and ends at 6/7.
  • A radial gradient with red -10%.
  • repeating-linear-gradient(to right, red -5mm, blue 5mm) starts halfway at 0 and has a hard edge at 0.05, 0.15 and so on.

Add a snapshot of the rows above.

Related, and probably worth fixing alongside:

  • A repeating gradient whose first stop is after 0 also tiles wrongly. repeating-linear-gradient(to right, red 5mm, blue 15mm) tiles the copy of the first stop that was put at 0, so every period has a solid red stretch and the period is 15mm, not 10mm.
  • The gradient position patterns drop the sign too (src/Gradient.php 193/197, 678/685 for the linear start point, 920/926 for the radial centre). radial-gradient(-10% 50%, circle, …) puts the centre at x 0.1.

Found while working on #552.

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions