Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.79% |
91 / 96 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| Parser | |
94.79% |
91 / 96 |
|
66.67% |
2 / 3 |
71.71 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parse | |
83.87% |
26 / 31 |
|
0.00% |
0 / 1 |
6.15 | |||
| 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) => $this->makeElementForName($localName), |
| 83 | createText: fn(string $data) => new Text($data), |
| 84 | setAttribute: static fn(Element $el, string $name, string $value) => $el->setAttribute($name, $value), |
| 85 | appendChild: static fn(Element $parent, Node $child) => $parent->appendChild($child), |
| 86 | ); |
| 87 | return $doc; |
| 88 | } |
| 89 | |
| 90 | private function makeElementForName(string $localName): Element |
| 91 | { |
| 92 | return match ($localName) { |
| 93 | 'rect' => new Rect(), |
| 94 | 'circle' => new Circle(), |
| 95 | 'ellipse' => new Ellipse(), |
| 96 | 'line' => new Line(), |
| 97 | 'polyline' => new Polyline(), |
| 98 | 'polygon' => new Polygon(), |
| 99 | 'g' => new Group(), |
| 100 | // SVG 2 §7.5 — a nested `<svg>` establishes a new viewport / |
| 101 | // user space. (The root `<svg>` is built as SvgDocument above.) |
| 102 | 'svg' => new NestedSvg(), |
| 103 | 'path' => new Path(), |
| 104 | 'text' => new Text\TextElement(), |
| 105 | 'tspan' => new Text\Tspan(), |
| 106 | 'defs' => new Defs(), |
| 107 | 'symbol' => new Symbol(), |
| 108 | 'use' => new Use_(), |
| 109 | 'clipPath' => new ClipPath(), |
| 110 | 'mask' => new Mask(), |
| 111 | 'image' => new Image(), |
| 112 | 'linearGradient' => new Gradient\LinearGradient(), |
| 113 | 'radialGradient' => new Gradient\RadialGradient(), |
| 114 | 'stop' => new Gradient\Stop(), |
| 115 | 'style' => new StyleElement(), |
| 116 | // SVG 2 §12.1.1 — anchor container; PDF maps to a link |
| 117 | // annotation when an href is present. |
| 118 | 'a' => new A_(), |
| 119 | // SVG 2 §15.3 — accessibility metadata; never paints. |
| 120 | 'title' => new Title(), |
| 121 | 'desc' => new Desc(), |
| 122 | // SVG 2 §5.7 — conditional rendering container. |
| 123 | 'switch' => new Switch_(), |
| 124 | // SVG 2 §11.6 — foreign-content placeholder. |
| 125 | 'foreignObject' => new ForeignObject(), |
| 126 | // SVG 2 §11.6 — vertex-marker definition (arrowheads etc.) |
| 127 | 'marker' => new Marker(), |
| 128 | // SVG 2 §13.3 — tiled fill pattern definition. |
| 129 | 'pattern' => new Pattern(), |
| 130 | // SVG 2 Filter Effects §6.1 — filter graph definition. |
| 131 | 'filter' => new Filter(), |
| 132 | // SVG 2 Filter Effects §15 — filter primitives that |
| 133 | // live inside `<filter>`. Each lifts to its own typed |
| 134 | // class for accessor convenience. |
| 135 | 'feGaussianBlur' => new Filter\FeGaussianBlur(), |
| 136 | 'feOffset' => new Filter\FeOffset(), |
| 137 | 'feFlood' => new Filter\FeFlood(), |
| 138 | 'feBlend' => new Filter\FeBlend(), |
| 139 | 'feComposite' => new Filter\FeComposite(), |
| 140 | 'feMorphology' => new Filter\FeMorphology(), |
| 141 | 'feMerge' => new Filter\FeMerge(), |
| 142 | 'feMergeNode' => new Filter\FeMergeNode(), |
| 143 | 'feColorMatrix' => new Filter\FeColorMatrix(), |
| 144 | 'feDropShadow' => new Filter\FeDropShadow(), |
| 145 | 'feTurbulence' => new Filter\FeTurbulence(), |
| 146 | 'feImage' => new Filter\FeImage(), |
| 147 | 'feTile' => new Filter\FeTile(), |
| 148 | 'feDisplacementMap' => new Filter\FeDisplacementMap(), |
| 149 | 'feConvolveMatrix' => new Filter\FeConvolveMatrix(), |
| 150 | 'feComponentTransfer' => new Filter\FeComponentTransfer(), |
| 151 | 'feFuncR' => new Filter\FeFuncR(), |
| 152 | 'feFuncG' => new Filter\FeFuncG(), |
| 153 | 'feFuncB' => new Filter\FeFuncB(), |
| 154 | 'feFuncA' => new Filter\FeFuncA(), |
| 155 | 'feDiffuseLighting' => new Filter\FeDiffuseLighting(), |
| 156 | 'feSpecularLighting' => new Filter\FeSpecularLighting(), |
| 157 | 'feDistantLight' => new Filter\FeDistantLight(), |
| 158 | 'fePointLight' => new Filter\FePointLight(), |
| 159 | 'feSpotLight' => new Filter\FeSpotLight(), |
| 160 | // SVG 2 §6.3 — declarative named viewport. |
| 161 | 'view' => new View(), |
| 162 | // SVG 2 §15.2 — out-of-scope script content; the typed |
| 163 | // class lets the Translator skip it explicitly rather |
| 164 | // than recursing into any nested `<text>` etc. children |
| 165 | // a malicious document might smuggle in. |
| 166 | 'script' => new Script(), |
| 167 | // SVG 2 §19 — animation elements. Out of scope for the |
| 168 | // static print medium; typed for explicit Translator |
| 169 | // skip and external-tooling recognition. |
| 170 | 'animate' => new Animate(), |
| 171 | 'animateTransform' => new AnimateTransform(), |
| 172 | 'animateMotion' => new AnimateMotion(), |
| 173 | 'set' => new SetElement(), |
| 174 | 'mpath' => new MPath(), |
| 175 | // SVG 2 §6.4 — RDF/metadata, never renders. |
| 176 | 'metadata' => new Metadata(), |
| 177 | default => new GenericElement($localName), |
| 178 | }; |
| 179 | } |
| 180 | } |