Skip to main content

citum_engine/render/
djot.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Djot output format.
7
8use std::ops::Range;
9
10use super::format::{OutputFormat, QuoteMarks};
11use super::visible_scan::{RunBuilder, find_matching, skip_balanced};
12use citum_schema::template::WrapPunctuation;
13
14#[derive(Default, Clone)]
15/// Renders processed citations and bibliography entries as Djot markup.
16pub struct Djot;
17
18impl OutputFormat for Djot {
19    type Output = String;
20
21    fn text(&self, s: &str) -> Self::Output {
22        // No escaping for Djot as requested.
23        s.to_string()
24    }
25
26    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
27        items.join(delimiter)
28    }
29
30    fn finish(&self, output: Self::Output) -> String {
31        output
32    }
33
34    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
35        let marks = "#".repeat(level.max(1) as usize);
36        format!("{marks} {content}\n\n")
37    }
38
39    fn emph(&self, content: Self::Output) -> Self::Output {
40        if content.is_empty() {
41            return content;
42        }
43        format!("_{content}_")
44    }
45
46    fn strong(&self, content: Self::Output) -> Self::Output {
47        if content.is_empty() {
48            return content;
49        }
50        format!("*{content}*")
51    }
52
53    fn small_caps(&self, content: Self::Output) -> Self::Output {
54        if content.is_empty() {
55            return content;
56        }
57        format!("[{content}]{{.small-caps}}")
58    }
59
60    fn superscript(&self, content: Self::Output) -> Self::Output {
61        if content.is_empty() {
62            return content;
63        }
64        format!("[{content}]{{.superscript}}")
65    }
66
67    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
68        if content.is_empty() {
69            return content;
70        }
71        let (open, close) = marks.for_depth(0);
72        format!("{open}{content}{close}")
73    }
74
75    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
76        format!("{prefix}{content}{suffix}")
77    }
78
79    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
80        format!("{prefix}{content}{suffix}")
81    }
82
83    fn wrap_punctuation(
84        &self,
85        wrap: &WrapPunctuation,
86        content: Self::Output,
87        marks: &QuoteMarks,
88    ) -> Self::Output {
89        match wrap {
90            WrapPunctuation::Parentheses => format!("({content})"),
91            WrapPunctuation::Brackets => format!("[{content}]"),
92            WrapPunctuation::Quotes => self.quote(content, marks),
93        }
94    }
95
96    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
97        if content.is_empty() {
98            return content;
99        }
100        format!("[{content}]{{.{class}}}")
101    }
102
103    fn annotation(&self, content: Self::Output) -> Self::Output {
104        if content.is_empty() {
105            return content;
106        }
107        format!("\n\n::: citum-annotation\n{content}\n:::")
108    }
109
110    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
111        if content.is_empty() {
112            return content;
113        }
114        format!("[{content}]({url})")
115    }
116
117    fn entry(
118        &self,
119        _id: &str,
120        content: Self::Output,
121        url: Option<&str>,
122        _metadata: &super::format::ProcEntryMetadata,
123    ) -> Self::Output {
124        if let Some(u) = url {
125            self.link(u, content)
126        } else {
127            content
128        }
129    }
130
131    /// Strip `_`/`*` emphasis delimiters and a `[content]{.class}` span's or
132    /// `[content](url)` link's bracket-plus-attribute markup, keeping the
133    /// bracketed content visible. A bracket pair followed by neither `{` nor
134    /// `(` (i.e. from [`Self::wrap_punctuation`]'s `WrapPunctuation::Brackets`)
135    /// keeps its brackets visible, since those are house-style punctuation.
136    ///
137    /// Djot's [`Self::text`] does not escape its input ("no escaping for
138    /// Djot as requested"), so a data field containing a literal `_`, `*`,
139    /// `[`, or `]` is inherently ambiguous with markup here — the same
140    /// ambiguity the Djot renderer itself already has.
141    fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
142        let mut runs = RunBuilder::default();
143        let chars: Vec<(usize, char)> = fragment.char_indices().collect();
144        let mut i = 0;
145        let mut pending_close: Option<usize> = None;
146        while let Some(&(pos, ch)) = chars.get(i) {
147            match ch {
148                '_' | '*' => i += 1,
149                '[' => {
150                    let close_i = find_matching(&chars, i, '[', ']', false);
151                    let after = close_i.and_then(|c| chars.get(c + 1).map(|&(_, next)| next));
152                    if matches!(after, Some('{' | '(')) {
153                        pending_close = close_i;
154                        i += 1;
155                    } else {
156                        runs.push_visible(pos, pos + 1);
157                        i += 1;
158                    }
159                }
160                ']' => {
161                    if pending_close == Some(i) {
162                        pending_close = None;
163                        let mut j = i + 1;
164                        match chars.get(j).map(|&(_, c)| c) {
165                            Some('{') => j = skip_balanced(&chars, j, '{', '}', false),
166                            Some('(') => j = skip_balanced(&chars, j, '(', ')', false),
167                            _ => {}
168                        }
169                        i = j;
170                    } else {
171                        runs.push_visible(pos, pos + 1);
172                        i += 1;
173                    }
174                }
175                _ => {
176                    runs.push_visible(pos, pos + ch.len_utf8());
177                    i += 1;
178                }
179            }
180        }
181        runs.finish()
182    }
183}
184
185#[cfg(test)]
186#[allow(
187    clippy::unwrap_used,
188    clippy::expect_used,
189    clippy::panic,
190    clippy::indexing_slicing,
191    clippy::todo,
192    clippy::unimplemented,
193    clippy::unreachable,
194    clippy::get_unwrap,
195    reason = "Panicking is acceptable and often desired in tests."
196)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_djot_emph() {
202        let fmt = Djot;
203
204        for (input, expected) in [("", ""), ("text", "_text_")] {
205            assert_eq!(fmt.emph(input.to_string()), expected);
206        }
207    }
208
209    #[test]
210    fn test_djot_strong() {
211        let fmt = Djot;
212
213        for (input, expected) in [("", ""), ("text", "*text*")] {
214            assert_eq!(fmt.strong(input.to_string()), expected);
215        }
216    }
217
218    #[test]
219    fn test_djot_small_caps() {
220        let fmt = Djot;
221
222        for (input, expected) in [("", ""), ("text", "[text]{.small-caps}")] {
223            assert_eq!(fmt.small_caps(input.to_string()), expected);
224        }
225    }
226
227    #[test]
228    fn test_djot_quote() {
229        let fmt = Djot;
230        let marks = QuoteMarks::default();
231
232        for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
233            assert_eq!(fmt.quote(input.to_string(), &marks), expected);
234        }
235    }
236
237    #[test]
238    fn test_djot_quote_uses_locale_marks() {
239        let fmt = Djot;
240        let marks = QuoteMarks {
241            open: "\u{ab}".to_string(),
242            close: "\u{bb}".to_string(),
243            open_inner: "\u{2039}".to_string(),
244            close_inner: "\u{203a}".to_string(),
245        };
246
247        assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
248    }
249
250    #[test]
251    fn test_djot_semantic() {
252        let fmt = Djot;
253
254        for (input, class, expected) in [("", "author", ""), ("text", "author", "[text]{.author}")]
255        {
256            assert_eq!(fmt.semantic(class, input.to_string()), expected);
257        }
258    }
259
260    #[test]
261    fn test_djot_link() {
262        let fmt = Djot;
263
264        for (input, url, expected) in [
265            ("", "https://example.com", ""),
266            ("text", "https://example.com", "[text](https://example.com)"),
267        ] {
268            assert_eq!(fmt.link(url, input.to_string()), expected);
269        }
270    }
271
272    #[test]
273    fn test_djot_wrap_punctuation() {
274        let fmt = Djot;
275        let marks = QuoteMarks::default();
276
277        for (wrap, input, expected) in [
278            (WrapPunctuation::Parentheses, "text", "(text)"),
279            (WrapPunctuation::Brackets, "text", "[text]"),
280            (WrapPunctuation::Quotes, "text", "\u{201C}text\u{201D}"),
281        ] {
282            assert_eq!(
283                fmt.wrap_punctuation(&wrap, input.to_string(), &marks),
284                expected
285            );
286        }
287    }
288
289    #[test]
290    fn visible_text_strips_emph_and_strong_delimiters() {
291        let fmt = Djot;
292        assert_eq!(fmt.visible_text("_Title._"), "Title.");
293        assert_eq!(fmt.visible_text("*Title.*"), "Title.");
294    }
295
296    #[test]
297    fn visible_text_strips_semantic_span_attributes() {
298        let fmt = Djot;
299        assert_eq!(fmt.visible_text("[Smith]{.author}"), "Smith");
300    }
301
302    #[test]
303    fn visible_text_hides_link_url_keeps_text() {
304        let fmt = Djot;
305        assert_eq!(
306            fmt.visible_text("[Example](https://example.com/a.b)"),
307            "Example"
308        );
309    }
310
311    #[test]
312    fn visible_text_keeps_literal_wrap_brackets_visible() {
313        let fmt = Djot;
314        // WrapPunctuation::Brackets: bare `[content]`, no `{...}`/`(...)` follows.
315        assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
316    }
317}