Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.35% |
845 / 915 |
|
58.11% |
43 / 74 |
CRAP | |
0.00% |
0 / 1 |
| Translator | |
92.35% |
845 / 915 |
|
58.11% |
43 / 74 |
455.41 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| paint | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
8 | |||
| paintChildren | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| paintNestedSvg | |
96.88% |
31 / 32 |
|
0.00% |
0 / 1 |
6 | |||
| resolveViewportLength | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
| nestedViewBoxTransform | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
6.01 | |||
| nestedAlignRatios | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
6 | |||
| paintElement | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
11 | |||
| resolveMaskState | |
98.33% |
59 / 60 |
|
0.00% |
0 / 1 |
14 | |||
| computeMaskRegion | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
| resolveClipPath | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
6.02 | |||
| applyClipPath | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
12 | |||
| inverseAffine | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
2.00 | |||
| resolveClipRule | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| emitElementPath | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
9.49 | |||
| emitRectPath | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| resolvePercentLength | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| currentViewport | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
7.02 | |||
| parseLengthPrefixForViewport | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
4.37 | |||
| emitCirclePath | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| emitEllipsePathFor | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
30 | |||
| emitPolylinePath | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| emitPolygonPath | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| emitPathPath | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| resolveOpacityState | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| needsStrokeParams | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
9.02 | |||
| applyStrokeParams | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
12 | |||
| dispatchElement | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
18 | |||
| paintSwitch | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| switchChildPasses | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
9 | |||
| resolveSystemLanguage | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
4.13 | |||
| paintUse | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
13 | |||
| paintUseReferent | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| paintImage | |
81.48% |
22 / 27 |
|
0.00% |
0 / 1 |
14.07 | |||
| paintImageFromPath | |
82.76% |
24 / 29 |
|
0.00% |
0 / 1 |
13.87 | |||
| decodeDataUri | |
86.36% |
19 / 22 |
|
0.00% |
0 / 1 |
8.16 | |||
| materialiseDataUri | |
50.00% |
4 / 8 |
|
0.00% |
0 / 1 |
4.12 | |||
| fetchAndMaterialiseHttpHref | |
61.54% |
8 / 13 |
|
0.00% |
0 / 1 |
6.42 | |||
| paintTextElement | |
97.96% |
48 / 49 |
|
0.00% |
0 / 1 |
14 | |||
| parseTextShadow | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| splitTextShadowItems | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
7 | |||
| parseTextShadowLayer | |
93.33% |
28 / 30 |
|
0.00% |
0 / 1 |
11.04 | |||
| tokenizeTextShadowLayer | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
9 | |||
| parseLengthToken | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
9.86 | |||
| paintTextPerGlyph | |
96.55% |
28 / 29 |
|
0.00% |
0 / 1 |
7 | |||
| emitTextMatrix | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| collectTextContent | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| paintPath | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| emitPathCommand | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
12.11 | |||
| emitMoveTo | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| emitLineTo | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| emitHorizontalLineTo | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| emitVerticalLineTo | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| emitCurveTo | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| emitSmoothCurveTo | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| emitQuadraticCurveTo | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| emitSmoothQuadraticCurveTo | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| emitArcTo | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
5 | |||
| emitClosePath | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| emitQuadraticAsCubic | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| resolvePoint | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| paintRect | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| paintCircle | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| paintEllipse | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| paintLine | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| paintPolyline | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| paintPolygon | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| emitEllipsePath | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| emitPolyPath | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| applyFillAndStroke | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
7 | |||
| applyFillPaint | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| applyStrokePaint | |
66.67% |
6 / 9 |
|
0.00% |
0 / 1 |
5.93 | |||
| setFillColor | |
25.00% |
1 / 4 |
|
0.00% |
0 / 1 |
15.55 | |||
| setStrokeColor | |
25.00% |
1 / 4 |
|
0.00% |
0 / 1 |
15.55 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\SvgToPdf; |
| 6 | |
| 7 | use Phpdftk\Color\CmykColor; |
| 8 | use Phpdftk\Color\ColorInterface; |
| 9 | use Phpdftk\Color\GrayColor; |
| 10 | use Phpdftk\Color\RgbColor; |
| 11 | use Phpdftk\Filesystem\LocalFilesystem; |
| 12 | use Phpdftk\ImageMetadata\ImageParser; |
| 13 | use Phpdftk\ResourceLoader\Exception\FetchFailedException; |
| 14 | use Phpdftk\ResourceLoader\Exception\SsrfBlockedException; |
| 15 | use Phpdftk\ResourceLoader\ResourceLoader; |
| 16 | use Phpdftk\Pdf\Core\Content\ContentStream; |
| 17 | use Phpdftk\Pdf\Core\Document\GroupAttributes; |
| 18 | use Phpdftk\Pdf\Core\Graphics\ExtGState; |
| 19 | use Phpdftk\Pdf\Core\Graphics\SoftMask; |
| 20 | use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject; |
| 21 | use Phpdftk\Pdf\Core\PdfArray; |
| 22 | use Phpdftk\Pdf\Core\PdfName; |
| 23 | use Phpdftk\Pdf\Core\PdfNumber; |
| 24 | use Phpdftk\Pdf\Core\PdfReference; |
| 25 | use Phpdftk\Pdf\Writer\Page; |
| 26 | use Phpdftk\Pdf\Writer\PdfWriter; |
| 27 | use Phpdftk\SvgToPdf\Gradient\GradientPainter; |
| 28 | use Phpdftk\SvgToPdf\Text\FontResolver; |
| 29 | use Phpdftk\Svg\ClipPath; |
| 30 | use Phpdftk\Svg\Defs; |
| 31 | use Phpdftk\Svg\Element; |
| 32 | use Phpdftk\Svg\Image as SvgImage; |
| 33 | use Phpdftk\Svg\Mask; |
| 34 | use Phpdftk\Svg\Symbol; |
| 35 | use Phpdftk\Svg\Use_; |
| 36 | use Phpdftk\SvgToPdf\Geometry\BoundingBox; |
| 37 | use Phpdftk\Svg\Path; |
| 38 | use Phpdftk\Svg\Path\ArcTo; |
| 39 | use Phpdftk\Svg\Path\ClosePath; |
| 40 | use Phpdftk\Svg\Path\CurveTo; |
| 41 | use Phpdftk\Svg\Path\HorizontalLineTo; |
| 42 | use Phpdftk\Svg\Path\LineTo; |
| 43 | use Phpdftk\Svg\Path\MoveTo; |
| 44 | use Phpdftk\Svg\Path\PathCommand; |
| 45 | use Phpdftk\Svg\Path\QuadraticCurveTo; |
| 46 | use Phpdftk\Svg\Path\SmoothCurveTo; |
| 47 | use Phpdftk\Svg\Path\SmoothQuadraticCurveTo; |
| 48 | use Phpdftk\Svg\Path\VerticalLineTo; |
| 49 | use Phpdftk\Svg\Shape\Circle; |
| 50 | use Phpdftk\Svg\Shape\Ellipse; |
| 51 | use Phpdftk\Svg\Shape\Line; |
| 52 | use Phpdftk\Svg\Shape\Polygon; |
| 53 | use Phpdftk\Svg\Shape\Polyline; |
| 54 | use Phpdftk\Svg\Shape\Rect; |
| 55 | use Phpdftk\Svg\SvgDocument; |
| 56 | use Phpdftk\Svg\Text as TextNode; |
| 57 | use Phpdftk\Svg\Text\TextElement; |
| 58 | use Phpdftk\Svg\Value\Paint; |
| 59 | use Phpdftk\Svg\Value\Color as SvgColor; |
| 60 | use Phpdftk\Svg\Value\Paint\CurrentColor; |
| 61 | use Phpdftk\Svg\Value\Paint\None_; |
| 62 | use Phpdftk\Svg\Value\Paint\SolidColor; |
| 63 | use Phpdftk\Svg\Value\Paint\Url; |
| 64 | use Phpdftk\SvgToPdf\Path\ArcToCubic; |
| 65 | use Phpdftk\SvgToPdf\Path\PathPainterState; |
| 66 | |
| 67 | /** |
| 68 | * Translates a parsed `Phpdftk\Svg\SvgDocument` into PDF content-stream |
| 69 | * operators. The translator is a thin recursive walk: each element is |
| 70 | * dispatched to a per-shape painter that emits the right path and |
| 71 | * `f`/`S`/`B` operator combination. |
| 72 | * |
| 73 | * Coordinate convention: SVG and PDF disagree on Y-axis direction (SVG |
| 74 | * Y-down, PDF Y-up). The translator emits SVG coordinates verbatim — the |
| 75 | * caller is responsible for setting up a PDF transformation (`cm`) that |
| 76 | * flips and translates if it wants the SVG to appear at a specific PDF |
| 77 | * position. Tests can paint directly into a fresh PDF stream because the |
| 78 | * default user space happens to put numbers in a viewable range for small |
| 79 | * SVGs. |
| 80 | * |
| 81 | * What 3K covers: basic shapes (`<rect>`, `<circle>`, `<ellipse>`, |
| 82 | * `<line>`, `<polyline>`, `<polygon>`) and the SolidColor fill / stroke |
| 83 | * paint cases. `<path>` lands in 3L, `<g>` + transforms in 3M, gradients |
| 84 | * in 3O, text in 3P, use/clip/mask/image in 3Q. Until then unrecognised |
| 85 | * elements are walked through transparently — their children paint as if |
| 86 | * the unknown container weren't there. |
| 87 | * |
| 88 | * Default paint per SVG 2 §13.2.1: black fill, no stroke. The translator |
| 89 | * applies that fallback when no explicit fill is set on the element. |
| 90 | */ |
| 91 | final class Translator |
| 92 | { |
| 93 | /** |
| 94 | * Cubic-Bézier "magic number" approximating a unit-circle quarter |
| 95 | * arc — `(4/3) · tan(π/8) ≈ 0.5522847498`. Standard κ for |
| 96 | * `<circle>` / `<ellipse>` rendering. |
| 97 | */ |
| 98 | private const float KAPPA = 0.5522847498; |
| 99 | |
| 100 | /** |
| 101 | * Optional `phpdftk/resource-loader` for `http(s)://` `<image>` |
| 102 | * hrefs. When `null` (the default — preserves existing call- |
| 103 | * site behaviour), network hrefs drop silently per the SVG 2 |
| 104 | * §12.6 "no image available" outcome. When supplied, the |
| 105 | * loader runs (with its SSRF guard, redirect handling, body |
| 106 | * cap, and MIME sniffing) and the embedded bytes get |
| 107 | * materialised to a temp file the same way `data:` URIs do. |
| 108 | */ |
| 109 | public function __construct( |
| 110 | private readonly ?ResourceLoader $resourceLoader = null, |
| 111 | ) {} |
| 112 | |
| 113 | /** |
| 114 | * Paint a parsed SVG document into the given content stream. |
| 115 | * |
| 116 | * When `$page` is supplied, the painter registers an `ExtGState` |
| 117 | * resource on that page for any element that carries `opacity`, |
| 118 | * `fill-opacity`, or `stroke-opacity` < 1 and emits the `gs` |
| 119 | * operator to invoke it. Without a `$page` reference opacity |
| 120 | * attributes are silently ignored — the painter falls back to |
| 121 | * fully-opaque rendering. |
| 122 | * |
| 123 | * When `$page` AND `$writer` are both supplied, gradient paint |
| 124 | * references (`fill="url(#id)"`) resolve through the writer's |
| 125 | * `PdfDoc` for shading registration and the page's |
| 126 | * `useGradient` for resource attachment. Without them, gradient |
| 127 | * fills fall back to no paint per SVG 2's "invalid → no paint" |
| 128 | * semantics. |
| 129 | */ |
| 130 | /** |
| 131 | * @param array{w: float, h: float}|null $effectiveViewport |
| 132 | * Override viewport for inner percentage-attribute resolution |
| 133 | * when the document's own width/height/viewBox don't yield |
| 134 | * useful dimensions. Set by `SvgRenderer::draw` when it |
| 135 | * synthesises a source rect from the destination. |
| 136 | */ |
| 137 | public function paint( |
| 138 | SvgDocument $document, |
| 139 | ContentStream $stream, |
| 140 | ?Page $page = null, |
| 141 | ?PdfWriter $writer = null, |
| 142 | bool $compensateTextFlip = false, |
| 143 | ?array $effectiveViewport = null, |
| 144 | ): void { |
| 145 | $this->page = $page; |
| 146 | $this->writer = $writer; |
| 147 | $this->document = $document; |
| 148 | $this->compensateTextFlip = $compensateTextFlip; |
| 149 | $this->effectiveViewport = $effectiveViewport; |
| 150 | $this->gradientPainter = $page !== null && $writer !== null |
| 151 | ? new GradientPainter($writer, $page, $document) |
| 152 | : null; |
| 153 | $this->fontResolver = $page !== null && $writer !== null |
| 154 | ? new FontResolver($writer, $page) |
| 155 | : null; |
| 156 | try { |
| 157 | $viewBox = $document->viewBox(); |
| 158 | if ($viewBox !== null && ($viewBox[0] !== 0.0 || $viewBox[1] !== 0.0)) { |
| 159 | // SVG 2 §7 — the viewBox's `min-x`/`min-y` shift the |
| 160 | // origin of the local coordinate system. The proper |
| 161 | // viewBox-to-viewport mapping (with `preserveAspectRatio`) |
| 162 | // needs a caller-supplied target rectangle, so it lives |
| 163 | // in the 3R adapter layer; here we honour just the |
| 164 | // translation so the painted content stays anchored |
| 165 | // correctly relative to the viewBox. |
| 166 | $stream->saveGraphicsState(); |
| 167 | $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, -$viewBox[0], -$viewBox[1]); |
| 168 | $this->paintChildren($document, $stream); |
| 169 | $stream->restoreGraphicsState(); |
| 170 | return; |
| 171 | } |
| 172 | $this->paintChildren($document, $stream); |
| 173 | } finally { |
| 174 | $this->page = null; |
| 175 | $this->writer = null; |
| 176 | $this->document = null; |
| 177 | $this->gradientPainter = null; |
| 178 | $this->fontResolver = null; |
| 179 | $this->compensateTextFlip = false; |
| 180 | $this->effectiveViewport = null; |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | private ?Page $page = null; |
| 185 | private ?PdfWriter $writer = null; |
| 186 | /** @var array{w: float, h: float}|null */ |
| 187 | private ?array $effectiveViewport = null; |
| 188 | private ?SvgDocument $document = null; |
| 189 | /** |
| 190 | * Stack of nested `<svg>` viewports (viewBox units), innermost last. |
| 191 | * Percentage lengths + `currentViewport()` resolve against the top. |
| 192 | * |
| 193 | * @var list<array{w: float, h: float}> |
| 194 | */ |
| 195 | private array $viewportStack = []; |
| 196 | /** |
| 197 | * A `<use>`'s width/height override for the nested `<svg>` it |
| 198 | * references (SVG 2 §5.6.1), consumed by the next `paintNestedSvg`. |
| 199 | * |
| 200 | * @var array{w: float, h: float}|null |
| 201 | */ |
| 202 | private ?array $pendingUseViewport = null; |
| 203 | private ?GradientPainter $gradientPainter = null; |
| 204 | private ?FontResolver $fontResolver = null; |
| 205 | private bool $compensateTextFlip = false; |
| 206 | |
| 207 | private function paintChildren(Element $parent, ContentStream $stream): void |
| 208 | { |
| 209 | foreach ($parent->children as $child) { |
| 210 | if ($child instanceof Element) { |
| 211 | $this->paintElement($child, $stream); |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * SVG 2 §7.5 — a nested `<svg>` establishes a new viewport from its |
| 218 | * `x` / `y` / `width` / `height`, clips overflow to it, and (when it |
| 219 | * carries a `viewBox`) sets up a new user coordinate system via the |
| 220 | * viewBox-to-viewport scale + `preserveAspectRatio` alignment. |
| 221 | */ |
| 222 | private function paintNestedSvg(\Phpdftk\Svg\NestedSvg $svg, ContentStream $stream): void |
| 223 | { |
| 224 | // A `<use>` that references this svg overrides its viewport size. |
| 225 | $override = $this->pendingUseViewport; |
| 226 | $this->pendingUseViewport = null; |
| 227 | $vp = $this->currentViewport(); |
| 228 | $x = $this->resolveViewportLength($svg->getAttribute('x'), $vp['w'], 0.0); |
| 229 | $y = $this->resolveViewportLength($svg->getAttribute('y'), $vp['h'], 0.0); |
| 230 | // Width / height: a `<use>` override wins; otherwise the svg's own |
| 231 | // attributes, defaulting to 100% of the enclosing viewport. |
| 232 | $w = $override['w'] ?? $this->resolveViewportLength($svg->widthAttribute(), $vp['w'], $vp['w']); |
| 233 | $h = $override['h'] ?? $this->resolveViewportLength($svg->heightAttribute(), $vp['h'], $vp['h']); |
| 234 | if ($w <= 0.0 || $h <= 0.0) { |
| 235 | return; // A zero-sized viewport disables rendering (SVG 2 §7.5). |
| 236 | } |
| 237 | |
| 238 | $stream->saveGraphicsState(); |
| 239 | // Position the viewport, then clip overflow to it (the default |
| 240 | // `overflow: hidden` on a nested `<svg>`). |
| 241 | $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y); |
| 242 | $stream->rectangle(0.0, 0.0, $w, $h); |
| 243 | $stream->clip(); |
| 244 | $stream->endPath(); |
| 245 | |
| 246 | $childViewport = ['w' => $w, 'h' => $h]; |
| 247 | $viewBox = $svg->viewBox(); |
| 248 | if ($viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0) { |
| 249 | [$scaleX, $scaleY, $offsetX, $offsetY] |
| 250 | = $this->nestedViewBoxTransform($svg, $viewBox[2], $viewBox[3], $w, $h); |
| 251 | $stream->concatMatrix( |
| 252 | $scaleX, |
| 253 | 0.0, |
| 254 | 0.0, |
| 255 | $scaleY, |
| 256 | $offsetX - $viewBox[0] * $scaleX, |
| 257 | $offsetY - $viewBox[1] * $scaleY, |
| 258 | ); |
| 259 | $childViewport = ['w' => $viewBox[2], 'h' => $viewBox[3]]; |
| 260 | } |
| 261 | |
| 262 | $this->viewportStack[] = $childViewport; |
| 263 | $this->paintChildren($svg, $stream); |
| 264 | array_pop($this->viewportStack); |
| 265 | $stream->restoreGraphicsState(); |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Resolve a nested-viewport length attribute: a `%` resolves against |
| 270 | * the given viewport dimension, a plain number is taken as-is, and an |
| 271 | * absent/empty value falls back to `$default`. |
| 272 | */ |
| 273 | private function resolveViewportLength(?string $raw, float $viewport, float $default): float |
| 274 | { |
| 275 | if ($raw === null || trim($raw) === '') { |
| 276 | return $default; |
| 277 | } |
| 278 | if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*%\s*$/', $raw, $m) === 1) { |
| 279 | return ((float) $m[1]) / 100.0 * $viewport; |
| 280 | } |
| 281 | $plain = self::parseLengthPrefixForViewport($raw); |
| 282 | return $plain ?? $default; |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Nested viewBox-to-viewport mapping in SVG user space (y-down — the |
| 287 | * outer document transform applies the PDF flip). Returns |
| 288 | * `[scaleX, scaleY, offsetX, offsetY]`. Mirrors the root |
| 289 | * `applyPreserveAspectRatio` but without the PDF y-axis inversion, |
| 290 | * since a nested `<svg>` composes inside the already-flipped space. |
| 291 | * |
| 292 | * @return array{0: float, 1: float, 2: float, 3: float} |
| 293 | */ |
| 294 | private function nestedViewBoxTransform( |
| 295 | \Phpdftk\Svg\NestedSvg $svg, |
| 296 | float $srcW, |
| 297 | float $srcH, |
| 298 | float $dstW, |
| 299 | float $dstH, |
| 300 | ): array { |
| 301 | $sx = $srcW > 0.0 ? $dstW / $srcW : 1.0; |
| 302 | $sy = $srcH > 0.0 ? $dstH / $srcH : 1.0; |
| 303 | $par = strtolower(trim($svg->getAttribute('preserveAspectRatio') ?? '')); |
| 304 | if ($par === 'none') { |
| 305 | return [$sx, $sy, 0.0, 0.0]; |
| 306 | } |
| 307 | $tokens = preg_split('/\s+/', $par) ?: []; |
| 308 | $align = $tokens[0] ?? ''; |
| 309 | $slice = ($tokens[1] ?? 'meet') === 'slice'; |
| 310 | $scale = $slice ? max($sx, $sy) : min($sx, $sy); |
| 311 | [$xRatio, $yRatio] = self::nestedAlignRatios($align); |
| 312 | return [ |
| 313 | $scale, |
| 314 | $scale, |
| 315 | $xRatio * ($dstW - $scale * $srcW), |
| 316 | $yRatio * ($dstH - $scale * $srcH), |
| 317 | ]; |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * `preserveAspectRatio` align keyword → `[xRatio, yRatio]` leftover |
| 322 | * fractions, in SVG y-down space (`yMin` → 0, `yMid` → 0.5, |
| 323 | * `yMax` → 1). Defaults to `xMidYMid`. |
| 324 | * |
| 325 | * @return array{0: float, 1: float} |
| 326 | */ |
| 327 | private static function nestedAlignRatios(string $align): array |
| 328 | { |
| 329 | $align = $align === '' ? 'xmidymid' : $align; |
| 330 | $xRatio = str_contains($align, 'xmax') ? 1.0 : (str_contains($align, 'xmin') ? 0.0 : 0.5); |
| 331 | $yRatio = str_contains($align, 'ymax') ? 1.0 : (str_contains($align, 'ymin') ? 0.0 : 0.5); |
| 332 | return [$xRatio, $yRatio]; |
| 333 | } |
| 334 | |
| 335 | private function paintElement(Element $element, ContentStream $stream): void |
| 336 | { |
| 337 | // Any of these scope-leaking attributes triggers a `q`/`Q` wrap |
| 338 | // so the state doesn't leak across siblings: |
| 339 | // |
| 340 | // - transform: emits `cm`. |
| 341 | // - opacity / fill-opacity / stroke-opacity (< 1): emits `gs`. |
| 342 | // - stroke params (w / J / j / M / d): each emits its own op. |
| 343 | // - clip-path: emits the clip region inside the same wrap. |
| 344 | // - mask: emits `gs` referencing a SMask-bearing ExtGState. |
| 345 | // |
| 346 | // Painting the same shape with all defaults stays a one-shot |
| 347 | // op stream — no overhead when none of the above is set. |
| 348 | $transform = $element->transform(); |
| 349 | $opacityGs = $this->resolveOpacityState($element); |
| 350 | $needsStrokeParams = $this->needsStrokeParams($element); |
| 351 | $clipPath = $this->resolveClipPath($element); |
| 352 | $maskGs = $this->resolveMaskState($element); |
| 353 | $needsWrap = $transform !== null |
| 354 | || $opacityGs !== null |
| 355 | || $needsStrokeParams |
| 356 | || $clipPath !== null |
| 357 | || $maskGs !== null; |
| 358 | |
| 359 | if (!$needsWrap) { |
| 360 | $this->dispatchElement($element, $stream); |
| 361 | return; |
| 362 | } |
| 363 | |
| 364 | $stream->saveGraphicsState(); |
| 365 | if ($transform !== null) { |
| 366 | $matrix = $transform->toMatrix(); |
| 367 | $stream->concatMatrix( |
| 368 | $matrix[0], |
| 369 | $matrix[1], |
| 370 | $matrix[2], |
| 371 | $matrix[3], |
| 372 | $matrix[4], |
| 373 | $matrix[5], |
| 374 | ); |
| 375 | } |
| 376 | if ($opacityGs !== null) { |
| 377 | $stream->setGraphicsState($opacityGs); |
| 378 | } |
| 379 | if ($needsStrokeParams) { |
| 380 | $this->applyStrokeParams($element, $stream); |
| 381 | } |
| 382 | if ($clipPath !== null) { |
| 383 | $this->applyClipPath($clipPath, $element, $stream); |
| 384 | } |
| 385 | if ($maskGs !== null) { |
| 386 | $stream->setGraphicsState($maskGs); |
| 387 | } |
| 388 | $this->dispatchElement($element, $stream); |
| 389 | $stream->restoreGraphicsState(); |
| 390 | } |
| 391 | |
| 392 | /** |
| 393 | * Resolve `mask="url(#id)"` to a registered `ExtGState` whose |
| 394 | * `/SMask` references a Form XObject containing the mask's |
| 395 | * painted children. Returns the resource name to invoke via |
| 396 | * `gs`, or null when no mask is set / can't be resolved. |
| 397 | * |
| 398 | * Pipeline: |
| 399 | * |
| 400 | * 1. Paint the `<mask>`'s children into a new `ContentStream` |
| 401 | * (running the full Translator pipeline so gradients, fonts, |
| 402 | * images, etc. all register on the host page). |
| 403 | * 2. Wrap the resulting bytes in a Form XObject. `/Group /S |
| 404 | * Transparency /CS DeviceGray` so the form's pixels become an |
| 405 | * alpha channel via their luminance. |
| 406 | * 3. Build a `SoftMask` dict with `/S Luminosity` and `/BC [0]` |
| 407 | * so the backdrop outside the mask region is black (hidden). |
| 408 | * 4. Drop the SMask in an `ExtGState`, register, and attach to |
| 409 | * the page's resources under a stable name. |
| 410 | * |
| 411 | * Implemented: |
| 412 | * |
| 413 | * - `maskContentUnits = objectBoundingBox` applies a bbox `cm` |
| 414 | * to the mask content stream so authored coords are in [0, 1]. |
| 415 | * - SVG 2 §14.5.4 defaults are honoured: bbox-mode defaults |
| 416 | * `(-10%, -10%, 120%, 120%)` so the mask reaches a hair |
| 417 | * beyond the painted geometry; userspace-mode defaults to |
| 418 | * the masked element's own bbox. |
| 419 | * - Explicit `x` / `y` / `width` / `height` attributes on |
| 420 | * the `<mask>` element override the defaults. |
| 421 | */ |
| 422 | private function resolveMaskState(Element $element): ?string |
| 423 | { |
| 424 | if ($this->writer === null |
| 425 | || $this->page === null |
| 426 | || $this->document === null |
| 427 | ) { |
| 428 | return null; |
| 429 | } |
| 430 | $raw = $element->getAttribute('mask'); |
| 431 | if ($raw === null) { |
| 432 | return null; |
| 433 | } |
| 434 | $trimmed = trim($raw); |
| 435 | if ($trimmed === 'none') { |
| 436 | return null; |
| 437 | } |
| 438 | if (preg_match('/^url\(\s*#([^)\s]+)\s*\)/i', $trimmed, $m) !== 1) { |
| 439 | return null; |
| 440 | } |
| 441 | $referent = $this->document->findById($m[1]); |
| 442 | if (!$referent instanceof Mask) { |
| 443 | return null; |
| 444 | } |
| 445 | $elementBbox = BoundingBox::compute($element); |
| 446 | if ($elementBbox === null) { |
| 447 | return null; |
| 448 | } |
| 449 | $region = self::computeMaskRegion($referent, $elementBbox); |
| 450 | |
| 451 | $maskStream = new ContentStream(); |
| 452 | if ($referent->maskContentUnits() === 'objectBoundingBox') { |
| 453 | // Reify mask children's [0, 1] coords against the masked |
| 454 | // element's bbox (the same reference frame the masked |
| 455 | // element's geometry inhabits — not the mask region). |
| 456 | $maskStream->concatMatrix( |
| 457 | $elementBbox['width'], |
| 458 | 0.0, |
| 459 | 0.0, |
| 460 | $elementBbox['height'], |
| 461 | $elementBbox['minX'], |
| 462 | $elementBbox['minY'], |
| 463 | ); |
| 464 | } |
| 465 | foreach ($referent->children as $child) { |
| 466 | if ($child instanceof Element) { |
| 467 | $this->paintElement($child, $maskStream); |
| 468 | } |
| 469 | } |
| 470 | $operatorBytes = implode("\n", $maskStream->getOperators()); |
| 471 | $form = new FormXObject( |
| 472 | new PdfArray([ |
| 473 | new PdfNumber($region['minX']), |
| 474 | new PdfNumber($region['minY']), |
| 475 | new PdfNumber($region['minX'] + $region['width']), |
| 476 | new PdfNumber($region['minY'] + $region['height']), |
| 477 | ]), |
| 478 | $operatorBytes, |
| 479 | ); |
| 480 | // Transparency group with the DeviceGray colour space so the |
| 481 | // luminance of the painted pixels becomes the mask's alpha. |
| 482 | $group = new GroupAttributes('Transparency'); |
| 483 | $group->cs = new PdfName('DeviceGray'); |
| 484 | $form->group = $group; |
| 485 | $this->writer->register($form); |
| 486 | |
| 487 | // SVG 2 §14.5: `mask-type="alpha"` uses the painted pixels' |
| 488 | // alpha channel directly; the default `luminance` (also |
| 489 | // covering the old `mask-type` absent case) uses the |
| 490 | // luminance of the RGB pixels. PDF maps these to the SoftMask |
| 491 | // `/S /Alpha` and `/S /Luminosity` modes. |
| 492 | $maskSubtype = strtolower(trim($referent->getAttribute('mask-type') ?? '')); |
| 493 | $smask = new SoftMask( |
| 494 | $maskSubtype === 'alpha' ? 'Alpha' : 'Luminosity', |
| 495 | new PdfReference($form->objectNumber), |
| 496 | ); |
| 497 | // Backdrop colour black ([0]) so anywhere the mask content |
| 498 | // doesn't paint stays hidden — matches SVG 2's "outside the |
| 499 | // mask region the alpha is 0" semantic. |
| 500 | $smask->bc = new PdfArray([new PdfNumber(0)]); |
| 501 | |
| 502 | $gstate = new ExtGState(); |
| 503 | $gstate->sMask = $smask; |
| 504 | $this->writer->register($gstate); |
| 505 | |
| 506 | $name = 'GS_mask_' . $gstate->objectNumber; |
| 507 | $resources = $this->page->corePage()->resources; |
| 508 | if ($resources !== null) { |
| 509 | $resources->extGState[$name] = new PdfReference($gstate->objectNumber); |
| 510 | } |
| 511 | return $name; |
| 512 | } |
| 513 | |
| 514 | /** |
| 515 | * Compute the rectangular region that the mask covers, in user |
| 516 | * space. SVG 2 §14.5.4 defaults: |
| 517 | * |
| 518 | * - `maskUnits="objectBoundingBox"` (default): `x=-10%, y=-10%, |
| 519 | * width=120%, height=120%` of the masked element's bounding |
| 520 | * box. The 10% pad on each side is what makes the mask |
| 521 | * naturally reach a hair beyond the painted geometry so |
| 522 | * anti-aliased edges aren't clipped. |
| 523 | * - `maskUnits="userSpaceOnUse"`: SVG defaults to the viewport |
| 524 | * rect (-10% etc. of the viewport), which we don't have direct |
| 525 | * access to here — fall back to the masked element's bbox |
| 526 | * (matches 3R+8 behaviour for unset attributes). |
| 527 | * |
| 528 | * @param array{minX: float, minY: float, width: float, height: float} $elementBbox |
| 529 | * @return array{minX: float, minY: float, width: float, height: float} |
| 530 | */ |
| 531 | private static function computeMaskRegion(Mask $mask, array $elementBbox): array |
| 532 | { |
| 533 | $bboxMode = $mask->maskUnits() === 'objectBoundingBox'; |
| 534 | |
| 535 | if ($bboxMode) { |
| 536 | $x = $mask->x() ?? -0.1; |
| 537 | $y = $mask->y() ?? -0.1; |
| 538 | $w = $mask->width() ?? 1.2; |
| 539 | $h = $mask->height() ?? 1.2; |
| 540 | return [ |
| 541 | 'minX' => $elementBbox['minX'] + $x * $elementBbox['width'], |
| 542 | 'minY' => $elementBbox['minY'] + $y * $elementBbox['height'], |
| 543 | 'width' => $w * $elementBbox['width'], |
| 544 | 'height' => $h * $elementBbox['height'], |
| 545 | ]; |
| 546 | } |
| 547 | |
| 548 | return [ |
| 549 | 'minX' => $mask->x() ?? $elementBbox['minX'], |
| 550 | 'minY' => $mask->y() ?? $elementBbox['minY'], |
| 551 | 'width' => $mask->width() ?? $elementBbox['width'], |
| 552 | 'height' => $mask->height() ?? $elementBbox['height'], |
| 553 | ]; |
| 554 | } |
| 555 | |
| 556 | /** |
| 557 | * Resolve an element's `clip-path="url(#id)"` reference to the |
| 558 | * matching `<clipPath>` element. Returns null when no clip-path is |
| 559 | * set, the reference can't be parsed, or the id doesn't resolve to |
| 560 | * a clipPath — each case falls through to the unclipped paint per |
| 561 | * SVG 2's "invalid → no clip" rule. |
| 562 | */ |
| 563 | private function resolveClipPath(Element $element): ?ClipPath |
| 564 | { |
| 565 | if ($this->document === null) { |
| 566 | return null; |
| 567 | } |
| 568 | $raw = $element->getAttribute('clip-path'); |
| 569 | if ($raw === null) { |
| 570 | return null; |
| 571 | } |
| 572 | $trimmed = trim($raw); |
| 573 | if ($trimmed === 'none') { |
| 574 | return null; |
| 575 | } |
| 576 | if (preg_match('/^url\(\s*#([^)\s]+)\s*\)/i', $trimmed, $m) !== 1) { |
| 577 | return null; |
| 578 | } |
| 579 | $referent = $this->document->findById($m[1]); |
| 580 | return $referent instanceof ClipPath ? $referent : null; |
| 581 | } |
| 582 | |
| 583 | /** |
| 584 | * Construct the PDF clipping region from the `<clipPath>` element's |
| 585 | * geometry and emit `W`/`W*` + `n` so subsequent painting is |
| 586 | * scoped to it. `clipPathUnits="objectBoundingBox"` is honoured by |
| 587 | * sandwiching the path construction between a bbox-space `cm` and |
| 588 | * its inverse — the clip region is "frozen" by `W` in user space, |
| 589 | * so applying the inverse `cm` returns the CTM to its pre-clip |
| 590 | * state without disturbing the established region. |
| 591 | */ |
| 592 | private function applyClipPath(ClipPath $clipPath, Element $element, ContentStream $stream): void |
| 593 | { |
| 594 | $useBbox = $clipPath->clipPathUnits() === 'objectBoundingBox'; |
| 595 | $bbox = $useBbox ? BoundingBox::compute($element) : null; |
| 596 | if ($useBbox && $bbox === null) { |
| 597 | // bbox required for objectBoundingBox mode but unavailable |
| 598 | // (e.g. `<path>` element with no bbox helper at 3R+3) — |
| 599 | // fall back to no clip rather than emitting a broken clip. |
| 600 | return; |
| 601 | } |
| 602 | |
| 603 | $bboxMatrix = $bbox === null |
| 604 | ? null |
| 605 | : [$bbox['width'], 0.0, 0.0, $bbox['height'], $bbox['minX'], $bbox['minY']]; |
| 606 | $clipTransform = $clipPath->transform()?->toMatrix(); |
| 607 | |
| 608 | // Apply outer→inner: bbox first, then the clipPath's own |
| 609 | // `transform`. Children paint in their authored coordinate |
| 610 | // system; the cm composition reifies that into user space |
| 611 | // for the W operator to capture. |
| 612 | if ($bboxMatrix !== null) { |
| 613 | $stream->concatMatrix(...$bboxMatrix); |
| 614 | } |
| 615 | if ($clipTransform !== null) { |
| 616 | $stream->concatMatrix(...$clipTransform); |
| 617 | } |
| 618 | foreach ($clipPath->children as $child) { |
| 619 | if ($child instanceof Element) { |
| 620 | $this->emitElementPath($child, $stream); |
| 621 | } |
| 622 | } |
| 623 | // Undo the cms in reverse order so the CTM is back to the |
| 624 | // pre-clip user space. The clip region established by `W` |
| 625 | // already lives in device space, so it survives the CTM |
| 626 | // changes. |
| 627 | if ($clipTransform !== null) { |
| 628 | $stream->concatMatrix(...self::inverseAffine($clipTransform)); |
| 629 | } |
| 630 | if ($bboxMatrix !== null) { |
| 631 | $stream->concatMatrix(...self::inverseAffine($bboxMatrix)); |
| 632 | } |
| 633 | |
| 634 | $rule = self::resolveClipRule($clipPath); |
| 635 | if ($rule === 'evenodd') { |
| 636 | $stream->clipEvenOdd(); |
| 637 | } else { |
| 638 | $stream->clip(); |
| 639 | } |
| 640 | $stream->endPath(); |
| 641 | } |
| 642 | |
| 643 | /** |
| 644 | * Inverse of a 3×2 affine matrix in PDF `[a b c d e f]` order. The |
| 645 | * full 3×3 affine has bottom row `[0 0 1]` so we only need the six |
| 646 | * SVG/PDF entries. Singular matrices (det == 0) shouldn't occur |
| 647 | * for any transform we generate; if one does, returning identity |
| 648 | * makes the worst-case behaviour "no inverse applied" rather than |
| 649 | * a divide-by-zero. |
| 650 | * |
| 651 | * @param array{float, float, float, float, float, float} $m |
| 652 | * @return array{float, float, float, float, float, float} |
| 653 | */ |
| 654 | private static function inverseAffine(array $m): array |
| 655 | { |
| 656 | [$a, $b, $c, $d, $e, $f] = $m; |
| 657 | $det = $a * $d - $b * $c; |
| 658 | if (abs($det) < 1.0e-12) { |
| 659 | return [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; |
| 660 | } |
| 661 | return [ |
| 662 | $d / $det, |
| 663 | -$b / $det, |
| 664 | -$c / $det, |
| 665 | $a / $det, |
| 666 | ($c * $f - $d * $e) / $det, |
| 667 | ($b * $e - $a * $f) / $det, |
| 668 | ]; |
| 669 | } |
| 670 | |
| 671 | /** |
| 672 | * `clip-rule` per SVG 2 §14.4.4. The attribute lives on the |
| 673 | * `<clipPath>` element itself at 3R+3; per-child clip-rule support |
| 674 | * (the spec allows overriding on individual children) lands later. |
| 675 | * |
| 676 | * @return 'nonzero'|'evenodd' |
| 677 | */ |
| 678 | private static function resolveClipRule(ClipPath $clipPath): string |
| 679 | { |
| 680 | $raw = $clipPath->getAttribute('clip-rule'); |
| 681 | return strtolower(trim($raw ?? '')) === 'evenodd' ? 'evenodd' : 'nonzero'; |
| 682 | } |
| 683 | |
| 684 | /** |
| 685 | * Emit just the geometry of an element — the path operators that |
| 686 | * would have ended up before the fill/stroke op. Used by |
| 687 | * `applyClipPath` to assemble a clipping region without painting. |
| 688 | */ |
| 689 | private function emitElementPath(Element $element, ContentStream $stream): void |
| 690 | { |
| 691 | match (true) { |
| 692 | $element instanceof Rect => $this->emitRectPath($element, $stream), |
| 693 | $element instanceof Circle => $this->emitCirclePath($element, $stream), |
| 694 | $element instanceof Ellipse => $this->emitEllipsePathFor($element, $stream), |
| 695 | $element instanceof Polyline => $this->emitPolylinePath($element, $stream), |
| 696 | $element instanceof Polygon => $this->emitPolygonPath($element, $stream), |
| 697 | $element instanceof Path => $this->emitPathPath($element, $stream), |
| 698 | // `<line>` and other non-area-enclosing children don't |
| 699 | // contribute to a clip region per SVG 2 §14.4.1. |
| 700 | default => null, |
| 701 | }; |
| 702 | } |
| 703 | |
| 704 | private function emitRectPath(Rect $rect, ContentStream $stream): void |
| 705 | { |
| 706 | $vp = $this->currentViewport(); |
| 707 | $x = $this->resolvePercentLength($rect->getAttribute('x'), $vp['w'], $rect->x()); |
| 708 | $y = $this->resolvePercentLength($rect->getAttribute('y'), $vp['h'], $rect->y()); |
| 709 | $w = $this->resolvePercentLength($rect->getAttribute('width'), $vp['w'], $rect->width()); |
| 710 | $h = $this->resolvePercentLength($rect->getAttribute('height'), $vp['h'], $rect->height()); |
| 711 | if ($w > 0.0 && $h > 0.0) { |
| 712 | $stream->rectangle($x, $y, $w, $h); |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | /** |
| 717 | * Resolve a length attribute against the current viewport. SVG 2 §10.3: |
| 718 | * percentage values resolve against the relevant viewport dimension |
| 719 | * (`%` width → viewport width, `%` height → viewport height). Anything |
| 720 | * else falls back to `$plain` — the shape's existing unit-stripped |
| 721 | * float, which is what every shape accessor returned before this method |
| 722 | * existed. |
| 723 | */ |
| 724 | private function resolvePercentLength(?string $raw, float $viewport, float $plain): float |
| 725 | { |
| 726 | if ($raw === null || trim($raw) === '') { |
| 727 | return 0.0; |
| 728 | } |
| 729 | if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)(\s*%)/', $raw, $m) !== 1) { |
| 730 | return $plain; |
| 731 | } |
| 732 | return ((float) $m[1]) / 100.0 * $viewport; |
| 733 | } |
| 734 | |
| 735 | /** |
| 736 | * Width / height of the current viewport in viewBox units. Falls back |
| 737 | * to the root SVG width/height attributes when no `viewBox` is set, and |
| 738 | * to zero when neither is available — matching SVG 2's "no useful |
| 739 | * viewport" outcome (percentages collapse to 0). Nested `<svg>` / |
| 740 | * `<symbol>` viewports land later; this picks the document root, which |
| 741 | * is what `background-image: url(...svg)` paints into. |
| 742 | * |
| 743 | * @return array{w: float, h: float} |
| 744 | */ |
| 745 | private function currentViewport(): array |
| 746 | { |
| 747 | // Innermost nested `<svg>` viewport wins for percentage resolution. |
| 748 | if ($this->viewportStack !== []) { |
| 749 | return $this->viewportStack[count($this->viewportStack) - 1]; |
| 750 | } |
| 751 | if ($this->document === null) { |
| 752 | return ['w' => 0.0, 'h' => 0.0]; |
| 753 | } |
| 754 | $viewBox = $this->document->viewBox(); |
| 755 | if ($viewBox !== null) { |
| 756 | return ['w' => $viewBox[2], 'h' => $viewBox[3]]; |
| 757 | } |
| 758 | $w = self::parseLengthPrefixForViewport($this->document->widthAttribute()); |
| 759 | $h = self::parseLengthPrefixForViewport($this->document->heightAttribute()); |
| 760 | if ($w !== null && $h !== null) { |
| 761 | // Mirror SvgRenderer::resolveSourceRect's near-integral |
| 762 | // snap (#143 / crbug.com/1392140): an SVG with |
| 763 | // `width="99.99999"` resolves child `width="100%"` to |
| 764 | // 100, matching the integer-snap browsers apply to |
| 765 | // <img>-embedded SVG intrinsic dimensions. Without this, |
| 766 | // the inner viewport stays at 99.99999 even after the |
| 767 | // outer rendering box rounds to 100, so a `width="100%"` |
| 768 | // child paints 0.00001 short of the box's right edge. |
| 769 | return ['w' => (float) round($w), 'h' => (float) round($h)]; |
| 770 | } |
| 771 | // Fall back to the caller's effective viewport (the destination |
| 772 | // rect from `SvgRenderer::draw`) when document attributes alone |
| 773 | // can't give a useful viewport — e.g. an SVG with only a |
| 774 | // percentage `width` and no `height` or `viewBox`. |
| 775 | if ($this->effectiveViewport !== null) { |
| 776 | return $this->effectiveViewport; |
| 777 | } |
| 778 | return ['w' => $w ?? 0.0, 'h' => $h ?? 0.0]; |
| 779 | } |
| 780 | |
| 781 | private static function parseLengthPrefixForViewport(?string $raw): ?float |
| 782 | { |
| 783 | if ($raw === null) { |
| 784 | return null; |
| 785 | } |
| 786 | if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*([%a-zA-Z]*)/', $raw, $m) !== 1) { |
| 787 | return null; |
| 788 | } |
| 789 | // Percentage attributes carry no intrinsic viewport extent — they |
| 790 | // resolve against the caller-supplied effective viewport (CSS |
| 791 | // Images 3 §5.2). Reject so the fallback path picks up the |
| 792 | // dst-derived viewport instead of mis-anchoring to the bare |
| 793 | // percentage value. |
| 794 | if ($m[2] === '%') { |
| 795 | return null; |
| 796 | } |
| 797 | return (float) $m[1]; |
| 798 | } |
| 799 | |
| 800 | private function emitCirclePath(Circle $circle, ContentStream $stream): void |
| 801 | { |
| 802 | if ($circle->r() > 0.0) { |
| 803 | $this->emitEllipsePath($stream, $circle->cx(), $circle->cy(), $circle->r(), $circle->r()); |
| 804 | } |
| 805 | } |
| 806 | |
| 807 | private function emitEllipsePathFor(Ellipse $ellipse, ContentStream $stream): void |
| 808 | { |
| 809 | $rx = $ellipse->rx(); |
| 810 | $ry = $ellipse->ry(); |
| 811 | if ($rx !== null && $ry !== null && $rx > 0.0 && $ry > 0.0) { |
| 812 | $this->emitEllipsePath($stream, $ellipse->cx(), $ellipse->cy(), $rx, $ry); |
| 813 | } |
| 814 | } |
| 815 | |
| 816 | private function emitPolylinePath(Polyline $polyline, ContentStream $stream): void |
| 817 | { |
| 818 | $points = $polyline->points(); |
| 819 | if (count($points) >= 2) { |
| 820 | $this->emitPolyPath($stream, $points, closed: false); |
| 821 | } |
| 822 | } |
| 823 | |
| 824 | private function emitPolygonPath(Polygon $polygon, ContentStream $stream): void |
| 825 | { |
| 826 | $points = $polygon->points(); |
| 827 | if (count($points) >= 3) { |
| 828 | $this->emitPolyPath($stream, $points, closed: true); |
| 829 | } |
| 830 | } |
| 831 | |
| 832 | private function emitPathPath(Path $path, ContentStream $stream): void |
| 833 | { |
| 834 | $commands = $path->d()->commands; |
| 835 | if ($commands === []) { |
| 836 | return; |
| 837 | } |
| 838 | $state = new PathPainterState(); |
| 839 | foreach ($commands as $command) { |
| 840 | $this->emitPathCommand($command, $stream, $state); |
| 841 | } |
| 842 | } |
| 843 | |
| 844 | /** |
| 845 | * Register (or reuse) the `ExtGState` resource encoding this |
| 846 | * element's effective opacity. Returns the resource name or null |
| 847 | * when no `gs` op is needed (no Page reference, or every opacity |
| 848 | * channel is already ≥ 0.999). |
| 849 | */ |
| 850 | private function resolveOpacityState(Element $element): ?string |
| 851 | { |
| 852 | if ($this->page === null) { |
| 853 | return null; |
| 854 | } |
| 855 | $opacity = $element->opacity() ?? 1.0; |
| 856 | $fillOpacity = ($element->fillOpacity() ?? 1.0) * $opacity; |
| 857 | $strokeOpacity = ($element->strokeOpacity() ?? 1.0) * $opacity; |
| 858 | if ($fillOpacity >= 0.999 && $strokeOpacity >= 0.999) { |
| 859 | return null; |
| 860 | } |
| 861 | return $this->page->ensureOpacityState($strokeOpacity, $fillOpacity); |
| 862 | } |
| 863 | |
| 864 | /** |
| 865 | * Whether the element carries any stroke parameter that would |
| 866 | * leak past a sibling shape if emitted inline. Used to decide |
| 867 | * whether to wrap the element's painting in `q`/`Q`. |
| 868 | */ |
| 869 | private function needsStrokeParams(Element $element): bool |
| 870 | { |
| 871 | if ($element->stroke() === null || $element->stroke() instanceof None_) { |
| 872 | return false; |
| 873 | } |
| 874 | if ($element->strokeWidth() !== null) { |
| 875 | return true; |
| 876 | } |
| 877 | if ($element->strokeLinecap() !== null) { |
| 878 | return true; |
| 879 | } |
| 880 | if ($element->strokeLinejoin() !== null) { |
| 881 | return true; |
| 882 | } |
| 883 | if ($element->strokeMiterlimit() !== null) { |
| 884 | return true; |
| 885 | } |
| 886 | if ($element->strokeDasharray() !== []) { |
| 887 | return true; |
| 888 | } |
| 889 | if ($element->strokeDashoffset() !== null) { |
| 890 | return true; |
| 891 | } |
| 892 | return false; |
| 893 | } |
| 894 | |
| 895 | private function applyStrokeParams(Element $element, ContentStream $stream): void |
| 896 | { |
| 897 | $width = $element->strokeWidth(); |
| 898 | if ($width !== null) { |
| 899 | $stream->setLineWidth($width); |
| 900 | } |
| 901 | $cap = $element->strokeLinecap(); |
| 902 | if ($cap !== null) { |
| 903 | $stream->setLineCap(match ($cap) { |
| 904 | 'round' => 1, |
| 905 | 'square' => 2, |
| 906 | default => 0, // butt |
| 907 | }); |
| 908 | } |
| 909 | $join = $element->strokeLinejoin(); |
| 910 | if ($join !== null) { |
| 911 | $stream->setLineJoin(match ($join) { |
| 912 | 'round' => 1, |
| 913 | 'bevel' => 2, |
| 914 | default => 0, // miter / miter-clip / arcs all fall back to PDF's miter |
| 915 | }); |
| 916 | } |
| 917 | $miterLimit = $element->strokeMiterlimit(); |
| 918 | if ($miterLimit !== null) { |
| 919 | $stream->setMiterLimit($miterLimit); |
| 920 | } |
| 921 | $dash = $element->strokeDasharray(); |
| 922 | if ($dash !== []) { |
| 923 | $offset = (int) round($element->strokeDashoffset() ?? 0.0); |
| 924 | $stream->setDashPattern($dash, $offset); |
| 925 | } |
| 926 | } |
| 927 | |
| 928 | private function dispatchElement(Element $element, ContentStream $stream): void |
| 929 | { |
| 930 | match (true) { |
| 931 | $element instanceof Rect => $this->paintRect($element, $stream), |
| 932 | $element instanceof Circle => $this->paintCircle($element, $stream), |
| 933 | $element instanceof Ellipse => $this->paintEllipse($element, $stream), |
| 934 | $element instanceof Line => $this->paintLine($element, $stream), |
| 935 | $element instanceof Polyline => $this->paintPolyline($element, $stream), |
| 936 | $element instanceof Polygon => $this->paintPolygon($element, $stream), |
| 937 | $element instanceof Path => $this->paintPath($element, $stream), |
| 938 | $element instanceof TextElement => $this->paintTextElement($element, $stream), |
| 939 | $element instanceof \Phpdftk\Svg\NestedSvg => $this->paintNestedSvg($element, $stream), |
| 940 | $element instanceof Use_ => $this->paintUse($element, $stream), |
| 941 | $element instanceof SvgImage => $this->paintImage($element, $stream), |
| 942 | // `<defs>` and `<symbol>` are referenceable containers: they |
| 943 | // never paint themselves at the document level. `<use>` |
| 944 | // expands `<defs>` / `<symbol>` referents; `clip-path` on |
| 945 | // a painted element pulls in a `<clipPath>`; `mask` pulls |
| 946 | // in a `<mask>`. Skipping them here also skips their |
| 947 | // nested shape children, which is what the spec wants |
| 948 | // (SVG 2 §5.5 / §5.6 / §14.4 / §14.5). |
| 949 | $element instanceof Defs, |
| 950 | $element instanceof Symbol, |
| 951 | $element instanceof ClipPath, |
| 952 | $element instanceof Mask, |
| 953 | // SVG 2 §11.6 — `<marker>` is referenced via `marker-*` |
| 954 | // properties on shapes; it never paints at the document |
| 955 | // level. The painter pulls in a marker when it's |
| 956 | // requested at a path vertex (future deliverable). |
| 957 | $element instanceof \Phpdftk\Svg\Marker, |
| 958 | // SVG 2 §13.3 — `<pattern>` is referenced via |
| 959 | // `fill="url(#id)"` / `stroke="url(#id)"`; the painter |
| 960 | // pulls in pattern content when a shape's paint |
| 961 | // resolves to a pattern URL (future deliverable). |
| 962 | $element instanceof \Phpdftk\Svg\Pattern, |
| 963 | // SVG 2 Filter Effects §6.1 — `<filter>` is referenced |
| 964 | // via `filter="url(#id)"` and applied via SoftMask. Never |
| 965 | // paints at document level. |
| 966 | $element instanceof \Phpdftk\Svg\Filter, |
| 967 | // SVG 2 §6.3 — `<view>` is a fragment-targeted viewport |
| 968 | // definition. Activated only when callers pass a view id; |
| 969 | // never paints at document level. |
| 970 | $element instanceof \Phpdftk\Svg\View, |
| 971 | // SVG 2 §15.2 — `<script>` content is JS; never executes |
| 972 | // server-side and never paints. Explicit skip prevents |
| 973 | // any nested `<text>` etc. children from leaking into the |
| 974 | // output stream. |
| 975 | $element instanceof \Phpdftk\Svg\Script, |
| 976 | // SVG 2 §19 — animation elements never paint at the |
| 977 | // static print medium. Skip to avoid recursing into any |
| 978 | // nested `<mpath>` etc. that would otherwise fall through |
| 979 | // to the default container walk. |
| 980 | $element instanceof \Phpdftk\Svg\Animation => null, |
| 981 | // SVG 2 §6.4 — `<metadata>` carries RDF/XML or other |
| 982 | // non-render metadata. Skip explicitly so any embedded |
| 983 | // RDF text doesn't accidentally leak. |
| 984 | $element instanceof \Phpdftk\Svg\Metadata => null, |
| 985 | // SVG 2 §15.3 — `<title>` and `<desc>` are accessibility |
| 986 | // metadata that never renders directly. Skip the recursive |
| 987 | // walk so their text content doesn't leak into output. |
| 988 | $element instanceof \Phpdftk\Svg\Title, |
| 989 | $element instanceof \Phpdftk\Svg\Desc, |
| 990 | // SVG 2 §11.6 — `<foreignObject>` holds non-SVG content |
| 991 | // (HTML / MathML). Rendering that content requires a |
| 992 | // separate pipeline; the typed class lets callers detect |
| 993 | // and route it. Inside the SVG dispatch we skip the |
| 994 | // foreign tree entirely to avoid painting GenericElement |
| 995 | // children that aren't actual SVG shapes. |
| 996 | $element instanceof \Phpdftk\Svg\ForeignObject => null, |
| 997 | // SVG 2 §12.1.1 — `<a>` paints its children. The PDF link |
| 998 | // annotation (which is what makes the rendered region |
| 999 | // clickable) is a future concern that needs page-relative |
| 1000 | // bounding boxes; we paint the inner content faithfully |
| 1001 | // here so the visual stays correct. |
| 1002 | $element instanceof \Phpdftk\Svg\A_ |
| 1003 | => $this->paintChildren($element, $stream), |
| 1004 | // SVG 2 §5.7 — `<switch>` picks the first child whose |
| 1005 | // conditional-processing attributes all evaluate true. |
| 1006 | $element instanceof \Phpdftk\Svg\Switch_ |
| 1007 | => $this->paintSwitch($element, $stream), |
| 1008 | // `<g>` and any other container fall through here — the |
| 1009 | // recursive walk still descends into their children. |
| 1010 | default => $this->paintChildren($element, $stream), |
| 1011 | }; |
| 1012 | } |
| 1013 | |
| 1014 | /** |
| 1015 | * SVG 2 §5.7 — paint the first child of `<switch>` whose |
| 1016 | * conditional-processing tests all evaluate true. Empty-test |
| 1017 | * children always pass. Stops after painting the first |
| 1018 | * matching child. |
| 1019 | */ |
| 1020 | private function paintSwitch(\Phpdftk\Svg\Switch_ $switch, ContentStream $stream): void |
| 1021 | { |
| 1022 | foreach ($switch->children as $child) { |
| 1023 | if (!$child instanceof Element) { |
| 1024 | continue; |
| 1025 | } |
| 1026 | if (!$this->switchChildPasses($child)) { |
| 1027 | continue; |
| 1028 | } |
| 1029 | $this->paintElement($child, $stream); |
| 1030 | return; |
| 1031 | } |
| 1032 | } |
| 1033 | |
| 1034 | /** |
| 1035 | * Evaluate the conditional-processing attributes on a |
| 1036 | * `<switch>` child: |
| 1037 | * |
| 1038 | * - `requiredFeatures` — legacy SVG 1.1 list of feature |
| 1039 | * URIs. All listed URIs evaluate true here so the test |
| 1040 | * never fails (matches major browser behaviour now). |
| 1041 | * - `requiredExtensions` — author-supplied extension URIs. |
| 1042 | * Any presence fails: print medium can't observe any |
| 1043 | * UA-specific extensions. |
| 1044 | * - `systemLanguage` — comma-separated BCP 47 tags. The |
| 1045 | * test passes when at least one tag prefix-matches the |
| 1046 | * `xml:lang` (or `lang`) ancestor chain. |
| 1047 | */ |
| 1048 | private function switchChildPasses(Element $child): bool |
| 1049 | { |
| 1050 | if ($child->hasAttribute('requiredExtensions')) { |
| 1051 | $exts = trim($child->getAttribute('requiredExtensions') ?? ''); |
| 1052 | if ($exts !== '') { |
| 1053 | return false; |
| 1054 | } |
| 1055 | } |
| 1056 | if ($child->hasAttribute('systemLanguage')) { |
| 1057 | $needed = preg_split( |
| 1058 | '/[\s,]+/', |
| 1059 | strtolower(trim($child->getAttribute('systemLanguage') ?? '')), |
| 1060 | ) ?: []; |
| 1061 | $needed = array_filter($needed, static fn(string $s): bool => $s !== ''); |
| 1062 | $documentLang = $this->resolveSystemLanguage($child); |
| 1063 | $matched = false; |
| 1064 | foreach ($needed as $tag) { |
| 1065 | if ($tag === $documentLang || str_starts_with($documentLang, $tag . '-')) { |
| 1066 | $matched = true; |
| 1067 | break; |
| 1068 | } |
| 1069 | } |
| 1070 | if (!$matched) { |
| 1071 | return false; |
| 1072 | } |
| 1073 | } |
| 1074 | return true; |
| 1075 | } |
| 1076 | |
| 1077 | /** |
| 1078 | * Walk ancestor `xml:lang` / `lang` attributes. Defaults to |
| 1079 | * `en` when nothing is set, matching the SVG default UA |
| 1080 | * behaviour for systemLanguage matching. |
| 1081 | */ |
| 1082 | private function resolveSystemLanguage(Element $element): string |
| 1083 | { |
| 1084 | for ($n = $element; $n !== null; $n = $n->parent) { |
| 1085 | $lang = $n->getAttribute('xml:lang') ?? $n->getAttribute('lang'); |
| 1086 | if ($lang !== null && $lang !== '') { |
| 1087 | return strtolower($lang); |
| 1088 | } |
| 1089 | } |
| 1090 | return 'en'; |
| 1091 | } |
| 1092 | |
| 1093 | private function paintUse(Use_ $use, ContentStream $stream): void |
| 1094 | { |
| 1095 | if ($this->document === null) { |
| 1096 | return; |
| 1097 | } |
| 1098 | $referent = $use->resolve($this->document); |
| 1099 | if ($referent === null) { |
| 1100 | return; |
| 1101 | } |
| 1102 | // SVG 2 §5.6 — the `<use>`'s `x` / `y` translate the referent's |
| 1103 | // coordinate system. Width / height overrides on `<symbol>` |
| 1104 | // referents resolve through viewBox-to-viewport mapping; that |
| 1105 | // mapping needs target-rectangle context we don't have until |
| 1106 | // 3R, so 3Q honours the translation only. |
| 1107 | $x = $use->x(); |
| 1108 | $y = $use->y(); |
| 1109 | // SVG 2 §5.6.1 — when the referent is a nested `<svg>`, the use's |
| 1110 | // width/height (if both set) become that svg's viewport. |
| 1111 | $useW = $use->width(); |
| 1112 | $useH = $use->height(); |
| 1113 | $override = ($referent instanceof \Phpdftk\Svg\NestedSvg |
| 1114 | && $useW !== null && $useH !== null && $useW > 0.0 && $useH > 0.0) |
| 1115 | ? ['w' => $useW, 'h' => $useH] |
| 1116 | : null; |
| 1117 | if ($x === 0.0 && $y === 0.0 && $override === null) { |
| 1118 | $this->paintUseReferent($referent, $stream); |
| 1119 | return; |
| 1120 | } |
| 1121 | $stream->saveGraphicsState(); |
| 1122 | if ($x !== 0.0 || $y !== 0.0) { |
| 1123 | $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y); |
| 1124 | } |
| 1125 | $prevOverride = $this->pendingUseViewport; |
| 1126 | $this->pendingUseViewport = $override; |
| 1127 | $this->paintUseReferent($referent, $stream); |
| 1128 | $this->pendingUseViewport = $prevOverride; |
| 1129 | $stream->restoreGraphicsState(); |
| 1130 | } |
| 1131 | |
| 1132 | /** |
| 1133 | * `<symbol>` referents have their children painted directly per |
| 1134 | * SVG 2 §5.5 — referencing them via `<use>` is the only way they |
| 1135 | * paint at all. Everything else routes through normal dispatch. |
| 1136 | */ |
| 1137 | private function paintUseReferent(Element $referent, ContentStream $stream): void |
| 1138 | { |
| 1139 | if ($referent instanceof Symbol) { |
| 1140 | $this->paintChildren($referent, $stream); |
| 1141 | return; |
| 1142 | } |
| 1143 | $this->paintElement($referent, $stream); |
| 1144 | } |
| 1145 | |
| 1146 | private function paintImage(SvgImage $image, ContentStream $stream): void |
| 1147 | { |
| 1148 | if ($this->writer === null || $this->page === null) { |
| 1149 | return; |
| 1150 | } |
| 1151 | $href = $image->href(); |
| 1152 | if ($href === null) { |
| 1153 | return; |
| 1154 | } |
| 1155 | // 3Q: filesystem hrefs. |
| 1156 | // 3R+18: `data:` URIs decoded and materialised to a temp |
| 1157 | // file so the same `ImageParser` + `PdfWriter::addImage` |
| 1158 | // flow (which only accepts paths) can embed them. |
| 1159 | // 4F.1: `http(s)://` hrefs route through the optional |
| 1160 | // `ResourceLoader` injected at construction. SSRF guard, |
| 1161 | // redirect handling, content-length cap, and MIME sniffing |
| 1162 | // all live in the loader; we just hand off the URL and |
| 1163 | // materialise the bytes to a temp file like the data: |
| 1164 | // path. When no loader is configured, http(s):// drops |
| 1165 | // silently per the original 3R+18 posture so existing |
| 1166 | // callers don't change behaviour. |
| 1167 | $sourcePath = $href; |
| 1168 | $tempPath = null; |
| 1169 | if (str_starts_with($href, 'data:')) { |
| 1170 | $decoded = self::decodeDataUri($href); |
| 1171 | if ($decoded === null) { |
| 1172 | return; |
| 1173 | } |
| 1174 | $tempPath = self::materialiseDataUri($decoded); |
| 1175 | if ($tempPath === null) { |
| 1176 | return; |
| 1177 | } |
| 1178 | $sourcePath = $tempPath; |
| 1179 | } elseif (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) { |
| 1180 | if ($this->resourceLoader === null) { |
| 1181 | return; |
| 1182 | } |
| 1183 | $tempPath = $this->fetchAndMaterialiseHttpHref($href); |
| 1184 | if ($tempPath === null) { |
| 1185 | return; |
| 1186 | } |
| 1187 | $sourcePath = $tempPath; |
| 1188 | } elseif (str_contains($href, '://')) { |
| 1189 | return; |
| 1190 | } |
| 1191 | try { |
| 1192 | $this->paintImageFromPath($sourcePath, $image, $stream); |
| 1193 | } finally { |
| 1194 | if ($tempPath !== null) { |
| 1195 | @unlink($tempPath); |
| 1196 | } |
| 1197 | } |
| 1198 | } |
| 1199 | |
| 1200 | private function paintImageFromPath(string $path, SvgImage $image, ContentStream $stream): void |
| 1201 | { |
| 1202 | // Resolve intrinsic source dimensions before registering so we |
| 1203 | // can fall back to them when the SVG omits width / height. |
| 1204 | // `ImageParser::parse` is cheap (header-only read) and |
| 1205 | // bypassing the writer keeps this self-contained. |
| 1206 | try { |
| 1207 | $info = ImageParser::parse($path); |
| 1208 | } catch (\Throwable) { |
| 1209 | return; |
| 1210 | } |
| 1211 | if ($this->writer === null || $this->page === null) { |
| 1212 | return; |
| 1213 | } |
| 1214 | try { |
| 1215 | $resourceName = $this->writer->addImage($path, $this->page); |
| 1216 | } catch (\Throwable) { |
| 1217 | // Missing file / unparseable bytes / unsupported format — |
| 1218 | // SVG 2 §12.6's "no image available" outcome is to paint |
| 1219 | // nothing. |
| 1220 | return; |
| 1221 | } |
| 1222 | |
| 1223 | $x = $image->x(); |
| 1224 | $y = $image->y(); |
| 1225 | $w = $image->width(); |
| 1226 | $h = $image->height(); |
| 1227 | $intrinsicW = (float) $info->width; |
| 1228 | $intrinsicH = (float) $info->height; |
| 1229 | // SVG 2 §12.6 fallback ladder for missing width / height: when |
| 1230 | // one dimension is given the other is scaled to preserve the |
| 1231 | // intrinsic aspect ratio; when both are absent the intrinsic |
| 1232 | // dimensions are used directly. A zero-size intrinsic image |
| 1233 | // still paints nothing. |
| 1234 | if ($intrinsicW <= 0.0 || $intrinsicH <= 0.0) { |
| 1235 | return; |
| 1236 | } |
| 1237 | if ($w === null && $h === null) { |
| 1238 | $w = $intrinsicW; |
| 1239 | $h = $intrinsicH; |
| 1240 | } elseif ($w === null) { |
| 1241 | // height set, width follows the intrinsic aspect. |
| 1242 | $w = ($h ?? 0.0) * ($intrinsicW / $intrinsicH); |
| 1243 | } elseif ($h === null) { |
| 1244 | $h = $w * ($intrinsicH / $intrinsicW); |
| 1245 | } |
| 1246 | if ($w <= 0.0 || $h <= 0.0) { |
| 1247 | return; |
| 1248 | } |
| 1249 | |
| 1250 | // PDF Do paints the image inside a unit square at (0, 0) → (1, 1). |
| 1251 | // The transformation matrix translates + scales it to the SVG |
| 1252 | // rectangle. Y is flipped so the image's top-left lands at |
| 1253 | // (x, y) — PDF's image space is y-down within the unit square, |
| 1254 | // SVG's image element is y-down too, so the flip cancels them |
| 1255 | // out and the image renders right-side-up at the SVG-stated |
| 1256 | // position. |
| 1257 | $stream->saveGraphicsState(); |
| 1258 | $stream->concatMatrix($w, 0.0, 0.0, -$h, $x, $y + $h); |
| 1259 | $stream->doXObject($resourceName); |
| 1260 | $stream->restoreGraphicsState(); |
| 1261 | } |
| 1262 | |
| 1263 | /** |
| 1264 | * Decode a `data:` URI per RFC 2397. |
| 1265 | * |
| 1266 | * - `data:image/png;base64,iVBOR…` → base64 payload |
| 1267 | * - `data:image/svg+xml,<svg…>` → percent-decoded payload |
| 1268 | * - `data:,hello` → empty mime, percent-decoded |
| 1269 | * |
| 1270 | * Returns `null` if the URI is malformed or the base64 won't |
| 1271 | * decode. The MIME type is returned for the caller's information |
| 1272 | * but is not authoritative — `ImageParser::parse` still sniffs the |
| 1273 | * actual bytes to determine the PDF colour space + filter. |
| 1274 | * |
| 1275 | * @return array{bytes: string, mime: string}|null |
| 1276 | */ |
| 1277 | private static function decodeDataUri(string $uri): ?array |
| 1278 | { |
| 1279 | if (!str_starts_with($uri, 'data:')) { |
| 1280 | return null; |
| 1281 | } |
| 1282 | $rest = substr($uri, 5); |
| 1283 | $commaPos = strpos($rest, ','); |
| 1284 | if ($commaPos === false) { |
| 1285 | return null; |
| 1286 | } |
| 1287 | $meta = substr($rest, 0, $commaPos); |
| 1288 | $data = substr($rest, $commaPos + 1); |
| 1289 | $isBase64 = false; |
| 1290 | $mime = ''; |
| 1291 | if ($meta !== '') { |
| 1292 | $parts = explode(';', $meta); |
| 1293 | $mime = $parts[0]; |
| 1294 | foreach (array_slice($parts, 1) as $param) { |
| 1295 | if ($param === 'base64') { |
| 1296 | $isBase64 = true; |
| 1297 | } |
| 1298 | } |
| 1299 | } |
| 1300 | if ($isBase64) { |
| 1301 | $bytes = base64_decode($data, true); |
| 1302 | if ($bytes === false) { |
| 1303 | return null; |
| 1304 | } |
| 1305 | } else { |
| 1306 | $bytes = rawurldecode($data); |
| 1307 | } |
| 1308 | return ['bytes' => $bytes, 'mime' => $mime]; |
| 1309 | } |
| 1310 | |
| 1311 | /** |
| 1312 | * Write the decoded data-URI bytes to a temp file that lives only |
| 1313 | * for the duration of `paintImage`. Returns the path on success |
| 1314 | * or `null` if either the temp file couldn't be opened or the |
| 1315 | * write failed — both fall back to the SVG "no image available" |
| 1316 | * outcome. |
| 1317 | * |
| 1318 | * @param array{bytes: string, mime: string} $decoded |
| 1319 | */ |
| 1320 | private static function materialiseDataUri(array $decoded): ?string |
| 1321 | { |
| 1322 | $tmpPath = tempnam(sys_get_temp_dir(), 'svg-img-'); |
| 1323 | if ($tmpPath === false) { |
| 1324 | return null; |
| 1325 | } |
| 1326 | try { |
| 1327 | LocalFilesystem::writeFile($tmpPath, $decoded['bytes']); |
| 1328 | return $tmpPath; |
| 1329 | } catch (\Throwable) { |
| 1330 | @unlink($tmpPath); |
| 1331 | return null; |
| 1332 | } |
| 1333 | } |
| 1334 | |
| 1335 | /** |
| 1336 | * 4F.1 — fetch an `http(s)://` `<image>` href through the |
| 1337 | * injected ResourceLoader and materialise the bytes to a temp |
| 1338 | * file so the existing `ImageParser` + `PdfWriter::addImage` |
| 1339 | * flow can register them as a PDF XObject. Returns the temp |
| 1340 | * path on success or null on any failure (SSRF policy |
| 1341 | * violation, network error, non-2xx, body cap exceeded, write |
| 1342 | * failure) — all of which surface as the SVG 2 §12.6 "no image |
| 1343 | * available" outcome. |
| 1344 | */ |
| 1345 | private function fetchAndMaterialiseHttpHref(string $href): ?string |
| 1346 | { |
| 1347 | if ($this->resourceLoader === null) { |
| 1348 | return null; |
| 1349 | } |
| 1350 | try { |
| 1351 | $result = $this->resourceLoader->fetch($href); |
| 1352 | } catch (SsrfBlockedException | FetchFailedException) { |
| 1353 | return null; |
| 1354 | } |
| 1355 | $tmpPath = tempnam(sys_get_temp_dir(), 'svg-http-img-'); |
| 1356 | if ($tmpPath === false) { |
| 1357 | return null; |
| 1358 | } |
| 1359 | try { |
| 1360 | LocalFilesystem::writeFile($tmpPath, $result->bytes); |
| 1361 | return $tmpPath; |
| 1362 | } catch (\Throwable) { |
| 1363 | @unlink($tmpPath); |
| 1364 | return null; |
| 1365 | } |
| 1366 | } |
| 1367 | |
| 1368 | private function paintTextElement(TextElement $text, ContentStream $stream): void |
| 1369 | { |
| 1370 | // Without writer + page references the resolver can't register a |
| 1371 | // font, so the text silently drops. Matches the same standalone |
| 1372 | // posture the gradient painter uses at 3O. |
| 1373 | if ($this->fontResolver === null) { |
| 1374 | return; |
| 1375 | } |
| 1376 | $content = self::collectTextContent($text); |
| 1377 | if ($content === '') { |
| 1378 | return; |
| 1379 | } |
| 1380 | |
| 1381 | // SVG 2 default fill for `<text>` is black; the existing |
| 1382 | // applyFillPaint path covers that, but we apply it *before* |
| 1383 | // entering the text object so the colour persists across the |
| 1384 | // Tj sequence (PDF text objects share the page graphics state). |
| 1385 | $fill = $text->fill(); |
| 1386 | if (!($fill instanceof None_)) { |
| 1387 | $this->applyFillPaint($fill, $text, $stream); |
| 1388 | } |
| 1389 | |
| 1390 | $font = $this->fontResolver->resolve( |
| 1391 | $text->fontFamily(), |
| 1392 | $text->fontWeight(), |
| 1393 | $text->fontStyle(), |
| 1394 | ); |
| 1395 | $size = $text->fontSize() ?? 16.0; |
| 1396 | |
| 1397 | // SVG 2 §11.6 list-valued positioning. `<text x="10 20 30">ABC</text>` |
| 1398 | // positions each glyph individually. When any list has > 1 entry |
| 1399 | // the painter walks character-by-character; otherwise it falls |
| 1400 | // through to the cheaper single-Tj path. Single-value rotate |
| 1401 | // also takes the per-glyph code path so the rotation lands in |
| 1402 | // the text matrix. |
| 1403 | $xList = $text->x(); |
| 1404 | $yList = $text->y(); |
| 1405 | $dxList = $text->dx(); |
| 1406 | $dyList = $text->dy(); |
| 1407 | $rotateList = $text->rotate(); |
| 1408 | $perGlyph = count($xList) > 1 |
| 1409 | || count($yList) > 1 |
| 1410 | || $dxList !== [] |
| 1411 | || $dyList !== [] |
| 1412 | || $rotateList !== []; |
| 1413 | |
| 1414 | // CSS Text Decoration 4 §6 — paint text-shadow layers BEHIND |
| 1415 | // the real text. Each layer is a sharp-offset copy of the |
| 1416 | // glyph run in the shadow colour. Blur-radius is parsed but |
| 1417 | // not currently rasterised (matches the html-to-pdf path, |
| 1418 | // which also ignores blur). The first listed shadow paints on |
| 1419 | // top of later shadows, so we reverse for back-to-front order. |
| 1420 | // Per-glyph positioning is intentionally NOT shadowed at v1 - |
| 1421 | // the WPT corpus only exercises single-position <text> with |
| 1422 | // shadows, and per-glyph shadows would compound complexity |
| 1423 | // without coverage to validate them. |
| 1424 | if (!$perGlyph) { |
| 1425 | $fillFallback = $fill instanceof SolidColor ? $fill->color : null; |
| 1426 | $shadows = self::parseTextShadow($text->textShadow(), $fillFallback); |
| 1427 | foreach (array_reverse($shadows) as $shadow) { |
| 1428 | $stream->saveGraphicsState(); |
| 1429 | $this->setFillColor($stream, $shadow['color']); |
| 1430 | $stream->beginText()->setFont($font, $size); |
| 1431 | $sx = ($xList[0] ?? 0.0) + $shadow['offsetX']; |
| 1432 | $sy = ($yList[0] ?? 0.0) + $shadow['offsetY']; |
| 1433 | if ($this->compensateTextFlip) { |
| 1434 | $stream->setTextMatrix(1.0, 0.0, 0.0, -1.0, $sx, $sy); |
| 1435 | } else { |
| 1436 | $stream->moveTextPosition($sx, $sy); |
| 1437 | } |
| 1438 | $stream->showText($content); |
| 1439 | $stream->endText(); |
| 1440 | $stream->restoreGraphicsState(); |
| 1441 | } |
| 1442 | } |
| 1443 | |
| 1444 | $stream->beginText()->setFont($font, $size); |
| 1445 | if (!$perGlyph) { |
| 1446 | $x = $xList[0] ?? 0.0; |
| 1447 | $y = $yList[0] ?? 0.0; |
| 1448 | if ($this->compensateTextFlip) { |
| 1449 | // Under an outer Y-flip CTM (`SvgRenderer::draw` applies |
| 1450 | // one), `Td` would render glyphs upside-down. Setting Tm |
| 1451 | // with `d = -1` flips text space so the combined |
| 1452 | // `Tm · CTM` cancels the outer flip and glyphs render |
| 1453 | // upright at the SVG-stated baseline. |
| 1454 | $stream->setTextMatrix(1.0, 0.0, 0.0, -1.0, $x, $y); |
| 1455 | } else { |
| 1456 | $stream->moveTextPosition($x, $y); |
| 1457 | } |
| 1458 | $stream->showText($content); |
| 1459 | } else { |
| 1460 | $this->paintTextPerGlyph($content, $xList, $yList, $dxList, $dyList, $rotateList, $stream); |
| 1461 | } |
| 1462 | $stream->endText(); |
| 1463 | } |
| 1464 | |
| 1465 | /** |
| 1466 | * Parse the raw CSS `text-shadow` value into renderable shadow |
| 1467 | * layers per CSS Text Decoration 4 §6. |
| 1468 | * |
| 1469 | * Grammar: |
| 1470 | * |
| 1471 | * <text-shadow> = none | <shadow># (comma-separated layers) |
| 1472 | * <shadow> = <length>{2,3} <color>? |
| 1473 | * |
| 1474 | * The two lengths are offset-x and offset-y; the optional third |
| 1475 | * is blur-radius (parsed and validated as non-negative, but the |
| 1476 | * painter does not currently rasterise blur — same posture as |
| 1477 | * the html-to-pdf path's `collectTextShadowLayers`). When a layer |
| 1478 | * omits the colour, it falls back to `$fillFallback` (the text's |
| 1479 | * own fill colour) and finally to opaque black. |
| 1480 | * |
| 1481 | * Returns an empty list for `null` input, `none`, or any layer |
| 1482 | * that fails to parse - SVG 2's "invalid → ignored" semantics |
| 1483 | * applied per-shadow keeps a malformed shadow from poisoning |
| 1484 | * earlier valid ones. |
| 1485 | * |
| 1486 | * @return list<array{offsetX: float, offsetY: float, color: ColorInterface}> |
| 1487 | */ |
| 1488 | private static function parseTextShadow(?string $raw, ?ColorInterface $fillFallback): array |
| 1489 | { |
| 1490 | if ($raw === null) { |
| 1491 | return []; |
| 1492 | } |
| 1493 | $items = self::splitTextShadowItems($raw); |
| 1494 | $layers = []; |
| 1495 | foreach ($items as $item) { |
| 1496 | $layer = self::parseTextShadowLayer($item, $fillFallback); |
| 1497 | if ($layer !== null) { |
| 1498 | $layers[] = $layer; |
| 1499 | } |
| 1500 | } |
| 1501 | return $layers; |
| 1502 | } |
| 1503 | |
| 1504 | /** |
| 1505 | * Split a `text-shadow` value on top-level commas, preserving |
| 1506 | * commas inside `rgb(...)` / `rgba(...)` / `hsl(...)` etc. |
| 1507 | * |
| 1508 | * @return list<string> |
| 1509 | */ |
| 1510 | private static function splitTextShadowItems(string $raw): array |
| 1511 | { |
| 1512 | $items = []; |
| 1513 | $depth = 0; |
| 1514 | $buffer = ''; |
| 1515 | $length = strlen($raw); |
| 1516 | for ($i = 0; $i < $length; $i++) { |
| 1517 | $ch = $raw[$i]; |
| 1518 | if ($ch === '(') { |
| 1519 | $depth++; |
| 1520 | } elseif ($ch === ')') { |
| 1521 | $depth = max(0, $depth - 1); |
| 1522 | } |
| 1523 | if ($ch === ',' && $depth === 0) { |
| 1524 | $items[] = $buffer; |
| 1525 | $buffer = ''; |
| 1526 | continue; |
| 1527 | } |
| 1528 | $buffer .= $ch; |
| 1529 | } |
| 1530 | if (trim($buffer) !== '') { |
| 1531 | $items[] = $buffer; |
| 1532 | } |
| 1533 | return array_values(array_filter(array_map('trim', $items), static fn($s) => $s !== '')); |
| 1534 | } |
| 1535 | |
| 1536 | /** |
| 1537 | * Parse a single shadow layer. Returns null on any structural |
| 1538 | * malformation (fewer than two lengths, blur < 0, more than three |
| 1539 | * lengths, unparseable colour token). |
| 1540 | * |
| 1541 | * @return array{offsetX: float, offsetY: float, color: ColorInterface}|null |
| 1542 | */ |
| 1543 | private static function parseTextShadowLayer(string $raw, ?ColorInterface $fillFallback): ?array |
| 1544 | { |
| 1545 | // Tokenise on whitespace, but keep `rgb(1, 2, 3)` etc. as a |
| 1546 | // single token. Lengths are simple; the colour may contain |
| 1547 | // spaces inside its parens. |
| 1548 | $tokens = self::tokenizeTextShadowLayer($raw); |
| 1549 | if ($tokens === []) { |
| 1550 | return null; |
| 1551 | } |
| 1552 | $lengths = []; |
| 1553 | $colorRaw = null; |
| 1554 | foreach ($tokens as $token) { |
| 1555 | $length = self::parseLengthToken($token); |
| 1556 | if ($length !== null) { |
| 1557 | if (count($lengths) >= 3) { |
| 1558 | return null; |
| 1559 | } |
| 1560 | $lengths[] = $length; |
| 1561 | continue; |
| 1562 | } |
| 1563 | if ($colorRaw !== null) { |
| 1564 | return null; |
| 1565 | } |
| 1566 | $colorRaw = $token; |
| 1567 | } |
| 1568 | if (count($lengths) < 2) { |
| 1569 | return null; |
| 1570 | } |
| 1571 | if (count($lengths) === 3 && $lengths[2] < 0.0) { |
| 1572 | // CSS Text Decoration 4: blur-radius must be non-negative. |
| 1573 | return null; |
| 1574 | } |
| 1575 | $color = null; |
| 1576 | if ($colorRaw !== null) { |
| 1577 | $color = SvgColor::parse($colorRaw); |
| 1578 | if ($color === null) { |
| 1579 | return null; |
| 1580 | } |
| 1581 | } else { |
| 1582 | $color = $fillFallback ?? new RgbColor(0.0, 0.0, 0.0); |
| 1583 | } |
| 1584 | return [ |
| 1585 | 'offsetX' => $lengths[0], |
| 1586 | 'offsetY' => $lengths[1], |
| 1587 | 'color' => $color, |
| 1588 | ]; |
| 1589 | } |
| 1590 | |
| 1591 | /** |
| 1592 | * Whitespace-tokenise a single shadow layer keeping function |
| 1593 | * forms (`rgb(0, 128, 0)`) as one token. |
| 1594 | * |
| 1595 | * @return list<string> |
| 1596 | */ |
| 1597 | private static function tokenizeTextShadowLayer(string $raw): array |
| 1598 | { |
| 1599 | $tokens = []; |
| 1600 | $buffer = ''; |
| 1601 | $depth = 0; |
| 1602 | $length = strlen($raw); |
| 1603 | for ($i = 0; $i < $length; $i++) { |
| 1604 | $ch = $raw[$i]; |
| 1605 | if ($ch === '(') { |
| 1606 | $depth++; |
| 1607 | $buffer .= $ch; |
| 1608 | continue; |
| 1609 | } |
| 1610 | if ($ch === ')') { |
| 1611 | $depth = max(0, $depth - 1); |
| 1612 | $buffer .= $ch; |
| 1613 | continue; |
| 1614 | } |
| 1615 | if ($depth === 0 && (ctype_space($ch) || $ch === ',')) { |
| 1616 | if ($buffer !== '') { |
| 1617 | $tokens[] = $buffer; |
| 1618 | $buffer = ''; |
| 1619 | } |
| 1620 | continue; |
| 1621 | } |
| 1622 | $buffer .= $ch; |
| 1623 | } |
| 1624 | if ($buffer !== '') { |
| 1625 | $tokens[] = $buffer; |
| 1626 | } |
| 1627 | return $tokens; |
| 1628 | } |
| 1629 | |
| 1630 | /** |
| 1631 | * Parse a CSS length token used inside `text-shadow`. Supports |
| 1632 | * unitless (`0`), `px`, `pt`, `em`, and a couple of absolute |
| 1633 | * physical units. Returns null when the token doesn't look like |
| 1634 | * a length so the caller treats it as the colour slot instead. |
| 1635 | * Unit support is intentionally narrow - text-shadow in real |
| 1636 | * documents almost always uses `px` (or `0`). |
| 1637 | */ |
| 1638 | private static function parseLengthToken(string $raw): ?float |
| 1639 | { |
| 1640 | $trimmed = trim($raw); |
| 1641 | if ($trimmed === '') { |
| 1642 | return null; |
| 1643 | } |
| 1644 | if (preg_match('/^([+-]?(?:\d+\.?\d*|\.\d+))([a-zA-Z%]*)$/', $trimmed, $m) !== 1) { |
| 1645 | return null; |
| 1646 | } |
| 1647 | $value = (float) $m[1]; |
| 1648 | $unit = strtolower($m[2]); |
| 1649 | return match ($unit) { |
| 1650 | '', 'px', 'pt' => $value, |
| 1651 | 'in' => $value * 72.0, |
| 1652 | 'cm' => $value * (72.0 / 2.54), |
| 1653 | 'mm' => $value * (72.0 / 25.4), |
| 1654 | // em / rem / vw / vh / % aren't resolvable without a |
| 1655 | // containing context — reject so an unknown unit doesn't |
| 1656 | // silently land at 0. |
| 1657 | default => null, |
| 1658 | }; |
| 1659 | } |
| 1660 | |
| 1661 | /** |
| 1662 | * Per-glyph positioning per SVG 2 §11.6: walk content character-by- |
| 1663 | * character, emit one `Tm` per explicitly-positioned glyph, then |
| 1664 | * batch the remaining characters as a single `Tj` so their natural |
| 1665 | * advance handles the trailing positioning. |
| 1666 | * |
| 1667 | * Sticky semantics: when a glyph specifies `x[i]` but not `y[i]` |
| 1668 | * (or vice-versa), the unspecified component carries over from the |
| 1669 | * previous glyph's position. SVG 2 actually defines this as "the |
| 1670 | * previous glyph's effective position" which requires knowing the |
| 1671 | * per-glyph advance — we don't have font metrics here at 3R+5, so |
| 1672 | * we use the last explicit value instead. The result is correct |
| 1673 | * for the common case where `x` and `y` have matching lengths. |
| 1674 | * |
| 1675 | * `dx[i]` / `dy[i]` are additive deltas applied to the resolved |
| 1676 | * `(stickyX, stickyY)` — SVG 2 §11.6 specifies them as relative |
| 1677 | * offsets from the glyph's natural position. Without font metrics |
| 1678 | * "natural position" collapses to "sticky position", so we treat |
| 1679 | * dx/dy as deltas from sticky. The deltas accumulate into sticky |
| 1680 | * so subsequent glyphs without their own dx/dy inherit the shift, |
| 1681 | * matching the common renderer behaviour for stacked offsets like |
| 1682 | * super/subscript adjustments. |
| 1683 | * |
| 1684 | * @param list<float> $xList |
| 1685 | * @param list<float> $yList |
| 1686 | * @param list<float> $dxList |
| 1687 | * @param list<float> $dyList |
| 1688 | * @param list<float> $rotateList |
| 1689 | */ |
| 1690 | private function paintTextPerGlyph( |
| 1691 | string $content, |
| 1692 | array $xList, |
| 1693 | array $yList, |
| 1694 | array $dxList, |
| 1695 | array $dyList, |
| 1696 | array $rotateList, |
| 1697 | ContentStream $stream, |
| 1698 | ): void { |
| 1699 | $chars = mb_str_split($content); |
| 1700 | if ($chars === []) { |
| 1701 | return; |
| 1702 | } |
| 1703 | $explicitCount = max( |
| 1704 | count($xList), |
| 1705 | count($yList), |
| 1706 | count($dxList), |
| 1707 | count($dyList), |
| 1708 | count($rotateList), |
| 1709 | ); |
| 1710 | $stickyX = $xList[0] ?? 0.0; |
| 1711 | $stickyY = $yList[0] ?? 0.0; |
| 1712 | $stickyRotate = $rotateList[0] ?? 0.0; |
| 1713 | |
| 1714 | $emitted = 0; |
| 1715 | foreach ($chars as $i => $char) { |
| 1716 | if ($i >= $explicitCount) { |
| 1717 | break; |
| 1718 | } |
| 1719 | $stickyX = $xList[$i] ?? $stickyX; |
| 1720 | $stickyY = $yList[$i] ?? $stickyY; |
| 1721 | $stickyRotate = $rotateList[$i] ?? $stickyRotate; |
| 1722 | if (isset($dxList[$i])) { |
| 1723 | $stickyX += $dxList[$i]; |
| 1724 | } |
| 1725 | if (isset($dyList[$i])) { |
| 1726 | $stickyY += $dyList[$i]; |
| 1727 | } |
| 1728 | |
| 1729 | $this->emitTextMatrix($stickyX, $stickyY, $stickyRotate, $stream); |
| 1730 | $stream->showText($char); |
| 1731 | $emitted++; |
| 1732 | } |
| 1733 | if ($emitted < count($chars)) { |
| 1734 | // Remaining glyphs ride the auto-advance from the last |
| 1735 | // positioned glyph — emit them as a single `Tj` so the |
| 1736 | // PDF reader inter-glyph kerning still applies. |
| 1737 | $stream->showText(implode('', array_slice($chars, $emitted))); |
| 1738 | } |
| 1739 | } |
| 1740 | |
| 1741 | /** |
| 1742 | * Set the text matrix for a single positioned glyph. Combines the |
| 1743 | * per-glyph rotation with the optional outer-flip compensation |
| 1744 | * established by `SvgRenderer::draw`. Algebra: |
| 1745 | * |
| 1746 | * Without flip: Tm = T(x,y) · R(θ) = [cosθ sinθ -sinθ cosθ x y] |
| 1747 | * With flip: Tm = T(x,y) · F · R(θ) = [cosθ sinθ sinθ -cosθ x y] |
| 1748 | * |
| 1749 | * where `F = [1 0 0 -1 0 0]` is the y-axis flip. |
| 1750 | */ |
| 1751 | private function emitTextMatrix(float $x, float $y, float $rotateDegrees, ContentStream $stream): void |
| 1752 | { |
| 1753 | $rad = deg2rad($rotateDegrees); |
| 1754 | $cos = cos($rad); |
| 1755 | $sin = sin($rad); |
| 1756 | if ($this->compensateTextFlip) { |
| 1757 | $stream->setTextMatrix($cos, $sin, $sin, -$cos, $x, $y); |
| 1758 | } else { |
| 1759 | $stream->setTextMatrix($cos, $sin, -$sin, $cos, $x, $y); |
| 1760 | } |
| 1761 | } |
| 1762 | |
| 1763 | /** |
| 1764 | * Concatenate all `Phpdftk\Svg\Text` (data) descendants in document |
| 1765 | * order. SVG 2's whitespace handling is complex (xml:space="preserve" |
| 1766 | * vs the default collapse); 3P keeps things simple by emitting the |
| 1767 | * source bytes verbatim and leaving whitespace policy to the future |
| 1768 | * cascade-aware text painter. |
| 1769 | */ |
| 1770 | private static function collectTextContent(Element $element): string |
| 1771 | { |
| 1772 | $out = ''; |
| 1773 | foreach ($element->children as $child) { |
| 1774 | if ($child instanceof TextNode) { |
| 1775 | $out .= $child->data; |
| 1776 | continue; |
| 1777 | } |
| 1778 | if ($child instanceof Element) { |
| 1779 | $out .= self::collectTextContent($child); |
| 1780 | } |
| 1781 | } |
| 1782 | return $out; |
| 1783 | } |
| 1784 | |
| 1785 | private function paintPath(Path $path, ContentStream $stream): void |
| 1786 | { |
| 1787 | $commands = $path->d()->commands; |
| 1788 | if ($commands === []) { |
| 1789 | return; |
| 1790 | } |
| 1791 | $state = new PathPainterState(); |
| 1792 | foreach ($commands as $command) { |
| 1793 | $this->emitPathCommand($command, $stream, $state); |
| 1794 | } |
| 1795 | $this->applyFillAndStroke($path, $stream); |
| 1796 | } |
| 1797 | |
| 1798 | private function emitPathCommand( |
| 1799 | PathCommand $command, |
| 1800 | ContentStream $stream, |
| 1801 | PathPainterState $state, |
| 1802 | ): void { |
| 1803 | match (true) { |
| 1804 | $command instanceof MoveTo => $this->emitMoveTo($command, $stream, $state), |
| 1805 | $command instanceof LineTo => $this->emitLineTo($command, $stream, $state), |
| 1806 | $command instanceof HorizontalLineTo => $this->emitHorizontalLineTo($command, $stream, $state), |
| 1807 | $command instanceof VerticalLineTo => $this->emitVerticalLineTo($command, $stream, $state), |
| 1808 | $command instanceof CurveTo => $this->emitCurveTo($command, $stream, $state), |
| 1809 | $command instanceof SmoothCurveTo => $this->emitSmoothCurveTo($command, $stream, $state), |
| 1810 | $command instanceof QuadraticCurveTo => $this->emitQuadraticCurveTo($command, $stream, $state), |
| 1811 | $command instanceof SmoothQuadraticCurveTo => $this->emitSmoothQuadraticCurveTo($command, $stream, $state), |
| 1812 | $command instanceof ArcTo => $this->emitArcTo($command, $stream, $state), |
| 1813 | $command instanceof ClosePath => $this->emitClosePath($stream, $state), |
| 1814 | // Sealed-via-convention: 3rd-party impls of `PathCommand` are |
| 1815 | // not part of the SVG spec, so we no-op silently rather than |
| 1816 | // throw — same posture the parser uses for unknown content. |
| 1817 | default => null, |
| 1818 | }; |
| 1819 | } |
| 1820 | |
| 1821 | private function emitMoveTo(MoveTo $cmd, ContentStream $stream, PathPainterState $state): void |
| 1822 | { |
| 1823 | [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state); |
| 1824 | $stream->moveTo($x, $y); |
| 1825 | $state->moveTo($x, $y); |
| 1826 | } |
| 1827 | |
| 1828 | private function emitLineTo(LineTo $cmd, ContentStream $stream, PathPainterState $state): void |
| 1829 | { |
| 1830 | [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state); |
| 1831 | $stream->lineTo($x, $y); |
| 1832 | $state->lineTo($x, $y); |
| 1833 | } |
| 1834 | |
| 1835 | private function emitHorizontalLineTo( |
| 1836 | HorizontalLineTo $cmd, |
| 1837 | ContentStream $stream, |
| 1838 | PathPainterState $state, |
| 1839 | ): void { |
| 1840 | $x = $cmd->absolute ? $cmd->x : $state->currentX + $cmd->x; |
| 1841 | $stream->lineTo($x, $state->currentY); |
| 1842 | $state->lineTo($x, $state->currentY); |
| 1843 | } |
| 1844 | |
| 1845 | private function emitVerticalLineTo( |
| 1846 | VerticalLineTo $cmd, |
| 1847 | ContentStream $stream, |
| 1848 | PathPainterState $state, |
| 1849 | ): void { |
| 1850 | $y = $cmd->absolute ? $cmd->y : $state->currentY + $cmd->y; |
| 1851 | $stream->lineTo($state->currentX, $y); |
| 1852 | $state->lineTo($state->currentX, $y); |
| 1853 | } |
| 1854 | |
| 1855 | private function emitCurveTo(CurveTo $cmd, ContentStream $stream, PathPainterState $state): void |
| 1856 | { |
| 1857 | [$x1, $y1] = $this->resolvePoint($cmd->x1, $cmd->y1, $cmd->absolute, $state); |
| 1858 | [$x2, $y2] = $this->resolvePoint($cmd->x2, $cmd->y2, $cmd->absolute, $state); |
| 1859 | [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state); |
| 1860 | $stream->curveTo($x1, $y1, $x2, $y2, $x, $y); |
| 1861 | $state->currentX = $x; |
| 1862 | $state->currentY = $y; |
| 1863 | $state->recordCubicControl($x2, $y2); |
| 1864 | } |
| 1865 | |
| 1866 | private function emitSmoothCurveTo( |
| 1867 | SmoothCurveTo $cmd, |
| 1868 | ContentStream $stream, |
| 1869 | PathPainterState $state, |
| 1870 | ): void { |
| 1871 | [$x1, $y1] = $state->reflectedCubicControl(); |
| 1872 | [$x2, $y2] = $this->resolvePoint($cmd->x2, $cmd->y2, $cmd->absolute, $state); |
| 1873 | [$x, $y] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state); |
| 1874 | $stream->curveTo($x1, $y1, $x2, $y2, $x, $y); |
| 1875 | $state->currentX = $x; |
| 1876 | $state->currentY = $y; |
| 1877 | $state->recordCubicControl($x2, $y2); |
| 1878 | } |
| 1879 | |
| 1880 | /** |
| 1881 | * PDF has no native quadratic curve operator. Lift to cubic via the |
| 1882 | * standard `C1 = P0 + 2/3·(P1-P0)`, `C2 = P2 + 2/3·(P1-P2)` formula — |
| 1883 | * mathematically exact, no approximation error. |
| 1884 | */ |
| 1885 | private function emitQuadraticCurveTo( |
| 1886 | QuadraticCurveTo $cmd, |
| 1887 | ContentStream $stream, |
| 1888 | PathPainterState $state, |
| 1889 | ): void { |
| 1890 | [$qx, $qy] = $this->resolvePoint($cmd->x1, $cmd->y1, $cmd->absolute, $state); |
| 1891 | [$ex, $ey] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state); |
| 1892 | $this->emitQuadraticAsCubic($state->currentX, $state->currentY, $qx, $qy, $ex, $ey, $stream); |
| 1893 | $state->currentX = $ex; |
| 1894 | $state->currentY = $ey; |
| 1895 | $state->recordQuadraticControl($qx, $qy); |
| 1896 | } |
| 1897 | |
| 1898 | private function emitSmoothQuadraticCurveTo( |
| 1899 | SmoothQuadraticCurveTo $cmd, |
| 1900 | ContentStream $stream, |
| 1901 | PathPainterState $state, |
| 1902 | ): void { |
| 1903 | [$qx, $qy] = $state->reflectedQuadraticControl(); |
| 1904 | [$ex, $ey] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state); |
| 1905 | $this->emitQuadraticAsCubic($state->currentX, $state->currentY, $qx, $qy, $ex, $ey, $stream); |
| 1906 | $state->currentX = $ex; |
| 1907 | $state->currentY = $ey; |
| 1908 | $state->recordQuadraticControl($qx, $qy); |
| 1909 | } |
| 1910 | |
| 1911 | private function emitArcTo(ArcTo $cmd, ContentStream $stream, PathPainterState $state): void |
| 1912 | { |
| 1913 | [$endX, $endY] = $this->resolvePoint($cmd->x, $cmd->y, $cmd->absolute, $state); |
| 1914 | $segments = ArcToCubic::convert( |
| 1915 | $state->currentX, |
| 1916 | $state->currentY, |
| 1917 | $cmd->rx, |
| 1918 | $cmd->ry, |
| 1919 | $cmd->xAxisRotation, |
| 1920 | $cmd->largeArc, |
| 1921 | $cmd->sweep, |
| 1922 | $endX, |
| 1923 | $endY, |
| 1924 | ); |
| 1925 | if ($segments === []) { |
| 1926 | // Degenerate: zero-length or zero-radius. Per SVG 2 §9.5.1, |
| 1927 | // an arc with a zero radius is rendered as a straight line. |
| 1928 | if ($cmd->rx === 0.0 || $cmd->ry === 0.0) { |
| 1929 | $stream->lineTo($endX, $endY); |
| 1930 | $state->lineTo($endX, $endY); |
| 1931 | } |
| 1932 | return; |
| 1933 | } |
| 1934 | foreach ($segments as $segment) { |
| 1935 | $stream->curveTo( |
| 1936 | $segment['x1'], |
| 1937 | $segment['y1'], |
| 1938 | $segment['x2'], |
| 1939 | $segment['y2'], |
| 1940 | $segment['x'], |
| 1941 | $segment['y'], |
| 1942 | ); |
| 1943 | } |
| 1944 | $state->currentX = $endX; |
| 1945 | $state->currentY = $endY; |
| 1946 | $state->clearControlPoints(); |
| 1947 | } |
| 1948 | |
| 1949 | private function emitClosePath(ContentStream $stream, PathPainterState $state): void |
| 1950 | { |
| 1951 | $stream->closePath(); |
| 1952 | $state->closeSubpath(); |
| 1953 | } |
| 1954 | |
| 1955 | private function emitQuadraticAsCubic( |
| 1956 | float $p0x, |
| 1957 | float $p0y, |
| 1958 | float $p1x, |
| 1959 | float $p1y, |
| 1960 | float $p2x, |
| 1961 | float $p2y, |
| 1962 | ContentStream $stream, |
| 1963 | ): void { |
| 1964 | $twoThirds = 2.0 / 3.0; |
| 1965 | $c1x = $p0x + $twoThirds * ($p1x - $p0x); |
| 1966 | $c1y = $p0y + $twoThirds * ($p1y - $p0y); |
| 1967 | $c2x = $p2x + $twoThirds * ($p1x - $p2x); |
| 1968 | $c2y = $p2y + $twoThirds * ($p1y - $p2y); |
| 1969 | $stream->curveTo($c1x, $c1y, $c2x, $c2y, $p2x, $p2y); |
| 1970 | } |
| 1971 | |
| 1972 | /** |
| 1973 | * @return array{float, float} |
| 1974 | */ |
| 1975 | private function resolvePoint(float $x, float $y, bool $absolute, PathPainterState $state): array |
| 1976 | { |
| 1977 | if ($absolute) { |
| 1978 | return [$x, $y]; |
| 1979 | } |
| 1980 | return [$state->currentX + $x, $state->currentY + $y]; |
| 1981 | } |
| 1982 | |
| 1983 | private function paintRect(Rect $rect, ContentStream $stream): void |
| 1984 | { |
| 1985 | $vp = $this->currentViewport(); |
| 1986 | $x = $this->resolvePercentLength($rect->getAttribute('x'), $vp['w'], $rect->x()); |
| 1987 | $y = $this->resolvePercentLength($rect->getAttribute('y'), $vp['h'], $rect->y()); |
| 1988 | $w = $this->resolvePercentLength($rect->getAttribute('width'), $vp['w'], $rect->width()); |
| 1989 | $h = $this->resolvePercentLength($rect->getAttribute('height'), $vp['h'], $rect->height()); |
| 1990 | if ($w <= 0.0 || $h <= 0.0) { |
| 1991 | return; |
| 1992 | } |
| 1993 | $stream->rectangle($x, $y, $w, $h); |
| 1994 | $this->applyFillAndStroke($rect, $stream); |
| 1995 | } |
| 1996 | |
| 1997 | private function paintCircle(Circle $circle, ContentStream $stream): void |
| 1998 | { |
| 1999 | if ($circle->r() <= 0.0) { |
| 2000 | return; |
| 2001 | } |
| 2002 | $this->emitEllipsePath($stream, $circle->cx(), $circle->cy(), $circle->r(), $circle->r()); |
| 2003 | $this->applyFillAndStroke($circle, $stream); |
| 2004 | } |
| 2005 | |
| 2006 | private function paintEllipse(Ellipse $ellipse, ContentStream $stream): void |
| 2007 | { |
| 2008 | $rx = $ellipse->rx(); |
| 2009 | $ry = $ellipse->ry(); |
| 2010 | if ($rx === null || $ry === null || $rx <= 0.0 || $ry <= 0.0) { |
| 2011 | return; |
| 2012 | } |
| 2013 | $this->emitEllipsePath($stream, $ellipse->cx(), $ellipse->cy(), $rx, $ry); |
| 2014 | $this->applyFillAndStroke($ellipse, $stream); |
| 2015 | } |
| 2016 | |
| 2017 | private function paintLine(Line $line, ContentStream $stream): void |
| 2018 | { |
| 2019 | // Lines never enclose an area; only stroke is meaningful. Skip |
| 2020 | // entirely when stroke resolves to no paint — emitting a stroke |
| 2021 | // op with no colour would otherwise draw a black line by |
| 2022 | // accident. |
| 2023 | $stroke = $line->stroke(); |
| 2024 | if ($stroke === null || $stroke instanceof None_) { |
| 2025 | return; |
| 2026 | } |
| 2027 | if (!$this->applyStrokePaint($stroke, $line, $stream)) { |
| 2028 | return; |
| 2029 | } |
| 2030 | $stream->moveTo($line->x1(), $line->y1()) |
| 2031 | ->lineTo($line->x2(), $line->y2()) |
| 2032 | ->stroke(); |
| 2033 | } |
| 2034 | |
| 2035 | private function paintPolyline(Polyline $polyline, ContentStream $stream): void |
| 2036 | { |
| 2037 | $points = $polyline->points(); |
| 2038 | if (count($points) < 2) { |
| 2039 | return; |
| 2040 | } |
| 2041 | $this->emitPolyPath($stream, $points, closed: false); |
| 2042 | $this->applyFillAndStroke($polyline, $stream); |
| 2043 | } |
| 2044 | |
| 2045 | private function paintPolygon(Polygon $polygon, ContentStream $stream): void |
| 2046 | { |
| 2047 | $points = $polygon->points(); |
| 2048 | if (count($points) < 3) { |
| 2049 | return; |
| 2050 | } |
| 2051 | $this->emitPolyPath($stream, $points, closed: true); |
| 2052 | $this->applyFillAndStroke($polygon, $stream); |
| 2053 | } |
| 2054 | |
| 2055 | /** |
| 2056 | * Standard 4-cubic-Bézier ellipse approximation. Maximum radial |
| 2057 | * error against the true ellipse is ~0.027 % — well below print |
| 2058 | * resolution for any reasonable PDF size. |
| 2059 | */ |
| 2060 | private function emitEllipsePath( |
| 2061 | ContentStream $stream, |
| 2062 | float $cx, |
| 2063 | float $cy, |
| 2064 | float $rx, |
| 2065 | float $ry, |
| 2066 | ): void { |
| 2067 | $kx = $rx * self::KAPPA; |
| 2068 | $ky = $ry * self::KAPPA; |
| 2069 | $stream |
| 2070 | ->moveTo($cx + $rx, $cy) |
| 2071 | ->curveTo($cx + $rx, $cy + $ky, $cx + $kx, $cy + $ry, $cx, $cy + $ry) |
| 2072 | ->curveTo($cx - $kx, $cy + $ry, $cx - $rx, $cy + $ky, $cx - $rx, $cy) |
| 2073 | ->curveTo($cx - $rx, $cy - $ky, $cx - $kx, $cy - $ry, $cx, $cy - $ry) |
| 2074 | ->curveTo($cx + $kx, $cy - $ry, $cx + $rx, $cy - $ky, $cx + $rx, $cy) |
| 2075 | ->closePath(); |
| 2076 | } |
| 2077 | |
| 2078 | /** |
| 2079 | * @param list<array{float, float}> $points |
| 2080 | */ |
| 2081 | private function emitPolyPath(ContentStream $stream, array $points, bool $closed): void |
| 2082 | { |
| 2083 | $first = $points[0]; |
| 2084 | $stream->moveTo($first[0], $first[1]); |
| 2085 | for ($i = 1, $n = count($points); $i < $n; $i++) { |
| 2086 | $stream->lineTo($points[$i][0], $points[$i][1]); |
| 2087 | } |
| 2088 | if ($closed) { |
| 2089 | $stream->closePath(); |
| 2090 | } |
| 2091 | } |
| 2092 | |
| 2093 | /** |
| 2094 | * Resolve the element's fill and stroke and emit the right PDF |
| 2095 | * paint operator combination. Defaults follow SVG 2 §13.2.1 — black |
| 2096 | * fill, no stroke — so a bare `<rect width=… height=…/>` paints as |
| 2097 | * a filled black rectangle. |
| 2098 | */ |
| 2099 | private function applyFillAndStroke(Element $element, ContentStream $stream): void |
| 2100 | { |
| 2101 | $fill = $element->fill(); |
| 2102 | $stroke = $element->stroke(); |
| 2103 | |
| 2104 | $hasFill = $this->applyFillPaint($fill, $element, $stream); |
| 2105 | $hasStroke = $this->applyStrokePaint($stroke, $element, $stream); |
| 2106 | |
| 2107 | $rule = $element->fillRule() ?? 'nonzero'; |
| 2108 | |
| 2109 | if ($hasFill && $hasStroke) { |
| 2110 | $rule === 'evenodd' ? $stream->fillAndStrokeEvenOdd() : $stream->fillAndStroke(); |
| 2111 | return; |
| 2112 | } |
| 2113 | if ($hasFill) { |
| 2114 | $rule === 'evenodd' ? $stream->fillEvenOdd() : $stream->fill(); |
| 2115 | return; |
| 2116 | } |
| 2117 | if ($hasStroke) { |
| 2118 | $stream->stroke(); |
| 2119 | return; |
| 2120 | } |
| 2121 | // Path constructed but nothing wants to paint it — discard so |
| 2122 | // we don't bake a leftover current-path into the graphics state. |
| 2123 | $stream->endPath(); |
| 2124 | } |
| 2125 | |
| 2126 | /** |
| 2127 | * Configure the fill colour and report whether the element wants a |
| 2128 | * fill at all. Default (null paint) = SVG-spec black fill; explicit |
| 2129 | * `none` = no fill; `currentColor` resolves to black at 3K (the |
| 2130 | * cascade-resolved `color` lands later). Gradient/pattern `url(#…)` |
| 2131 | * is deferred to 3O. |
| 2132 | */ |
| 2133 | private function applyFillPaint(?Paint $paint, Element $element, ContentStream $stream): bool |
| 2134 | { |
| 2135 | if ($paint instanceof None_) { |
| 2136 | return false; |
| 2137 | } |
| 2138 | if ($paint instanceof Url) { |
| 2139 | return $this->gradientPainter?->applyAsFill($paint->id, $element, $stream) ?? false; |
| 2140 | } |
| 2141 | if ($paint instanceof SolidColor) { |
| 2142 | $this->setFillColor($stream, $paint->color); |
| 2143 | return true; |
| 2144 | } |
| 2145 | // null or CurrentColor → SVG 2 §13.2.1 default of black. |
| 2146 | $stream->setFillColorRGB(0.0, 0.0, 0.0); |
| 2147 | return true; |
| 2148 | } |
| 2149 | |
| 2150 | /** |
| 2151 | * Configure the stroke colour and report whether the element wants |
| 2152 | * to stroke. Default (null paint) = SVG-spec "no stroke"; explicit |
| 2153 | * `none` = no stroke. |
| 2154 | */ |
| 2155 | private function applyStrokePaint(?Paint $paint, Element $element, ContentStream $stream): bool |
| 2156 | { |
| 2157 | if ($paint === null || $paint instanceof None_) { |
| 2158 | return false; |
| 2159 | } |
| 2160 | if ($paint instanceof Url) { |
| 2161 | return $this->gradientPainter?->applyAsStroke($paint->id, $element, $stream) ?? false; |
| 2162 | } |
| 2163 | if ($paint instanceof SolidColor) { |
| 2164 | $this->setStrokeColor($stream, $paint->color); |
| 2165 | return true; |
| 2166 | } |
| 2167 | // CurrentColor → black at 3K. |
| 2168 | $stream->setStrokeColorRGB(0.0, 0.0, 0.0); |
| 2169 | return true; |
| 2170 | } |
| 2171 | |
| 2172 | private function setFillColor(ContentStream $stream, ColorInterface $color): void |
| 2173 | { |
| 2174 | match (true) { |
| 2175 | $color instanceof RgbColor => $stream->setFillRgbColor($color), |
| 2176 | $color instanceof CmykColor => $stream->setFillCmykColor($color), |
| 2177 | $color instanceof GrayColor => $stream->setFillGrayColor($color), |
| 2178 | default => $stream->setFillColorRGB(0.0, 0.0, 0.0), |
| 2179 | }; |
| 2180 | } |
| 2181 | |
| 2182 | private function setStrokeColor(ContentStream $stream, ColorInterface $color): void |
| 2183 | { |
| 2184 | match (true) { |
| 2185 | $color instanceof RgbColor => $stream->setStrokeRgbColor($color), |
| 2186 | $color instanceof CmykColor => $stream->setStrokeCmykColor($color), |
| 2187 | $color instanceof GrayColor => $stream->setStrokeGrayColor($color), |
| 2188 | default => $stream->setStrokeColorRGB(0.0, 0.0, 0.0), |
| 2189 | }; |
| 2190 | } |
| 2191 | } |