Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.46% covered (warning)
79.46%
89 / 112
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
GradientPainter
79.46% covered (warning)
79.46%
89 / 112
44.44% covered (danger)
44.44%
4 / 9
53.86
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
 registerForElement
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 applyGradientTransform
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 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
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
7.89
 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    public function applyAsFill(string $gradientId, Element $element, ContentStream $stream): bool
74    {
75        $pattern = $this->registerForElement($gradientId, $element);
76        if ($pattern === null) {
77            return false;
78        }
79        $name = $this->page->useGradient($pattern);
80        $stream->setFillColorSpace('Pattern');
81        $stream->setFillColor('/' . $name);
82        return true;
83    }
84
85    /** Same shape as `applyAsFill` but for the stroke channel. */
86    public function applyAsStroke(string $gradientId, Element $element, ContentStream $stream): bool
87    {
88        $pattern = $this->registerForElement($gradientId, $element);
89        if ($pattern === null) {
90            return false;
91        }
92        $name = $this->page->useGradient($pattern);
93        $stream->setStrokeColorSpace('Pattern');
94        $stream->setStrokeColor('/' . $name);
95        return true;
96    }
97
98    private function registerForElement(string $gradientId, Element $element): ?ShadingPattern
99    {
100        $gradient = $this->document->findById($gradientId);
101        if (!$gradient instanceof Gradient) {
102            return null;
103        }
104        $stops = $this->resolveStops($gradient);
105        if (count($stops) < 2) {
106            return null;
107        }
108        $bbox = null;
109        if ($gradient->gradientUnits() === 'objectBoundingBox') {
110            $bbox = BoundingBox::compute($element);
111            if ($bbox === null) {
112                return null;
113            }
114        }
115        $doc = PdfDoc::wrap($this->writer);
116        $pattern = match (true) {
117            $gradient instanceof LinearGradient => $this->registerLinear($gradient, $stops, $bbox, $doc),
118            $gradient instanceof RadialGradient => $this->registerRadial($gradient, $stops, $bbox, $doc),
119            default => null,
120        };
121        if ($pattern !== null) {
122            self::applyGradientTransform($pattern, $gradient->gradientTransform());
123        }
124        return $pattern;
125    }
126
127    /**
128     * Bake the SVG `gradientTransform` attribute into the PDF
129     * `ShadingPattern`'s `Matrix` entry. PDF pattern Matrix maps
130     * pattern space (where `Coords` live) to user space, which is
131     * exactly the SVG-2 §13.6.5 semantic for `gradientTransform`.
132     */
133    private static function applyGradientTransform(ShadingPattern $pattern, ?Transform $transform): void
134    {
135        if ($transform === null) {
136            return;
137        }
138        $matrix = $transform->toMatrix();
139        $pattern->matrix = new PdfArray([
140            new PdfNumber($matrix[0]),
141            new PdfNumber($matrix[1]),
142            new PdfNumber($matrix[2]),
143            new PdfNumber($matrix[3]),
144            new PdfNumber($matrix[4]),
145            new PdfNumber($matrix[5]),
146        ]);
147    }
148
149    /**
150     * @param list<array{offset: float, rgb: array{float, float, float}}> $stops
151     * @param array{minX: float, minY: float, width: float, height: float}|null $bbox
152     */
153    private function registerLinear(
154        LinearGradient $gradient,
155        array $stops,
156        ?array $bbox,
157        PdfDoc $doc,
158    ): ShadingPattern {
159        // SVG 2 §13.6.5 — defaults differ per units mode:
160        //
161        //   objectBoundingBox: x1=0, y1=0, x2=1, y2=0
162        //   userSpaceOnUse:    x1=0%, y1=0%, x2=100%, y2=0%
163        //
164        // Both default to a left-to-right horizontal gradient; the
165        // difference is the resolution coordinate.
166        $x1 = $gradient->x1() ?? 0.0;
167        $y1 = $gradient->y1() ?? 0.0;
168        $x2 = $gradient->x2() ?? ($bbox !== null ? 1.0 : 0.0);
169        $y2 = $gradient->y2() ?? 0.0;
170        if ($bbox !== null) {
171            $x1 = $bbox['minX'] + $x1 * $bbox['width'];
172            $y1 = $bbox['minY'] + $y1 * $bbox['height'];
173            $x2 = $bbox['minX'] + $x2 * $bbox['width'];
174            $y2 = $bbox['minY'] + $y2 * $bbox['height'];
175        }
176        // SVG 2 §13.6.5 — `pad` is the default; we surface it as PDF
177        // `/Extend [true true]` so the endpoint colours fill the
178        // shading rect beyond `[from, to]`. `reflect` / `repeat` still
179        // degrade to no-extend (would need a wider function domain with
180        // mirrored / cycled stops to render properly).
181        $extend = $gradient->spreadMethod() === 'pad';
182        return $doc->addLinearGradientStops(new Point($x1, $y1), new Point($x2, $y2), $stops, extend: $extend);
183    }
184
185    /**
186     * @param list<array{offset: float, rgb: array{float, float, float}}> $stops
187     * @param array{minX: float, minY: float, width: float, height: float}|null $bbox
188     */
189    private function registerRadial(
190        RadialGradient $gradient,
191        array $stops,
192        ?array $bbox,
193        PdfDoc $doc,
194    ): ShadingPattern {
195        // SVG 2 §13.7.5 defaults — cx, cy, r default to 50% / 50% / 50%
196        // in the resolution mode's coordinate space. `fx` / `fy`
197        // default to the centre point; `fr` defaults to 0.
198        $cx = $gradient->cx() ?? ($bbox !== null ? 0.5 : 0.0);
199        $cy = $gradient->cy() ?? ($bbox !== null ? 0.5 : 0.0);
200        $r = $gradient->r() ?? ($bbox !== null ? 0.5 : 0.0);
201        $fx = $gradient->fx() ?? $cx;
202        $fy = $gradient->fy() ?? $cy;
203        $fr = $gradient->fr() ?? 0.0;
204        if ($r <= 0.0) {
205            // Zero-radius radial paints nothing; return a shading that
206            // produces transparent output. Simpler: bail and let
207            // `applyAsFill` fall back to no fill.
208            $r = 0.0001;
209        }
210        if ($bbox !== null) {
211            $cx = $bbox['minX'] + $cx * $bbox['width'];
212            $cy = $bbox['minY'] + $cy * $bbox['height'];
213            $fx = $bbox['minX'] + $fx * $bbox['width'];
214            $fy = $bbox['minY'] + $fy * $bbox['height'];
215            // SVG specifies `r` against the bbox diagonal scaled by
216            // `√2/2` (the "user space units" mapping). At 3O we used
217            // the larger axis as a conservative approximation — the
218            // gradient still emanates from the centre, scaled to
219            // cover the bbox. `fr` rides on the same factor.
220            $axis = max($bbox['width'], $bbox['height']);
221            $r *= $axis;
222            $fr *= $axis;
223        }
224        $extend = $gradient->spreadMethod() === 'pad';
225        return $doc->addRadialGradientStops(
226            new Point($fx, $fy),
227            max(0.0, $fr),
228            new Point($cx, $cy),
229            $r,
230            $stops,
231            extend: $extend,
232        );
233    }
234
235    /**
236     * Resolve the gradient's stop list down to the `{offset, rgb}` tuple
237     * format `PdfDoc::addLinearGradientStops` expects. Walks `Gradient::
238     * stops` (which already does the cycle-safe href chain), drops
239     * stops with unparseable colours, and clamps the offset sequence
240     * to monotonically non-decreasing per CSS Images §3.5.1.
241     *
242     * @return list<array{offset: float, rgb: array{float, float, float}}>
243     */
244    private function resolveStops(Gradient $gradient): array
245    {
246        $stops = $gradient->stops($this->document);
247        if ($stops === []) {
248            return [];
249        }
250        $out = [];
251        $lastOffset = 0.0;
252        foreach ($stops as $stop) {
253            $rgb = $this->stopColor($stop);
254            if ($rgb === null) {
255                continue;
256            }
257            $offset = max($lastOffset, $stop->offset());
258            $lastOffset = $offset;
259            $out[] = ['offset' => $offset, 'rgb' => $rgb];
260        }
261        if ($out === []) {
262            return [];
263        }
264        // Ensure the function domain covers [0, 1] — pad the ends with
265        // the first/last stop value if the author didn't anchor them.
266        if ($out[0]['offset'] > 0.0) {
267            array_unshift($out, ['offset' => 0.0, 'rgb' => $out[0]['rgb']]);
268        }
269        if ($out[count($out) - 1]['offset'] < 1.0) {
270            $out[] = ['offset' => 1.0, 'rgb' => $out[count($out) - 1]['rgb']];
271        }
272        return $out;
273    }
274
275    /**
276     * Coerce a `<stop>`'s `stop-color` down to a 3-tuple sRGB triple in
277     * `[0, 1]`. Stops without a usable colour are dropped by the caller
278     * so the function builder never sees an unparseable input.
279     *
280     * @return array{float, float, float}|null
281     */
282    private function stopColor(Stop $stop): ?array
283    {
284        $color = $stop->stopColor();
285        if ($color === null) {
286            return null;
287        }
288        $rgb = $color instanceof RgbColor ? $color : null;
289        if ($rgb === null && method_exists($color, 'toRgb')) {
290            $candidate = $color->toRgb();
291            $rgb = $candidate instanceof RgbColor ? $candidate : null;
292        }
293        if ($rgb === null) {
294            $components = $color->toArray();
295            if (count($components) !== 3) {
296                return null;
297            }
298            return [(float) $components[0], (float) $components[1], (float) $components[2]];
299        }
300        return [$rgb->r, $rgb->g, $rgb->b];
301    }
302}