1use blockworx_doc::block_model::Asset;
5use blockworx_geom::Vec2;
6
7#[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
18pub 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
26pub 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
44pub 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
65fn 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
83fn 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 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]); 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}