Skip to main content

blockworx/canvas/
image.rs

1//! Canvas-owned image registry, and the intrinsic-size parsers behind it.
2//!
3//! A block's [`Image`](blockworx_doc::block_model::Image) holds an
4//! [`Asset`] — raw SVG text or embedded PNG bytes, content-addressed by its own
5//! [`AssetHash`]; before it can be drawn the Canvas must *register* it: parse
6//! its intrinsic size, hand the bytes to egui's image loader, and mint an
7//! [`ImageHandle`] — the lightweight token the on-screen `Painter` draws from,
8//! polling egui's loader by [`ImageHandle::uri`]. The [`ImageRegistry`] caches
9//! by that hash, so the painter registers an image lazily on first draw (via
10//! `Painter::draw_image`) and every later draw is a cheap lookup.
11
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use blockworx_doc::{block_model::Asset, hash::AssetHash};
16use blockworx_geom::Vec2;
17use blockworx_paint::{ImageHandle, ImageImportError};
18
19/// Content-addressed cache of registered images, keyed on the payload's own
20/// [`AssetHash`] — the same identity an [`Asset`] carries everywhere else, so
21/// two placements of equal bytes always share one registration whether or not
22/// they share an allocation. Owned by the Canvas ([`View`](crate::canvas::View))
23/// and shared with the per-frame `Painter`.
24#[derive(Default)]
25pub struct ImageRegistry(BTreeMap<AssetHash, ImageHandle>);
26
27impl ImageRegistry {
28    /// Register `asset` (returning a cached handle for content already seen).
29    /// Parses the intrinsic size (rejecting a zero width/height so the image is
30    /// never cached and won't display) and hands the bytes to egui's loader once.
31    /// The loader URI carries the matching extension (`.svg`/`.png`) so egui
32    /// picks the right decoder.
33    pub fn register(
34        &mut self,
35        ctx: &egui::Context,
36        asset: &Asset,
37    ) -> Result<ImageHandle, ImageImportError> {
38        let ext = match asset {
39            Asset::Svg(_) => "svg",
40            Asset::Png(_) => "png",
41        };
42        let key = asset.hash();
43        if let Some(handle) = self.0.get(&key) {
44            return Ok(handle.clone());
45        }
46        let size = image_intrinsic_size(asset)?;
47        let uri: Arc<str> = Arc::from(format!("bytes://image-{key}.{ext}"));
48        // One-time registration of the bytes with egui's loader (keyed by `uri`).
49        ctx.include_bytes(uri.to_string(), asset.bytes().to_vec());
50        let handle = ImageHandle { uri, size };
51        self.0.insert(key, handle.clone());
52        Ok(handle)
53    }
54}
55
56/// The intrinsic size of an image, dispatching by kind.
57pub fn image_intrinsic_size(asset: &Asset) -> Result<Vec2, ImageImportError> {
58    match asset {
59        Asset::Svg(bytes) => svg_intrinsic_size(&String::from_utf8_lossy(bytes)),
60        Asset::Png(bytes) => png_intrinsic_size(bytes).ok_or(ImageImportError::NotPng),
61    }
62}
63
64/// The intrinsic size of a PNG, read straight from its IHDR chunk — no decoder
65/// needed. A PNG is the 8-byte signature followed by the IHDR chunk, whose data
66/// begins at byte 16: width is the big-endian `u32` at bytes 16..20 and height at
67/// bytes 20..24. `None` if the signature is wrong, the header is truncated, or a
68/// dimension is zero.
69pub fn png_intrinsic_size(bytes: &[u8]) -> Option<Vec2> {
70    const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
71    if bytes.len() < 24 || bytes[..8] != SIGNATURE {
72        return None;
73    }
74    let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
75    let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
76    if width == 0 || height == 0 {
77        return None;
78    }
79    Some(Vec2::new(width as f32, height as f32))
80}
81
82/// The intrinsic size of an SVG: its `viewBox` (preferred) or `width`/`height`
83/// attributes, defaulting to a 1×1 square when none are declared.
84///
85/// Errors with [`ImageImportError::NotSvg`] when there is no `<svg>` root, and
86/// [`ImageImportError::ZeroSize`] when the determined width or height is ≤ 0 — a
87/// nonzero width and height are required for the image to display.
88pub fn svg_intrinsic_size(svg: &str) -> Result<Vec2, ImageImportError> {
89    let parser = svg::read(svg).map_err(|_| ImageImportError::NotSvg)?;
90    for event in parser {
91        let svg::parser::Event::Tag("svg", _, attributes) = event else {
92            continue;
93        };
94        let size = size_from_attrs(&attributes).unwrap_or(Vec2::new(1.0, 1.0));
95        if size.x <= 0.0 || size.y <= 0.0 {
96            return Err(ImageImportError::ZeroSize);
97        }
98        return Ok(size);
99    }
100    Err(ImageImportError::NotSvg)
101}
102
103/// Read the root `<svg>` size: prefer `viewBox` (`"min-x min-y w h"`), else the
104/// numeric part of `width`/`height`. `None` when neither is present.
105fn size_from_attrs(attributes: &svg::node::Attributes) -> Option<Vec2> {
106    if let Some(view_box) = attributes.get("viewBox") {
107        let dims: Vec<f32> = view_box
108            .split(|c: char| c.is_whitespace() || c == ',')
109            .filter(|t| !t.is_empty())
110            .filter_map(|t| t.parse::<f32>().ok())
111            .collect();
112        if let [_min_x, _min_y, w, h] = dims[..] {
113            return Some(Vec2::new(w, h));
114        }
115    }
116    let w = attributes.get("width").and_then(|v| parse_length(v))?;
117    let h = attributes.get("height").and_then(|v| parse_length(v))?;
118    Some(Vec2::new(w, h))
119}
120
121/// Parse a leading length value, dropping any unit suffix (`px`, `pt`, …).
122/// Percentages are not absolute sizes, so they yield `None`.
123fn parse_length(value: &str) -> Option<f32> {
124    let s = value.trim();
125    if s.ends_with('%') {
126        return None;
127    }
128    let end = s
129        .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '+' | '-' | 'e' | 'E')))
130        .unwrap_or(s.len());
131    s[..end].parse::<f32>().ok()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn view_box_gives_intrinsic_size() {
140        let size = svg_intrinsic_size(
141            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"></svg>"#,
142        )
143        .unwrap();
144        assert_eq!(size, Vec2::new(200.0, 100.0));
145    }
146
147    #[test]
148    fn width_height_fallback() {
149        let size = svg_intrinsic_size(r#"<svg width="40px" height="80px"></svg>"#).unwrap();
150        assert_eq!(size, Vec2::new(40.0, 80.0));
151    }
152
153    #[test]
154    fn no_size_defaults_to_square() {
155        let size = svg_intrinsic_size(r#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#).unwrap();
156        assert_eq!(size, Vec2::new(1.0, 1.0));
157    }
158
159    #[test]
160    fn view_box_preferred_over_width_height() {
161        let size =
162            svg_intrinsic_size(r#"<svg viewBox="0 0 10 30" width="999" height="999"></svg>"#)
163                .unwrap();
164        assert_eq!(size, Vec2::new(10.0, 30.0));
165    }
166
167    #[test]
168    fn non_svg_is_rejected() {
169        assert!(matches!(
170            svg_intrinsic_size("not svg at all"),
171            Err(ImageImportError::NotSvg)
172        ));
173    }
174
175    #[test]
176    fn zero_dimension_is_rejected() {
177        assert!(matches!(
178            svg_intrinsic_size(r#"<svg viewBox="0 0 0 100"></svg>"#),
179            Err(ImageImportError::ZeroSize)
180        ));
181        assert!(matches!(
182            svg_intrinsic_size(r#"<svg width="0" height="50"></svg>"#),
183            Err(ImageImportError::ZeroSize)
184        ));
185    }
186
187    /// Build the leading 24 bytes of a PNG (signature + IHDR width/height) for
188    /// `png_intrinsic_size` to parse; the rest of a real PNG is irrelevant here.
189    fn png_header(width: u32, height: u32) -> Vec<u8> {
190        let mut bytes = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
191        bytes.extend_from_slice(&[0, 0, 0, 13]); // IHDR length
192        bytes.extend_from_slice(b"IHDR");
193        bytes.extend_from_slice(&width.to_be_bytes());
194        bytes.extend_from_slice(&height.to_be_bytes());
195        bytes
196    }
197
198    #[test]
199    fn png_intrinsic_size_reads_ihdr() {
200        assert_eq!(
201            png_intrinsic_size(&png_header(640, 480)),
202            Some(Vec2::new(640.0, 480.0))
203        );
204    }
205
206    #[test]
207    fn png_intrinsic_size_rejects_bad_signature_or_zero() {
208        assert_eq!(png_intrinsic_size(b"not a png at all really"), None);
209        assert_eq!(png_intrinsic_size(&png_header(0, 100)), None);
210        assert_eq!(png_intrinsic_size(&[0x89, b'P', b'N', b'G']), None);
211    }
212
213    #[test]
214    fn register_caches_by_content() {
215        let ctx = egui::Context::default();
216        let mut reg = ImageRegistry::default();
217        let svg = Asset::Svg(r#"<svg viewBox="0 0 20 10"></svg>"#.as_bytes().into());
218        let a = reg.register(&ctx, &svg).unwrap();
219        let b = reg.register(&ctx, &svg).unwrap();
220        assert_eq!(a.uri, b.uri, "same content → same cached URI");
221        assert!(a.uri.ends_with(".svg"));
222        assert_eq!(a.size, Vec2::new(20.0, 10.0));
223        assert!(matches!(
224            reg.register(&ctx, &Asset::Svg("nope".as_bytes().into()))
225                .unwrap_err(),
226            ImageImportError::NotSvg
227        ));
228
229        let png = Asset::Png(png_header(8, 4).into());
230        let handle = reg.register(&ctx, &png).unwrap();
231        assert!(handle.uri.ends_with(".png"));
232        assert_eq!(handle.size, Vec2::new(8.0, 4.0));
233    }
234}