Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion app/playground/src/components/disposition_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,10 +553,13 @@ pub fn DispositionEditor(editor_state: ReadSignal<EditorState>) -> Element {
});

let svg: Memo<String> = 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()
Expand Down
7 changes: 7 additions & 0 deletions crate/input_ir_rt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
114 changes: 110 additions & 4 deletions crate/input_ir_rt/src/svg_elements_to_svg_mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 `<source><![CDATA[...]]></source>` element within the SVG.
///
/// The output follows the format:
///
/// ```xml
/// <?xml version="1.0" encoding="UTF-8"?>
/// <!--
/// This diagram was generated using `disposition` on `2026-05-13 06:15:00.000+13:00`.
///
/// See <https://azriel.im/disposition>.
/// -->
/// <svg xmlns="http://www.w3.org/2000/svg" ...>
/// <source><![CDATA[---
/// things:
/// t_alice: Alice
/// ]]></source>
/// <!-- .. -->
/// </svg>
/// ```
///
/// # Notes
///
/// - The only sequence that would break a CDATA section (`]]>`) is escaped
/// by splitting it across two adjacent CDATA sections: `]]]]><![CDATA[>`.
/// - If `input_diagram` cannot be serialized to YAML, the `<source>`
/// 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("]]>", "]]]]><![CDATA[>")
} 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(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".len() + 256 + yaml.len(),
);

// XML declaration
buffer.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");

// Brief comment with generation info (source YAML goes inside the SVG)
buffer.push_str("<!--\n");
writeln!(
buffer,
" This diagram was generated using `disposition` on `{timestamp}`."
)
.unwrap();
buffer.push('\n');
buffer.push_str(" See <https://azriel.im/disposition>.\n");
buffer.push_str("-->\n");

Self::map_svg(&mut buffer, svg_elements, source_yaml);

buffer
}

/// Writes the `<svg>` element to `buffer`.
///
/// If `source_yaml` is `Some`, a `<source><![CDATA[...]]></source>` element
/// is injected immediately after the opening `<svg ...>` 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,
Expand Down Expand Up @@ -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| "<source><![CDATA[".len() + yaml.len() + "]]></source>".len() + 1)
.unwrap_or(0);
buffer.reserve(128 + style_content.len() + content_buffer.len() + source_len);

// Start SVG element
write!(
Expand All @@ -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("<source><![CDATA[");
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("]]></source>");
}

// Add style element first (before content)
if !style_content.is_empty() {
write!(buffer, "<style>{style_content}</style>").unwrap();
Expand All @@ -194,8 +302,6 @@ impl SvgElementsToSvgMapper {

// Close SVG element
buffer.push_str("</svg>");

buffer
}

/// Writes nodes to the SVG content buffer.
Expand Down
Loading