From fb6392fbf155488683ba240e956fafaa55b33f9d Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Tue, 4 Aug 2026 10:08:24 -0400 Subject: [PATCH 1/2] mermaid: render with safe pipeline closes #74 --- src/lib.rs | 7 ++- tests/unit.rs | 145 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 5c9e6b8..3d278b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1560,7 +1560,12 @@ impl StreamingParser { let (tx, rx) = std::sync::mpsc::channel(); let source_clone = source.clone(); std::thread::spawn(move || { - let result = merman::render::HeadlessRenderer::new().render_svg_sync(&source_clone); + // Use the resvg-safe pipeline: Mermaid-parity SVG puts labels inside + // HTML for flowcharts, class/ER/state diagrams, mindmaps and + // kanban. resvg does not implement foreignObject, so those labels would be + // silently dropped. This pipeline converts HTML labels to native SVG . + let result = + merman::render::HeadlessRenderer::new().render_svg_resvg_safe_sync(&source_clone); // Ignore send errors — receiver may have timed out and been dropped let _ = tx.send(result); }); diff --git a/tests/unit.rs b/tests/unit.rs index 353011c..4475e14 100644 --- a/tests/unit.rs +++ b/tests/unit.rs @@ -1617,13 +1617,36 @@ mod mermaid_rendering { #[test] fn test_mermaid_invalid_fallback_to_code() { - // this test verifies that *something* is produced (image or code) from an empty block - // without panicking. The key contract is: no panic, always produce output. + // merman returns typed errors for undetectable/unparseable diagrams (unlike the + // old mermaid-rs-renderer, which returned a blank 16x16 SVG), so each of these + // must fall back to a highlighted code block rather than emitting an image. + for input in [ + "```mermaid\n\n```\n", // empty + "```mermaid\nflowchart LR\n A[unclosed\n```\n", // unterminated node label + "```mermaid\nnotADiagramType\n a b c\n```\n", // unknown diagram type + ] { + let mut p = kitty_parser(); + let output = feed_all(&mut p, input); + assert!( + !output.contains("\x1b_G"), + "invalid mermaid should not emit a kitty image, input: {:?}", + input + ); + assert!( + !output.is_empty(), + "invalid mermaid should still produce output, input: {:?}", + input + ); + } + + // The fallback must show the original mermaid source as code. let mut p = kitty_parser(); - let output = feed_all(&mut p, "```mermaid\n\n```\n"); + let output = feed_all(&mut p, "```mermaid\nnotADiagramType\n a b c\n```\n"); + let stripped = super::strip_ansi(&output); assert!( - !output.is_empty(), - "should produce some output even for empty mermaid block" + stripped.contains("notADiagramType"), + "fallback should show mermaid source as code, got: {:?}", + stripped ); } @@ -1679,4 +1702,116 @@ mod mermaid_rendering { "should contain following text" ); } + + /// Decode the base64 PNG payload out of a kitty graphics escape sequence. + /// + /// The payload is split across `\x1b_Gf=100,...;\x1b\\` plus zero or more + /// `\x1b_Gm=<0|1>;\x1b\\` continuation chunks. + fn decode_kitty_png(output: &str) -> Vec { + use base64::Engine; + + let mut b64 = String::new(); + let mut rest = output; + while let Some(start) = rest.find("\x1b_G") { + let after = &rest[start + 3..]; + let Some(semi) = after.find(';') else { break }; + let Some(end) = after.find("\x1b\\") else { + break; + }; + if semi < end { + b64.push_str(&after[semi + 1..end]); + } + rest = &after[end + 2..]; + } + assert!(!b64.is_empty(), "no kitty payload found in output"); + base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .expect("kitty payload should be valid base64") + } + + /// Count dark (glyph-colored) pixels in the decoded image. + /// + /// Mermaid diagrams use light fills with dark strokes and dark text, so the count of + /// near-black pixels is a proxy for "how much ink is on the page". Diagrams whose + /// labels were dropped (the `foreignObject` bug) draw only shape outlines and land + /// far below the thresholds asserted here. + fn dark_pixel_count(png: &[u8]) -> usize { + let img = image::load_from_memory_with_format(png, image::ImageFormat::Png) + .expect("kitty payload should decode as PNG") + .to_rgba8(); + img.pixels() + .filter(|p| p.0[3] > 128 && p.0[0] < 120 && p.0[1] < 120 && p.0[2] < 120) + .count() + } + + /// Regression test for the `foreignObject` bug: flowchart labels live inside + /// `` in Mermaid-parity SVG, which resvg silently skips. Rendering must + /// go through merman's resvg-safe pipeline so labels become native SVG ``. + /// + /// Before the fix this diagram rasterized to 175 dark pixels (shape outlines only); + /// after it produces ~700, so 400 is a comfortable regression boundary. + #[test] + fn test_mermaid_flowchart_labels_are_rasterized() { + let mut p = kitty_parser(); + let output = feed_all( + &mut p, + "```mermaid\nflowchart LR\n A[Client] -->|HTTP| B(Gateway)\n B --> C{Auth?}\n```\n", + ); + let dark = dark_pixel_count(&decode_kitty_png(&output)); + assert!( + dark > 400, + "flowchart node/edge labels appear to be missing: only {} dark pixels \ + (empty shapes render ~175, labeled shapes render ~700). \ + Is try_render_mermaid using render_svg_sync instead of \ + render_svg_resvg_safe_sync?", + dark + ); + } + + /// Same regression, for a diagram family whose class-member text is also HTML-labeled. + /// Before the fix: 237 dark pixels. After: ~760. + #[test] + fn test_mermaid_class_diagram_labels_are_rasterized() { + let mut p = kitty_parser(); + let output = feed_all( + &mut p, + "```mermaid\nclassDiagram\n class Animal {\n +String name\n \ + +eat() void\n }\n class Dog {\n +bark() void\n }\n \ + Animal <|-- Dog\n```\n", + ); + let dark = dark_pixel_count(&decode_kitty_png(&output)); + assert!( + dark > 450, + "class diagram member text appears to be missing: only {} dark pixels \ + (empty boxes render ~237, populated boxes render ~760)", + dark + ); + } + + /// The SVG handed to resvg must not contain `foreignObject`, since resvg does not + /// implement it and drops the contents without warning. + #[test] + fn test_mermaid_svg_has_no_foreign_objects() { + for source in [ + "flowchart LR\n A[Client] -->|HTTP| B(Gateway)\n", + "classDiagram\n class Animal {\n +String name\n }\n", + "stateDiagram-v2\n [*] --> Still\n Still --> Moving\n", + "erDiagram\n CUSTOMER ||--o{ ORDER : places\n", + ] { + let svg = merman::render::HeadlessRenderer::new() + .render_svg_resvg_safe_sync(source) + .expect("render should succeed") + .expect("diagram type should be detected"); + assert!( + !svg.contains("foreignObject"), + "resvg-safe SVG still contains foreignObject for source: {:?}", + source + ); + assert!( + svg.contains(" labels for source: {:?}", + source + ); + } + } } From d10c625de2bd679e02dd311910403241b511310b Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Tue, 4 Aug 2026 11:27:33 -0400 Subject: [PATCH 2/2] fonts: handle missing generic fonts better --- .github/workflows/ci.yml | 8 ++++ src/lib.rs | 87 ++++++++++++++++++++++++++++++++++++++++ tests/unit.rs | 80 +++++++++++++++++++++++++++++++++--- 3 files changed, 169 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58debfb..cf01ad5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,14 @@ jobs: - uses: actions/checkout@v7 - uses: jdx/mise-action@v4 + # resvg needs a real text font to rasterize Mermaid labels. The ubuntu runner + # ships only fonts-noto-color-emoji (emoji, no Latin glyphs), which makes the + # mermaid label-rendering tests skip themselves. Install a Latin font so they + # actually run and can catch dropped-text regressions. + - name: Install fonts (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends fonts-dejavu-core + - name: Run tests run: cargo test --verbose diff --git a/src/lib.rs b/src/lib.rs index 3d278b5..469929b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2626,6 +2626,84 @@ impl StreamingParser { || trimmed.starts_with(" Option { + candidates.iter().find_map(|want| { + db.faces() + .flat_map(|face| face.families.iter()) + .find(|(name, _)| name.eq_ignore_ascii_case(want)) + .map(|(name, _)| name.clone()) + }) + } + + // Any face at all, used as a last resort so text is never dropped outright. + let any = db + .faces() + .next() + .and_then(|face| face.families.first().map(|(name, _)| name.clone())); + + let sans = find( + db, + &[ + "Arial", + "Helvetica", + "Liberation Sans", + "DejaVu Sans", + "Noto Sans", + "FreeSans", + "Nimbus Sans", + "Ubuntu", + "Cantarell", + ], + ) + .or_else(|| any.clone()); + + let serif = find( + db, + &[ + "Times New Roman", + "Liberation Serif", + "DejaVu Serif", + "Noto Serif", + "FreeSerif", + "Nimbus Roman", + ], + ) + .or_else(|| sans.clone()); + + let mono = find( + db, + &[ + "Courier New", + "Liberation Mono", + "DejaVu Sans Mono", + "Noto Sans Mono", + "FreeMono", + "Nimbus Mono PS", + "Menlo", + "Consolas", + ], + ) + .or_else(|| sans.clone()); + + if let Some(name) = sans { + db.set_sans_serif_family(name); + } + if let Some(name) = serif { + db.set_serif_family(name); + } + if let Some(name) = mono { + db.set_monospace_family(name); + } + } + /// Render SVG to a raster image using resvg fn render_svg(&self, data: &[u8]) -> Result> { use resvg::tiny_skia::Pixmap; @@ -2682,6 +2760,15 @@ impl StreamingParser { } fontdb.load_system_fonts(); + // Point the generic CSS families (sans-serif, serif, monospace) at fonts that + // actually exist here. fontdb defaults them to "Arial"/"Times New Roman"/"Courier + // New", which are absent on most Linux systems and minimal containers. usvg does + // not fall back to an arbitrary installed font when the whole family stack is + // unresolvable — it drops the text run entirely and silently. Mermaid asks for + // `"trebuchet ms",verdana,arial,sans-serif`, so on a box with none of those and an + // unmapped `sans-serif`, every label vanishes while shapes still draw. + Self::map_generic_font_families(&mut fontdb); + let opts = Options { fontdb: std::sync::Arc::new(fontdb), ..Options::default() diff --git a/tests/unit.rs b/tests/unit.rs index 4475e14..e65bda2 100644 --- a/tests/unit.rs +++ b/tests/unit.rs @@ -1744,14 +1744,77 @@ mod mermaid_rendering { .count() } + /// True if this system has a font that can actually draw Latin text. + /// + /// resvg can only rasterize glyphs it has a font for. On a machine with no text font + /// (e.g. a bare container, or a GitHub runner that ships only an emoji font) every + /// label is dropped no matter how the SVG was produced, so the pixel-count assertions + /// below would fail for a reason unrelated to what they test. Detect that and skip. + /// + /// This deliberately asks resvg to use each installed family *by explicit name*, rather + /// than going through mdriver's rendering path or the generic `sans-serif` alias. If it + /// relied on either, a regression in mdriver's own font handling would make this return + /// false and silently skip the very tests meant to catch it. + fn has_text_font() -> bool { + use resvg::tiny_skia::Pixmap; + use resvg::usvg::{fontdb, Options, Tree}; + + let probe = concat!( + r#""#, + r#"HHH"# + ); + + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + + let mut families: Vec = db + .faces() + .flat_map(|face| face.families.iter().map(|(name, _)| name.clone())) + .collect(); + families.sort(); + families.dedup(); + + families.iter().any(|family| { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + let opts = Options { + font_family: family.clone(), + fontdb: std::sync::Arc::new(db), + ..Options::default() + }; + let Ok(tree) = Tree::from_str(probe, &opts) else { + return false; + }; + let size = tree.size(); + let Some(mut pixmap) = Pixmap::new(size.width() as u32, size.height() as u32) else { + return false; + }; + resvg::render( + &tree, + resvg::tiny_skia::Transform::default(), + &mut pixmap.as_mut(), + ); + let ink = pixmap + .pixels() + .iter() + .filter(|p| p.alpha() > 128 && p.red() < 120 && p.green() < 120 && p.blue() < 120) + .count(); + ink > 50 + }) + } + /// Regression test for the `foreignObject` bug: flowchart labels live inside /// `` in Mermaid-parity SVG, which resvg silently skips. Rendering must /// go through merman's resvg-safe pipeline so labels become native SVG ``. /// - /// Before the fix this diagram rasterized to 175 dark pixels (shape outlines only); + /// Before the fix this diagram rasterized to 114 dark pixels (shape outlines only); /// after it produces ~700, so 400 is a comfortable regression boundary. #[test] fn test_mermaid_flowchart_labels_are_rasterized() { + if !has_text_font() { + eprintln!("skipping: no Latin text font available to rasterize labels"); + return; + } let mut p = kitty_parser(); let output = feed_all( &mut p, @@ -1761,17 +1824,22 @@ mod mermaid_rendering { assert!( dark > 400, "flowchart node/edge labels appear to be missing: only {} dark pixels \ - (empty shapes render ~175, labeled shapes render ~700). \ - Is try_render_mermaid using render_svg_sync instead of \ - render_svg_resvg_safe_sync?", + (unlabeled shapes render ~114, labeled shapes render ~700). Either \ + try_render_mermaid regressed to render_svg_sync (foreignObject labels \ + resvg cannot draw), or render_svg stopped mapping the generic CSS font \ + families onto fonts that exist on this system.", dark ); } /// Same regression, for a diagram family whose class-member text is also HTML-labeled. - /// Before the fix: 237 dark pixels. After: ~760. + /// Before the fix: 117 dark pixels. After: ~760. #[test] fn test_mermaid_class_diagram_labels_are_rasterized() { + if !has_text_font() { + eprintln!("skipping: no Latin text font available to rasterize labels"); + return; + } let mut p = kitty_parser(); let output = feed_all( &mut p, @@ -1783,7 +1851,7 @@ mod mermaid_rendering { assert!( dark > 450, "class diagram member text appears to be missing: only {} dark pixels \ - (empty boxes render ~237, populated boxes render ~760)", + (empty boxes render ~117, populated boxes render ~760)", dark ); }