Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.46% |
89 / 112 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
| GradientPainter | |
79.46% |
89 / 112 |
|
44.44% |
4 / 9 |
53.86 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| applyAsFill | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| applyAsStroke | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| registerForElement | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
9.37 | |||
| applyGradientTransform | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| registerLinear | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| registerRadial | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
6 | |||
| resolveStops | |
73.68% |
14 / 19 |
|
0.00% |
0 / 1 |
7.89 | |||
| stopColor | |
46.15% |
6 / 13 |
|
0.00% |
0 / 1 |
17.99 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\SvgToPdf\Gradient; |
| 6 | |
| 7 | use Phpdftk\Color\RgbColor; |
| 8 | use Phpdftk\Geometry\Point; |
| 9 | use Phpdftk\Pdf\Core\Content\ContentStream; |
| 10 | use Phpdftk\Pdf\Core\Graphics\Pattern\ShadingPattern; |
| 11 | use Phpdftk\Pdf\Core\PdfArray; |
| 12 | use Phpdftk\Pdf\Core\PdfNumber; |
| 13 | use Phpdftk\Pdf\Writer\Page; |
| 14 | use Phpdftk\Pdf\Writer\PdfDoc; |
| 15 | use Phpdftk\Pdf\Writer\PdfWriter; |
| 16 | use Phpdftk\Svg\Element; |
| 17 | use Phpdftk\Svg\Gradient\Gradient; |
| 18 | use Phpdftk\Svg\Gradient\LinearGradient; |
| 19 | use Phpdftk\Svg\Gradient\RadialGradient; |
| 20 | use Phpdftk\Svg\Gradient\Stop; |
| 21 | use Phpdftk\Svg\SvgDocument; |
| 22 | use Phpdftk\Svg\Value\Transform; |
| 23 | use 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 | */ |
| 58 | final 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 | } |