Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.00% |
95 / 100 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
| Parser | |
95.00% |
95 / 100 |
|
75.00% |
3 / 4 |
74 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parse | |
84.38% |
27 / 32 |
|
0.00% |
0 / 1 |
6.14 | |||
| makeElement | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| makeElementForName | |
100.00% |
64 / 64 |
|
100.00% |
1 / 1 |
64 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Svg; |
| 6 | |
| 7 | use Phpdftk\Svg\Exception\InvalidSvgException; |
| 8 | use Phpdftk\Svg\Shape\Circle; |
| 9 | use Phpdftk\Svg\Shape\Ellipse; |
| 10 | use Phpdftk\Svg\Shape\Line; |
| 11 | use Phpdftk\Svg\Shape\Polygon; |
| 12 | use Phpdftk\Svg\Shape\Polyline; |
| 13 | use Phpdftk\Svg\Shape\Rect; |
| 14 | use Phpdftk\Xml\Exception\InvalidXmlException; |
| 15 | use Phpdftk\Xml\HardenedLoader; |
| 16 | use 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 | */ |
| 33 | final 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 | } |