Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.96% covered (warning)
84.96%
113 / 133
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
GradientPainter
84.96% covered (warning)
84.96%
113 / 133
58.33% covered (warning)
58.33%
7 / 12
63.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyAsFill
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 applyAsStroke
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 paintsAsNone
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 registerForElement
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
12.12
 applyPatternMatrix
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 multiplyAffine
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 isIdentity
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
6
 registerLinear
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 registerRadial
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 resolveStops
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
7.19
 stopColor
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
17.99
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf\Gradient;
6
7use Phpdftk\Color\RgbColor;
8use Phpdftk\Geometry\Point;
9use Phpdftk\Pdf\Core\Content\ContentStream;
10use Phpdftk\Pdf\Core\Graphics\Pattern\ShadingPattern;
11use Phpdftk\Pdf\Core\PdfArray;
12use Phpdftk\Pdf\Core\PdfNumber;
13use Phpdftk\Pdf\Writer\Page;
14use Phpdftk\Pdf\Writer\PdfDoc;
15use Phpdftk\Pdf\Writer\PdfWriter;
16use Phpdftk\Svg\Element;
17use Phpdftk\Svg\Gradient\Gradient;
18use Phpdftk\Svg\Gradient\LinearGradient;
19use Phpdftk\Svg\Gradient\RadialGradient;
20use Phpdftk\Svg\Gradient\Stop;
21use Phpdftk\Svg\SvgDocument;
22use Phpdftk\Svg\Value\Transform;
23use Phpdftk\SvgToPdf\Geometry\BoundingBox;
24
25/**
26 * Register an SVG `<linearGradient>` / `<radialGradient>` as a PDF
27 * `ShadingPattern` and configure the supplied content stream to fill or
28 * stroke with it.
29 *
30 * Requires a `PdfWriter` (to register the shading objects) and a `Page`
31 * (to attach the pattern resource). The Translator only calls into the
32 * painter when both are available — without them the gradient silently
33 * falls back to no paint, matching SVG 2's "invalid → no paint" rule.
34 *
35 * Scope at 3O:
36 *
37 *  - Linear (Type 2) and radial (Type 3) shadings.
38 *  - N-stop interpolation via `PdfDoc::addLinearGradientStops` /
39 *    `addRadialGradientStops` (Type 3 stitching of Type 2 segments
40 *    when N > 2).
41 *  - `gradientUnits = userSpaceOnUse | objectBoundingBox` with the
42 *    element's axis-aligned bbox supplied by
43 *    `Phpdftk\SvgToPdf\Geometry\BoundingBox`.
44 *
45 * Deferred (documented in plan and README):
46 *
47 *  - `spreadMethod: reflect | repeat` (still treated as `pad` — would
48 *    need a synthesised wider function domain with mirrored / cycled
49 *    stops to render properly; PDF's `/Extend` only does pad).
50 *  - `gradientTransform` (would need to bake into the shading's
51 *    `Matrix`, deferred).
52 *  - `radialGradient`'s `fx` / `fy` / `fr` focal-point and focal-radius
53 *    (we use cx/cy and r; PDF's two-circle radial supports it but the
54 *    SVG-to-PDF mapping needs more care than 3O has room for).
55 *  - Stops in a colour space other than sRGB. The painter coerces to
56 *    `RgbColor` via the existing `phpdftk/color` converters.
57 */
58final class GradientPainter
59{
60    public function __construct(
61        private readonly PdfWriter $writer,
62        private readonly Page $page,
63        private readonly SvgDocument $document,
64    ) {}
65
66    /**
67     * Configure `$stream` for a gradient fill keyed by the given id.
68     * Returns true when the gradient was successfully registered and
69     * the stream is now set up for `f`/`B`; false when the gradient is
70     * missing, empty, or otherwise unrenderable (caller must skip the
71     * fill).
72     */
73    /**
74     * @param array{float, float, float, float, float, float} $currentMatrix
75     *        the cumulative SVG→page transform (renderer base × ancestor ×
76     *        element) the shape is painted under, baked into the pattern
77     *        `/Matrix` so the shading tracks the geometry (PDF resolves a
78     *        pattern matrix against default page space, not the fill CTM).
79     * @param array{w: float, h: float}|null $viewport the basis a
80     *        PERCENTAGE geometry attribute on `$element` resolves
81     *        against when measuring its `objectBoundingBox` (SVG 2 §7.10)
82     */
83    public function applyAsFill(string $gradientId, Element $element, ContentStream $stream, array $currentMatrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], ?array $viewport = null): bool
84    {
85        $pattern = $this->registerForElement($gradientId, $element, $currentMatrix, $viewport);
86        if ($pattern === null) {
87            return false;
88        }
89        $name = $this->page->useGradient($pattern);
90        $stream->setFillColorSpace('Pattern');
91        $stream->setFillColor('/' . $name);
92        return true;
93    }
94
95    /**
96     * Same shape as `applyAsFill` but for the stroke channel.
97     *
98     * @param array{float, float, float, float, float, float} $currentMatrix
99     * @param array{w: float, h: float}|null $viewport
100     */
101    public function applyAsStroke(string $gradientId, Element $element, ContentStream $stream, array $currentMatrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], ?array $viewport = null): bool
102    {
103        $pattern = $this->registerForElement($gradientId, $element, $currentMatrix, $viewport);
104        if ($pattern === null) {
105            return false;
106        }
107        $name = $this->page->useGradient($pattern);
108        $stream->setStrokeColorSpace('Pattern');
109        $stream->setStrokeColor('/' . $name);
110        return true;
111    }
112
113    /**
114     * SVG 2 §13.4 — whether `$gradientId` names a gradient that
115     * resolves but defines NO colour stops.
116     *
117     * "If no stops are defined, then painting shall occur as if
118     * 'none' were specified as the paint style." That is a DIFFERENT
119     * outcome from an unresolvable reference: the paint server exists
120     * and says "paint nothing", so the `<paint>` fallback must NOT
121     * kick in and overpaint whatever the document stacked underneath.
122     */
123    public function paintsAsNone(string $gradientId): bool
124    {
125        $gradient = $this->document->findByFragment($gradientId);
126        if (!$gradient instanceof Gradient || !$gradient->isInRenderTree()) {
127            return false;
128        }
129        return $this->resolveStops($gradient) === [];
130    }
131
132    /**
133     * @param array{float, float, float, float, float, float} $currentMatrix
134     * @param array{w: float, h: float}|null $viewport the basis a
135     *        PERCENTAGE geometry attribute on `$element` resolves
136     *        against, for the `objectBoundingBox` box (SVG 2 §7.10)
137     */
138    private function registerForElement(string $gradientId, Element $element, array $currentMatrix, ?array $viewport = null): ?ShadingPattern
139    {
140        $gradient = $this->document->findByFragment($gradientId);
141        // SVG 2 §13.4 — a paint server outside the render tree (defined
142        // inside a `<text>`, say) does not resolve at all, and the
143        // reference falls back.
144        if (!$gradient instanceof Gradient || !$gradient->isInRenderTree()) {
145            return null;
146        }
147        // CSS Transforms 1 §11 — a non-invertible `gradientTransform`
148        // makes the paint SERVER invalid, so the referencing element
149        // falls back. Baking the singular matrix into the PDF
150        // pattern's `/Matrix` is undefined behaviour; renderers
151        // silently paint nothing, which looks the same as a missing
152        // fallback and isn't.
153        $transform = $gradient->gradientTransform();
154        if ($transform !== null && !$transform->isInvertible()) {
155            return null;
156        }
157        $stops = $this->resolveStops($gradient);
158        if (count($stops) < 2) {
159            return null;
160        }
161        $bbox = null;
162        if ($gradient->gradientUnits() === 'objectBoundingBox') {
163            $bbox = BoundingBox::compute($element, viewport: $viewport);
164            if ($bbox === null) {
165                return null;
166            }
167        }
168        $doc = PdfDoc::wrap($this->writer);
169        $pattern = match (true) {
170            $gradient instanceof LinearGradient => $this->registerLinear($gradient, $stops, $bbox, $doc),
171            $gradient instanceof RadialGradient => $this->registerRadial($gradient, $stops, $bbox, $doc),
172            default => null,
173        };
174        if ($pattern !== null) {
175            self::applyPatternMatrix($pattern, $currentMatrix, $gradient->gradientTransform());
176        }
177        return $pattern;
178    }
179
180    /**
181     * Bake the pattern-space → page-space mapping into the `ShadingPattern`'s
182     * `/Matrix`. PDF resolves a pattern matrix against the page's DEFAULT
183     * user space (not the fill-time CTM), so it must carry the full transform
184     * the shape is painted under — the renderer base + ancestor + element
185     * CTM (`$currentMatrix`) composed with the SVG `gradientTransform`
186     * (SVG 2 §13.6.5). Omitted when the composite is the identity so simple
187     * page-space gradients stay byte-identical.
188     *
189     * @param array{float, float, float, float, float, float} $currentMatrix
190     */
191    private static function applyPatternMatrix(ShadingPattern $pattern, array $currentMatrix, ?Transform $gradientTransform): void
192    {
193        $matrix = $gradientTransform === null
194            ? $currentMatrix
195            : self::multiplyAffine($currentMatrix, $gradientTransform->toMatrix());
196        if (self::isIdentity($matrix)) {
197            return;
198        }
199        $pattern->matrix = new PdfArray([
200            new PdfNumber($matrix[0]),
201            new PdfNumber($matrix[1]),
202            new PdfNumber($matrix[2]),
203            new PdfNumber($matrix[3]),
204            new PdfNumber($matrix[4]),
205            new PdfNumber($matrix[5]),
206        ]);
207    }
208
209    /**
210     * Affine composition `m1 × m2` (m1 applied after m2), matching the PDF
211     * `cm` convention.
212     *
213     * @param array{float, float, float, float, float, float} $m1
214     * @param array{float, float, float, float, float, float} $m2
215     * @return array{float, float, float, float, float, float}
216     */
217    private static function multiplyAffine(array $m1, array $m2): array
218    {
219        [$a1, $b1, $c1, $d1, $e1, $f1] = $m1;
220        [$a2, $b2, $c2, $d2, $e2, $f2] = $m2;
221
222        return [
223            $a1 * $a2 + $c1 * $b2,
224            $b1 * $a2 + $d1 * $b2,
225            $a1 * $c2 + $c1 * $d2,
226            $b1 * $c2 + $d1 * $d2,
227            $a1 * $e2 + $c1 * $f2 + $e1,
228            $b1 * $e2 + $d1 * $f2 + $f1,
229        ];
230    }
231
232    /**
233     * @param array{float, float, float, float, float, float} $m
234     */
235    private static function isIdentity(array $m): bool
236    {
237        return abs($m[0] - 1.0) < 1e-9 && abs($m[1]) < 1e-9 && abs($m[2]) < 1e-9
238            && abs($m[3] - 1.0) < 1e-9 && abs($m[4]) < 1e-9 && abs($m[5]) < 1e-9;
239    }
240
241    /**
242     * @param list<array{offset: float, rgb: array{float, float, float}}> $stops
243     * @param array{minX: float, minY: float, width: float, height: float}|null $bbox
244     */
245    private function registerLinear(
246        LinearGradient $gradient,
247        array $stops,
248        ?array $bbox,
249        PdfDoc $doc,
250    ): ShadingPattern {
251        // SVG 2 §13.6.5 — defaults differ per units mode:
252        //
253        //   objectBoundingBox: x1=0, y1=0, x2=1, y2=0
254        //   userSpaceOnUse:    x1=0%, y1=0%, x2=100%, y2=0%
255        //
256        // Both default to a left-to-right horizontal gradient; the
257        // difference is the resolution coordinate.
258        $x1 = $gradient->x1() ?? 0.0;
259        $y1 = $gradient->y1() ?? 0.0;
260        $x2 = $gradient->x2() ?? ($bbox !== null ? 1.0 : 0.0);
261        $y2 = $gradient->y2() ?? 0.0;
262        if ($bbox !== null) {
263            $x1 = $bbox['minX'] + $x1 * $bbox['width'];
264            $y1 = $bbox['minY'] + $y1 * $bbox['height'];
265            $x2 = $bbox['minX'] + $x2 * $bbox['width'];
266            $y2 = $bbox['minY'] + $y2 * $bbox['height'];
267        }
268        // SVG 2 §13.6.5 — `pad` is the default; we surface it as PDF
269        // `/Extend [true true]` so the endpoint colours fill the
270        // shading rect beyond `[from, to]`. `reflect` / `repeat` still
271        // degrade to no-extend (would need a wider function domain with
272        // mirrored / cycled stops to render properly).
273        $extend = $gradient->spreadMethod() === 'pad';
274        return $doc->addLinearGradientStops(new Point($x1, $y1), new Point($x2, $y2), $stops, extend: $extend);
275    }
276
277    /**
278     * @param list<array{offset: float, rgb: array{float, float, float}}> $stops
279     * @param array{minX: float, minY: float, width: float, height: float}|null $bbox
280     */
281    private function registerRadial(
282        RadialGradient $gradient,
283        array $stops,
284        ?array $bbox,
285        PdfDoc $doc,
286    ): ShadingPattern {
287        // SVG 2 §13.7.5 defaults — cx, cy, r default to 50% / 50% / 50%
288        // in the resolution mode's coordinate space. `fx` / `fy`
289        // default to the centre point; `fr` defaults to 0.
290        $cx = $gradient->cx() ?? ($bbox !== null ? 0.5 : 0.0);
291        $cy = $gradient->cy() ?? ($bbox !== null ? 0.5 : 0.0);
292        $r = $gradient->r() ?? ($bbox !== null ? 0.5 : 0.0);
293        $fx = $gradient->fx() ?? $cx;
294        $fy = $gradient->fy() ?? $cy;
295        $fr = $gradient->fr() ?? 0.0;
296        if ($r <= 0.0) {
297            // Zero-radius radial paints nothing; return a shading that
298            // produces transparent output. Simpler: bail and let
299            // `applyAsFill` fall back to no fill.
300            $r = 0.0001;
301        }
302        if ($bbox !== null) {
303            $cx = $bbox['minX'] + $cx * $bbox['width'];
304            $cy = $bbox['minY'] + $cy * $bbox['height'];
305            $fx = $bbox['minX'] + $fx * $bbox['width'];
306            $fy = $bbox['minY'] + $fy * $bbox['height'];
307            // SVG specifies `r` against the bbox diagonal scaled by
308            // `√2/2` (the "user space units" mapping). At 3O we used
309            // the larger axis as a conservative approximation — the
310            // gradient still emanates from the centre, scaled to
311            // cover the bbox. `fr` rides on the same factor.
312            $axis = max($bbox['width'], $bbox['height']);
313            $r *= $axis;
314            $fr *= $axis;
315        }
316        $extend = $gradient->spreadMethod() === 'pad';
317        return $doc->addRadialGradientStops(
318            new Point($fx, $fy),
319            max(0.0, $fr),
320            new Point($cx, $cy),
321            $r,
322            $stops,
323            extend: $extend,
324        );
325    }
326
327    /**
328     * Resolve the gradient's stop list down to the `{offset, rgb}` tuple
329     * format `PdfDoc::addLinearGradientStops` expects. Walks `Gradient::
330     * stops` (which already does the cycle-safe href chain), drops
331     * stops with unparseable colours, and clamps the offset sequence
332     * to monotonically non-decreasing per CSS Images §3.5.1.
333     *
334     * @return list<array{offset: float, rgb: array{float, float, float}}>
335     */
336    private function resolveStops(Gradient $gradient): array
337    {
338        $stops = $gradient->stops($this->document);
339        if ($stops === []) {
340            return [];
341        }
342        $out = [];
343        $lastOffset = 0.0;
344        foreach ($stops as $stop) {
345            $rgb = $this->stopColor($stop);
346            if ($rgb === null) {
347                continue;
348            }
349            $offset = max($lastOffset, $stop->offset());
350            $lastOffset = $offset;
351            $out[] = ['offset' => $offset, 'rgb' => $rgb];
352        }
353        if ($out === []) {
354            return [];
355        }
356        // Ensure the function domain covers [0, 1] — pad the ends with
357        // the first/last stop value if the author didn't anchor them.
358        if ($out[0]['offset'] > 0.0) {
359            array_unshift($out, ['offset' => 0.0, 'rgb' => $out[0]['rgb']]);
360        }
361        if ($out[count($out) - 1]['offset'] < 1.0) {
362            $out[] = ['offset' => 1.0, 'rgb' => $out[count($out) - 1]['rgb']];
363        }
364        return $out;
365    }
366
367    /**
368     * Coerce a `<stop>`'s `stop-color` down to a 3-tuple sRGB triple in
369     * `[0, 1]`. Stops without a usable colour are dropped by the caller
370     * so the function builder never sees an unparseable input.
371     *
372     * @return array{float, float, float}|null
373     */
374    private function stopColor(Stop $stop): ?array
375    {
376        $color = $stop->stopColor();
377        if ($color === null) {
378            return null;
379        }
380        $rgb = $color instanceof RgbColor ? $color : null;
381        if ($rgb === null && method_exists($color, 'toRgb')) {
382            $candidate = $color->toRgb();
383            $rgb = $candidate instanceof RgbColor ? $candidate : null;
384        }
385        if ($rgb === null) {
386            $components = $color->toArray();
387            if (count($components) !== 3) {
388                return null;
389            }
390            return [(float) $components[0], (float) $components[1], (float) $components[2]];
391        }
392        return [$rgb->r, $rgb->g, $rgb->b];
393    }
394}