Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.43% covered (success)
97.43%
265 / 272
88.46% covered (warning)
88.46%
46 / 52
CRAP
0.00% covered (danger)
0.00%
0 / 1
Element
97.43% covered (success)
97.43%
265 / 272
88.46% covered (warning)
88.46%
46 / 52
167
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
 getAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 appendChild
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 findByTag
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 parseLengthOrZero
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 absoluteUnitScale
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 transform
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 transformOrigin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 geometryValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 colorValue
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 overflowValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 textTransform
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isInRenderTree
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 cannotHoldDefinitions
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
13
 displayValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 clipPathValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 vectorEffect
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 maskValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markerStart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markerMid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markerEnd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markerProperty
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 transformBox
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 classList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 inlineStyleText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 fill
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stroke
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fillOpacity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 strokeOpacity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 opacity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fillRule
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 strokeWidth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 strokeLinecap
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 strokeLinejoin
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 strokeMiterlimit
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 strokeDasharray
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
7.01
 strokeDashoffset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 pathLength
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 fontFamily
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
11.02
 fontSize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 fontWeight
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 fontStyle
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 textShadow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 parsePaint
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseClampedFraction
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 presentationOrStyle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isCssWideKeyword
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 styleProperty
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 parseNumberPrefix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parsePoints
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;
6
7use Phpdftk\Svg\Value\Paint;
8use Phpdftk\Svg\Value\Transform;
9
10/**
11 * An SVG element with a tag name, attributes, and a child list. Concrete
12 * subclasses (`Shape\Rect`, `Group`, `SvgDocument`, …) add typed accessors
13 * over the raw attribute strings stored here, so callers never have to
14 * remember whether `cx` is a length or a number.
15 *
16 * Attribute names are case-sensitive per the SVG spec — `viewBox` and
17 * `clipPathUnits` etc. keep their camelCase. The parser passes them
18 * through verbatim.
19 */
20abstract class Element extends Node
21{
22    /** @var array<string, string> */
23    public array $attributes = [];
24
25    /** @var list<Node> */
26    public array $children = [];
27
28    public function __construct(public readonly string $localName) {}
29
30    public function getAttribute(string $name): ?string
31    {
32        return $this->attributes[$name] ?? null;
33    }
34
35    public function hasAttribute(string $name): bool
36    {
37        return isset($this->attributes[$name]);
38    }
39
40    public function setAttribute(string $name, string $value): void
41    {
42        $this->attributes[$name] = $value;
43    }
44
45    public function appendChild(Node $node): void
46    {
47        $node->parent = $this;
48        $this->children[] = $node;
49    }
50
51    /** @return list<Element> elements with the given local name in document order. */
52    public function findByTag(string $localName): array
53    {
54        $out = [];
55        foreach ($this->children as $child) {
56            if ($child instanceof Element) {
57                if ($child->localName === $localName) {
58                    $out[] = $child;
59                }
60                foreach ($child->findByTag($localName) as $nested) {
61                    $out[] = $nested;
62                }
63            }
64        }
65        return $out;
66    }
67
68    /**
69     * Read an SVG length attribute as a float in user units, falling back
70     * to 0 when the attribute is absent OR doesn't start with a parseable
71     * number — per SVG 2's "invalid value → initial value" rule for
72     * `<length>`.
73     *
74     * Absolute unit suffixes are CONVERTED (CSS Values 4 §6.2, 96dpi):
75     * `2cm` is 75.59 user units, not 2. Relative units (`%`, `em`, `ex`,
76     * `ch`, `rem`) need viewport or font context this accessor doesn't
77     * have, so their numeric part is returned unscaled as before.
78     */
79    protected function parseLengthOrZero(string $attr): float
80    {
81        $raw = $this->attributes[$attr] ?? null;
82        if ($raw === null) {
83            return 0.0;
84        }
85        if (preg_match(
86            '/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([a-zA-Z]*)/',
87            $raw,
88            $m,
89        ) !== 1) {
90            return 0.0;
91        }
92        return (float) $m[1] * self::absoluteUnitScale($m[2]);
93    }
94
95    /**
96     * Scale factor from an absolute CSS unit to user units (px), per CSS
97     * Values 4 §6.2. Unknown, empty, and relative units scale by 1.
98     */
99    public static function absoluteUnitScale(string $unit): float
100    {
101        return match (strtolower($unit)) {
102            'cm' => 96.0 / 2.54,
103            'mm' => 96.0 / 25.4,
104            'q' => 96.0 / 101.6,
105            'in' => 96.0,
106            'pt' => 96.0 / 72.0,
107            'pc' => 16.0,
108            default => 1.0,
109        };
110    }
111
112    /**
113     * Parse the `transform` attribute per SVG 2 §8.4, or the CSS
114     * `transform` property of the same name when no attribute is
115     * present. Returns null when both are absent, empty, `none`, or
116     * malformed — SVG 2's "invalid → ignored" semantics. Callers that
117     * want a hard error should call `Transform::parse()` directly.
118     */
119    public function transform(): ?Transform
120    {
121        $raw = $this->presentationOrStyle('transform');
122        if ($raw === null || trim($raw) === '' || strtolower(trim($raw)) === 'none') {
123            return null;
124        }
125        try {
126            return Transform::parse($raw);
127        } catch (\InvalidArgumentException) {
128            return null;
129        }
130    }
131
132    /**
133     * The raw `transform-origin` presentation attribute (or the `style`
134     * declaration of the same name) per CSS Transforms 1 §6. Null when
135     * absent or empty — SVG elements have no associated CSS layout box,
136     * so their initial `transform-origin` is `0 0` rather than the
137     * `50% 50%` that applies to CSS boxes.
138     */
139    public function transformOrigin(): ?string
140    {
141        $raw = $this->presentationOrStyle('transform-origin');
142        if ($raw === null || trim($raw) === '') {
143            return null;
144        }
145        return $raw;
146    }
147
148    /**
149     * The `clip-path` presentation attribute or CSS property
150     * (CSS Masking 1 §4). Raw string — the translator resolves the
151     * `url(#id)` reference against the document.
152     */
153    /**
154     * SVG 2 §10.1 — the geometry properties (`x`, `y`, `width`,
155     * `height`, `cx`, `cy`, `r`, `rx`, `ry`) are BOTH presentation
156     * attributes and CSS properties, so a value can arrive as an
157     * attribute or through the cascade. `SvgCascadeProjector` projects
158     * cascaded geometry into `style`, so reading through
159     * `presentationOrStyle` here is what lets a `<style>` block size a
160     * shape that carries no geometry attributes at all.
161     */
162    public function geometryValue(string $property): ?string
163    {
164        return $this->presentationOrStyle($property);
165    }
166
167    /**
168     * The `color` property (CSS Color 4 §3.1), raw. Inherited, so the
169     * cascade projection is what normally puts it here; SVG uses it
170     * only as the referent of `currentColor`. Null when nothing
171     * declared it, which the caller reads as the initial black.
172     */
173    public function colorValue(): ?string
174    {
175        $raw = $this->presentationOrStyle('color');
176        if ($raw === null || trim($raw) === '') {
177            return null;
178        }
179        return trim($raw);
180    }
181
182    /**
183     * The `overflow` property, lowercased, or null when nothing
184     * declared it.
185     *
186     * Null is NOT the same as `visible` here: SVG 2 §8.2 has the UA
187     * stylesheet set `overflow: hidden` on viewport elements, so the
188     * caller's no-value branch clips. Returning CSS's own `visible`
189     * initial would stop every nested `<svg>` clipping.
190     */
191    public function overflowValue(): ?string
192    {
193        $raw = $this->presentationOrStyle('overflow');
194        if ($raw === null || trim($raw) === '') {
195            return null;
196        }
197        return strtolower(trim($raw));
198    }
199
200    /**
201     * The `text-transform` property (CSS Text 3 §2.1), lowercased, or
202     * null when nothing declared it. SVG 2 §11 delegates text styling
203     * to CSS wholesale, so it applies to `<text>` exactly as it does
204     * to an HTML box.
205     */
206    public function textTransform(): ?string
207    {
208        $raw = $this->presentationOrStyle('text-transform');
209        if ($raw === null || trim($raw) === '') {
210            return null;
211        }
212        return strtolower(trim($raw));
213    }
214
215    /**
216     * Whether this element sits in the document's RENDER TREE — that
217     * is, whether every ancestor is an element that may contain it.
218     *
219     * SVG 2 §13.4 (and the "stop notes" it links) — a paint server
220     * defined somewhere it is not allowed, such as a
221     * `<linearGradient>` inside a `<text>`, is not part of the render
222     * tree, and a reference to it does not resolve. The referencing
223     * element then behaves as if the server were missing, which for
224     * `<paint>` means its fallback applies.
225     *
226     * Implemented as a DENYLIST of ancestors that cannot hold
227     * definitions — the shapes, the text-content elements,
228     * `<foreignObject>`, `<image>`, `<use>` and any foreign-namespace
229     * element. An allowlist would have to be revisited every time the
230     * object model grows a new container, and would silently start
231     * rejecting valid documents when it wasn't.
232     */
233    public function isInRenderTree(): bool
234    {
235        for ($node = $this->parent; $node !== null; $node = $node->parent) {
236            if (self::cannotHoldDefinitions($node)) {
237                return false;
238            }
239        }
240        return true;
241    }
242
243    private static function cannotHoldDefinitions(Element $element): bool
244    {
245        return $element instanceof Shape\Rect
246            || $element instanceof Shape\Circle
247            || $element instanceof Shape\Ellipse
248            || $element instanceof Shape\Line
249            || $element instanceof Shape\Polyline
250            || $element instanceof Shape\Polygon
251            || $element instanceof Path
252            || $element instanceof Text\TextElement
253            || $element instanceof Text\Tspan
254            || $element instanceof ForeignObject
255            || $element instanceof ForeignElement
256            || $element instanceof Image
257            || $element instanceof Use_;
258    }
259
260    /**
261     * The `display` property, lowercased, or null when nothing
262     * declared it.
263     *
264     * SVG 2 §8.9 — `display: none` removes the element AND its
265     * descendants from the rendering tree. Every other value renders,
266     * so callers only ever need the `none` test.
267     */
268    public function displayValue(): ?string
269    {
270        $raw = $this->presentationOrStyle('display');
271        if ($raw === null || trim($raw) === '') {
272            return null;
273        }
274        return strtolower(trim($raw));
275    }
276
277    public function clipPathValue(): ?string
278    {
279        return $this->presentationOrStyle('clip-path');
280    }
281
282    /**
283     * SVG 2 §13.6 — `vector-effect`, lowercased. Null when absent or
284     * unrecognised, which is the same as the initial `none`.
285     *
286     * NOT inherited: a `<g vector-effect="non-scaling-stroke">` does
287     * not hand the effect to the shapes inside it.
288     */
289    public function vectorEffect(): ?string
290    {
291        $raw = $this->presentationOrStyle('vector-effect');
292        if ($raw === null) {
293            return null;
294        }
295        $value = strtolower(trim($raw));
296        return match ($value) {
297            'none',
298            'non-scaling-stroke',
299            'non-scaling-size',
300            'non-rotation',
301            'fixed-position' => $value,
302            default => null,
303        };
304    }
305
306    /**
307     * The `mask` presentation attribute or CSS property
308     * (CSS Masking 1 §7). Raw string, resolved by the translator.
309     */
310    public function maskValue(): ?string
311    {
312        return $this->presentationOrStyle('mask');
313    }
314
315    /**
316     * SVG 2 §11.6.2 — `marker-start` / `marker-mid` / `marker-end`,
317     * raw (`none`, or a `url(#id)` the translator resolves).
318     *
319     * Each falls back to the `marker` shorthand, which sets all three.
320     * The cascade registers `marker` as an inherited property of its
321     * own rather than expanding it into longhands, so an ancestor's
322     * `marker: url(#m)` reaches here through inheritance exactly as
323     * the expansion would have. The one case the two models disagree
324     * on is a longhand INHERITED from an ancestor competing with a
325     * `marker` shorthand declared on this element, where real CSS
326     * would let the shorthand win; the longhand wins here.
327     */
328    public function markerStart(): ?string
329    {
330        return $this->markerProperty('marker-start');
331    }
332
333    public function markerMid(): ?string
334    {
335        return $this->markerProperty('marker-mid');
336    }
337
338    public function markerEnd(): ?string
339    {
340        return $this->markerProperty('marker-end');
341    }
342
343    private function markerProperty(string $property): ?string
344    {
345        $raw = $this->presentationOrStyle($property)
346            ?? $this->presentationOrStyle('marker');
347        if ($raw === null || trim($raw) === '') {
348            return null;
349        }
350        return trim($raw);
351    }
352
353    /**
354     * The `transform-box` presentation attribute / CSS property
355     * (CSS Transforms 1 §7), lowercased. Null when absent, which is
356     * NOT the same as the initial `view-box`: this renderer keeps a
357     * separate no-value path so SVG's historical `transform-origin`
358     * reference-box behaviour stays intact when nothing asks for a
359     * specific box.
360     */
361    public function transformBox(): ?string
362    {
363        $raw = $this->presentationOrStyle('transform-box');
364        if ($raw === null || trim($raw) === '') {
365            return null;
366        }
367        return strtolower(trim($raw));
368    }
369
370    /**
371     * Class names from the `class` attribute, whitespace-separated per
372     * HTML / CSS conventions. Empty list when absent or empty.
373     *
374     * @return list<string>
375     */
376    public function classList(): array
377    {
378        $raw = $this->attributes['class'] ?? null;
379        if ($raw === null || trim($raw) === '') {
380            return [];
381        }
382        $parts = preg_split('/\s+/', trim($raw)) ?: [];
383        return array_values(array_filter($parts, static fn(string $c): bool => $c !== ''));
384    }
385
386    /**
387     * Raw `style=""` attribute text, or null if absent. Parsing into
388     * typed declarations happens in `Css\CssBridge`; sanitiser callers
389     * can read the raw text without pulling in the CSS dependency.
390     */
391    public function inlineStyleText(): ?string
392    {
393        $raw = $this->attributes['style'] ?? null;
394        if ($raw === null) {
395            return null;
396        }
397        return trim($raw) === '' ? null : $raw;
398    }
399
400    /**
401     * `fill` presentation attribute per SVG 2 §13.2. Null when absent or
402     * malformed; the painter then applies inherited or initial values.
403     */
404    public function fill(): ?Paint
405    {
406        return $this->parsePaint('fill');
407    }
408
409    /**
410     * `stroke` presentation attribute per SVG 2 §13.2. Default is `none`
411     * per spec; absent here returns null so the painter can distinguish
412     * "not set on this element" from "explicitly none".
413     */
414    public function stroke(): ?Paint
415    {
416        return $this->parsePaint('stroke');
417    }
418
419    /** `fill-opacity` — clamped to [0, 1] per SVG 2 §13.2. */
420    public function fillOpacity(): ?float
421    {
422        return $this->parseClampedFraction('fill-opacity');
423    }
424
425    /** `stroke-opacity` — clamped to [0, 1]. */
426    public function strokeOpacity(): ?float
427    {
428        return $this->parseClampedFraction('stroke-opacity');
429    }
430
431    /** Group `opacity` — clamped to [0, 1]. */
432    public function opacity(): ?float
433    {
434        return $this->parseClampedFraction('opacity');
435    }
436
437    /**
438     * `fill-rule` — one of `nonzero` or `evenodd`. Returns null for absent
439     * or unrecognised values so the painter applies the initial value
440     * (`nonzero`) rather than guessing.
441     */
442    public function fillRule(): ?string
443    {
444        $raw = $this->presentationOrStyle('fill-rule');
445        if ($raw === null) {
446            return null;
447        }
448        $value = strtolower(trim($raw));
449        return match ($value) {
450            'nonzero', 'evenodd' => $value,
451            default => null,
452        };
453    }
454
455    /** `stroke-width` — non-negative length, default `1`. Negative → null. */
456    public function strokeWidth(): ?float
457    {
458        $raw = $this->presentationOrStyle('stroke-width');
459        if ($raw === null) {
460            return null;
461        }
462        $value = $this->parseNumberPrefix($raw);
463        if ($value === null || $value < 0.0) {
464            return null;
465        }
466        return $value;
467    }
468
469    /** `stroke-linecap` — one of `butt`, `round`, `square`. */
470    public function strokeLinecap(): ?string
471    {
472        $raw = $this->presentationOrStyle('stroke-linecap');
473        if ($raw === null) {
474            return null;
475        }
476        $value = strtolower(trim($raw));
477        return match ($value) {
478            'butt', 'round', 'square' => $value,
479            default => null,
480        };
481    }
482
483    /**
484     * `stroke-linejoin` — `miter`, `round`, `bevel`, plus SVG 2's
485     * `miter-clip` and `arcs`.
486     */
487    public function strokeLinejoin(): ?string
488    {
489        $raw = $this->presentationOrStyle('stroke-linejoin');
490        if ($raw === null) {
491            return null;
492        }
493        $value = strtolower(trim($raw));
494        return match ($value) {
495            'miter', 'round', 'bevel', 'miter-clip', 'arcs' => $value,
496            default => null,
497        };
498    }
499
500    /** `stroke-miterlimit` — must be ≥ 1 per SVG 2 §13.4; otherwise null. */
501    public function strokeMiterlimit(): ?float
502    {
503        $raw = $this->presentationOrStyle('stroke-miterlimit');
504        if ($raw === null) {
505            return null;
506        }
507        $value = $this->parseNumberPrefix($raw);
508        if ($value === null || $value < 1.0) {
509            return null;
510        }
511        return $value;
512    }
513
514    /**
515     * `stroke-dasharray` — `none` or a list of lengths. Returns an empty
516     * list for both null and `none` so the painter has a single
517     * "no dashes" branch.
518     *
519     * @return list<float>
520     */
521    public function strokeDasharray(): array
522    {
523        $raw = $this->presentationOrStyle('stroke-dasharray');
524        if ($raw === null) {
525            return [];
526        }
527        $trimmed = trim($raw);
528        if ($trimmed === '' || strcasecmp($trimmed, 'none') === 0) {
529            return [];
530        }
531        if (preg_match_all('/[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/', $trimmed, $m) === false) {
532            return [];
533        }
534        $out = [];
535        foreach ($m[0] as $token) {
536            $n = (float) $token;
537            if ($n < 0.0) {
538                // SVG 2 §13.4: a single negative value invalidates the
539                // entire list. Painter falls back to no dashes.
540                return [];
541            }
542            $out[] = $n;
543        }
544        return $out;
545    }
546
547    /** `stroke-dashoffset` — any number; default `0`. */
548    public function strokeDashoffset(): ?float
549    {
550        $raw = $this->presentationOrStyle('stroke-dashoffset');
551        if ($raw === null) {
552            return null;
553        }
554        return $this->parseNumberPrefix($raw);
555    }
556
557    /**
558     * SVG 2 §9.6 — `pathLength`, the author's own declaration of how
559     * long this element's path is. Every distance-ALONG-the-path
560     * quantity (`stroke-dasharray`, `stroke-dashoffset`, a
561     * `<textPath>` `startOffset`) is then authored in those units and
562     * scaled by `geometricLength / pathLength` at paint time.
563     *
564     * Precedence: the CSS `path-length` property beats the
565     * `pathLength` presentation attribute, per SVG 2 §6.7 — a
566     * presentation attribute is an author-origin declaration of the
567     * lowest possible specificity, so any rule that names the element
568     * out-ranks it. The cascade reaches us through the projected
569     * `style` attribute, which is why the CSS spelling is consulted
570     * first.
571     *
572     * Returns null when absent or invalid. A NEGATIVE value is an
573     * error: SVG 2's "invalid value → the declaration is ignored" rule
574     * leaves the path unscaled rather than collapsing it. ZERO is
575     * explicitly valid and means a scaling factor of infinity, so it
576     * is returned as `0.0` for the caller to interpret — not folded
577     * into the null case.
578     */
579    public function pathLength(): ?float
580    {
581        $raw = $this->presentationOrStyle('path-length')
582            ?? $this->attributes['pathLength']
583            ?? null;
584        if ($raw === null) {
585            return null;
586        }
587        $value = $this->parseNumberPrefix($raw);
588        if ($value === null || $value < 0.0) {
589            return null;
590        }
591        return $value;
592    }
593
594    /**
595     * `font-family` — CSS Fonts 4 §3.2. A comma-separated prioritised list
596     * of family names; each entry is trimmed and surrounding single or
597     * double quotes are stripped (CSS reserves quotes for names containing
598     * whitespace or reserved words). An absent attribute → empty list, so
599     * the painter applies the inherited or default stack.
600     *
601     * @return list<string>
602     */
603    public function fontFamily(): array
604    {
605        $raw = $this->presentationOrStyle('font-family');
606        if ($raw === null || trim($raw) === '') {
607            return [];
608        }
609        $parts = explode(',', $raw);
610        $out = [];
611        foreach ($parts as $part) {
612            $name = trim($part);
613            if ($name === '') {
614                continue;
615            }
616            if (strlen($name) >= 2) {
617                $first = $name[0];
618                $last = $name[strlen($name) - 1];
619                if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
620                    $name = substr($name, 1, -1);
621                }
622            }
623            if ($name !== '') {
624                $out[] = $name;
625            }
626        }
627        return $out;
628    }
629
630    /**
631     * `font-size` — CSS Fonts 4 §3.5. Length value; absolute keywords
632     * (`small`, `large`, …) and percentages are deferred to the cascade
633     * work in 3J.
634     */
635    public function fontSize(): ?float
636    {
637        $raw = $this->presentationOrStyle('font-size');
638        if ($raw === null) {
639            return null;
640        }
641        $value = $this->parseNumberPrefix($raw);
642        if ($value === null || $value < 0.0) {
643            return null;
644        }
645        return $value;
646    }
647
648    /**
649     * `font-weight` — returned as a raw string (`normal`, `bold`,
650     * `bolder`, `lighter`, or a numeric weight like `400`). The painter
651     * normalises to the OpenType weight axis; the parser stays neutral.
652     */
653    public function fontWeight(): ?string
654    {
655        $raw = $this->presentationOrStyle('font-weight');
656        if ($raw === null) {
657            return null;
658        }
659        $value = trim($raw);
660        return $value === '' ? null : $value;
661    }
662
663    /**
664     * `font-style` — `normal`, `italic`, or `oblique`. Returns null for
665     * absent and unrecognised values so the painter applies the inherited
666     * or initial style.
667     */
668    public function fontStyle(): ?string
669    {
670        $raw = $this->presentationOrStyle('font-style');
671        if ($raw === null) {
672            return null;
673        }
674        $value = strtolower(trim($raw));
675        return match ($value) {
676            'normal', 'italic', 'oblique' => $value,
677            default => null,
678        };
679    }
680
681    /**
682     * `text-shadow` per CSS Text Decoration 4 §6 — a raw, trimmed CSS
683     * value string. Returns null for absent, empty, or `none`. The
684     * painter is responsible for parsing the comma-separated layer
685     * list (`<offset-x> <offset-y> [<blur-radius>] [<color>]`); we
686     * keep the parser at the painter level because that's where the
687     * Color / Length consumers live.
688     */
689    public function textShadow(): ?string
690    {
691        $raw = $this->presentationOrStyle('text-shadow');
692        if ($raw === null) {
693            return null;
694        }
695        $value = trim($raw);
696        if ($value === '' || strcasecmp($value, 'none') === 0) {
697            return null;
698        }
699        return $value;
700    }
701
702    private function parsePaint(string $attr): ?Paint
703    {
704        $raw = $this->presentationOrStyle($attr);
705        if ($raw === null) {
706            return null;
707        }
708        return Paint::parse($raw);
709    }
710
711    private function parseClampedFraction(string $attr): ?float
712    {
713        $raw = $this->presentationOrStyle($attr);
714        if ($raw === null) {
715            return null;
716        }
717        $value = $this->parseNumberPrefix($raw);
718        if ($value === null) {
719            return null;
720        }
721        return max(0.0, min(1.0, $value));
722    }
723
724    /**
725     * Resolve a presentation attribute value, falling back to the
726     * `style` attribute's CSS declaration of the same property when
727     * the presentation form is absent. SVG 2 §6.1 defines this
728     * equivalence: `fill="green"` and `style="fill: green"` are
729     * interchangeable. The wpt-harness DOM settler projects
730     * computed CSS values into inline `style` so external rules
731     * reach this hook too.
732     *
733     * Block comments (the settler emits a `/* phpdftk-settle-dom *\/`
734     * marker) are stripped before tokenising so the prefix doesn't
735     * fool the property-name match.
736     */
737    protected function presentationOrStyle(string $property): ?string
738    {
739        $attribute = $this->attributes[$property] ?? null;
740        // CSS Cascade 5 §7.3 — `inherit` / `initial` / `unset` /
741        // `revert` are not values, they are instructions to the
742        // cascade, and no accessor here can do anything with the
743        // literal string. `fill="inherit"` used to reach the painter
744        // verbatim, fail to parse as a paint, and fall through to the
745        // black default. Ignoring the attribute lets the projected
746        // cascade result — which HAS resolved the keyword — through
747        // instead.
748        if ($attribute !== null && !self::isCssWideKeyword($attribute)) {
749            return $attribute;
750        }
751        return $this->styleProperty($property);
752    }
753
754    /** CSS Cascade 5 §7.3 — the CSS-wide keywords. */
755    public static function isCssWideKeyword(string $value): bool
756    {
757        return in_array(
758            strtolower(trim($value)),
759            ['inherit', 'initial', 'unset', 'revert', 'revert-layer'],
760            true,
761        );
762    }
763
764    /**
765     * One declaration from the element's `style` attribute, ignoring
766     * the presentation attribute of the same name.
767     *
768     * Almost every SVG property wants
769     * {@see presentationOrStyle()} instead. This exists for the
770     * handful where the CSS property and the presentation attribute
771     * share a NAME and the CSS side has to win — `d` (SVG 2 §9.3) is
772     * the one that matters, since §6.7 puts a presentation attribute
773     * at specificity 0 and any rule naming the element out-ranks it.
774     *
775     * The first matching declaration wins, which is what makes an
776     * author's own inline `style` beat the cascade projection the
777     * renderer appends after it.
778     */
779    protected function styleProperty(string $property): ?string
780    {
781        $style = $this->attributes['style'] ?? null;
782        if ($style === null) {
783            return null;
784        }
785        $cleaned = preg_replace('/\/\*.*?\*\//s', ' ', $style) ?? $style;
786        $target = strtolower($property);
787        foreach (explode(';', $cleaned) as $decl) {
788            $colon = strpos($decl, ':');
789            if ($colon === false) {
790                continue;
791            }
792            $name = strtolower(trim(substr($decl, 0, $colon)));
793            if ($name !== $target) {
794                continue;
795            }
796            $value = trim(substr($decl, $colon + 1));
797            return $value === '' ? null : $value;
798        }
799        return null;
800    }
801
802    private function parseNumberPrefix(string $raw): ?float
803    {
804        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/', $raw, $m) !== 1) {
805            return null;
806        }
807        return (float) $m[1];
808    }
809
810    /**
811     * Parse the `points` attribute grammar (SVG 2 §9.7) — a list of `(x, y)`
812     * coordinate pairs separated by comma-or-whitespace. Per spec, a malformed
813     * tail (odd count, non-numeric value) terminates parsing; pairs read
814     * before the error are kept.
815     *
816     * @return list<array{float, float}>
817     */
818    protected static function parsePoints(?string $raw): array
819    {
820        if ($raw === null || trim($raw) === '') {
821            return [];
822        }
823        $numberPattern = '[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?';
824        if (preg_match_all('/' . $numberPattern . '/', $raw, $m) === false) {
825            return [];
826        }
827        $values = $m[0];
828        if ($values === []) {
829            return [];
830        }
831        $pairs = [];
832        $count = (int) (count($values) / 2) * 2;
833        for ($i = 0; $i < $count; $i += 2) {
834            $pairs[] = [(float) $values[$i], (float) $values[$i + 1]];
835        }
836        return $pairs;
837    }
838}