Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.96% |
113 / 133 |
|
58.33% |
7 / 12 |
CRAP | |
0.00% |
0 / 1 |
| GradientPainter | |
84.96% |
113 / 133 |
|
58.33% |
7 / 12 |
63.92 | |
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 | |||
| paintsAsNone | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| registerForElement | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
12.12 | |||
| applyPatternMatrix | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| multiplyAffine | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| isIdentity | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
6 | |||
| registerLinear | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| registerRadial | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
6 | |||
| resolveStops | |
84.21% |
16 / 19 |
|
0.00% |
0 / 1 |
7.19 | |||
| 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 | /** |
| 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 | } |