Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.50% covered (warning)
87.50%
28 / 32
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
CssBridge
87.50% covered (warning)
87.50%
28 / 32
50.00% covered (danger)
50.00%
2 / 4
12.28
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
 computeStyle
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 collectAuthorSheets
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 presentationAttributeSheet
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Svg\Css;
6
7use Phpdftk\Css\Cascade\Cascade;
8use Phpdftk\Css\Cascade\CascadedValues;
9use Phpdftk\Css\Cascade\PropertyRegistry;
10use Phpdftk\Css\Parser as CssParser;
11use Phpdftk\Css\Selector\SelectorParser;
12use Phpdftk\Css\Sheet\Declaration;
13use Phpdftk\Css\Sheet\Origin;
14use Phpdftk\Css\Sheet\StyleRule;
15use Phpdftk\Css\Sheet\Stylesheet;
16use Phpdftk\Css\ValueParser;
17use Phpdftk\Svg\Element;
18use Phpdftk\Svg\StyleElement;
19use Phpdftk\Svg\SvgDocument;
20
21/**
22 * Bridge between an SVG element tree and the `phpdftk/css` cascade.
23 *
24 * `computeStyle()` resolves the effective CSS values for one element by
25 * layering, in increasing precedence:
26 *
27 *  1. SVG presentation attributes — author origin, specificity 0,0,0,0
28 *     per SVG 2 §6.7. Synthesised into a one-rule stylesheet with a `*`
29 *     selector so it matches but doesn't out-rank anything.
30 *  2. `<style>` element CSS from the document — author origin, normal
31 *     selector specificity per Selectors 4.
32 *  3. The element's inline `style=""` attribute — author origin,
33 *     specificity `(1024, 0, 0)` per CSS Cascade 5 §6.4.4. The `Cascade`
34 *     reads this directly from `MatchableElement::getAttributeValue`
35 *     so we don't need a manual overlay.
36 *
37 * The bridge depends on `phpdftk/css`. It lives in its own namespace so
38 * loading `Phpdftk\Svg\Element` doesn't transitively pull the CSS
39 * dependency — callers who don't need the cascade keep working without
40 * `phpdftk/css` installed.
41 */
42final class CssBridge
43{
44    /**
45     * The subset of SVG presentation attributes we synthesise into CSS
46     * declarations. Matches the typed accessors landed in 3E and 3F; the
47     * painter is free to extend the list when it adds support for more.
48     *
49     * @var list<string>
50     */
51    private const array PRESENTATION_ATTRIBUTES = [
52        'fill', 'stroke',
53        'fill-opacity', 'stroke-opacity', 'opacity',
54        'fill-rule',
55        'stroke-width', 'stroke-linecap', 'stroke-linejoin',
56        'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset',
57        'font-family', 'font-size', 'font-weight', 'font-style',
58        'color', 'display', 'visibility', 'overflow',
59        // SVG 2 §11.6.2 — the marker family. Inherited, so a
60        // presentation attribute on a `<g>` has to enter the cascade
61        // to reach the shapes underneath it.
62        'marker', 'marker-start', 'marker-mid', 'marker-end',
63        // SVG 2 §13.6 — `vector-effect`.
64        'vector-effect',
65        // SVG 2 §10.1 geometry properties. Present so the cascade can
66        // do what only the cascade can: substitute `var()` out of a
67        // presentation attribute (§6.7 makes one an ordinary CSS
68        // declaration at specificity 0). The PAINTER still reads these
69        // off the attribute — see SvgCascadeProjector, which writes the
70        // substituted value back — so adding them here does not change
71        // which value wins for anything that has no `var()` in it.
72        'x', 'y', 'width', 'height', 'cx', 'cy', 'r', 'rx', 'ry',
73    ];
74
75    /**
76     * The cascade the bridge resolves through.
77     *
78     * NOT `new Cascade()`: that builds a BARE `PropertyRegistry`, which
79     * is empty. The registry is what tells `Cascade::applyInheritance`
80     * which properties inherit and what each one's initial value is, so
81     * a cascade over an empty registry resolves every element in
82     * isolation and NOTHING inherits — a standalone
83     * `<svg><g fill="green"><rect/></g></svg>` gave its `<rect>` no
84     * `fill` at all and the painter fell back to black.
85     *
86     * `PropertyRegistry::default()` is a static call, which PHP does not
87     * accept as a parameter default, so the fallback lives in the body.
88     */
89    private readonly Cascade $cascade;
90
91    public function __construct(
92        ?Cascade $cascade = null,
93        private readonly CssParser $cssParser = new CssParser(),
94        private readonly ValueParser $valueParser = new ValueParser(),
95    ) {
96        $this->cascade = $cascade ?? new Cascade(PropertyRegistry::default());
97    }
98
99    /**
100     * Compute the cascade for `$element`. `$parentValues` is the
101     * already-computed result for the element's parent — pass `null` for
102     * the root.
103     */
104    public function computeStyle(
105        Element $element,
106        SvgDocument $document,
107        ?CascadedValues $parentValues = null,
108    ): CascadedValues {
109        $sheets = [
110            $this->presentationAttributeSheet($element),
111            ...$this->collectAuthorSheets($document),
112        ];
113        return $this->cascade->computeFor(
114            $sheets,
115            new MatchableSvgElement($element),
116            $parentValues,
117        );
118    }
119
120    /**
121     * Walk the document and parse every `<style>` element's body into a
122     * Stylesheet. Returned in document order so later sheets shadow
123     * earlier ones on specificity ties, matching how browsers resolve
124     * multiple `<style>` blocks.
125     *
126     * @return list<Stylesheet>
127     */
128    public function collectAuthorSheets(SvgDocument $document): array
129    {
130        $sheets = [];
131        foreach ($document->findByTag('style') as $node) {
132            if (!$node instanceof StyleElement) {
133                continue;
134            }
135            $css = $node->cssText();
136            if (trim($css) === '') {
137                continue;
138            }
139            $sheets[] = $this->cssParser->parseStylesheet($css, Origin::Author);
140        }
141        return $sheets;
142    }
143
144    /**
145     * Build a single-rule Stylesheet whose declarations are the
146     * presentation attributes carried by `$element`. The rule's selector
147     * is `*` so it matches the element (and would match every other
148     * element too, but `computeStyle` only ever passes this sheet for
149     * one specific element).
150     */
151    public function presentationAttributeSheet(Element $element): Stylesheet
152    {
153        $declarations = [];
154        foreach (self::PRESENTATION_ATTRIBUTES as $name) {
155            $raw = $element->getAttribute($name);
156            if ($raw === null || trim($raw) === '') {
157                continue;
158            }
159            try {
160                $value = $this->valueParser->parseFromString($raw);
161            } catch (\Throwable) {
162                continue;
163            }
164            $declarations[] = new Declaration($name, $value, important: false);
165        }
166        if ($declarations === []) {
167            return new Stylesheet([], Origin::Author);
168        }
169        $rule = new StyleRule(SelectorParser::parse('*'), $declarations);
170        return new Stylesheet([$rule], Origin::Author);
171    }
172}