1use base64::Engine;
25
26const MARKER: &str = "blockworx-diagram";
29
30const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
31
32fn b64_decode(s: &str) -> Option<String> {
33 let bytes = base64::engine::general_purpose::STANDARD
34 .decode(s.trim())
35 .ok()?;
36 String::from_utf8(bytes).ok()
37}
38
39pub fn extract_from_svg(svg: &str) -> Option<String> {
44 let opener = format!("id=\"{MARKER}\">");
45 let start = svg.find(&opener)? + opener.len();
46 let end = svg[start..].find("</metadata>")? + start;
47 b64_decode(&svg[start..end])
48}
49
50pub fn extract_from_png(png: &[u8]) -> Option<String> {
55 if png.len() < 8 || png[..8] != PNG_SIGNATURE {
56 return None;
57 }
58 let mut pos = 8;
59 while pos + 8 <= png.len() {
60 let len = u32::from_be_bytes(png[pos..pos + 4].try_into().ok()?) as usize;
61 let kind = &png[pos + 4..pos + 8];
62 let data_start = pos + 8;
63 let data_end = data_start.checked_add(len)?;
64 if data_end + 4 > png.len() {
65 break;
66 }
67 if kind == b"tEXt" {
68 let data = &png[data_start..data_end];
69 if let Some(sep) = data.iter().position(|&b| b == 0)
70 && &data[..sep] == MARKER.as_bytes()
71 {
72 return b64_decode(std::str::from_utf8(&data[sep + 1..]).ok()?);
73 }
74 }
75 pos = data_end + 4; }
77 None
78}
79
80#[cfg(test)]
85pub(crate) mod fixtures {
86 use super::{MARKER, PNG_SIGNATURE};
87 use base64::Engine;
88
89 fn b64_encode(s: &str) -> String {
90 base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
91 }
92
93 pub(crate) fn embed_in_svg(svg: &str, diagram_json: &str) -> String {
97 let Some(insert_at) = svg_open_tag_end(svg) else {
98 return svg.to_string();
99 };
100 let metadata = format!(
101 "<metadata id=\"{MARKER}\">{}</metadata>",
102 b64_encode(diagram_json)
103 );
104 let (head, tail) = svg.split_at(insert_at);
105 format!("{head}{metadata}{tail}")
106 }
107
108 fn svg_open_tag_end(svg: &str) -> Option<usize> {
111 let tag = svg.find("<svg")?;
112 let close = svg[tag..].find('>')? + tag;
113 Some(close + 1)
114 }
115
116 pub(crate) fn embed_in_png(png: &[u8], diagram_json: &str) -> Vec<u8> {
119 let Some(after_ihdr) = ihdr_end(png) else {
120 return png.to_vec();
121 };
122 let mut data = Vec::new();
123 data.extend_from_slice(MARKER.as_bytes());
124 data.push(0); data.extend_from_slice(b64_encode(diagram_json).as_bytes());
126 let chunk = png_chunk(*b"tEXt", &data);
127
128 let mut out = Vec::with_capacity(png.len() + chunk.len());
129 out.extend_from_slice(&png[..after_ihdr]);
130 out.extend_from_slice(&chunk);
131 out.extend_from_slice(&png[after_ihdr..]);
132 out
133 }
134
135 fn ihdr_end(png: &[u8]) -> Option<usize> {
138 if png.len() < 8 || png[..8] != PNG_SIGNATURE {
139 return None;
140 }
141 let ihdr_len = u32::from_be_bytes(png[8..12].try_into().ok()?) as usize;
142 let end = 8 + 4 + 4 + ihdr_len + 4; (end <= png.len()).then_some(end)
144 }
145
146 pub(crate) fn png_chunk(kind: [u8; 4], data: &[u8]) -> Vec<u8> {
149 let mut out = Vec::with_capacity(12 + data.len());
150 out.extend_from_slice(&(data.len() as u32).to_be_bytes());
151 out.extend_from_slice(&kind);
152 out.extend_from_slice(data);
153 let mut crc_input = Vec::with_capacity(4 + data.len());
154 crc_input.extend_from_slice(&kind);
155 crc_input.extend_from_slice(data);
156 out.extend_from_slice(&crc32(&crc_input).to_be_bytes());
157 out
158 }
159
160 fn crc32(data: &[u8]) -> u32 {
163 let mut crc: u32 = 0xFFFF_FFFF;
164 for &byte in data {
165 crc ^= byte as u32;
166 for _ in 0..8 {
167 let mask = (crc & 1).wrapping_neg();
168 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
169 }
170 }
171 !crc
172 }
173
174 pub(crate) fn tiny_png() -> Vec<u8> {
177 let mut png = PNG_SIGNATURE.to_vec();
178 let mut ihdr = Vec::new();
179 ihdr.extend_from_slice(&1u32.to_be_bytes()); ihdr.extend_from_slice(&1u32.to_be_bytes()); ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); png.extend_from_slice(&png_chunk(*b"IHDR", &ihdr));
183 png.extend_from_slice(&png_chunk(*b"IEND", &[]));
184 png
185 }
186
187 #[test]
188 fn crc32_matches_known_value() {
189 assert_eq!(crc32(b"IEND"), 0xAE42_6082);
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::fixtures::{embed_in_png, embed_in_svg, tiny_png};
197 use super::*;
198
199 const DIAGRAM: &str = r#"{"top":"b0","blocks":[{"id":"b0","x":0,"y":0,"w":10,"h":10}]}"#;
200
201 #[test]
202 fn svg_round_trips_the_diagram() {
203 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect/></svg>"#;
204 let embedded = embed_in_svg(svg, DIAGRAM);
205 assert!(embedded.contains("<rect/>"), "the picture is preserved");
206 assert_eq!(extract_from_svg(&embedded).as_deref(), Some(DIAGRAM));
207 }
208
209 #[test]
210 fn svg_without_metadata_extracts_none() {
211 let svg = r#"<svg viewBox="0 0 1 1"><rect/></svg>"#;
212 assert_eq!(extract_from_svg(svg), None);
213 }
214
215 #[test]
216 fn png_round_trips_the_diagram() {
217 let png = tiny_png();
218 let embedded = embed_in_png(&png, DIAGRAM);
219 assert!(embedded.len() > png.len(), "a chunk was added");
220 assert_eq!(&embedded[..8], &PNG_SIGNATURE);
222 assert_eq!(extract_from_png(&embedded).as_deref(), Some(DIAGRAM));
223 }
224
225 #[test]
226 fn png_without_text_chunk_extracts_none() {
227 assert_eq!(extract_from_png(&tiny_png()), None);
228 assert_eq!(extract_from_png(b"not a png"), None);
229 }
230}