mdr 0.6.0

A lightweight Markdown viewer with live reload and multiple rendering backends
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
// `process_mermaid_blocks` and its HTML entity helpers exist for the webview
// backend's HTML pipeline; egui and tui go through `preprocess_mermaid_for_egui`
// instead. They are still compiled (and tested) in single-backend builds, so the
// dead-code lint is silenced only in the builds that genuinely do not call them.
#![cfg_attr(not(feature = "webview-backend"), allow(dead_code))]

use regex::Regex;

/// Preprocess mermaid source to fix known incompatibilities with mermaid-rs-renderer.
/// This increases the success rate of the native Rust renderer across all backends.
fn preprocess_mermaid_source(source: &str) -> String {
    let mut result = String::with_capacity(source.len());
    for line in source.lines() {
        let processed = line
            // Replace HTML line breaks in node labels with spaces
            .replace("<br/>", " ")
            .replace("<br>", " ")
            .replace("<br />", " ")
            // Replace bidirectional arrows (not supported) with unidirectional
            .replace("<-->", "---")
            .replace("x--x", "---")
            .replace("o--o", "---");
        result.push_str(&processed);
        result.push('\n');
    }
    result
}

/// Render a single mermaid diagram source to SVG.
/// First preprocesses the source to fix common incompatibilities,
/// then catches panics from mermaid-rs-renderer (which can panic on some inputs).
/// Suppresses stderr to prevent panic backtraces from corrupting TUI terminal output.
pub fn render_mermaid_to_svg(source: &str) -> Result<String, String> {
    // Suppress stderr during rendering — the mermaid renderer can print panic
    // backtraces/errors to stderr which corrupts the terminal in TUI mode.
    let _stderr_guard = suppress_stderr();

    // Try with preprocessed source first (fixes common syntax issues)
    let preprocessed = preprocess_mermaid_source(source);
    if let Ok(Ok(svg)) = std::panic::catch_unwind(|| mermaid_rs_renderer::render(&preprocessed)) {
        return Ok(svg);
    }
    // Fall back to original source (in case preprocessing made things worse)
    let source = source.to_string();
    match std::panic::catch_unwind(|| mermaid_rs_renderer::render(&source)) {
        Ok(Ok(svg)) => Ok(svg),
        Ok(Err(e)) => Err(format!("{e}")),
        Err(_) => Err("mermaid renderer panicked (unsupported diagram syntax)".to_string()),
    }
}

/// Temporarily redirect stderr to /dev/null. Restores on drop.
/// This prevents mermaid-rs-renderer panic output from corrupting TUI display.
struct StderrGuard {
    #[cfg(unix)]
    saved_fd: Option<std::os::unix::io::RawFd>,
}

impl Drop for StderrGuard {
    fn drop(&mut self) {
        #[cfg(unix)]
        if let Some(saved) = self.saved_fd {
            unsafe {
                libc::dup2(saved, 2);
                libc::close(saved);
            }
        }
    }
}

fn suppress_stderr() -> StderrGuard {
    #[cfg(unix)]
    {
        unsafe {
            let saved = libc::dup(2);
            if saved >= 0 {
                let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
                if devnull >= 0 {
                    libc::dup2(devnull, 2);
                    libc::close(devnull);
                    return StderrGuard {
                        saved_fd: Some(saved),
                    };
                }
                libc::close(saved);
            }
        }
        StderrGuard { saved_fd: None }
    }
    #[cfg(not(unix))]
    StderrGuard {}
}

