Skip to main content

citum_engine/render/
markdown.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! CommonMark (Markdown) output format.
7//!
8//! This renderer is designed for **Pandoc interop**: citum processes citations
9//! inline (replacing `[@key]` markers with rendered text) and emits the document
10//! body verbatim, so the output can be piped directly to `pandoc` or any other
11//! CommonMark-aware formatter. Only citation and bibliography strings are
12//! rendered in CommonMark markup; block-level document markup passes through
13//! unchanged.
14//!
15//! # Note styles
16//!
17//! Note-based styles (Chicago notes, etc.) emit `[^label]` anchors in prose and
18//! `[^label]: …` footnote definitions at the end of the document. These follow
19//! the Pandoc/GFM footnote extension — **not** core CommonMark. Downstream
20//! consumers must enable the extension:
21//! `pandoc --from commonmark+footnotes` (or `--from gfm`).
22
23use std::ops::Range;
24
25use super::format::{OutputFormat, QuoteMarks, realize_wrap};
26use super::visible_scan::{RunBuilder, find_matching, skip_balanced};
27use crate::values::ScriptClass;
28use citum_schema::template::WrapPunctuation;
29
30/// Escape CommonMark-active characters in raw bibliography data text.
31///
32/// Backslash-escapes `\`, `*`, `_`, `[`, `]`, `` ` ``, `<`, `>`, and `&`
33/// so that data fields (titles, author names, etc.) cannot accidentally
34/// activate emphasis, strong, link, code-span, autolink, inline HTML, or
35/// HTML-entity syntax. Style-applied markup (`emph`, `strong`, `link`)
36/// wraps already-escaped text, so intentional markup is unaffected.
37fn escape_commonmark_text(s: &str) -> String {
38    let mut out = String::with_capacity(s.len() + 4);
39    for ch in s.chars() {
40        match ch {
41            '\\' | '*' | '_' | '[' | ']' | '`' | '<' | '>' | '&' => {
42                out.push('\\');
43                out.push(ch);
44            }
45            _ => out.push(ch),
46        }
47    }
48    out
49}
50
51/// Renders processed citations and bibliography entries as CommonMark markup.
52#[derive(Default, Clone)]
53pub struct Markdown;
54
55impl OutputFormat for Markdown {
56    type Output = String;
57
58    fn text(&self, s: &str) -> Self::Output {
59        escape_commonmark_text(s)
60    }
61
62    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
63        items.join(delimiter)
64    }
65
66    fn finish(&self, output: Self::Output) -> String {
67        output
68    }
69
70    /// Render a heading using ATX syntax (`#`, `##`, ...).
71    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
72        let marks = "#".repeat(level.max(1) as usize);
73        format!("{marks} {content}\n\n")
74    }
75
76    /// Render emphasis as `*content*` (CommonMark italic).
77    fn emph(&self, content: Self::Output) -> Self::Output {
78        if content.is_empty() {
79            return content;
80        }
81        format!("*{content}*")
82    }
83
84    /// Render strong emphasis as `**content**` (CommonMark bold).
85    fn strong(&self, content: Self::Output) -> Self::Output {
86        if content.is_empty() {
87            return content;
88        }
89        format!("**{content}**")
90    }
91
92    /// Render small caps as raw inline HTML.
93    ///
94    /// CommonMark has no native small-caps syntax. Raw `<span>` HTML is passed
95    /// through by Pandoc's CommonMark reader and most other processors.
96    fn small_caps(&self, content: Self::Output) -> Self::Output {
97        if content.is_empty() {
98            return content;
99        }
100        format!("<span style=\"font-variant:small-caps\">{content}</span>")
101    }
102
103    /// Render superscript as raw inline HTML.
104    ///
105    /// CommonMark has no native superscript syntax. Raw `<sup>` HTML is passed
106    /// through by Pandoc and most processors.
107    fn superscript(&self, content: Self::Output) -> Self::Output {
108        if content.is_empty() {
109            return content;
110        }
111        format!("<sup>{content}</sup>")
112    }
113
114    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
115        if content.is_empty() {
116            return content;
117        }
118        let (open, close) = marks.for_depth(0);
119        format!("{open}{content}{close}")
120    }
121
122    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
123        format!("{prefix}{content}{suffix}")
124    }
125
126    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
127        format!("{prefix}{content}{suffix}")
128    }
129
130    fn wrap_punctuation(
131        &self,
132        wrap: &WrapPunctuation,
133        content: Self::Output,
134        marks: &QuoteMarks,
135        script: ScriptClass,
136        realization: Option<&citum_schema::options::PunctuationRealization>,
137    ) -> Self::Output {
138        match realize_wrap(wrap, script, realization) {
139            Some((open, close)) => {
140                format!("{}{}{}", self.text(&open), content, self.text(&close))
141            }
142            None => self.quote(content, marks),
143        }
144    }
145
146    /// Render a semantic class as a plain passthrough.
147    ///
148    /// CommonMark has no attribute syntax. Content is returned unchanged so
149    /// citations remain readable plain text. Use `--format html` or `--format djot`
150    /// if machine-readable semantic spans are needed.
151    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
152        content
153    }
154
155    fn annotation(&self, content: Self::Output) -> Self::Output {
156        if content.is_empty() {
157            return content;
158        }
159        format!("\n\n{content}")
160    }
161
162    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
163        if content.is_empty() {
164            return content;
165        }
166        format!("[{content}]({url})")
167    }
168
169    fn entry(
170        &self,
171        _id: &str,
172        content: Self::Output,
173        url: Option<&str>,
174        _metadata: &super::format::ProcEntryMetadata,
175    ) -> Self::Output {
176        if let Some(u) = url {
177            self.link(u, content)
178        } else {
179            content
180        }
181    }
182
183    /// Strip `*`/`**` emphasis delimiters, `<span>`/`<sup>` raw-HTML wrappers,
184    /// and a `[content](url)` link's `[`/`](url)` markup — keeping link text
185    /// visible but the URL hidden. A bracket pair *not* followed by `(url)`
186    /// (i.e. from [`Self::wrap_punctuation`]'s `WrapPunctuation::Brackets`)
187    /// keeps its brackets visible, since those are house-style punctuation.
188    /// Backslash-escaped characters are visible as themselves.
189    fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
190        let mut runs = RunBuilder::default();
191        let chars: Vec<(usize, char)> = fragment.char_indices().collect();
192        let mut i = 0;
193        let mut in_tag = false;
194        let mut pending_link_close: Option<usize> = None;
195        while let Some(&(pos, ch)) = chars.get(i) {
196            if in_tag {
197                if ch == '>' {
198                    in_tag = false;
199                }
200                i += 1;
201                continue;
202            }
203            match ch {
204                '\\' => {
205                    if let Some(&(epos, echar)) = chars.get(i + 1) {
206                        runs.push_visible(epos, epos + echar.len_utf8());
207                    }
208                    i += 2;
209                }
210                '<' => {
211                    in_tag = true;
212                    i += 1;
213                }
214                '*' => {
215                    i += 1;
216                    if chars.get(i).map(|&(_, c)| c) == Some('*') {
217                        i += 1;
218                    }
219                }
220                '[' => {
221                    let close_i = find_matching(&chars, i, '[', ']', true);
222                    let is_link = close_i
223                        .is_some_and(|c| chars.get(c + 1).map(|&(_, next)| next) == Some('('));
224                    if is_link {
225                        pending_link_close = close_i;
226                        i += 1;
227                    } else {
228                        runs.push_visible(pos, pos + 1);
229                        i += 1;
230                    }
231                }
232                ']' => {
233                    if pending_link_close == Some(i) {
234                        pending_link_close = None;
235                        let mut j = i + 1;
236                        if chars.get(j).map(|&(_, c)| c) == Some('(') {
237                            j = skip_balanced(&chars, j, '(', ')', true);
238                        }
239                        i = j;
240                    } else {
241                        runs.push_visible(pos, pos + 1);
242                        i += 1;
243                    }
244                }
245                _ => {
246                    runs.push_visible(pos, pos + ch.len_utf8());
247                    i += 1;
248                }
249            }
250        }
251        runs.finish()
252    }
253}
254
255#[cfg(test)]
256#[allow(
257    clippy::unwrap_used,
258    clippy::expect_used,
259    clippy::panic,
260    clippy::indexing_slicing,
261    reason = "tests"
262)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_markdown_emph() {
268        let fmt = Markdown;
269        for (input, expected) in [("", ""), ("text", "*text*")] {
270            assert_eq!(fmt.emph(input.to_string()), expected);
271        }
272    }
273
274    #[test]
275    fn test_markdown_strong() {
276        let fmt = Markdown;
277        for (input, expected) in [("", ""), ("text", "**text**")] {
278            assert_eq!(fmt.strong(input.to_string()), expected);
279        }
280    }
281
282    #[test]
283    fn test_markdown_small_caps() {
284        let fmt = Markdown;
285        assert_eq!(fmt.small_caps(String::new()), "");
286        assert_eq!(
287            fmt.small_caps("Smith".to_string()),
288            "<span style=\"font-variant:small-caps\">Smith</span>"
289        );
290    }
291
292    #[test]
293    fn test_markdown_superscript() {
294        let fmt = Markdown;
295        assert_eq!(fmt.superscript(String::new()), "");
296        assert_eq!(fmt.superscript("2".to_string()), "<sup>2</sup>");
297    }
298
299    #[test]
300    fn test_markdown_quote() {
301        let fmt = Markdown;
302        let marks = QuoteMarks::default();
303        for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
304            assert_eq!(fmt.quote(input.to_string(), &marks), expected);
305        }
306    }
307
308    #[test]
309    fn test_markdown_quote_uses_locale_marks() {
310        let fmt = Markdown;
311        let marks = QuoteMarks {
312            open: "\u{ab}".to_string(),
313            close: "\u{bb}".to_string(),
314            open_inner: "\u{2039}".to_string(),
315            close_inner: "\u{203a}".to_string(),
316            punctuation_realization: None,
317        };
318
319        assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
320    }
321
322    #[test]
323    fn test_markdown_semantic_passthrough() {
324        let fmt = Markdown;
325        assert_eq!(fmt.semantic("author", "Jane Doe".to_string()), "Jane Doe");
326        assert_eq!(fmt.semantic("title", String::new()), "");
327    }
328
329    #[test]
330    fn test_markdown_link() {
331        let fmt = Markdown;
332        assert_eq!(fmt.link("https://example.com", String::new()), "");
333        assert_eq!(
334            fmt.link("https://example.com", "Example".to_string()),
335            "[Example](https://example.com)"
336        );
337    }
338
339    #[test]
340    fn test_markdown_wrap_punctuation() {
341        let fmt = Markdown;
342        let marks = QuoteMarks::default();
343        for (wrap, script, input, expected) in [
344            (
345                WrapPunctuation::Parentheses,
346                ScriptClass::Latin,
347                "text",
348                "(text)",
349            ),
350            (
351                WrapPunctuation::Brackets,
352                ScriptClass::Latin,
353                "text",
354                "\\[text\\]",
355            ),
356            (
357                WrapPunctuation::Quotes,
358                ScriptClass::Latin,
359                "text",
360                "\u{201C}text\u{201D}",
361            ),
362            (
363                WrapPunctuation::Parentheses,
364                ScriptClass::Cjk,
365                "text",
366                "\u{ff08}text\u{ff09}",
367            ),
368            (
369                WrapPunctuation::Brackets,
370                ScriptClass::Cjk,
371                "text",
372                "\u{3010}text\u{3011}",
373            ),
374        ] {
375            assert_eq!(
376                fmt.wrap_punctuation(&wrap, input.to_string(), &marks, script, None),
377                expected
378            );
379        }
380    }
381
382    #[test]
383    fn test_markdown_text_escapes_active_chars() {
384        let fmt = Markdown;
385        assert_eq!(fmt.text("plain"), "plain");
386        assert_eq!(fmt.text("A * B"), "A \\* B");
387        assert_eq!(fmt.text("use [x]"), "use \\[x\\]");
388        assert_eq!(fmt.text("code `foo`"), "code \\`foo\\`");
389        assert_eq!(fmt.text("back\\slash"), "back\\\\slash");
390        assert_eq!(fmt.text("under_score"), "under\\_score");
391        // Angle brackets and ampersand: escape to prevent autolinks,
392        // inline HTML, and HTML entity expansion.
393        assert_eq!(fmt.text("<doi:10.1/x>"), "\\<doi:10.1/x\\>");
394        assert_eq!(fmt.text("Smith & Jones"), "Smith \\& Jones");
395        assert_eq!(fmt.text("<em>bold</em>"), "\\<em\\>bold\\</em\\>");
396    }
397
398    #[test]
399    fn visible_text_strips_emph_and_strong_delimiters() {
400        let fmt = Markdown;
401        assert_eq!(fmt.visible_text("*Title.*"), "Title.");
402        assert_eq!(fmt.visible_text("**Title.**"), "Title.");
403    }
404
405    #[test]
406    fn visible_text_hides_link_url_keeps_text() {
407        let fmt = Markdown;
408        assert_eq!(
409            fmt.visible_text("[Example](https://example.com/a.b)"),
410            "Example"
411        );
412    }
413
414    #[test]
415    fn visible_text_keeps_literal_wrap_brackets_visible() {
416        let fmt = Markdown;
417        // WrapPunctuation::Brackets: bare `[content]`, not a link.
418        assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
419    }
420
421    #[test]
422    fn visible_text_strips_raw_html_spans() {
423        let fmt = Markdown;
424        assert_eq!(
425            fmt.visible_text("<span style=\"font-variant:small-caps\">Smith</span>"),
426            "Smith"
427        );
428        assert_eq!(fmt.visible_text("<sup>2</sup>"), "2");
429    }
430
431    #[test]
432    fn visible_text_keeps_escaped_punctuation() {
433        let fmt = Markdown;
434        assert_eq!(fmt.visible_text(r"A \* B"), "A * B");
435    }
436}