From 49eb6b2de732d460321c189ce4a0e99820fd347e Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Thu, 11 Jun 2026 16:49:58 -0700 Subject: [PATCH] mermaid_render: Fix overlapping text in diagrams with long labels (#59140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mermaid diagrams with long node labels rendered as an unreadable jumble: each label was painted as one long line over a box sized for wrapped text, overflowing into neighboring nodes. With this change, labels wrap to the box width the layout computed. **Root cause:** mermaid's default `htmlLabels: true` makes merman emit labels as HTML inside ``, which resvg cannot rasterize. merman's resvg-safe pipeline replaces each one with a single-line `` fallback that only honors explicit `
` breaks, so soft-wrapped labels collapse onto one long line — while the node boxes were laid out for the wrapped text. **Fix:** disable HTML labels in the site config (both the top-level key, which merman reads for node labels, and `flowchart.htmlLabels`, which it reads for edge labels). merman then emits native SVG ``/`` labels wrapped to the same measured width the layout used, and the foreignObject fallback never runs for these diagrams. One behavior preserved deliberately: a literal `\n` in label text used to become a line break as a side effect of the foreignObject fallback (`fallback.rs` converts `\n` when flattening HTML labels). `render_mermaid` now normalizes `\n` to `
` before rendering so the native path keeps that behavior; the existing `backslash_n_converted_to_line_break` test covers it. Side effect: class diagram member text also switches from the lossy fallback to native SVG text; the accent-class test was updated accordingly (accent classes now land directly on `text`/`tspan` elements). Verified with `cargo nextest run -p mermaid_render`, `-p markdown`, `script/clippy -p mermaid_render`, and `cargo fmt --check`. Release Notes: - Fixed mermaid diagram labels overflowing and overlapping nodes when long labels wrap --------- Co-authored-by: cameron --- crates/mermaid_render/src/render.rs | 33 +++++++++++-- .../tests/check_invalid_attrs.rs | 47 ++++--------------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/crates/mermaid_render/src/render.rs b/crates/mermaid_render/src/render.rs index b32a36cbfaf..5db7ca87536 100644 --- a/crates/mermaid_render/src/render.rs +++ b/crates/mermaid_render/src/render.rs @@ -122,7 +122,12 @@ fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig { "theme": "base", "darkMode": theme.dark_mode, "fontFamily": theme.font_family, + // resvg can't rasterize HTML `` labels, and the + // fallback that replaces them loses soft wrapping, so emit native SVG + // text labels. Nodes read the top-level key; edges read `flowchart`. + "htmlLabels": false, "flowchart": { + "htmlLabels": false, "padding": 16, }, "themeVariables": theme_vars, @@ -144,10 +149,6 @@ mod tests { !html_label_svg.contains("` lines rather + /// than the resvg-safe pipeline's single-line `` fallback, + /// which overflows the node box (see the `htmlLabels` comment in + /// [`to_merman_config`]). If the fallback marker reappears after a merman + /// upgrade, the config has stopped disabling HTML labels. + #[test] + fn long_labels_render_as_wrapped_svg_text() { + let source = "flowchart TD\n \ + A[\"Pass 2: search transcript with annotation blocks excised, \ + map offsets back to buffer space\"] --> \ + |ambiguous or zero| B[\"Error describing where matches were found\"]"; + let svg = render_mermaid(source, &MermaidTheme::default()).expect("render failed"); + + assert!( + !svg.contains(r#"data-merman-foreignobject="fallback""#), + "labels went through the foreignObject fallback, which loses soft wrapping: {svg}" + ); + let wrapped_line_count = svg.matches("text-outer-tspan").count(); + assert!( + wrapped_line_count > 3, + "expected long labels to wrap onto multiple tspan lines, got {wrapped_line_count}: {svg}" + ); + } } diff --git a/crates/mermaid_render/tests/check_invalid_attrs.rs b/crates/mermaid_render/tests/check_invalid_attrs.rs index e4d49384bf2..93093d899dc 100644 --- a/crates/mermaid_render/tests/check_invalid_attrs.rs +++ b/crates/mermaid_render/tests/check_invalid_attrs.rs @@ -268,25 +268,7 @@ fn generics_not_double_escaped() { } #[test] -fn backslash_n_converted_to_line_break() { - let theme = rgb_theme(); - let source = r#"graph TD - L7["Layer 7\nHTTP, FTP"] - L6["Layer 6\nEncryption"] - L7 --> L6"#; - let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); - assert!( - !svg.contains(r"\n"), - "Literal \\n should not appear in SVG output" - ); - assert!( - svg.contains(">Layer 7<") && svg.contains(">HTTP, FTP<"), - "Label lines should be split into separate elements" - ); -} - -#[test] -fn class_diagram_fallback_text_uses_accent_classes() { +fn class_diagram_label_text_uses_accent_classes() { let theme = rgb_theme(); let source = r#"classDiagram class Animal { @@ -303,32 +285,19 @@ fn class_diagram_fallback_text_uses_accent_classes() { use quick_xml::events::Event; let mut reader = quick_xml::Reader::from_str(&svg); - let mut in_fallback = false; let mut accent_classes: Vec = Vec::new(); loop { match reader.read_event() { Ok(Event::Eof) => break, - Ok(Event::Start(e)) => { - if e.name().as_ref() == b"g" { - if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") { - if attr.value.as_ref() == b"fallback" { - in_fallback = true; + Ok(Event::Start(e)) if e.name().as_ref() == b"text" => { + if let Ok(Some(class_attr)) = e.try_get_attribute("class") { + let class = class_attr.unescape_value().unwrap_or_default().to_string(); + for token in class.split_whitespace() { + if token.starts_with("zed-accent-") { + accent_classes.push(token.to_string()); } } } - if in_fallback && e.name().as_ref() == b"text" { - if let Ok(Some(class_attr)) = e.try_get_attribute("class") { - let class = class_attr.unescape_value().unwrap_or_default().to_string(); - for token in class.split_whitespace() { - if token.starts_with("zed-accent-") { - accent_classes.push(token.to_string()); - } - } - } - } - } - Ok(Event::End(e)) if e.name().as_ref() == b"g" => { - in_fallback = false; } _ => {} } @@ -336,7 +305,7 @@ fn class_diagram_fallback_text_uses_accent_classes() { assert!( !accent_classes.is_empty(), - "expected zed-accent-N classes on text elements in fallback groups", + "expected zed-accent-N classes on label text elements", ); }