Skip to main content

apace_dom/node/
svg.rs

1//! SVG image data and CSS intrinsic sizing for SVG.
2
3use std::sync::Arc;
4
5use usvg::roxmltree;
6
7/// Dimensions declared on the root `<svg>` element, before any resolution.
8///
9/// Unlike [`usvg::Tree::size`], which always produces a concrete size, this
10/// preserves what the SVG actually declared: absent attributes are `None` and
11/// percentage lengths are kept unresolved.
12#[derive(Debug, Clone, Copy, Default)]
13pub struct SvgIntrinsicDimensions {
14    /// The root `width` attribute, if declared. Percentages are unresolved.
15    pub width: Option<svgtypes::Length>,
16    /// The root `height` attribute, if declared. Percentages are unresolved.
17    pub height: Option<svgtypes::Length>,
18    /// The root `viewBox`, if declared and valid.
19    pub view_box: Option<usvg::NonZeroRect>,
20    /// The root `preserveAspectRatio` (default `xMidYMid meet`).
21    pub preserve_aspect_ratio: svgtypes::AspectRatio,
22    /// Whether the root declared a `viewBox` with a zero width or height,
23    /// which disables rendering of the element per the SVG spec.
24    pub degenerate_view_box: bool,
25}
26
27impl SvgIntrinsicDimensions {
28    /// Extract the `width`/`height`/`viewBox` attributes declared on the root
29    /// element of an already-parsed SVG document, with absent attributes as
30    /// `None` and percentages unresolved. [`usvg::Tree`] does not preserve
31    /// these (it always resolves to a concrete size), so they are read from
32    /// the XML document here.
33    pub fn from_xmltree(doc: &roxmltree::Document) -> Self {
34        let root = doc.root_element();
35
36        let parse_length = |name: &str| -> Option<svgtypes::Length> {
37            root.attribute(name)?.parse::<svgtypes::Length>().ok()
38        };
39        // Parsed manually rather than via `svgtypes::ViewBox`, which rejects
40        // zero sizes: a zero `viewBox` width/height is distinguished from an
41        // invalid `viewBox` as it disables rendering of the element.
42        let view_box_dims = root.attribute("viewBox").and_then(|s| {
43            let mut numbers = svgtypes::NumberListParser::from(s);
44            let x = numbers.next()?.ok()? as f32;
45            let y = numbers.next()?.ok()? as f32;
46            let w = numbers.next()?.ok()? as f32;
47            let h = numbers.next()?.ok()? as f32;
48            (numbers.next().is_none() && w.is_finite() && h.is_finite() && w >= 0.0 && h >= 0.0)
49                .then_some((x, y, w, h))
50        });
51        let view_box =
52            view_box_dims.and_then(|(x, y, w, h)| usvg::NonZeroRect::from_xywh(x, y, w, h));
53        let degenerate_view_box = view_box_dims.is_some_and(|(_, _, w, h)| w == 0.0 || h == 0.0);
54        let preserve_aspect_ratio = root
55            .attribute("preserveAspectRatio")
56            .and_then(|s| s.parse().ok())
57            .unwrap_or_default();
58
59        Self {
60            width: parse_length("width"),
61            height: parse_length("height"),
62            view_box,
63            preserve_aspect_ratio,
64            degenerate_view_box,
65        }
66    }
67}
68
69/// A parsed SVG image.
70///
71/// usvg always resolves the root `<svg>` to a concrete [`usvg::Tree::size`],
72/// falling back to the `viewBox` size when `width`/`height` are absent or given
73/// as percentages. For CSS sizing purposes, however, such an SVG has *no*
74/// intrinsic width/height (only an intrinsic aspect ratio). The accessors on
75/// this type resolve the CSS intrinsic dimensions from the declared root
76/// attributes, which are captured at parse time.
77#[derive(Debug, Clone)]
78pub struct SvgImageData {
79    /// The parsed SVG tree.
80    pub tree: Arc<usvg::Tree>,
81    /// The dimensions declared on the root `<svg>` element.
82    pub intrinsic_dimensions: SvgIntrinsicDimensions,
83}
84
85impl SvgImageData {
86    /// Parse an SVG image from raw data, capturing both the rendered
87    /// [`usvg::Tree`] and the declared root dimensions from a single XML
88    /// parse.
89    ///
90    /// Like [`usvg::Tree::from_data`], gzip-compressed data (SVGZ) is
91    /// decompressed first.
92    pub fn from_data(data: &[u8], options: &usvg::Options) -> Result<Self, usvg::Error> {
93        // Gzip magic bytes, matching the SVGZ detection in `usvg::Tree::from_data`.
94        let decompressed;
95        let data = if data.starts_with(&[0x1f, 0x8b]) {
96            decompressed = usvg::decompress_svgz(data)?;
97            decompressed.as_slice()
98        } else {
99            data
100        };
101
102        let text = std::str::from_utf8(data).map_err(|_| usvg::Error::NotAnUtf8Str)?;
103        let xml_options = roxmltree::ParsingOptions {
104            allow_dtd: true,
105            ..Default::default()
106        };
107        let doc = roxmltree::Document::parse_with_options(text, xml_options)
108            .map_err(usvg::Error::ParsingFailed)?;
109        let tree = usvg::Tree::from_xmltree(&doc, options)?;
110        Ok(Self {
111            tree: Arc::new(tree),
112            intrinsic_dimensions: SvgIntrinsicDimensions::from_xmltree(&doc),
113        })
114    }
115
116    /// The intrinsic width in CSS px, present only when the root `<svg>`
117    /// declared an absolute (non-percentage) `width`.
118    pub fn intrinsic_width(&self) -> Option<f32> {
119        use svgtypes::LengthUnit;
120        let declared = self
121            .intrinsic_dimensions
122            .width
123            .is_some_and(|len| len.unit != LengthUnit::Percent);
124        declared.then(|| self.tree.size().width())
125    }
126
127    /// The intrinsic height in CSS px, present only when the root `<svg>`
128    /// declared an absolute (non-percentage) `height`.
129    pub fn intrinsic_height(&self) -> Option<f32> {
130        use svgtypes::LengthUnit;
131        let declared = self
132            .intrinsic_dimensions
133            .height
134            .is_some_and(|len| len.unit != LengthUnit::Percent);
135        declared.then(|| self.tree.size().height())
136    }
137
138    /// The aspect ratio of the root `<svg>`'s `viewBox`, if it declares one.
139    pub fn viewbox_aspect_ratio(&self) -> Option<f32> {
140        self.intrinsic_dimensions
141            .view_box
142            .map(|view_box| view_box.width() / view_box.height())
143    }
144
145    /// The transform from the rendered tree (in [`usvg::Tree::size`] units) to a
146    /// viewport of the given size, applying the root `viewBox` and
147    /// `preserveAspectRatio`. Without a `viewBox` the tree is stretched to fill it.
148    pub fn viewport_transform(&self, width: f32, height: f32) -> usvg::Transform {
149        let size = self.tree.size();
150        let stretch = usvg::Transform::from_scale(width / size.width(), height / size.height());
151        let Some(view_box) = self.intrinsic_dimensions.view_box else {
152            return stretch;
153        };
154        let aspect = self.intrinsic_dimensions.preserve_aspect_ratio;
155        // usvg has already mapped the `viewBox` onto `Tree::size`, so undo that first.
156        let view_box_to_tree = view_box_transform(view_box, aspect, size.width(), size.height());
157        match view_box_to_tree.invert() {
158            Some(tree_to_view_box) => {
159                view_box_transform(view_box, aspect, width, height).pre_concat(tree_to_view_box)
160            }
161            None => stretch,
162        }
163    }
164
165    /// The root `width` attribute resolved against a containing block width:
166    /// percentages resolve against the containing block (`None` if it is
167    /// indefinite) and an absent attribute is `None`.
168    ///
169    /// This is only appropriate for an inline `<svg>` element, where the
170    /// attributes behave as presentation attributes. SVG used as an image
171    /// (e.g. `<img src>` or a background) must use [`Self::intrinsic_width`],
172    /// as its intrinsic dimensions are context-free per CSS.
173    pub fn resolved_width(&self, container_width: Option<f32>) -> Option<f32> {
174        use svgtypes::LengthUnit;
175        match self.intrinsic_dimensions.width {
176            Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().width()),
177            Some(len) => container_width.map(|cw| cw * (len.number as f32) / 100.0),
178            None => None,
179        }
180    }
181
182    /// The root `height` attribute resolved against a containing block height.
183    /// See [`Self::resolved_width`].
184    pub fn resolved_height(&self, container_height: Option<f32>) -> Option<f32> {
185        use svgtypes::LengthUnit;
186        match self.intrinsic_dimensions.height {
187            Some(len) if len.unit != LengthUnit::Percent => Some(self.tree.size().height()),
188            Some(len) => container_height.map(|ch| ch * (len.number as f32) / 100.0),
189            None => None,
190        }
191    }
192
193    /// The intrinsic aspect ratio of the SVG: the ratio of its declared
194    /// `width`/`height` when both are absolute lengths, otherwise the
195    /// `viewBox` ratio, otherwise the ratio of the resolved
196    /// [`usvg::Tree::size`] (which is always non-zero).
197    pub fn aspect_ratio(&self) -> f32 {
198        match (self.intrinsic_width(), self.intrinsic_height()) {
199            (Some(w), Some(h)) => w / h,
200            _ => self.viewbox_aspect_ratio().unwrap_or_else(|| {
201                let size = self.tree.size();
202                size.width() / size.height()
203            }),
204        }
205    }
206
207    /// The intrinsic dimensions of the SVG resolved per CSS replaced element
208    /// sizing: a missing dimension is computed from the declared one and the
209    /// intrinsic aspect ratio; if neither is declared, the resolved
210    /// [`usvg::Tree::size`] is used as a fallback.
211    pub fn intrinsic_size(&self) -> (f32, f32) {
212        let aspect_ratio = self.aspect_ratio();
213        match (self.intrinsic_width(), self.intrinsic_height()) {
214            (Some(w), Some(h)) => (w, h),
215            (Some(w), None) => (w, w / aspect_ratio),
216            (None, Some(h)) => (h * aspect_ratio, h),
217            (None, None) => {
218                // No intrinsic dimensions. If there is an intrinsic aspect ratio, apply
219                // the CSS default sizing algorithm: contain within the default object
220                // size of 300x150. Otherwise fall back to the resolved tree size.
221                if self.viewbox_aspect_ratio().is_some() {
222                    let scale = (300.0 / aspect_ratio).min(150.0);
223                    (scale * aspect_ratio, scale)
224                } else {
225                    let size = self.tree.size();
226                    (size.width(), size.height())
227                }
228            }
229        }
230    }
231}
232
233/// Map a `viewBox` onto a viewport of the given size per `preserveAspectRatio`.
234fn view_box_transform(
235    view_box: usvg::NonZeroRect,
236    aspect: svgtypes::AspectRatio,
237    width: f32,
238    height: f32,
239) -> usvg::Transform {
240    use svgtypes::Align;
241
242    let sx = width / view_box.width();
243    let sy = height / view_box.height();
244    let (sx, sy) = if aspect.align == Align::None {
245        (sx, sy)
246    } else if aspect.slice {
247        (sx.max(sy), sx.max(sy))
248    } else {
249        (sx.min(sy), sx.min(sy))
250    };
251    let (align_x, align_y) = match aspect.align {
252        Align::None | Align::XMinYMin => (0.0, 0.0),
253        Align::XMidYMin => (0.5, 0.0),
254        Align::XMaxYMin => (1.0, 0.0),
255        Align::XMinYMid => (0.0, 0.5),
256        Align::XMidYMid => (0.5, 0.5),
257        Align::XMaxYMid => (1.0, 0.5),
258        Align::XMinYMax => (0.0, 1.0),
259        Align::XMidYMax => (0.5, 1.0),
260        Align::XMaxYMax => (1.0, 1.0),
261    };
262    let tx = (width - view_box.width() * sx) * align_x - view_box.x() * sx;
263    let ty = (height - view_box.height() * sy) * align_y - view_box.y() * sy;
264    usvg::Transform::from_row(sx, 0.0, 0.0, sy, tx, ty)
265}