Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.68% covered (success)
92.68%
38 / 41
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SvgCascadeProjector
92.68% covered (success)
92.68%
38 / 41
50.00% covered (danger)
50.00%
2 / 4
16.10
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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 walk
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
11.03
 findDocument
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf;
6
7use Phpdftk\Css\Cascade\CascadedValues;
8use Phpdftk\Svg\Css\CssBridge;
9use Phpdftk\Svg\Element;
10use Phpdftk\Svg\SvgDocument;
11
12/**
13 * Project author CSS (cascaded values from `<style>` blocks inside
14 * the SVG) into each element's inline `style` attribute so the
15 * existing `Element::presentationOrStyle()` fallback picks them up
16 * during paint.
17 *
18 * Why a projector and not direct cascade reads at paint time:
19 *
20 *   - The painter is shape-by-shape with deeply tree-recursive
21 *     paths. Re-resolving the cascade at every accessor call would
22 *     dominate the render budget.
23 *   - The projection runs once per SvgDocument before paint, so
24 *     each element only pays the cascade cost once regardless of
25 *     how many accessors read it.
26 *   - The projection writes back to the same `style` attribute
27 *     that author-supplied inline declarations live in, so
28 *     accessors don't need to know whether a value came from
29 *     `<rect style="...">` or `<style>.s { ... }`. Same code path.
30 *
31 * Per-property allowlist:
32 *
33 *   The projection only touches properties the painter actually
34 *   reads through {@see Element::presentationOrStyle()}.
35 *   Projecting the full computed-style set would inject browser-
36 *   computed defaults (`stroke: none` everywhere, etc.) and shift
37 *   the paint behaviour for properties our renderer hasn't wired
38 *   yet, which would regress unrelated fixtures.
39 *
40 *   Adding a new accessor means adding the property here too.
41 */
42final class SvgCascadeProjector
43{
44    /**
45     * Properties projected into inline `style`. Add to this list
46     * when a new {@see Element} accessor starts reading via
47     * `presentationOrStyle`.
48     */
49    private const array PROJECTED = [
50        'fill',
51        'stroke',
52        'fill-rule',
53        'stroke-width',
54        'stroke-linecap',
55        'stroke-linejoin',
56        'stroke-miterlimit',
57        'stroke-dasharray',
58        'stroke-dashoffset',
59        'fill-opacity',
60        'stroke-opacity',
61        'opacity',
62        'font-family',
63        'font-size',
64        'font-weight',
65        'font-style',
66    ];
67
68    public function __construct(
69        private readonly CssBridge $bridge = new CssBridge(),
70    ) {}
71
72    /**
73     * Walk `$document` and project the cascade into every element's
74     * `style` attribute. Mutates the document in place. Safe to
75     * call more than once - the second pass overwrites the
76     * previous projection (it's idempotent for the same document
77     * state).
78     */
79    public function project(SvgDocument $document): void
80    {
81        $this->walk($document, null);
82    }
83
84    /**
85     * Recursive walker. Each call computes the cascade for
86     * `$element` (using `$parentValues` for inheritance), writes
87     * the relevant properties back as a `style` attribute
88     * declaration, then recurses into children with the new
89     * cascade as their parent values.
90     */
91    private function walk(Element $element, ?CascadedValues $parentValues): void
92    {
93        $document = $element instanceof SvgDocument
94            ? $element
95            : $this->findDocument($element);
96        if ($document === null) {
97            return;
98        }
99        $values = $this->bridge->computeStyle(
100            $element,
101            $document,
102            $parentValues,
103        );
104
105        $declarations = [];
106        foreach (self::PROJECTED as $property) {
107            // The author intent on the element itself (presentation
108            // attribute, inline style) already feeds the painter
109            // through `presentationOrStyle`. Skip properties where
110            // the element has its own source so we never overwrite
111            // the author's per-element value with the cascaded one.
112            if ($element->getAttribute($property) !== null) {
113                continue;
114            }
115            if (!$values->has($property)) {
116                continue;
117            }
118            $value = $values->get($property);
119            if ($value === null) {
120                continue;
121            }
122            $declarations[] = $property . ': ' . $value->toCss();
123        }
124
125        if ($declarations !== []) {
126            $existing = $element->getAttribute('style') ?? '';
127            $projection = '/* svg-cascade-projector */ '
128                . implode('; ', $declarations);
129            $element->setAttribute(
130                'style',
131                $existing === ''
132                    ? $projection
133                    : $existing . '; ' . $projection,
134            );
135        }
136
137        foreach ($element->children as $child) {
138            if ($child instanceof Element) {
139                $this->walk($child, $values);
140            }
141        }
142    }
143
144    /**
145     * Find the {@see SvgDocument} root for `$element` by walking
146     * up parent links. Falls back to null when the element is
147     * detached - we skip projection on those.
148     */
149    private function findDocument(Element $element): ?SvgDocument
150    {
151        $node = $element;
152        while ($node !== null) {
153            if ($node instanceof SvgDocument) {
154                return $node;
155            }
156            $node = $node->parent ?? null;
157        }
158        return null;
159    }
160}