Skip to main content

blockworx_paint/
image.rs

1//! The intrinsic size a placement is fitted to, read off an image's bytes
2//! without decoding them.
3
4use blockworx_doc::block_model::Asset;
5use blockworx_geom::Vec2;
6
7/// Why an image's size could not be read.
8#[derive(Debug, thiserror::Error)]
9pub enum ImageImportError {
10    #[error("not an SVG (no <svg> root)")]
11    NotSvg,
12    #[error("not a PNG (bad signature or truncated header)")]
13    NotPng,
14    #[error("image has a zero width or height")]
15    ZeroSize,
16}
17
18/// The intrinsic size of an image, dispatching by kind.
19pub fn image_intrinsic_size(asset: &Asset) -> Result<Vec2, ImageImportError> {
20    match asset {
21        Asset::Svg(bytes) => svg_intrinsic_size(&String::from_utf8_lossy(bytes)),
22        Asset::Png(bytes) => png_intrinsic_size(bytes).ok_or(ImageImportError::NotPng),
23    }
24}
25
26/// The intrinsic size of a PNG, read straight from its IHDR chunk — no decoder
27/// needed. A PNG is the 8-byte signature followed by the IHDR chunk, whose data
28/// begins at byte 16: width is the big-endian `u32` at bytes 16..20 and height at
29/// bytes 20..24. `None` if the signature is wrong, the header is truncated, or a
30/// dimension is zero.
31pub fn png_intrinsic_size(bytes: &[u8]) -> Option<Vec2> {
32    const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
33    if bytes.len() < 24 || bytes[..8] != SIGNATURE {
34        return None;
35    }
36    let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
37    let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
38    if width == 0 || height == 0 {
39        return None;
40    }
41    Some(Vec2::new(width as f32, height as f32))
42}
43
44/// The intrinsic size of an SVG: its `viewBox` (preferred) or `width`/`height`
45/// attributes, defaulting to a 1×1 square when none are declared.
46///
47/// Errors with [`ImageImportError::NotSvg`] when there is no `<svg>` root, and
48/// [`ImageImportError::ZeroSize`] when the determined width or height is ≤ 0 — a
49/// nonzero width and height are required for the image to display.
50pub fn svg_intrinsic_size(svg: &str) -> Result<Vec2, ImageImportError> {
51    let parser = svg::read(svg).map_err(|_| ImageImportError::NotSvg)?;
52    for event in parser {
53        let svg::parser::Event::Tag("svg", _, attributes) = event else {
54            continue;
55        };
56        let size = size_from_attrs(&attributes).unwrap_or(Vec2::new(1.0, 1.0));
57        if size.x <= 0.0 || size.y <= 0.0 {
58            return Err(ImageImportError::ZeroSize);
59        }
60        return Ok(size);
61    }
62    Err(ImageImportError::NotSvg)
63}
64
65/// Read the root `<svg>` size: prefer `viewBox` (`"min-x min-y w h"`), else the
66/// numeric part of `width`/`height`. `None` when neither is present.
67fn size_from_attrs(attributes: &svg::node::Attributes) -> Option<Vec2> {
68    if let Some(view_box) = attributes.get("viewBox") {
69        let dims: Vec<f32> = view_box
70            .split(|c: char| c.is_whitespace() || c == ',')
71            .filter(|t| !t.is_empty())
72            .filter_map(|t| t.parse::<f32>().ok())
73            .collect();
74        if let [_min_x, _min_y, w, h] = dims[..] {
75            return Some(Vec2::new(w, h));
76        }
77    }
78    let w = attributes.get("width").and_then(|v| parse_length(v))?;
79    let h = attributes.get("height").and_then(|v| parse_length(v))?;
80    Some(Vec2::new(w, h))
81}
82
83/// Parse a leading length value, dropping any unit suffix (`px`, `pt`, …).
84/// Percentages are not absolute sizes, so they yield `None`.
85fn parse_length(value: &str) -> Option<f32> {
86    let s = value.trim();
87    if s.ends_with('%') {
88        return None;
89    }
90    let end = s
91        .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '+' | '-' | 'e' | 'E')))
92        .unwrap_or(s.len());
93    s[..end].parse::<f32>().ok()
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn view_box_gives_intrinsic_size() {
102        let size = svg_intrinsic_size(
103            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"></svg>"#,
104        )
105        .unwrap();
106        assert_eq!(size, Vec2::new(200.0, 100.0));
107    }
108
109    #[test]
110    fn width_height_fallback() {
111        let size = svg_intrinsic_size(r#"<svg width="40px" height="80px"></svg>"#).unwrap();
112        assert_eq!(size, Vec2::new(40.0, 80.0));
113    }
114
115    #[test]
116    fn no_size_defaults_to_square() {
117        let size = svg_intrinsic_size(r#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#).unwrap();
118        assert_eq!(size, Vec2::new(1.0, 1.0));
119    }
120
121    #[test]
122    fn view_box_preferred_over_width_height() {
123        let size =
124            svg_intrinsic_size(r#"<svg viewBox="0 0 10 30" width="999" height="999"></svg>"#)
125                .unwrap();
126        assert_eq!(size, Vec2::new(10.0, 30.0));
127    }
128
129    #[test]
130    fn non_svg_is_rejected() {
131        assert!(matches!(
132            svg_intrinsic_size("not svg at all"),
133            Err(ImageImportError::NotSvg)
134        ));
135    }
136
137    #[test]
138    fn zero_dimension_is_rejected() {
139        assert!(matches!(
140            svg_intrinsic_size(r#"<svg viewBox="0 0 0 100"></svg>"#),
141            Err(ImageImportError::ZeroSize)
142        ));
143        assert!(matches!(
144            svg_intrinsic_size(r#"<svg width="0" height="50"></svg>"#),
145            Err(ImageImportError::ZeroSize)
146        ));
147    }
148
149    /// Build the leading 24 bytes of a PNG (signature + IHDR width/height) for
150    /// `png_intrinsic_size` to parse; the rest of a real PNG is irrelevant here.
151    fn png_header(width: u32, height: u32) -> Vec<u8> {
152        let mut bytes = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
153        bytes.extend_from_slice(&[0, 0, 0, 13]); // IHDR length
154        bytes.extend_from_slice(b"IHDR");
155        bytes.extend_from_slice(&width.to_be_bytes());
156        bytes.extend_from_slice(&height.to_be_bytes());
157        bytes
158    }
159
160    #[test]
161    fn png_intrinsic_size_reads_ihdr() {
162        assert_eq!(
163            png_intrinsic_size(&png_header(640, 480)),
164            Some(Vec2::new(640.0, 480.0))
165        );
166    }
167
168    #[test]
169    fn png_intrinsic_size_rejects_bad_signature_or_zero() {
170        assert_eq!(png_intrinsic_size(b"not a png at all really"), None);
171        assert_eq!(png_intrinsic_size(&png_header(0, 100)), None);
172        assert_eq!(png_intrinsic_size(&[0x89, b'P', b'N', b'G']), None);
173    }
174}