Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.93% covered (warning)
89.93%
125 / 139
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SvgCascadeProjector
89.93% covered (warning)
89.93%
125 / 139
54.55% covered (warning)
54.55%
6 / 11
62.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 project
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 collectUseShadowTrees
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 useReferentToClone
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
10.24
 cloneElement
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 cloneNode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 stripIds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 countElements
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 walk
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 walkFrom
95.16% covered (success)
95.16%
59 / 62
0.00% covered (danger)
0.00%
0 / 1
25
 findDocument
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf;
6
7use Phpdftk\Css\Cascade\CascadedValues;
8use Phpdftk\Css\Value\Calc;
9use Phpdftk\Css\Value\Length;
10use Phpdftk\Css\Value\Percentage;
11use Phpdftk\Svg\Css\CssBridge;
12use Phpdftk\Svg\Element;
13use Phpdftk\Svg\GenericElement;
14use Phpdftk\Svg\Node;
15use Phpdftk\Svg\Use_;
16use Phpdftk\Svg\SvgDocument;
17
18/**
19 * Project author CSS (cascaded values from `<style>` blocks inside
20 * the SVG) into each element's inline `style` attribute so the
21 * existing `Element::presentationOrStyle()` fallback picks them up
22 * during paint.
23 *
24 * Why a projector and not direct cascade reads at paint time:
25 *
26 *   - The painter is shape-by-shape with deeply tree-recursive
27 *     paths. Re-resolving the cascade at every accessor call would
28 *     dominate the render budget.
29 *   - The projection runs once per SvgDocument before paint, so
30 *     each element only pays the cascade cost once regardless of
31 *     how many accessors read it.
32 *   - The projection writes back to the same `style` attribute
33 *     that author-supplied inline declarations live in, so
34 *     accessors don't need to know whether a value came from
35 *     `<rect style="...">` or `<style>.s { ... }`. Same code path.
36 *
37 * Per-property allowlist:
38 *
39 *   The projection only touches properties the painter actually
40 *   reads through {@see Element::presentationOrStyle()}.
41 *   Projecting the full computed-style set would inject browser-
42 *   computed defaults (`stroke: none` everywhere, etc.) and shift
43 *   the paint behaviour for properties our renderer hasn't wired
44 *   yet, which would regress unrelated fixtures.
45 *
46 *   Adding a new accessor means adding the property here too.
47 */
48final class SvgCascadeProjector
49{
50    /**
51     * Properties projected into inline `style`. Add to this list
52     * when a new {@see Element} accessor starts reading via
53     * `presentationOrStyle`.
54     */
55    private const array PROJECTED = [
56        'fill',
57        'stroke',
58        // Inherited, and the referent of `currentColor`. Projected on
59        // the loose `has()` test precisely BECAUSE it inherits: the
60        // declaration usually sits on an ancestor, so `wasDeclared()`
61        // on this element would be false.
62        'color',
63        'fill-rule',
64        'stroke-width',
65        'stroke-linecap',
66        'stroke-linejoin',
67        'stroke-miterlimit',
68        'stroke-dasharray',
69        'stroke-dashoffset',
70        // SVG 2 §9.6 — `path-length` is the CSS spelling of the
71        // `pathLength` presentation attribute. Projected like any
72        // other property so a stylesheet rule out-ranks the
73        // attribute, which sits at specificity 0 per §6.7.
74        'path-length',
75        'fill-opacity',
76        'stroke-opacity',
77        'opacity',
78        'font-family',
79        'font-size',
80        'font-weight',
81        'font-style',
82        'clip-path',
83        'mask',
84        'stop-color',
85        'stop-opacity',
86        'text-shadow',
87        // SVG 2 §11.6.2 — the marker family inherits, so like `color`
88        // these are projected on the loose `has()` test: the winning
89        // declaration usually sits on an ancestor `<g>`. Only the
90        // longhands appear here — the cascade expands the `marker`
91        // shorthand before anything reaches this point.
92        'marker-start',
93        'marker-mid',
94        'marker-end',
95        // SVG 2 §13.6 — `vector-effect`. Not inherited, so this only
96        // ever carries a declaration that won on this element.
97        'vector-effect',
98    ];
99
100    /**
101     * SVG 2 §10.1 geometry properties. Split out from
102     * {@see PROJECTED} because they are projected on a STRICTER test:
103     * only when a declaration actually won the cascade
104     * (`wasDeclared()`), never from an inherited or initial value.
105     *
106     * `width` / `height` are registered CSS properties whose initial
107     * value is `auto`; projecting those on the looser `has()` test
108     * would stamp `width: auto` onto shapes the author never sized and
109     * change what the painter reads.
110     */
111    private const array PROJECTED_GEOMETRY = [
112        'x',
113        'y',
114        'width',
115        'height',
116        'cx',
117        'cy',
118        'r',
119        'rx',
120        'ry',
121    ];
122
123    /**
124     * CSS Transforms 1 — `transform`, `transform-origin` and
125     * `transform-box` are ordinary CSS properties on SVG elements, so a
126     * `<style>` rule has to reach the painter the same way a
127     * presentation attribute does.
128     *
129     * Projected on the STRICT `wasDeclared()` test, like the geometry
130     * properties and for the same reason: all three are registered with
131     * non-empty initial values, and `transform-origin`'s registered
132     * initial is the CSS box default `50% 50%`. An SVG element has no
133     * CSS layout box and pivots on the user-space origin instead, so
134     * stamping the registry initial onto every element would silently
135     * relocate every rotation and scale in the document.
136     *
137     * @var list<string>
138     */
139    private const array PROJECTED_TRANSFORM = [
140        'transform',
141        'transform-origin',
142        'transform-box',
143        // SVG 2 §8.2 — the UA stylesheet sets `overflow: hidden` on
144        // viewport elements, so the painter's "no value" branch CLIPS.
145        // CSS's registered initial is `visible`, and projecting that
146        // onto every element would switch clipping off document-wide;
147        // hence the strict `wasDeclared()` test, same as the geometry
148        // properties.
149        'overflow',
150        // SVG 2 §8.9 — `display: none` removes an element from the
151        // rendering tree. Declared-only for the same reason: `display`
152        // has a non-empty registered initial, and the painter only
153        // cares whether a declaration said `none`.
154        'display',
155    ];
156
157    /**
158     * Properties whose CSS declaration OUT-RANKS the presentation
159     * attribute of the same name, so the projection must run even when
160     * the element carries that attribute.
161     *
162     * Only `d` today (SVG 2 §9.3). The accessor reads the `style`
163     * declaration first and the attribute second, which is what makes
164     * the ordering work; every other property in this file relies on
165     * the reverse.
166     *
167     * @var list<string>
168     */
169    private const array PROJECTED_OVER_ATTRIBUTE = [
170        'd',
171    ];
172
173    /**
174     * Marker attribute on a materialised `<use>` instance. Spelled out
175     * rather than imported from `html-to-pdf`, which writes the same
176     * one: the dependency runs the other way, and the painter already
177     * reads this literal.
178     */
179    public const string USE_INSTANCE_ATTRIBUTE = 'data-phpdftk-use-instance';
180
181    /** Nesting cap on `<use>` -> `<use>` expansion. */
182    private const int MAX_USE_DEPTH = 8;
183
184    /**
185     * Total element budget for shadow-tree cloning, so a document that
186     * fans out exponentially through nested `<use>` can't exhaust
187     * memory.
188     */
189    private const int USE_NODE_BUDGET = 20000;
190
191    private int $useBudget = self::USE_NODE_BUDGET;
192
193    /**
194     * Clone per `<use>`, built in a first pass BEFORE anything is
195     * styled — a clone taken after the referenced subtree had been
196     * projected would carry that projection as its own inline style
197     * and out-rank what it should inherit from the `<use>`.
198     *
199     * @var \SplObjectStorage<Element, Element>|null
200     */
201    private ?\SplObjectStorage $useShadowTrees = null;
202
203    /**
204     * Referents currently being expanded, so `<use>` -> `<use>` cycles
205     * terminate.
206     *
207     * @var \SplObjectStorage<Element, true>|null
208     */
209    private ?\SplObjectStorage $useChain = null;
210
211    public function __construct(
212        private readonly CssBridge $bridge = new CssBridge(),
213    ) {}
214
215    /**
216     * Walk `$document` and project the cascade into every element's
217     * `style` attribute. Mutates the document in place. Safe to
218     * call more than once - the second pass overwrites the
219     * previous projection (it's idempotent for the same document
220     * state).
221     */
222    public function project(SvgDocument $document): void
223    {
224        // SVG 2 §5.6 — PHASE 1: clone every `<use>` referent while the
225        // tree is still unstyled. The clones stay DETACHED until the
226        // walk reaches their `<use>`, which is what buys the shadow
227        // boundary for free: a document-tree selector like
228        // `.container rect` cannot match inside a subtree whose root
229        // has no parent, while a bare `rect` rule still does — exactly
230        // the split §5.6 and Selectors 4 describe.
231        $this->useBudget = self::USE_NODE_BUDGET;
232        $this->useShadowTrees = new \SplObjectStorage();
233        $this->useChain = new \SplObjectStorage();
234        $this->collectUseShadowTrees($document, $document, 0);
235        $this->useChain = null;
236        try {
237            $this->walk($document, null);
238        } finally {
239            $this->useShadowTrees = null;
240        }
241    }
242
243    /**
244     * PHASE 1 — build the clone for every `<use>` in `$scope`.
245     */
246    private function collectUseShadowTrees(Element $scope, SvgDocument $document, int $depth): void
247    {
248        if ($depth >= self::MAX_USE_DEPTH) {
249            return;
250        }
251        foreach ($scope->children as $child) {
252            if (!$child instanceof Element) {
253                continue;
254            }
255            $this->collectUseShadowTrees($child, $document, $depth);
256            if (!$child instanceof Use_) {
257                continue;
258            }
259            $referent = $this->useReferentToClone($child, $document);
260            if ($referent === null) {
261                continue;
262            }
263            $this->useBudget -= self::countElements($referent);
264            $clone = self::cloneElement($referent);
265            $clone->setAttribute(self::USE_INSTANCE_ATTRIBUTE, '1');
266            self::stripIds($clone);
267            // The clone's parent is a synthetic SHADOW ROOT rather than
268            // nothing at all. Leaving it parentless would make it match
269            // `:root` — and WPT's struct/reftests/use-inheritance-001
270            // pins that it must not ("it is considered to have no
271            // parent, but it is not a root element"). The placeholder's
272            // local name is deliberately unspellable as a CSS type
273            // selector, so no document rule can reach through it, which
274            // is the boundary §5.6 asks for.
275            $shadowRoot = new GenericElement('#shadow-root');
276            $shadowRoot->appendChild($clone);
277            $this->useShadowTrees?->attach($child, $clone);
278            // Held only for the nested collect, so a cycle back through
279            // this referent stops here while a later, legitimate second
280            // reference still gets its own instance.
281            $this->useChain?->attach($referent, true);
282            $this->collectUseShadowTrees($clone, $document, $depth + 1);
283            $this->useChain?->detach($referent);
284        }
285    }
286
287    /**
288     * The element a `<use>` should clone, or null when the reference is
289     * missing, external, circular, or over budget.
290     */
291    private function useReferentToClone(Use_ $use, SvgDocument $document): ?Element
292    {
293        if ($this->useBudget <= 0) {
294            return null;
295        }
296        // Idempotent: a document projected twice must not grow a second
297        // instance under the same `<use>`.
298        foreach ($use->children as $existing) {
299            if ($existing instanceof Element
300                && $existing->getAttribute(self::USE_INSTANCE_ATTRIBUTE) !== null
301            ) {
302                return null;
303            }
304        }
305        $referent = $use->resolve($document);
306        if ($referent === null || $referent === $use) {
307            return null;
308        }
309        if ($this->useChain?->contains($referent) ?? false) {
310            return null;
311        }
312        // SVG 2 §5.6.2 — referencing an ancestor of the `<use>` is a
313        // circular reference and the element is not rendered.
314        for ($n = $use->parent; $n !== null; $n = $n->parent) {
315            if ($n === $referent) {
316                return null;
317            }
318        }
319        return $referent;
320    }
321
322    /** Deep copy of an element subtree, detached from its parent. */
323    private static function cloneElement(Element $element): Element
324    {
325        $copy = clone $element;
326        $copy->parent = null;
327        $children = $copy->children;
328        $copy->children = [];
329        foreach ($children as $child) {
330            $copy->appendChild(self::cloneNode($child));
331        }
332        return $copy;
333    }
334
335    /** Deep copy of any node, detached from its parent. */
336    private static function cloneNode(Node $node): Node
337    {
338        if ($node instanceof Element) {
339            return self::cloneElement($node);
340        }
341        $copy = clone $node;
342        $copy->parent = null;
343        return $copy;
344    }
345
346    /**
347     * SVG 2 §5.6 — a shadow-tree node is not addressable by id from the
348     * document. Leaving the ids on would let a later `url(#…)` — or the
349     * painter's own `href` resolution — land on the CLONE instead of
350     * the original, which is exactly what happens when the `<use>` is
351     * written before the `<defs>` it references.
352     */
353    private static function stripIds(Element $element): void
354    {
355        unset($element->attributes['id']);
356        foreach ($element->children as $child) {
357            if ($child instanceof Element) {
358                self::stripIds($child);
359            }
360        }
361    }
362
363    private static function countElements(Element $element): int
364    {
365        $count = 1;
366        foreach ($element->children as $child) {
367            if ($child instanceof Element) {
368                $count += self::countElements($child);
369            }
370        }
371        return $count;
372    }
373
374    /**
375     * Recursive walker. Each call computes the cascade for
376     * `$element` (using `$parentValues` for inheritance), writes
377     * the relevant properties back as a `style` attribute
378     * declaration, then recurses into children with the new
379     * cascade as their parent values.
380     */
381    private function walk(Element $element, ?CascadedValues $parentValues): void
382    {
383        $document = $element instanceof SvgDocument
384            ? $element
385            : $this->findDocument($element);
386        if ($document === null) {
387            return;
388        }
389        $this->walkFrom($element, $parentValues, $document);
390    }
391
392    /**
393     * The recursive half of {@see walk()}, with the document handed in.
394     *
395     * Split out because a `<use>`'s shadow-tree clone is projected
396     * while still DETACHED — that is what gives it a shadow boundary —
397     * and `walk()` finds its document by following parent links the
398     * clone does not have.
399     */
400    private function walkFrom(
401        Element $element,
402        ?CascadedValues $parentValues,
403        SvgDocument $document,
404    ): void {
405        $values = $this->bridge->computeStyle(
406            $element,
407            $document,
408            $parentValues,
409        );
410
411        // CSS Variables 1 §3 — `var()` is substituted at computed-value
412        // time, which only the cascade can do. A presentation attribute
413        // is an ordinary CSS declaration (SVG 2 §6.7), so
414        // `width="var(--length)"` is legal — but the painter reads
415        // geometry straight off the attribute, where the raw `var(...)`
416        // text parses as nothing and the shape collapsed to zero. Write
417        // the substituted value back over the attribute so the painter
418        // sees a length.
419        foreach (self::PROJECTED_GEOMETRY as $property) {
420            $own = $element->getAttribute($property);
421            if ($own === null || !str_contains(strtolower($own), 'var(')) {
422                continue;
423            }
424            // A substituted value still has to BE a valid value for
425            // the property, or the declaration is invalid at
426            // computed-value time and falls back to the initial —
427            // `auto`, which the painter reads as "no geometry".
428            // `--length: 100` is the case that matters: a presentation
429            // attribute may be a unitless number, but the CSS `width`
430            // property may not, and WPT pins the shape not rendering.
431            $substituted = $values->get($property);
432            $element->setAttribute(
433                $property,
434                $substituted instanceof Length
435                || $substituted instanceof Percentage
436                || $substituted instanceof Calc
437                    ? $substituted->toCss()
438                    : 'auto',
439            );
440        }
441
442        $declarations = [];
443        foreach ([...self::PROJECTED_GEOMETRY, ...self::PROJECTED_TRANSFORM] as $property) {
444            // A presentation attribute on the element is the author's
445            // own value and still reaches the painter first, so leave
446            // it alone. Otherwise project only a value a declaration
447            // won — see PROJECTED_GEOMETRY / PROJECTED_TRANSFORM.
448            if ($element->getAttribute($property) !== null) {
449                continue;
450            }
451            if (!$values->wasDeclared($property)) {
452                continue;
453            }
454            $value = $values->get($property);
455            if ($value === null) {
456                continue;
457            }
458            $declarations[] = $property . ': ' . $value->toCss();
459        }
460        foreach (self::PROJECTED_OVER_ATTRIBUTE as $property) {
461            if (!$values->wasDeclared($property)) {
462                continue;
463            }
464            $value = $values->get($property);
465            if ($value === null) {
466                continue;
467            }
468            $declarations[] = $property . ': ' . $value->toCss();
469        }
470        foreach (self::PROJECTED as $property) {
471            // The author intent on the element itself (presentation
472            // attribute, inline style) already feeds the painter
473            // through `presentationOrStyle`. Skip properties where
474            // the element has its own source so we never overwrite
475            // the author's per-element value with the cascaded one —
476            // UNLESS that source is a CSS-wide keyword, which is not a
477            // value at all but an instruction to the cascade. The
478            // painter can do nothing with the literal string
479            // `inherit`, so `fill="inherit"` used to fall through to
480            // the black default; the resolved value has to come from
481            // here.
482            $own = $element->getAttribute($property);
483            if ($own !== null && !Element::isCssWideKeyword($own)) {
484                continue;
485            }
486            if (!$values->has($property)) {
487                continue;
488            }
489            $value = $values->get($property);
490            if ($value === null) {
491                continue;
492            }
493            $declarations[] = $property . ': ' . $value->toCss();
494        }
495
496        if ($declarations !== []) {
497            $existing = $element->getAttribute('style') ?? '';
498            $projection = '/* svg-cascade-projector */ '
499                . implode('; ', $declarations);
500            $element->setAttribute(
501                'style',
502                $existing === ''
503                    ? $projection
504                    : $existing . '; ' . $projection,
505            );
506        }
507
508        foreach ($element->children as $child) {
509            if ($child instanceof Element) {
510                $this->walkFrom($child, $values, $document);
511            }
512        }
513
514        // SVG 2 §5.6 PHASE 2 — style this `<use>`'s clone with the
515        // `<use>`'s own cascade as its parent, which is what finally
516        // makes `<use fill="green">` reach the shapes inside the
517        // referent, then attach it. Done AFTER the element's own
518        // children are walked so the instance isn't walked twice, and
519        // while the clone is still detached so document-tree selectors
520        // can't reach into it.
521        if ($element instanceof Use_ && ($this->useShadowTrees?->contains($element) ?? false)) {
522            $clone = $this->useShadowTrees[$element];
523            $this->walkFrom($clone, $values, $document);
524            $element->appendChild($clone);
525        }
526    }
527
528    /**
529     * Find the {@see SvgDocument} root for `$element` by walking
530     * up parent links. Falls back to null when the element is
531     * detached - we skip projection on those.
532     */
533    private function findDocument(Element $element): ?SvgDocument
534    {
535        $node = $element;
536        while ($node !== null) {
537            if ($node instanceof SvgDocument) {
538                return $node;
539            }
540            $node = $node->parent ?? null;
541        }
542        return null;
543    }
544}