Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.15% covered (success)
96.15%
175 / 182
81.25% covered (warning)
81.25%
26 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Element
96.15% covered (success)
96.15%
175 / 182
81.25% covered (warning)
81.25%
26 / 32
104
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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 transform
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 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
 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
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
7.01
 parseNumberPrefix
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 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, falling back to 0 when the
70     * attribute is absent OR doesn't start with a parseable number — per
71     * SVG 2's "invalid value → initial value" rule for `<length>`. Unit
72     * suffixes (`px`, `pt`, `mm`, `%`, …) are tolerated and ignored.
73     */
74    protected function parseLengthOrZero(string $attr): float
75    {
76        $raw = $this->attributes[$attr] ?? null;
77        if ($raw === null) {
78            return 0.0;
79        }
80        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/', $raw, $m) !== 1) {
81            return 0.0;
82        }
83        return (float) $m[1];
84    }
85
86    /**
87     * Parse the `transform` attribute per SVG 2 §8.4. Returns null when the
88     * attribute is absent, empty, or malformed — SVG 2's "invalid →
89     * ignored" semantics. Callers that want a hard error should call
90     * `Transform::parse()` directly.
91     */
92    public function transform(): ?Transform
93    {
94        $raw = $this->attributes['transform'] ?? null;
95        if ($raw === null || trim($raw) === '') {
96            return null;
97        }
98        try {
99            return Transform::parse($raw);
100        } catch (\InvalidArgumentException) {
101            return null;
102        }
103    }
104
105    /**
106     * Class names from the `class` attribute, whitespace-separated per
107     * HTML / CSS conventions. Empty list when absent or empty.
108     *
109     * @return list<string>
110     */
111    public function classList(): array
112    {
113        $raw = $this->attributes['class'] ?? null;
114        if ($raw === null || trim($raw) === '') {
115            return [];
116        }
117        $parts = preg_split('/\s+/', trim($raw)) ?: [];
118        return array_values(array_filter($parts, static fn(string $c): bool => $c !== ''));
119    }
120
121    /**
122     * Raw `style=""` attribute text, or null if absent. Parsing into
123     * typed declarations happens in `Css\CssBridge`; sanitiser callers
124     * can read the raw text without pulling in the CSS dependency.
125     */
126    public function inlineStyleText(): ?string
127    {
128        $raw = $this->attributes['style'] ?? null;
129        if ($raw === null) {
130            return null;
131        }
132        return trim($raw) === '' ? null : $raw;
133    }
134
135    /**
136     * `fill` presentation attribute per SVG 2 §13.2. Null when absent or
137     * malformed; the painter then applies inherited or initial values.
138     */
139    public function fill(): ?Paint
140    {
141        return $this->parsePaint('fill');
142    }
143
144    /**
145     * `stroke` presentation attribute per SVG 2 §13.2. Default is `none`
146     * per spec; absent here returns null so the painter can distinguish
147     * "not set on this element" from "explicitly none".
148     */
149    public function stroke(): ?Paint
150    {
151        return $this->parsePaint('stroke');
152    }
153
154    /** `fill-opacity` — clamped to [0, 1] per SVG 2 §13.2. */
155    public function fillOpacity(): ?float
156    {
157        return $this->parseClampedFraction('fill-opacity');
158    }
159
160    /** `stroke-opacity` — clamped to [0, 1]. */
161    public function strokeOpacity(): ?float
162    {
163        return $this->parseClampedFraction('stroke-opacity');
164    }
165
166    /** Group `opacity` — clamped to [0, 1]. */
167    public function opacity(): ?float
168    {
169        return $this->parseClampedFraction('opacity');
170    }
171
172    /**
173     * `fill-rule` — one of `nonzero` or `evenodd`. Returns null for absent
174     * or unrecognised values so the painter applies the initial value
175     * (`nonzero`) rather than guessing.
176     */
177    public function fillRule(): ?string
178    {
179        $raw = $this->presentationOrStyle('fill-rule');
180        if ($raw === null) {
181            return null;
182        }
183        $value = strtolower(trim($raw));
184        return match ($value) {
185            'nonzero', 'evenodd' => $value,
186            default => null,
187        };
188    }
189
190    /** `stroke-width` — non-negative length, default `1`. Negative → null. */
191    public function strokeWidth(): ?float
192    {
193        $raw = $this->presentationOrStyle('stroke-width');
194        if ($raw === null) {
195            return null;
196        }
197        $value = $this->parseNumberPrefix($raw);
198        if ($value === null || $value < 0.0) {
199            return null;
200        }
201        return $value;
202    }
203
204    /** `stroke-linecap` — one of `butt`, `round`, `square`. */
205    public function strokeLinecap(): ?string
206    {
207        $raw = $this->presentationOrStyle('stroke-linecap');
208        if ($raw === null) {
209            return null;
210        }
211        $value = strtolower(trim($raw));
212        return match ($value) {
213            'butt', 'round', 'square' => $value,
214            default => null,
215        };
216    }
217
218    /**
219     * `stroke-linejoin` — `miter`, `round`, `bevel`, plus SVG 2's
220     * `miter-clip` and `arcs`.
221     */
222    public function strokeLinejoin(): ?string
223    {
224        $raw = $this->presentationOrStyle('stroke-linejoin');
225        if ($raw === null) {
226            return null;
227        }
228        $value = strtolower(trim($raw));
229        return match ($value) {
230            'miter', 'round', 'bevel', 'miter-clip', 'arcs' => $value,
231            default => null,
232        };
233    }
234
235    /** `stroke-miterlimit` — must be ≥ 1 per SVG 2 §13.4; otherwise null. */
236    public function strokeMiterlimit(): ?float
237    {
238        $raw = $this->presentationOrStyle('stroke-miterlimit');
239        if ($raw === null) {
240            return null;
241        }
242        $value = $this->parseNumberPrefix($raw);
243        if ($value === null || $value < 1.0) {
244            return null;
245        }
246        return $value;
247    }
248
249    /**
250     * `stroke-dasharray` — `none` or a list of lengths. Returns an empty
251     * list for both null and `none` so the painter has a single
252     * "no dashes" branch.
253     *
254     * @return list<float>
255     */
256    public function strokeDasharray(): array
257    {
258        $raw = $this->presentationOrStyle('stroke-dasharray');
259        if ($raw === null) {
260            return [];
261        }
262        $trimmed = trim($raw);
263        if ($trimmed === '' || strcasecmp($trimmed, 'none') === 0) {
264            return [];
265        }
266        if (preg_match_all('/[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/', $trimmed, $m) === false) {
267            return [];
268        }
269        $out = [];
270        foreach ($m[0] as $token) {
271            $n = (float) $token;
272            if ($n < 0.0) {
273                // SVG 2 §13.4: a single negative value invalidates the
274                // entire list. Painter falls back to no dashes.
275                return [];
276            }
277            $out[] = $n;
278        }
279        return $out;
280    }
281
282    /** `stroke-dashoffset` — any number; default `0`. */
283    public function strokeDashoffset(): ?float
284    {
285        $raw = $this->presentationOrStyle('stroke-dashoffset');
286        if ($raw === null) {
287            return null;
288        }
289        return $this->parseNumberPrefix($raw);
290    }
291
292    /**
293     * `font-family` — CSS Fonts 4 §3.2. A comma-separated prioritised list
294     * of family names; each entry is trimmed and surrounding single or
295     * double quotes are stripped (CSS reserves quotes for names containing
296     * whitespace or reserved words). An absent attribute → empty list, so
297     * the painter applies the inherited or default stack.
298     *
299     * @return list<string>
300     */
301    public function fontFamily(): array
302    {
303        $raw = $this->presentationOrStyle('font-family');
304        if ($raw === null || trim($raw) === '') {
305            return [];
306        }
307        $parts = explode(',', $raw);
308        $out = [];
309        foreach ($parts as $part) {
310            $name = trim($part);
311            if ($name === '') {
312                continue;
313            }
314            if (strlen($name) >= 2) {
315                $first = $name[0];
316                $last = $name[strlen($name) - 1];
317                if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
318                    $name = substr($name, 1, -1);
319                }
320            }
321            if ($name !== '') {
322                $out[] = $name;
323            }
324        }
325        return $out;
326    }
327
328    /**
329     * `font-size` — CSS Fonts 4 §3.5. Length value; absolute keywords
330     * (`small`, `large`, …) and percentages are deferred to the cascade
331     * work in 3J.
332     */
333    public function fontSize(): ?float
334    {
335        $raw = $this->presentationOrStyle('font-size');
336        if ($raw === null) {
337            return null;
338        }
339        $value = $this->parseNumberPrefix($raw);
340        if ($value === null || $value < 0.0) {
341            return null;
342        }
343        return $value;
344    }
345
346    /**
347     * `font-weight` — returned as a raw string (`normal`, `bold`,
348     * `bolder`, `lighter`, or a numeric weight like `400`). The painter
349     * normalises to the OpenType weight axis; the parser stays neutral.
350     */
351    public function fontWeight(): ?string
352    {
353        $raw = $this->presentationOrStyle('font-weight');
354        if ($raw === null) {
355            return null;
356        }
357        $value = trim($raw);
358        return $value === '' ? null : $value;
359    }
360
361    /**
362     * `font-style` — `normal`, `italic`, or `oblique`. Returns null for
363     * absent and unrecognised values so the painter applies the inherited
364     * or initial style.
365     */
366    public function fontStyle(): ?string
367    {
368        $raw = $this->presentationOrStyle('font-style');
369        if ($raw === null) {
370            return null;
371        }
372        $value = strtolower(trim($raw));
373        return match ($value) {
374            'normal', 'italic', 'oblique' => $value,
375            default => null,
376        };
377    }
378
379    /**
380     * `text-shadow` per CSS Text Decoration 4 §6 — a raw, trimmed CSS
381     * value string. Returns null for absent, empty, or `none`. The
382     * painter is responsible for parsing the comma-separated layer
383     * list (`<offset-x> <offset-y> [<blur-radius>] [<color>]`); we
384     * keep the parser at the painter level because that's where the
385     * Color / Length consumers live.
386     */
387    public function textShadow(): ?string
388    {
389        $raw = $this->presentationOrStyle('text-shadow');
390        if ($raw === null) {
391            return null;
392        }
393        $value = trim($raw);
394        if ($value === '' || strcasecmp($value, 'none') === 0) {
395            return null;
396        }
397        return $value;
398    }
399
400    private function parsePaint(string $attr): ?Paint
401    {
402        $raw = $this->presentationOrStyle($attr);
403        if ($raw === null) {
404            return null;
405        }
406        return Paint::parse($raw);
407    }
408
409    private function parseClampedFraction(string $attr): ?float
410    {
411        $raw = $this->presentationOrStyle($attr);
412        if ($raw === null) {
413            return null;
414        }
415        $value = $this->parseNumberPrefix($raw);
416        if ($value === null) {
417            return null;
418        }
419        return max(0.0, min(1.0, $value));
420    }
421
422    /**
423     * Resolve a presentation attribute value, falling back to the
424     * `style` attribute's CSS declaration of the same property when
425     * the presentation form is absent. SVG 2 §6.1 defines this
426     * equivalence: `fill="green"` and `style="fill: green"` are
427     * interchangeable. The wpt-harness DOM settler projects
428     * computed CSS values into inline `style` so external rules
429     * reach this hook too.
430     *
431     * Block comments (the settler emits a `/* phpdftk-settle-dom *\/`
432     * marker) are stripped before tokenising so the prefix doesn't
433     * fool the property-name match.
434     */
435    protected function presentationOrStyle(string $property): ?string
436    {
437        $raw = $this->attributes[$property] ?? null;
438        if ($raw !== null) {
439            return $raw;
440        }
441        $style = $this->attributes['style'] ?? null;
442        if ($style === null) {
443            return null;
444        }
445        $cleaned = preg_replace('/\/\*.*?\*\//s', ' ', $style) ?? $style;
446        $target = strtolower($property);
447        foreach (explode(';', $cleaned) as $decl) {
448            $colon = strpos($decl, ':');
449            if ($colon === false) {
450                continue;
451            }
452            $name = strtolower(trim(substr($decl, 0, $colon)));
453            if ($name !== $target) {
454                continue;
455            }
456            $value = trim(substr($decl, $colon + 1));
457            return $value === '' ? null : $value;
458        }
459        return null;
460    }
461
462    private function parseNumberPrefix(string $raw): ?float
463    {
464        if (preg_match('/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/', $raw, $m) !== 1) {
465            return null;
466        }
467        return (float) $m[1];
468    }
469
470    /**
471     * Parse the `points` attribute grammar (SVG 2 §9.7) — a list of `(x, y)`
472     * coordinate pairs separated by comma-or-whitespace. Per spec, a malformed
473     * tail (odd count, non-numeric value) terminates parsing; pairs read
474     * before the error are kept.
475     *
476     * @return list<array{float, float}>
477     */
478    protected static function parsePoints(?string $raw): array
479    {
480        if ($raw === null || trim($raw) === '') {
481            return [];
482        }
483        $numberPattern = '[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?';
484        if (preg_match_all('/' . $numberPattern . '/', $raw, $m) === false) {
485            return [];
486        }
487        $values = $m[0];
488        if ($values === []) {
489            return [];
490        }
491        $pairs = [];
492        $count = (int) (count($values) / 2) * 2;
493        for ($i = 0; $i < $count; $i += 2) {
494            $pairs[] = [(float) $values[$i], (float) $values[$i + 1]];
495        }
496        return $pairs;
497    }
498}