Skip to main content

blockworx/
embed.rs

1//! Embedding (and recovering) a diagram inside an exported PNG or SVG.
2//!
3//! An SVG/PNG export is a picture, but we also stash the source diagram as JSON
4//! inside it so the file round-trips: re-importing the picture rebuilds the
5//! editable diagram instead of dropping a flat image. The JSON is base64-encoded
6//! (ASCII, so it survives both containers untouched) and carried in a place every
7//! renderer ignores:
8//!
9//! - **SVG**: a `<metadata id="blockworx-diagram">…</metadata>` child of the root.
10//! - **PNG**: a `tEXt` chunk keyed `blockworx-diagram`, spliced in after `IHDR`.
11//!
12//! Extraction is the inverse: find the marker, base64-decode, and hand back the
13//! JSON. A picture without our marker yields `None`, and the importer treats it as
14//! a plain image.
15//!
16//! Only the READING half ships. The export that wrote these payloads died with
17//! the legacy serializer at the flag day (12·5) — a picture the editor exports
18//! now carries no diagram — while the importer still recovers one from a
19//! picture written before it, which is F5's courtesy read. The writing half
20//! survives in this module's `fixtures` (test-only) as the inverse the reader
21//! is tested against; phase 7 decides what, if anything, an export embeds
22//! again.
23
24use base64::Engine;
25
26/// Marker naming our payload in both containers (the SVG element id and the PNG
27/// text keyword).
28const 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
39// ── SVG ─────────────────────────────────────────────────────────────────────
40
41/// Recover the diagram JSON from an SVG's `blockworx-diagram` metadata element, or
42/// `None` if the SVG carries no such element (a plain image).
43pub 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
50// ── PNG ─────────────────────────────────────────────────────────────────────
51
52/// Recover the diagram JSON from a PNG's `blockworx-diagram` `tEXt` chunk, or
53/// `None` if there is none (a plain image).
54pub 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; // skip the 4-byte CRC
76    }
77    None
78}
79
80/// How a picture carrying a diagram is built: the inverse of the readers
81/// above, kept so the tests that recover a payload can write a real one, and
82/// so `import`'s can too. Nothing ships this — an export writes a picture and
83/// nothing else since 12·5.
84#[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    /// Insert `<metadata id="blockworx-diagram">base64(diagram)</metadata>` as
94    /// the first child of the root `<svg>` element. Renderers ignore
95    /// `<metadata>`, so the picture is unchanged.
96    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    /// The byte offset just past the root `<svg …>` opening tag (where a first
109    /// child is inserted). `None` if there is no `<svg` tag.
110    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    /// Splice a `tEXt` chunk carrying base64(diagram) into `png` right after
117    /// its `IHDR` chunk.
118    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); // keyword/text separator
125        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    /// The byte offset just past the `IHDR` chunk. `None` if the signature is
136    /// wrong or the header is truncated.
137    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; // length + type + data + CRC
143        (end <= png.len()).then_some(end)
144    }
145
146    /// Assemble a PNG chunk: big-endian length, 4-byte type, data, CRC-32 over
147    /// type+data.
148    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    /// PNG's CRC-32 (reflected, polynomial `0xEDB88320`) over a chunk's
161    /// type+data.
162    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    /// A minimal but structurally valid PNG: signature + IHDR + IEND. Enough
175    /// for the chunk splicer/scanner to walk.
176    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()); // width
180        ihdr.extend_from_slice(&1u32.to_be_bytes()); // height
181        ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // bit depth, color type, etc.
182        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        // The IEND chunk's CRC is a well-known constant (0xAE426082).
190        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        // The original IHDR and IEND still bracket the file.
221        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}