From 3d54486110f16f8eb3138796c883723eb1ef6d18 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Wed, 13 May 2026 07:21:26 +1200 Subject: [PATCH 1/4] Include `InputDiagram` source in generated SVG. --- Cargo.lock | 60 +++++++++ Cargo.toml | 1 + crate/input_ir_rt/Cargo.toml | 7 ++ .../src/svg_elements_to_svg_mapper.rs | 114 +++++++++++++++++- 4 files changed, 178 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17f8594..c2d4884 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1852,9 +1852,11 @@ dependencies = [ "disposition_taffy_model", "emojis", "encre-css", + "jiff", "kurbo", "linesweeper", "serde", + "serde-saphyr", "taffy", "typed-builder", "unicode-segmentation", @@ -3334,6 +3336,49 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "js-sys", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "wasm-bindgen", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -4691,6 +4736,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 5f3b321..8a0822b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ getrandom = "0.4.2" gloo-timers = "0.4.0" id_newtype = "0.3.0" indexmap = "2.14.0" +jiff = "0.2.24" kurbo = "0.13.0" linesweeper = "0.3.0" miette = "7.6.0" diff --git a/crate/input_ir_rt/Cargo.toml b/crate/input_ir_rt/Cargo.toml index b8bfa9f..3ae1404 100644 --- a/crate/input_ir_rt/Cargo.toml +++ b/crate/input_ir_rt/Cargo.toml @@ -35,4 +35,11 @@ linesweeper = { workspace = true } serde = { workspace = true, features = ["derive"] } taffy = { workspace = true } typed-builder = { workspace = true } +serde-saphyr = { workspace = true } unicode-segmentation = { workspace = true } + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +jiff = { workspace = true } + +[target.'cfg(target_family = "wasm")'.dependencies] +jiff = { workspace = true, features = ["js"] } diff --git a/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs b/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs index 2afd4c2..a8e5f2d 100644 --- a/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs +++ b/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs @@ -3,6 +3,7 @@ use std::fmt::Write; use crate::string_xml_escaper::StringXmlEscaper; use base64::{prelude::BASE64_STANDARD, Engine}; +use disposition_input_model::InputDiagram; use disposition_ir_model::entity::EntityTailwindClasses; use disposition_svg_model::{SvgEdgeInfo, SvgElements, SvgNodeInfo}; use disposition_taffy_model::{TEXT_FONT_SIZE, TEXT_LINE_HEIGHT}; @@ -79,7 +80,100 @@ pub struct SvgElementsToSvgMapper; impl SvgElementsToSvgMapper { /// Renders the SVG elements to a string. + /// + /// See [`Self::map_with_input`] if you want the `InputDiagram` source to be + /// included as well. pub fn map(svg_elements: &SvgElements) -> String { + let mut buffer = String::new(); + Self::map_svg(&mut buffer, svg_elements, None); + buffer + } + + /// Renders the SVG elements to a string, prepended with an XML declaration + /// and a brief XML comment, with the source `input_diagram` serialized as + /// YAML inside a `` element within the SVG. + /// + /// The output follows the format: + /// + /// ```xml + /// + /// + /// + /// --- + /// things: + /// t_alice: Alice + /// + /// + /// + /// ``` + /// + /// # Notes + /// + /// - The only sequence that would break a CDATA section (`]]>`) is escaped + /// by splitting it across two adjacent CDATA sections: `]]]]>`. + /// - If `input_diagram` cannot be serialized to YAML, the `` + /// element is omitted. + pub fn map_with_input(input_diagram: &InputDiagram<'_>, svg_elements: &SvgElements) -> String { + let timestamp = jiff::Zoned::now() + .strftime("%Y-%m-%d %H:%M:%S%.3f%:z") + .to_string(); + + let yaml = { + let mut yaml_buffer = String::new(); + let yaml_result = serde_saphyr::to_fmt_writer(&mut yaml_buffer, input_diagram); + if yaml_result.is_ok() { + // `]]>` is the only sequence that cannot appear unescaped + // inside a CDATA section. Split it across two adjacent + // CDATA sections so the content remains valid. + if yaml_buffer.contains("]]>") { + yaml_buffer.replace("]]>", "]]]]>") + } else { + yaml_buffer + } + } else { + String::new() + } + }; + + let source_yaml = if yaml.is_empty() { + None + } else { + Some(yaml.as_str()) + }; + + let mut buffer = String::with_capacity( + "\n".len() + 256 + yaml.len(), + ); + + // XML declaration + buffer.push_str("\n"); + + // Brief comment with generation info (source YAML goes inside the SVG) + buffer.push_str("\n"); + + Self::map_svg(&mut buffer, svg_elements, source_yaml); + + buffer + } + + /// Writes the `` element to `buffer`. + /// + /// If `source_yaml` is `Some`, a `...` element + /// is injected immediately after the opening `` tag, embedding the + /// YAML source so it can be copied verbatim. + fn map_svg(buffer: &mut String, svg_elements: &SvgElements, source_yaml: Option<&str>) { let SvgElements { svg_width, svg_height, @@ -169,8 +263,11 @@ impl SvgElementsToSvgMapper { style_content.push_str(css.as_str()); } - // Build final SVG - let mut buffer = String::with_capacity(128 + style_content.len() + content_buffer.len()); + // Reserve capacity for the SVG content before writing. + let source_len = source_yaml + .map(|yaml| "".len() + yaml.len() + "".len() + 1) + .unwrap_or(0); + buffer.reserve(128 + style_content.len() + content_buffer.len() + source_len); // Start SVG element write!( @@ -184,6 +281,17 @@ impl SvgElementsToSvgMapper { ) .unwrap(); + // Embed YAML source in a CDATA section so it can be copied verbatim. + if let Some(yaml) = source_yaml { + buffer.push_str(""); + buffer.push_str(yaml); + // Ensure the YAML ends with a newline before the closing marker. + if !yaml.ends_with('\n') { + buffer.push('\n'); + } + buffer.push_str(""); + } + // Add style element first (before content) if !style_content.is_empty() { write!(buffer, "").unwrap(); @@ -194,8 +302,6 @@ impl SvgElementsToSvgMapper { // Close SVG element buffer.push_str(""); - - buffer } /// Writes nodes to the SVG content buffer. From bce407e33bd73ad8e25a12450fad680621a39ed3 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Wed, 13 May 2026 07:47:30 +1200 Subject: [PATCH 2/4] Update `disposition_editor.rs` to use `SvgElementsToSvgMapper::map_with_input`. --- app/playground/src/components/disposition_editor.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/playground/src/components/disposition_editor.rs b/app/playground/src/components/disposition_editor.rs index ea68bd8..63e22a3 100644 --- a/app/playground/src/components/disposition_editor.rs +++ b/app/playground/src/components/disposition_editor.rs @@ -553,10 +553,13 @@ pub fn DispositionEditor(editor_state: ReadSignal) -> Element { }); let svg: Memo = use_memo(move || { + let input_diagram = input_diagram.read(); let svg_elements = &*svg_elements.read(); let svg_generation_start = Instant::now(); let svg = match svg_elements { - Ok(svg_elements) => SvgElementsToSvgMapper::map(svg_elements), + Ok(svg_elements) => { + SvgElementsToSvgMapper::map_with_input(&input_diagram, svg_elements) + } Err(_) => String::new(), }; let svg_generation_duration_ms = Instant::now() From 8d2cd4fb294299479932c92fbacb91f3b35ee233 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Wed, 13 May 2026 07:48:29 +1200 Subject: [PATCH 3/4] Update `CHANGELOG.md`. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f93261..3391682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ * Update `stroke_style: dashed` to mean `dasharray:4`. ([#27][#27]) * Fix duplication of tailwind classes on edges. ([#28][#28]) * Fix edge path routing issues regarding cross-container edges, spacers, and nested `NodeRank`s. ([#29][#29]) +* Include `InputDiagram` source in generated SVG. ([#30][#30]) [#26]: https://github.com/azriel91/disposition/pull/26 [#27]: https://github.com/azriel91/disposition/pull/27 [#28]: https://github.com/azriel91/disposition/pull/28 [#29]: https://github.com/azriel91/disposition/pull/29 +[#30]: https://github.com/azriel91/disposition/pull/30 ## 0.1.0 (2026-04-11) From 607576044c91baf18e9ae1486920a99636ae31f8 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Wed, 13 May 2026 07:51:45 +1200 Subject: [PATCH 4/4] Address clippy lints. --- crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs b/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs index a8e5f2d..ce7ce45 100644 --- a/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs +++ b/crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs @@ -154,9 +154,9 @@ impl SvgElementsToSvgMapper { // Brief comment with generation info (source YAML goes inside the SVG) buffer.push_str("