blockworx/canvas/
image.rs1use 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#[derive(Default)]
25pub struct ImageRegistry(BTreeMap<AssetHash, ImageHandle>);
26
27impl ImageRegistry {
28 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 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
56pub 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
64pub 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
82pub 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
103fn 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
121fn 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 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]); 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}