/// Process HTML from comrak: find mermaid code blocks and replace with rendered SVG.
/// Mermaid blocks appear as: <pre><code class="language-mermaid">...</code></pre>
pub fn process_mermaid_blocks(html: &str) -> String {
    use std::sync::OnceLock;
    static RE: OnceLock<Regex> = OnceLock::new();
    let re = RE.get_or_init(|| {
        Regex::new(r#"<pre><code class="language-mermaid">([\s\S]*?)</code></pre>"#).unwrap()
    });

    re.replace_all(html, |caps: &regex::Captures| {
        let source = html_decode(&caps[1]);
        match render_mermaid_to_svg(&source) {
            Ok(svg) => format!(r#"<div class="mermaid-diagram">{svg}</div>"#),
            Err(_) => format!(r#"<pre class="mermaid">{}</pre>"#, html_encode(&source)),
        }
    })
    .to_string()
}

/// Pre-process markdown for egui: find fenced `mermaid` blocks, render to SVG,
/// convert to base64 PNG data URI, replace block with image reference.
#[cfg(feature = "egui-backend")]
pub fn preprocess_mermaid_for_egui(markdown: &str) -> String {
    use std::sync::OnceLock;
    static RE: OnceLock<Regex> = OnceLock::new();
    let re = RE.get_or_init(|| Regex::new(r"```mermaid\n([\s\S]*?)```").unwrap());

    re.replace_all(markdown, |caps: &regex::Captures| {
        let source = &caps[1];
        match render_mermaid_to_svg(source) {
            Ok(svg) => match svg_to_png_base64(&svg) {
                Ok(b64) => format!("![mermaid diagram](data:image/png;base64,{b64})"),
                Err(_) => format!(
                    "> **◇ Mermaid Diagram** *(SVG to PNG conversion failed)*\n\n```\n{source}```"
                ),
            },
            Err(_) => format!(
                "> **◇ Mermaid Diagram** *(unsupported by native renderer)*\n\n```\n{source}```"
            ),
        }
    })
    .to_string()
}

/// Convert SVG string to PNG and return as base64-encoded string.
/// Scales down large SVGs to fit within GPU texture limits (max 8192px per side).
#[cfg(feature = "egui-backend")]
fn svg_to_png_base64(svg: &str) -> Result<String, Box<dyn std::error::Error>> {
    use base64::Engine;

    // Max texture size for egui/GPU — keep well under the 16384 hard limit
    const MAX_TEXTURE_SIZE: u32 = 8192;

    // Load system fonts once and reuse across calls
    // Shared, so the resolver that refuses an SVG's own file
    // references is the one every rasteriser uses.
    let options = crate::core::svg::options();
    let tree = usvg::Tree::from_str(svg, &options)?;
    let size = tree.size();
    let svg_w = size.width();
    let svg_h = size.height();

    if svg_w <= 0.0 || svg_h <= 0.0 {
        return Err("SVG has zero dimensions".into());
    }

    // Scale down if either dimension exceeds the limit
    let scale = {
        let scale_w = MAX_TEXTURE_SIZE as f32 / svg_w;
        let scale_h = MAX_TEXTURE_SIZE as f32 / svg_h;
        scale_w.min(scale_h).min(1.0) // never scale up, only down
    };

    let width = (svg_w * scale) as u32;
    let height = (svg_h * scale) as u32;

    if width == 0 || height == 0 {
        return Err("SVG dimensions too small after scaling".into());
    }

    let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or("Failed to create pixmap")?;
    let transform = tiny_skia::Transform::from_scale(scale, scale);
    resvg::render(&tree, transform, &mut pixmap.as_mut());

    let png_data = pixmap.encode_png()?;
    Ok(base64::engine::general_purpose::STANDARD.encode(&png_data))
}

fn html_decode(s: &str) -> String {
    s.replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
}

fn html_encode(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

#[cfg(test)]
mod tests {
    use super::*;

    // --- html_decode tests ---

    #[test]
    fn html_decode_all_entities() {
        assert_eq!(html_decode("&amp;&lt;&gt;&quot;&#39;"), "&<>\"'");
    }

    #[test]
    fn html_decode_no_entities() {
        assert_eq!(html_decode("plain text"), "plain text");
    }

    #[test]
    fn html_decode_mixed() {
        assert_eq!(html_decode("A &amp; B &lt; C"), "A & B < C");
    }

    // --- html_encode tests ---

    #[test]
    fn html_encode_special_chars() {
        assert_eq!(html_encode("A & B < C > D"), "A &amp; B &lt; C &gt; D");
    }

    #[test]
    fn html_encode_no_special_chars() {
        assert_eq!(html_encode("plain text"), "plain text");
    }

    #[test]
    fn html_encode_decode_roundtrip() {
        let original = "graph LR; A-->B";
        let encoded = html_encode(original);
        let decoded = html_decode(&encoded);
        assert_eq!(decoded, original);
    }

    // --- preprocess_mermaid_source tests ---

    #[test]
    fn preprocess_removes_html_breaks() {
        let source = "graph LR\n  A[Line 1<br/>Line 2]-->B";
        let result = preprocess_mermaid_source(source);
        assert!(!result.contains("<br/>"));
        assert!(result.contains("Line 1 Line 2"));
    }

    #[test]
    fn preprocess_converts_bidirectional_arrows() {
        let source = "graph LR\n  A<-->B";
        let result = preprocess_mermaid_source(source);
        assert!(!result.contains("<-->"));
        assert!(result.contains("A---B"));
    }

    #[test]
    fn preprocess_leaves_valid_syntax_unchanged() {
        let source = "graph LR\n  A-->B\n  B-->C";
        let result = preprocess_mermaid_source(source);
        assert!(result.contains("A-->B"));
        assert!(result.contains("B-->C"));
    }

    // --- render_mermaid_to_svg tests ---

    #[test]
    fn render_mermaid_valid_diagram() {
        // An ordinary flowchart has to render. Accepting an error here — which
        // this test used to do — meant a renderer that had stopped working
        // altogether still passed.
        let svg =
            render_mermaid_to_svg("graph LR\n  A-->B").expect("a plain flowchart must render");
        assert!(svg.contains("<svg"), "expected an SVG document, got: {svg}");
        assert!(
            svg.contains("</svg>"),
            "expected a closed SVG document, got: {svg}"
        );
    }

    #[test]
    fn render_mermaid_renders_subgraphs_and_sequence_diagrams() {
        // Two shapes the renderer reworked in 0.3; both must produce a diagram,
        // not merely avoid panicking.
        let subgraph = "graph TB\n  subgraph one\n    A-->B\n  end\n  B-->C";
        let svg = render_mermaid_to_svg(subgraph).expect("a subgraph must render");
        assert!(svg.contains("<svg"), "expected an SVG document, got: {svg}");

        let sequence = "sequenceDiagram\n  Alice->>Bob: Hello\n  Bob-->>Alice: Hi";
        let svg = render_mermaid_to_svg(sequence).expect("a sequence diagram must render");
        assert!(svg.contains("<svg"), "expected an SVG document, got: {svg}");
    }

    #[cfg(feature = "egui-backend")]
    #[test]
    fn a_rendered_diagram_rasterises_to_a_real_png() {
        // The SVG only matters if it survives the usvg/tiny-skia pass the egui
        // backend puts it through, so go all the way to the pixels.
        let svg = render_mermaid_to_svg("graph LR\n  A-->B").expect("diagram must render");
        let b64 = svg_to_png_base64(&svg).expect("SVG must rasterise");

        use base64::Engine;
        let png = base64::engine::general_purpose::STANDARD
            .decode(&b64)
            .expect("output must be valid base64");
        assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "expected a PNG header");

        let image = image::load_from_memory(&png).expect("PNG must decode");
        assert!(
            image.width() > 0 && image.height() > 0,
            "rasterised diagram must have a surface, got {}x{}",
            image.width(),
            image.height()
        );
    }

    #[test]
    fn render_mermaid_empty_input() {
        // `is_err() || is_ok()` is what this used to assert, which is every
        // possible outcome. Empty input has nothing to draw.
        assert!(
            render_mermaid_to_svg("").is_err(),
            "empty input must not produce a diagram"
        );
    }

    #[test]
    fn render_mermaid_invalid_syntax() {
        let result = render_mermaid_to_svg("this is not valid mermaid syntax at all %%% !@#");
        // Should not panic - catch_unwind protects us
        // Result can be Ok or Err but must not panic
        match result {
            Ok(_) => {} // Some renderers may be lenient
            Err(e) => assert!(!e.is_empty()),
        }
    }

    #[test]
    fn unusual_input_does_not_panic() {
        // Named for what it establishes. It does NOT prove the `catch_unwind`
        // fires: nothing here makes the renderer panic, so the guard is not
        // shown to be doing anything. What it rules out is this input taking
        // the process down, whichever path it goes through.
        let _ = render_mermaid_to_svg("\0\0\0");
        let _ = render_mermaid_to_svg("");
        let _ = render_mermaid_to_svg("graph");
    }

    // --- process_mermaid_blocks tests ---

    #[test]
    fn process_mermaid_blocks_no_mermaid() {
        let html = "<p>Hello</p><pre><code class=\"language-rust\">fn main() {}</code></pre>";
        let result = process_mermaid_blocks(html);
        assert_eq!(result, html);
    }

    #[test]
    fn process_mermaid_blocks_replaces_mermaid_code() {
        let html = r#"<p>Before</p><pre><code class="language-mermaid">graph LR
  A--&gt;B</code></pre><p>After</p>"#;
        let result = process_mermaid_blocks(html);
        // The mermaid code block should be replaced
        assert!(
            !result.contains(r#"class="language-mermaid""#),
            "Mermaid code block should be replaced, got: {result}"
        );
        // Should contain either a rendered diagram or an error
        assert!(
            result.contains("mermaid-diagram")
                || result.contains("mermaid-error")
                || result.contains("mermaid-fallback"),
            "Should contain diagram or fallback div, got: {result}"
        );
        // Surrounding content should be preserved
        assert!(result.contains("<p>Before</p>"));
        assert!(result.contains("<p>After</p>"));
    }

    #[test]
    fn process_mermaid_blocks_preserves_non_mermaid_content() {
        let html = "<h1>Title</h1><p>Content</p>";
        let result = process_mermaid_blocks(html);
        assert_eq!(result, html);
    }

    #[test]
    fn process_mermaid_blocks_error_contains_source() {
        // Use obviously invalid mermaid that will produce an error
        let html = r#"<pre><code class="language-mermaid">not valid %%% !@#</code></pre>"#;
        let result = process_mermaid_blocks(html);
        if result.contains("mermaid-fallback") {
            // Fallback div should contain the original source
            assert!(result.contains("Mermaid Diagram"));
        } else if result.contains("mermaid-error") {
            assert!(result.contains("Mermaid error:"));
        }
        // If it somehow renders successfully, that's also fine
    }

    // --- egui-specific tests ---

    #[cfg(feature = "egui-backend")]
    mod egui_tests {
        use super::super::*;

        #[test]
        fn preprocess_mermaid_for_egui_no_mermaid() {
            let md = "# Title\n\nSome text\n\n```rust\nfn main() {}\n```";
            let result = preprocess_mermaid_for_egui(md);
            assert_eq!(result, md);
        }

        #[test]
        fn preprocess_mermaid_for_egui_replaces_block() {
            let md = "Before\n\n```mermaid\ngraph LR\n  A-->B\n```\n\nAfter";
            let result = preprocess_mermaid_for_egui(md);
            // The mermaid block should be replaced with either an image or error message
            assert!(
                !result.contains("```mermaid"),
                "Mermaid block should be replaced, got: {result}"
            );
            assert!(result.contains("Before"));
            assert!(result.contains("After"));
        }

        #[test]
        fn preprocess_mermaid_for_egui_error_shows_source() {
            let md = "```mermaid\nnot valid mermaid\n```";
            let result = preprocess_mermaid_for_egui(md);
            if result.contains("error") || result.contains("Error") {
                assert!(result.contains("not valid mermaid"));
            }
        }
    }
}