diff --git a/Cargo.toml b/Cargo.toml index 94b08f1..fdd3095 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ term_size = "0.3.2" resvg = { version = "0.48.1", default-features = false, features = ["text", "system-fonts", "raster-images"] } unicode-width = "0.2.2" htmlentity = "1.3.2" -merman = { version = "0.8.0-alpha.3", default-features = false, features = ["render"] } +merman = { version = "0.8.0-alpha.3", default-features = false, features = ["render", "ascii"] } # Pulled in directly only to enable label/URL sanitization, which strips external # resource references (e.g. file:// image hrefs) before the SVG reaches resvg. merman-core = { version = "0.8.0-alpha.3", default-features = false, features = ["full-sanitization"] } diff --git a/README.md b/README.md index d6a665a..1e7cd9b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Pipe your llm output through it, or a markdown file from the web, and it will di - display images referenced in markdown in your terminal that supports the kitty protocol (kitty, ghostty, alacritty, and others) - syntax-highlight fenced code blocks for many languages using [syntect](https://github.com/trishume/syntect) -- render [mermaid diagrams](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-diagrams) into images and display them in your terminal +- render [mermaid diagrams](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-diagrams) into images and display them in your terminal, or as box-drawing text diagrams when image output isn't available - parse a subset of HTML and display it sensibly in the terminal - accept input from stdin, files, or URLs - Use [OSC8 hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) to make links clickable diff --git a/src/lib.rs b/src/lib.rs index 469929b..3268b55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1490,9 +1490,13 @@ impl StreamingParser { } fn format_code_block(&self, lines: &[String], info: &str) -> String { - // Attempt to render mermaid diagrams as images when image protocol is enabled - if info.eq_ignore_ascii_case("mermaid") && self.image_protocol != ImageProtocol::None { - if let Some(rendered) = self.try_render_mermaid(lines) { + if info.eq_ignore_ascii_case("mermaid") { + if self.image_protocol != ImageProtocol::None { + if let Some(rendered) = self.try_render_mermaid(lines) { + return rendered; + } + } + if let Some(rendered) = self.try_render_mermaid_ascii(lines) { return rendered; } // Fall through to normal code block rendering on failure @@ -1584,6 +1588,32 @@ impl StreamingParser { } } + /// Try to render a mermaid diagram as a box-drawing text diagram. + /// Returns `None` when the diagram family has no ASCII renderer, rendering fails, + /// or the result is too wide for the output width (where terminal wrapping would + /// garble the drawing). + fn try_render_mermaid_ascii(&self, lines: &[String]) -> Option { + let source = lines.join("\n"); + + let diagram = merman::ascii::HeadlessAsciiRenderer::new() + .with_charset(merman::ascii::AsciiCharset::Unicode) + .render_ascii_sync(&source) + .ok()??; + + let diagram = diagram.trim_end_matches('\n'); + if diagram.is_empty() { + return None; + } + if diagram + .lines() + .any(|line| self.display_width(line) > self.width) + { + return None; + } + + Some(format!("{}\n\n", diagram)) + } + /// Parse a task list item marker at the start of content. /// Returns Some((is_checked, remaining_content)) if a task list marker is found. /// Task list markers are: [ ] (unchecked) or [x]/[X] (checked), followed by whitespace. diff --git a/tests/unit.rs b/tests/unit.rs index e65bda2..784b2c4 100644 --- a/tests/unit.rs +++ b/tests/unit.rs @@ -1589,19 +1589,50 @@ mod mermaid_rendering { } #[test] - fn test_mermaid_renders_as_code_without_images() { + fn test_mermaid_renders_as_ascii_without_images() { let mut p = plain_parser(); let output = feed_all(&mut p, "```mermaid\nflowchart LR\n A-->B-->C\n```\n"); - // Should NOT contain kitty protocol assert!( !output.contains("\x1b_G"), "should not contain kitty graphics protocol" ); - // Should contain the mermaid source as code + let stripped = super::strip_ansi(&output); + assert!( + !stripped.contains("flowchart LR"), + "should render a diagram, not the source, got: {:?}", + stripped + ); + assert!( + stripped.contains('│') && stripped.contains('A'), + "should draw a box-drawing diagram, got: {:?}", + stripped + ); + } + + #[test] + fn test_mermaid_ascii_unsupported_falls_back_to_code() { + let mut p = plain_parser(); + let output = feed_all(&mut p, "```mermaid\nnotADiagramType\n a b c\n```\n"); + let stripped = super::strip_ansi(&output); + assert!( + stripped.contains("notADiagramType"), + "fallback should show mermaid source as code, got: {:?}", + stripped + ); + } + + #[test] + fn test_mermaid_ascii_too_wide_falls_back_to_code() { + let mut p = StreamingParser::with_width("base16-ocean.dark", ImageProtocol::None, 20); + let output = feed_all( + &mut p, + "```mermaid\nflowchart LR\n Alpha-->Beta-->Gamma-->Delta\n```\n", + ); let stripped = super::strip_ansi(&output); assert!( stripped.contains("flowchart LR"), - "should show mermaid source as code" + "diagram wider than the output width should fall back to code, got: {:?}", + stripped ); }