Skip to main content

tpnote_lib/
highlight.rs

1//! Syntax highlighting for (inline) source code blocks in Markdown input.
2
3use crate::config::EmbeddedContentErrorPolicy;
4#[cfg(any(feature = "mermaid", feature = "latex"))]
5use crate::error::NoteError;
6use pulldown_cmark::{CodeBlockKind, Event, Tag, TagEnd};
7#[cfg(any(feature = "mermaid", feature = "latex"))]
8use std::cell::RefCell;
9#[cfg(any(feature = "mermaid", feature = "latex"))]
10use std::rc::Rc;
11use syntect::highlighting::ThemeSet;
12use syntect::html::css_for_theme_with_class_style;
13use syntect::html::{ClassStyle, ClassedHTMLGenerator};
14use syntect::parsing::SyntaxSet;
15use syntect::util::LinesWithEndings;
16
17/// Marker that `latex2mathml` embeds in otherwise-`Ok` output when a formula
18/// fails to parse leniently (e.g. missing arguments, unknown commands) instead
19/// of returning `Err`. Its presence is treated as a render failure so such
20/// formulas honor the `EmbeddedContentErrorPolicy` too.
21#[cfg(feature = "latex")]
22const LATEX_PARSE_ERROR_MARKER: &str = "[PARSE ERROR:";
23
24/// Builds the inline HTML fallback shown in place of a Mermaid diagram that
25/// failed to render under the `Inline` error policy. The result is emitted via
26/// `Event::Html`, which pulldown-cmark does **not** escape, so both the error
27/// message and the offending source are HTML-escaped here.
28#[cfg(feature = "mermaid")]
29fn mermaid_error_html(msg: &str, code: &str) -> String {
30    format!(
31        "<div class=\"mermaid-error\"><p><em>Mermaid render error: {}</em></p>\
32         <pre><code class=\"language-mermaid\">{}</code></pre></div>",
33        html_escape::encode_text(msg),
34        html_escape::encode_text(code),
35    )
36}
37
38/// Builds the inline HTML fallback shown in place of a LaTeX formula that failed
39/// to render under the `Inline` error policy. Like `mermaid_error_html`, the
40/// output is emitted un-escaped via `Event::Html`, so both parts are escaped.
41#[cfg(feature = "latex")]
42fn math_error_html(msg: &str, code: &str) -> String {
43    format!(
44        "<div class=\"math-error\"><p><em>LaTeX render error: {}</em></p>\
45         <pre><code class=\"language-math\">{}</code></pre></div>",
46        html_escape::encode_text(msg),
47        html_escape::encode_text(code),
48    )
49}
50
51/// Inline counterpart of `math_error_html` for a failed `$…$` formula. A
52/// block-level `<div>`/`<pre>` box mid-paragraph is invalid and breaks the text
53/// flow, so an inline `<span>` (styled with the same red left border as the
54/// block boxes) is used instead.
55#[cfg(feature = "latex")]
56fn math_error_html_inline(msg: &str, code: &str) -> String {
57    format!(
58        "<span class=\"math-error-inline\"><em>LaTeX error: {}</em> \
59         <code class=\"language-math\">{}</code></span>",
60        html_escape::encode_text(msg),
61        html_escape::encode_text(code),
62    )
63}
64
65/// Extracts the first `[PARSE ERROR: …]` marker that `latex2mathml` embeds in
66/// otherwise-`Ok` output and pairs it with the source formula, for a useful
67/// diagnostic message (shown on the viewer error page / `--export` abort).
68#[cfg(feature = "latex")]
69fn parse_error_message(mathml: &str, code: &str) -> String {
70    let marker = mathml
71        .find(LATEX_PARSE_ERROR_MARKER)
72        .map(|start| {
73            let rest = &mathml[start..];
74            let end = rest.find(']').map_or(rest.len(), |i| i + 1);
75            &rest[..end]
76        })
77        .unwrap_or("[PARSE ERROR]");
78    format!("{marker} in formula: {code}")
79}
80
81/// Get the viewer syntax highlighting CSS configuration.
82pub(crate) fn get_highlighting_css(theme_name: &str) -> String {
83    let ts = ThemeSet::load_defaults();
84
85    ts.themes
86        .get(theme_name)
87        .and_then(|theme| {
88            css_for_theme_with_class_style(theme, syntect::html::ClassStyle::Spaced).ok()
89        })
90        .unwrap_or_default()
91}
92
93/// A wrapper for a `pulldown_cmark` event iterator.
94#[derive(Debug, Default)]
95pub struct SyntaxPreprocessor<'a, I: Iterator<Item = Event<'a>>> {
96    parent: I,
97    /// How a failed embedded renderer (e.g. Mermaid or LaTeX) is surfaced.
98    #[cfg(any(feature = "mermaid", feature = "latex"))]
99    error_policy: EmbeddedContentErrorPolicy,
100    /// Side channel for `HardError` mode: `next()` returns `Option<Event>` and
101    /// cannot fail, so the first `NoteError` is parked here for
102    /// `MarkupLanguage::render()` to pick up. Single-threaded, hence
103    /// `Rc<RefCell<…>>`.
104    #[cfg(any(feature = "mermaid", feature = "latex"))]
105    error_sink: Rc<RefCell<Option<NoteError>>>,
106}
107
108/// Constructor.
109impl<'a, I: Iterator<Item = Event<'a>>> SyntaxPreprocessor<'a, I> {
110    #[cfg_attr(
111        not(any(feature = "mermaid", feature = "latex")),
112        allow(unused_variables)
113    )]
114    pub fn new(parent: I, error_policy: EmbeddedContentErrorPolicy) -> Self {
115        Self {
116            parent,
117            #[cfg(any(feature = "mermaid", feature = "latex"))]
118            error_policy,
119            #[cfg(any(feature = "mermaid", feature = "latex"))]
120            error_sink: Rc::new(RefCell::new(None)),
121        }
122    }
123
124    /// Returns a clone of the shared error sink so `MarkupLanguage::render()` can
125    /// retrieve a `HardError` captured while the iterator was consumed.
126    #[cfg(any(feature = "mermaid", feature = "latex"))]
127    pub(crate) fn error_sink(&self) -> Rc<RefCell<Option<NoteError>>> {
128        self.error_sink.clone()
129    }
130
131    /// Applies the configured `EmbeddedContentErrorPolicy` to a failed embedded
132    /// renderer (Mermaid or LaTeX). Under `HardError` the first `NoteError` is
133    /// parked in the sink (`render()` turns it into `Err`) and an empty event is
134    /// returned; under `Inline` a `log::warn!` is emitted and the caller's
135    /// pre-built error box (`inline_html`) is returned. Shared by all embedded
136    /// renderers so their error handling stays identical.
137    #[cfg(any(feature = "mermaid", feature = "latex"))]
138    fn embedded_error_event(
139        &self,
140        renderer: &str,
141        inline_html: String,
142        msg: String,
143    ) -> Event<'static> {
144        match self.error_policy {
145            EmbeddedContentErrorPolicy::HardError => {
146                // Park the first error; `render()` returns the sink's `Err`.
147                let mut sink = self.error_sink.borrow_mut();
148                if sink.is_none() {
149                    *sink = Some(NoteError::RenderError {
150                        renderer: renderer.to_string(),
151                        msg,
152                    });
153                }
154                // Discarded — `render()` returns the sink's `Err`.
155                Event::Html(String::new().into())
156            }
157            EmbeddedContentErrorPolicy::Inline => {
158                log::warn!("{renderer} failed to render: {msg}");
159                Event::Html(inline_html.into())
160            }
161        }
162    }
163
164    /// Renders a LaTeX formula to MathML. On a parse error the configured
165    /// `EmbeddedContentErrorPolicy` applies (via `embedded_error_event`), so a
166    /// broken formula behaves like a broken Mermaid diagram.
167    #[cfg(feature = "latex")]
168    fn render_math(&self, latex: &str, style: latex2mathml::DisplayStyle) -> Event<'static> {
169        let inline = matches!(style, latex2mathml::DisplayStyle::Inline);
170        match latex2mathml::latex_to_mathml(latex, style) {
171            // `latex2mathml` is lenient: rather than `Err`, many malformed inputs
172            // (missing arguments, unknown commands) return `Ok` carrying an
173            // embedded `<mtext>[PARSE ERROR: …]</mtext>` marker. Detect it so such
174            // formulas honor the error policy too — otherwise a broken formula
175            // would ship silently even under `HardError`. Under `Inline` the
176            // marker-annotated MathML is kept (it pinpoints the fault in the
177            // formula); under `HardError` the marker text becomes the abort
178            // message.
179            Ok(mathml) if mathml.contains(LATEX_PARSE_ERROR_MARKER) => {
180                let msg = parse_error_message(&mathml, latex);
181                // Tag the embedded `<mtext>[PARSE ERROR: …]` marker(s) with a
182                // class so the CSS can colour them red where they appear in the
183                // formula (only shown under the `Inline` policy).
184                let tagged = mathml.replace(
185                    &format!("<mtext>{LATEX_PARSE_ERROR_MARKER}"),
186                    &format!("<mtext class=\"math-parse-error\">{LATEX_PARSE_ERROR_MARKER}"),
187                );
188                self.embedded_error_event("LaTeX", tagged, msg)
189            }
190            Ok(mathml) => Event::Html(mathml.into()),
191            Err(e) => {
192                let msg = e.to_string();
193                let html = if inline {
194                    math_error_html_inline(&msg, latex)
195                } else {
196                    math_error_html(&msg, latex)
197                };
198                self.embedded_error_event("LaTeX", html, msg)
199            }
200        }
201    }
202}
203
204/// Implement `Iterator` for wrapper `SyntaxPreprocessor`.
205impl<'a, I: Iterator<Item = Event<'a>>> Iterator for SyntaxPreprocessor<'a, I> {
206    type Item = Event<'a>;
207
208    fn next(&mut self) -> Option<Self::Item> {
209        // Detect inline LaTeX.
210        let lang = match self.parent.next()? {
211            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) if !lang.is_empty() => lang,
212            // This is the depreciated inline math syntax.
213            // It is kept here for backwards compatibility.
214            #[cfg(feature = "latex")]
215            Event::Code(c) if c.len() > 1 && c.starts_with('$') && c.ends_with('$') => {
216                return Some(self.render_math(&c[1..c.len() - 1], latex2mathml::DisplayStyle::Inline));
217            }
218            #[cfg(feature = "latex")]
219            Event::InlineMath(c) => {
220                return Some(self.render_math(c.as_ref(), latex2mathml::DisplayStyle::Inline));
221            }
222            #[cfg(feature = "latex")]
223            Event::DisplayMath(c) => {
224                return Some(self.render_math(c.as_ref(), latex2mathml::DisplayStyle::Block));
225            }
226            other => return Some(other),
227        };
228
229        let mut code = String::new();
230        let mut event = self.parent.next();
231        while let Some(Event::Text(ref code_block)) = event {
232            code.push_str(code_block);
233            event = self.parent.next();
234        }
235
236        debug_assert!(matches!(event, Some(Event::End(TagEnd::CodeBlock))));
237
238        #[cfg(feature = "latex")]
239        if lang.as_ref() == "math" {
240            return Some(self.render_math(&code, latex2mathml::DisplayStyle::Block));
241        }
242
243        #[cfg(feature = "mermaid")]
244        if lang.as_ref() == "mermaid" {
245            // The crate is young (v0.3.x): isolate a possible parser panic so a
246            // bad diagram can never abort the viewer thread or the export.
247            //
248            // Work around a renderer defect (mermaid-rs-renderer 0.3.1): node
249            // label text is auto-wrapped at every space, not only at explicit
250            // `\n`. This mis-sizes nodes and — because Dagre then routes edges
251            // against wrong widths — also corrupts inter-node edge paths. The
252            // crate exposes no wrap on/off switch (`measure_label` hard-codes
253            // `wrap = true`), but its `wrap_line()` only breaks a line once it
254            // exceeds `max_label_width_chars * avg_char`; a very large width
255            // therefore suppresses auto-wrapping and yields one rendered line
256            // per `\n`-delimited segment. Trade-off: genuinely long single-line
257            // labels no longer wrap either. Remove once the crate wraps only on
258            // explicit `\n` (upstream bug filed).
259            const NO_AUTO_WRAP_LABEL_CHARS: usize = 100_000;
260            let render = || {
261                let mut options = mermaid_rs_renderer::RenderOptions::default();
262                options.layout.max_label_width_chars = NO_AUTO_WRAP_LABEL_CHARS;
263                mermaid_rs_renderer::render_with_options(&code, options)
264            };
265            let result = match std::panic::catch_unwind(render) {
266                Ok(r) => r.map_err(|e| e.to_string()),
267                Err(_) => Err("internal renderer panic".to_string()),
268            };
269            return Some(match result {
270                // The SVG is inserted un-escaped via `Event::Html` (pulldown-cmark
271                // does not escape it). Low practical risk for local, user-authored
272                // notes; consistent with the existing raw-HTML passthrough.
273                // Sanitization deferred.
274                Ok(svg) => Event::Html(format!("<div class=\"mermaid\">{svg}</div>").into()),
275                Err(msg) => self.embedded_error_event("Mermaid", mermaid_error_html(&msg, &code), msg),
276            });
277        }
278
279        let mut html = String::with_capacity(code.len() + code.len() * 3 / 2 + 20);
280
281        // Use default syntax styling.
282        let ss = SyntaxSet::load_defaults_newlines();
283        let sr = match ss.find_syntax_by_token(lang.as_ref()) {
284            Some(sr) => {
285                html.push_str("<pre><code class=\"language-");
286                html.push_str(lang.as_ref());
287                html.push_str("\">");
288                sr
289            }
290            None => {
291                log::debug!(
292                    "renderer: no syntax definition found for: `{}`",
293                    lang.as_ref()
294                );
295                html.push_str("<pre><code>");
296                ss.find_syntax_plain_text()
297            }
298        };
299        let mut html_generator =
300            ClassedHTMLGenerator::new_with_class_style(sr, &ss, ClassStyle::Spaced);
301        for line in LinesWithEndings::from(&code) {
302            html_generator
303                .parse_html_for_line_which_includes_newline(line)
304                .unwrap_or_default();
305        }
306        html.push_str(html_generator.finalize().as_str());
307
308        html.push_str("</code></pre>");
309
310        Some(Event::Html(html.into()))
311    }
312}
313
314#[cfg(test)]
315mod test {
316    #[cfg(feature = "mermaid")]
317    use crate::config::EmbeddedContentErrorPolicy;
318    use crate::highlight::SyntaxPreprocessor;
319    use pulldown_cmark::{Options, Parser, html};
320
321    #[cfg(feature = "latex")]
322    #[test]
323    fn test_latex_math() {
324        // Inline math.
325        let input: &str = "casual $\\sum_{n=0}^\\infty \\frac{1}{n!}$ text";
326
327        let expected = "<p>casual <math xmlns=";
328
329        let options = Options::all();
330        let parser = Parser::new_ext(input, options);
331        let processed = SyntaxPreprocessor::new(parser, Default::default());
332
333        let mut rendered = String::new();
334        html::push_html(&mut rendered, processed);
335        println!("Rendered: {}", rendered);
336        assert!(rendered.starts_with(expected));
337
338        //
339        // Depreciated inline math.
340        // This code might be removed later.
341        let input: &str = "casual `$\\sum_{n=0}^\\infty \\frac{1}{n!}$` text";
342
343        let expected = "<p>casual <math xmlns=";
344
345        let options = Options::all();
346        let parser = Parser::new_ext(input, options);
347        let processed = SyntaxPreprocessor::new(parser, Default::default());
348
349        let mut rendered = String::new();
350        html::push_html(&mut rendered, processed);
351        assert!(rendered.starts_with(expected));
352
353        //
354        // Block math 1
355        let input = "text\n$$\nR(X, Y)Z = \\nabla_X\\nabla_Y Z - \
356            \\nabla_Y \\nabla_X Z - \\nabla_{[X, Y]} Z\n$$";
357
358        let expected = "<p>text\n\
359            <math xmlns=\"http://www.w3.org/1998/Math/MathML\" display=\"block\">\
360            <mi>R</mi><mo>(</mo><mi>X</mi><mo>,</mo><mi>Y</mi><mo>)</mo>\
361            <mi>Z</mi><mo>=</mo><msub><mo>∇</mo><mi>X</mi></msub><msub><mo>∇</mo>\
362            <mi>Y</mi></msub><mi>Z</mi><mo>-</mo><msub><mo>∇</mo><mi>Y</mi></msub>\
363            <msub><mo>∇</mo><mi>X</mi></msub><mi>Z</mi><mo>-</mo><msub><mo>∇</mo>\
364            <mrow><mo>[</mo><mi>X</mi><mo>,</mo><mi>Y</mi><mo>]</mo></mrow></msub>\
365            <mi>Z</mi></math></p>\n";
366
367        let options = Options::all();
368        let parser = Parser::new_ext(input, options);
369        let processed = SyntaxPreprocessor::new(parser, Default::default());
370
371        let mut rendered = String::new();
372        html::push_html(&mut rendered, processed);
373        assert_eq!(rendered, expected);
374
375        // Block math 2
376        let input = "text\n```math\nR(X, Y)Z = \\nabla_X\\nabla_Y Z - \
377            \\nabla_Y \\nabla_X Z - \\nabla_{[X, Y]} Z\n```";
378
379        let expected = "<p>text</p>\n\
380            <math xmlns=\"http://www.w3.org/1998/Math/MathML\" display=\"block\">\
381            <mi>R</mi><mo>(</mo><mi>X</mi><mo>,</mo><mi>Y</mi><mo>)</mo>\
382            <mi>Z</mi><mo>=</mo><msub><mo>∇</mo><mi>X</mi></msub><msub><mo>∇</mo>\
383            <mi>Y</mi></msub><mi>Z</mi><mo>-</mo><msub><mo>∇</mo><mi>Y</mi></msub>\
384            <msub><mo>∇</mo><mi>X</mi></msub><mi>Z</mi><mo>-</mo><msub><mo>∇</mo>\
385            <mrow><mo>[</mo><mi>X</mi><mo>,</mo><mi>Y</mi><mo>]</mo></mrow></msub>\
386            <mi>Z</mi></math>";
387
388        let options = Options::all();
389        let parser = Parser::new_ext(input, options);
390        let processed = SyntaxPreprocessor::new(parser, Default::default());
391
392        let mut rendered = String::new();
393        html::push_html(&mut rendered, processed);
394        assert_eq!(rendered, expected);
395    }
396
397    #[test]
398    fn test_rust_source() {
399        let input: &str = "```rust\n\
400            fn main() {\n\
401                println!(\"Hello, world!\");\n\
402            }\n\
403            ```";
404
405        let expected = "<pre><code class=\"language-rust\">\
406            <span class=\"source rust\">";
407
408        let parser = Parser::new(input);
409        let processed = SyntaxPreprocessor::new(parser, Default::default());
410
411        let mut rendered = String::new();
412        html::push_html(&mut rendered, processed);
413        assert!(rendered.starts_with(expected));
414    }
415
416    #[test]
417    fn test_plain_text() {
418        let input: &str = "```\nSome\nText\n```";
419
420        let expected = "<pre><code>\
421            Some\nText\n</code></pre>\n";
422
423        let parser = Parser::new(input);
424        let processed = SyntaxPreprocessor::new(parser, Default::default());
425
426        let mut rendered = String::new();
427        html::push_html(&mut rendered, processed);
428        assert_eq!(rendered, expected);
429    }
430
431    #[test]
432    fn test_unkown_source() {
433        let input: &str = "```abc\n\
434            fn main() {\n\
435                println!(\"Hello, world!\");\n\
436            }\n\
437            ```";
438
439        let expected = "<pre><code>\
440            <span class=\"text plain\">fn main()";
441
442        let parser = Parser::new(input);
443        let processed = SyntaxPreprocessor::new(parser, Default::default());
444
445        let mut rendered = String::new();
446        html::push_html(&mut rendered, processed);
447        assert!(rendered.starts_with(expected));
448    }
449
450    #[test]
451    fn test_md() {
452        let markdown_input = "# Titel\n\nBody";
453        let expected = "<h1>Titel</h1>\n<p>Body</p>\n";
454
455        let options = Options::all();
456        let parser = Parser::new_ext(markdown_input, options);
457        let parser = SyntaxPreprocessor::new(parser, Default::default());
458
459        // Write to String buffer.
460        let mut html_output: String = String::with_capacity(markdown_input.len() * 3 / 2);
461        html::push_html(&mut html_output, parser);
462        assert_eq!(html_output, expected);
463    }
464
465    #[test]
466    fn test_indented() {
467        let markdown_input = r#"
4681. test
469
470   ```bash
471   wget getreu.net
472   echo test
473   ```
474"#;
475
476        let expected = "<ol>\n<li>\n<p>test</p>\n<pre>\
477            <code class=\"language-bash\">\
478            <span class=\"source shell bash\">\
479            <span class=\"meta function-call shell\">\
480            <span class=\"variable function shell\">wget</span></span>";
481        let options = Options::all();
482        let parser = Parser::new_ext(markdown_input, options);
483        let parser = SyntaxPreprocessor::new(parser, Default::default());
484
485        // Write to String buffer.
486        let mut html_output: String = String::with_capacity(markdown_input.len() * 3 / 2);
487        html::push_html(&mut html_output, parser);
488        assert!(html_output.starts_with(expected));
489    }
490
491    #[cfg(feature = "mermaid")]
492    #[test]
493    fn test_mermaid_diagram() {
494        let input = "```mermaid\ngraph TD\n    A --> B\n```";
495
496        let parser = Parser::new_ext(input, Options::all());
497        let parser = SyntaxPreprocessor::new(parser, Default::default());
498
499        let mut rendered = String::new();
500        html::push_html(&mut rendered, parser);
501        assert!(rendered.contains("<div class=\"mermaid\">"));
502        assert!(rendered.contains("<svg"));
503    }
504
505    #[cfg(feature = "mermaid")]
506    #[test]
507    fn test_mermaid_invalid_inline() {
508        // Default policy is `Inline`: a malformed diagram must not panic; the
509        // output carries the inline error box instead of a diagram.
510        let input = "```mermaid\nthis is not a valid mermaid diagram !!!\n```";
511
512        let parser = Parser::new_ext(input, Options::all());
513        let parser = SyntaxPreprocessor::new(parser, EmbeddedContentErrorPolicy::Inline);
514
515        let mut rendered = String::new();
516        html::push_html(&mut rendered, parser);
517        assert!(rendered.contains("mermaid-error"));
518    }
519}