Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
SvgDocument
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
5 / 5
15
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findById
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 findByFragment
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 invalidateIdIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 indexInto
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Svg;
6
7/**
8 * The root `<svg>` element. Inherits the viewBox / width / height accessors
9 * from `ViewportElement` (shared with `<symbol>`), and owns the
10 * document-level `findById()` resolver used by `<use>` and gradient `href`
11 * lookups.
12 *
13 * The id index is lazily built on first lookup and then cached. Mutating the
14 * tree after the first lookup invalidates the cache; rebuild by calling
15 * `invalidateIdIndex()` (or just call findById on a freshly parsed
16 * document — the common case).
17 */
18final class SvgDocument extends ViewportElement
19{
20    /** @var array<string, Element>|null */
21    private ?array $idIndex = null;
22
23    public function __construct()
24    {
25        parent::__construct('svg');
26    }
27
28    /**
29     * Look up an element by its `id` attribute. Returns null when no
30     * element with that id exists. Lazily caches the index after the
31     * first call.
32     */
33    public function findById(string $id): ?Element
34    {
35        if ($id === '') {
36            return null;
37        }
38        if ($this->idIndex === null) {
39            $this->idIndex = [];
40            self::indexInto($this, $this->idIndex);
41        }
42        return $this->idIndex[$id] ?? null;
43    }
44
45    /**
46     * Resolve a URL FRAGMENT to the element it names.
47     *
48     * A `url(#…)` / `href="#…"` reference carries a URL fragment, and the
49     * URL Standard percent-DECODES a fragment before anything consumes
50     * it — so `url(#%66%6f%6f)` and `url(#foo)` name the same element.
51     * `findById()` deliberately stays a literal id lookup; every
52     * REFERENCE site goes through here instead.
53     *
54     * The literal form is tried as a fallback so a document that really
55     * does carry an id with a `%` in it stays reachable.
56     */
57    public function findByFragment(string $fragment): ?Element
58    {
59        if ($fragment === '') {
60            return null;
61        }
62        $decoded = rawurldecode($fragment);
63        $found = $this->findById($decoded);
64        if ($found !== null) {
65            return $found;
66        }
67        return $decoded === $fragment ? null : $this->findById($fragment);
68    }
69
70    /**
71     * Drop the cached id index — call this after mutating the tree if
72     * you need subsequent `findById` calls to see the new state.
73     */
74    public function invalidateIdIndex(): void
75    {
76        $this->idIndex = null;
77    }
78
79    /**
80     * @param array<string, Element> $into
81     */
82    private static function indexInto(Element $element, array &$into): void
83    {
84        $id = $element->getAttribute('id');
85        if ($id !== null && $id !== '' && !isset($into[$id])) {
86            // SVG 2: duplicate ids are technically invalid; pick the first
87            // in document order — same shape browsers use for
88            // querySelector('#id').
89            $into[$id] = $element;
90        }
91        foreach ($element->children as $child) {
92            if ($child instanceof Element) {
93                self::indexInto($child, $into);
94            }
95        }
96    }
97}