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