mermaid_render: Fix overlapping text in diagrams with long labels (#59140)

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 `<foreignObject>`, which resvg cannot rasterize.
merman's resvg-safe pipeline replaces each one with a single-line
`<text>` fallback that only honors explicit `<br>` 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 `<text>`/`<tspan>`
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 `<br/>` 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 <cameron.studdstreet@gmail.com>
This commit is contained in:
Eric Holk 2026-06-11 16:49:58 -07:00 committed by GitHub
parent 34cd17ff5e
commit 49eb6b2de7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 37 additions and 43 deletions

View file

@ -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 `<foreignObject>` 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("<foreignObject"),
"got: {html_label_svg}"
);
assert!(
html_label_svg.contains(r#"data-merman-foreignobject="fallback""#),
"got: {html_label_svg}"
);
assert!(
!html_label_svg.contains("&amp;lt;"),
"got: {html_label_svg}"
@ -163,4 +164,28 @@ mod tests {
assert!(!css_svg.contains("animation-name:"), "got: {css_svg}");
assert!(!css_svg.contains("!important"), "got: {css_svg}");
}
/// Soft-wrapped labels must render as native SVG `<tspan>` lines rather
/// than the resvg-safe pipeline's single-line `<foreignObject>` 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}"
);
}
}

View file

@ -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 <text> 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<String> = 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",
);
}