Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.00% covered (success)
95.00%
95 / 100
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
95.00% covered (success)
95.00%
95 / 100
75.00% covered (warning)
75.00%
3 / 4
74
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
 parse
84.38% covered (warning)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
6.14
 makeElement
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 makeElementForName
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
64
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Svg;
6
7use Phpdftk\Svg\Exception\InvalidSvgException;
8use Phpdftk\Svg\Shape\Circle;
9use Phpdftk\Svg\Shape\Ellipse;
10use Phpdftk\Svg\Shape\Line;
11use Phpdftk\Svg\Shape\Polygon;
12use Phpdftk\Svg\Shape\Polyline;
13use Phpdftk\Svg\Shape\Rect;
14use Phpdftk\Xml\Exception\InvalidXmlException;
15use Phpdftk\Xml\HardenedLoader;
16use Phpdftk\Xml\TreeWalker;
17
18/**
19 * Secure SVG-to-typed-tree parser. Consumes a string of SVG XML and
20 * returns an `SvgDocument`.
21 *
22 * Routing through {@see HardenedLoader} for libxml + {@see TreeWalker}
23 * for the DOM walk means this parser's only format-specific code is
24 * the root validation (must be `<svg>` in `SVG_NS`) and the
25 * `makeElementForName` typed-element factory. The security boundary
26 * (no entity substitution, no network fetches, no XInclude) is owned
27 * by `HardenedLoader` so SVG and MathML cannot drift.
28 *
29 * Unknown elements outside the implemented v1 subset are preserved as
30 * generic `Element` instances so sanitiser-style callers can inspect
31 * them.
32 */
33final class Parser
34{
35    /** SVG namespace URI per spec. */
36    public const string SVG_NS = 'http://www.w3.org/2000/svg';
37
38    public function __construct(
39        private readonly HardenedLoader $loader = new HardenedLoader(),
40        private readonly TreeWalker $walker = new TreeWalker(),
41    ) {}
42
43    public function parse(string $xml): SvgDocument
44    {
45        try {
46            $dom = $this->loader->load($xml);
47        } catch (InvalidXmlException $e) {
48            // Re-throw as the format-specific exception so consumers
49            // can keep a single catch block per parser. The original
50            // libxml message is preserved via the previous exception.
51            // The error text is reflavoured to mention "SVG" so the
52            // consumer's logs make the format clear.
53            $message = str_replace(
54                'parse XML',
55                'parse SVG XML',
56                $e->getMessage(),
57            );
58            throw new InvalidSvgException($message, 0, $e);
59        }
60
61        $root = $dom->documentElement;
62        if ($root === null) {
63            throw new InvalidSvgException('SVG document has no root element.');
64        }
65        if ($root->localName !== 'svg') {
66            throw new InvalidSvgException(sprintf(
67                'Expected <svg> root element, got <%s>.',
68                $root->localName,
69            ));
70        }
71        if ($root->namespaceURI !== null && $root->namespaceURI !== self::SVG_NS) {
72            throw new InvalidSvgException(sprintf(
73                'Root <svg> declared in unexpected namespace %s.',
74                $root->namespaceURI,
75            ));
76        }
77
78        $doc = new SvgDocument();
79        $this->walker->walk(
80            $root,
81            $doc,
82            createElement: fn(string $localName, ?string $namespaceUri = null): Element
83                => $this->makeElement($localName, $namespaceUri),
84            createText: fn(string $data) => new Text($data),
85            setAttribute: static fn(Element $el, string $name, string $value) => $el->setAttribute($name, $value),
86            appendChild: static fn(Element $parent, Node $child) => $parent->appendChild($child),
87        );
88        return $doc;
89    }
90
91    /**
92     * Build the typed element for one source node.
93     *
94     * SVG 2 Â§5.7 â€” an element from a FOREIGN namespace (and everything
95     * under it) is not rendered, so it becomes a
96     * {@see ForeignElement} the painter can skip wholesale. A null
97     * namespace means the document declared none at all, which older
98     * hand-written SVG does; those nodes are treated as SVG.
99     */
100    private function makeElement(string $localName, ?string $namespaceUri): Element
101    {
102        if ($namespaceUri !== null && $namespaceUri !== self::SVG_NS) {
103            return new ForeignElement($localName, $namespaceUri);
104        }
105        return $this->makeElementForName($localName);
106    }
107
108    private function makeElementForName(string $localName): Element
109    {
110        return match ($localName) {
111            'rect' => new Rect(),
112            'circle' => new Circle(),
113            'ellipse' => new Ellipse(),
114            'line' => new Line(),
115            'polyline' => new Polyline(),
116            'polygon' => new Polygon(),
117            'g' => new Group(),
118            // SVG 2 Â§7.5 â€” a nested `<svg>` establishes a new viewport /
119            // user space. (The root `<svg>` is built as SvgDocument above.)
120            'svg' => new NestedSvg(),
121            'path' => new Path(),
122            'text' => new Text\TextElement(),
123            'tspan' => new Text\Tspan(),
124            'defs' => new Defs(),
125            'symbol' => new Symbol(),
126            'use' => new Use_(),
127            'clipPath' => new ClipPath(),
128            'mask' => new Mask(),
129            'image' => new Image(),
130            'linearGradient' => new Gradient\LinearGradient(),
131            'radialGradient' => new Gradient\RadialGradient(),
132            'stop' => new Gradient\Stop(),
133            'style' => new StyleElement(),
134            // SVG 2 Â§12.1.1 â€” anchor container; PDF maps to a link
135            // annotation when an href is present.
136            'a' => new A_(),
137            // SVG 2 Â§15.3 â€” accessibility metadata; never paints.
138            'title' => new Title(),
139            'desc' => new Desc(),
140            // SVG 2 Â§5.7 â€” conditional rendering container.
141            'switch' => new Switch_(),
142            // SVG 2 Â§11.6 â€” foreign-content placeholder.
143            'foreignObject' => new ForeignObject(),
144            // SVG 2 Â§11.6 â€” vertex-marker definition (arrowheads etc.)
145            'marker' => new Marker(),
146            // SVG 2 Â§13.3 â€” tiled fill pattern definition.
147            'pattern' => new Pattern(),
148            // SVG 2 Filter Effects Â§6.1 â€” filter graph definition.
149            'filter' => new Filter(),
150            // SVG 2 Filter Effects Â§15 â€” filter primitives that
151            // live inside `<filter>`. Each lifts to its own typed
152            // class for accessor convenience.
153            'feGaussianBlur' => new Filter\FeGaussianBlur(),
154            'feOffset' => new Filter\FeOffset(),
155            'feFlood' => new Filter\FeFlood(),
156            'feBlend' => new Filter\FeBlend(),
157            'feComposite' => new Filter\FeComposite(),
158            'feMorphology' => new Filter\FeMorphology(),
159            'feMerge' => new Filter\FeMerge(),
160            'feMergeNode' => new Filter\FeMergeNode(),
161            'feColorMatrix' => new Filter\FeColorMatrix(),
162            'feDropShadow' => new Filter\FeDropShadow(),
163            'feTurbulence' => new Filter\FeTurbulence(),
164            'feImage' => new Filter\FeImage(),
165            'feTile' => new Filter\FeTile(),
166            'feDisplacementMap' => new Filter\FeDisplacementMap(),
167            'feConvolveMatrix' => new Filter\FeConvolveMatrix(),
168            'feComponentTransfer' => new Filter\FeComponentTransfer(),
169            'feFuncR' => new Filter\FeFuncR(),
170            'feFuncG' => new Filter\FeFuncG(),
171            'feFuncB' => new Filter\FeFuncB(),
172            'feFuncA' => new Filter\FeFuncA(),
173            'feDiffuseLighting' => new Filter\FeDiffuseLighting(),
174            'feSpecularLighting' => new Filter\FeSpecularLighting(),
175            'feDistantLight' => new Filter\FeDistantLight(),
176            'fePointLight' => new Filter\FePointLight(),
177            'feSpotLight' => new Filter\FeSpotLight(),
178            // SVG 2 Â§6.3 â€” declarative named viewport.
179            'view' => new View(),
180            // SVG 2 Â§15.2 â€” out-of-scope script content; the typed
181            // class lets the Translator skip it explicitly rather
182            // than recursing into any nested `<text>` etc. children
183            // a malicious document might smuggle in.
184            'script' => new Script(),
185            // SVG 2 Â§19 â€” animation elements. Out of scope for the
186            // static print medium; typed for explicit Translator
187            // skip and external-tooling recognition.
188            'animate' => new Animate(),
189            'animateTransform' => new AnimateTransform(),
190            'animateMotion' => new AnimateMotion(),
191            'set' => new SetElement(),
192            'mpath' => new MPath(),
193            // SVG 2 Â§6.4 â€” RDF/metadata, never renders.
194            'metadata' => new Metadata(),
195            default => new GenericElement($localName),
196        };
197    }
198}