Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.15% covered (warning)
86.15%
56 / 65
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SvgParser
86.15% covered (warning)
86.15%
56 / 65
20.00% covered (danger)
20.00%
1 / 5
51.62
0.00% covered (danger)
0.00%
0 / 1
 parseFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parse
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 extractRootAttributes
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 parseLengthAttribute
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
16.62
 parseViewBoxDimensions
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ImageMetadata;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8
9/**
10 * Extract intrinsic-size metadata from an SVG file's root `<svg>`
11 * element without parsing the full document tree.
12 *
13 * Per CSS Images 3 §3 / SVG 2 §6.1, an `<svg>` element's intrinsic
14 * dimensions come from a small set of root-element attributes:
15 *
16 *   - `width="..."` and `height="..."`: explicit intrinsic dimensions
17 *     when the value is an absolute CSS length (or unitless = px).
18 *     Percentages (`"50%"`) are not intrinsic and are reported as
19 *     "no intrinsic value on that axis".
20 *   - `viewBox="min-x min-y width height"`: defines an intrinsic
21 *     aspect ratio = width / height, even when no `width`/`height`
22 *     attributes are present.
23 *   - When `width` and `viewBox` are both present (but `height` is
24 *     absent), the height is derived as `width / ratio`. Vice versa
25 *     for height-only + viewBox.
26 *
27 * What this parser does NOT do:
28 *
29 *   - Parse `<style>` blocks for `svg { width: ... }` (cascade
30 *     dimensions are not "intrinsic" per CSS Images 3 §3.1).
31 *   - Resolve `em` / `rem` / `vw` / `vh` units. Those depend on
32 *     the embedding context; the consumer layout engine has the
33 *     containing block to resolve them.
34 *   - Validate the rest of the SVG content.
35 *
36 * The returned {@see ImageInfo} carries `format: 'svg'`. The `width`
37 * and `height` fields hold the resolved intrinsic pixel dimensions
38 * when both axes are known (either directly via attributes or
39 * computed from one attribute + the viewBox ratio). When neither
40 * intrinsic dimension can be resolved (e.g. `viewBox` only), both
41 * are zero and {@see ImageInfo::$intrinsicRatio} carries the aspect
42 * ratio so the layout can apply the CSS Images 3 §3.3 default
43 * object size fallback.
44 */
45final class SvgParser
46{
47    public static function parseFile(string $path): ImageInfo
48    {
49        // Read enough bytes to capture a typical `<svg ...>` opening
50        // tag. SVG roots are usually within the first few KB even
51        // for complex documents; 32 KB is a generous ceiling.
52        $data = LocalFilesystem::readPrefix($path, 32 * 1024, 'svg image');
53        return self::parse($data);
54    }
55
56    public static function parse(string $data): ImageInfo
57    {
58        $rootAttrs = self::extractRootAttributes($data);
59        if ($rootAttrs === null) {
60            throw new \RuntimeException('Not an SVG document');
61        }
62
63        $width = self::parseLengthAttribute($rootAttrs['width'] ?? null);
64        $height = self::parseLengthAttribute($rootAttrs['height'] ?? null);
65        $viewBoxDims = self::parseViewBoxDimensions($rootAttrs['viewbox'] ?? null);
66        $ratio = $viewBoxDims !== null ? $viewBoxDims[0] / $viewBoxDims[1] : null;
67
68        // Apply the intrinsic-aspect-ratio rule: when one axis is
69        // known and the viewBox supplies a ratio, derive the other.
70        if ($width !== null && $height === null && $ratio !== null && $ratio > 0.0) {
71            $height = $width / $ratio;
72        } elseif ($height !== null && $width === null && $ratio !== null && $ratio > 0.0) {
73            $width = $height * $ratio;
74        }
75
76        // Last-resort fallback: when no explicit width/height is given
77        // but a viewBox is present, treat the viewBox dimensions as the
78        // intrinsic pixel size. This is not strictly CSS Images 3
79        // §5.2 — viewBox alone gives only a ratio — but it matches the
80        // sizing the painter uses (Painter::intrinsicSvgSize) so the
81        // BoxGenerator-sized layout box agrees with the painted SVG.
82        if ($width === null && $height === null && $viewBoxDims !== null) {
83            $width = $viewBoxDims[0];
84            $height = $viewBoxDims[1];
85        }
86
87        // Compute the intrinsic ratio from explicit width/height when
88        // viewBox didn't supply one. Keeps the ratio field populated
89        // for the common explicit-dimensions case.
90        if ($ratio === null && $width !== null && $height !== null && $height > 0.0) {
91            $ratio = $width / $height;
92        }
93
94        return new ImageInfo(
95            width: $width !== null ? (int) round($width) : 0,
96            height: $height !== null ? (int) round($height) : 0,
97            colorSpace: 'DeviceRGB',
98            bitsPerComponent: 8,
99            format: 'svg',
100            intrinsicRatio: $ratio,
101        );
102    }
103
104    /**
105     * Pull the attributes off the root `<svg ...>` opening tag.
106     *
107     * Returns an associative array keyed by lowercased attribute
108     * name, or null when the input doesn't look like an SVG (no
109     * `<svg` token within the prefix).
110     *
111     * @return array<string, string>|null
112     */
113    private static function extractRootAttributes(string $data): ?array
114    {
115        // Skip an optional XML prolog and any leading processing
116        // instructions / comments / doctype. The first `<svg` (case
117        // insensitive, followed by whitespace, `>`, or `/`) starts
118        // the root element.
119        if (preg_match('/<svg\b([^>]*)>/i', $data, $m) !== 1) {
120            return null;
121        }
122        $attrSegment = $m[1];
123
124        $attrs = [];
125        $pattern = '/([A-Za-z_:][\w:.\-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/';
126        if (preg_match_all($pattern, $attrSegment, $matches, PREG_SET_ORDER) !== false) {
127            foreach ($matches as $match) {
128                $name = strtolower($match[1]);
129                $value = $match[2] !== '' ? $match[2] : ($match[3] !== '' ? $match[3] : ($match[4] ?? ''));
130                $attrs[$name] = $value;
131            }
132        }
133        return $attrs;
134    }
135
136    /**
137     * Parse an SVG length attribute (`width="100"`, `height="50px"`)
138     * into pixels.
139     *
140     * Returns null when the value is missing, empty, a percentage
141     * (intrinsic dims don't carry percentages), or in a unit this
142     * parser doesn't resolve without a containing block (`em`,
143     * `rem`, `vw`, `vh`). Absolute units we do resolve: unitless
144     * (= px), `px`, `pt`, `pc`, `in`, `cm`, `mm`, `Q`.
145     */
146    private static function parseLengthAttribute(?string $raw): ?float
147    {
148        if ($raw === null) {
149            return null;
150        }
151        $trimmed = trim($raw);
152        if ($trimmed === '' || str_ends_with($trimmed, '%')) {
153            return null;
154        }
155        if (preg_match('/^([+-]?\d+(?:\.\d+)?|[+-]?\.\d+)([a-zA-Z]*)$/', $trimmed, $m) !== 1) {
156            return null;
157        }
158        $value = (float) $m[1];
159        $unit = strtolower($m[2]);
160        return match ($unit) {
161            '', 'px' => $value,
162            'pt' => $value * (96.0 / 72.0),
163            'pc' => $value * (96.0 / 6.0),
164            'in' => $value * 96.0,
165            'cm' => $value * (96.0 / 2.54),
166            'mm' => $value * (96.0 / 25.4),
167            'q' => $value * (96.0 / 101.6),
168            default => null,
169        };
170    }
171
172    /**
173     * Extract the (width, height) pair from a `viewBox` attribute
174     * value (`"min-x min-y width height"`).
175     *
176     * Returns null when the attribute is missing, malformed, or
177     * either dimension is non-positive.
178     *
179     * @return array{float, float}|null
180     */
181    private static function parseViewBoxDimensions(?string $raw): ?array
182    {
183        if ($raw === null) {
184            return null;
185        }
186        $parts = preg_split('/[\s,]+/', trim($raw));
187        if ($parts === false || count($parts) !== 4) {
188            return null;
189        }
190        $w = (float) $parts[2];
191        $h = (float) $parts[3];
192        if ($w <= 0.0 || $h <= 0.0) {
193            return null;
194        }
195        return [$w, $h];
196    }
197}