Skip to main content

citum_engine/render/
typst.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Typst output format.
7
8use std::ops::Range;
9
10use super::format::{OutputFormat, QuoteMarks};
11use super::visible_scan::{RunBuilder, skip_balanced};
12use citum_schema::template::WrapPunctuation;
13
14/// Typst renderer.
15#[derive(Debug, Clone, Default)]
16pub struct Typst;
17
18impl Typst {
19    fn escape_text(input: &str) -> String {
20        let mut escaped = String::with_capacity(input.len());
21        for ch in input.chars() {
22            match ch {
23                '\\' => escaped.push_str("\\\\"),
24                '#' | '[' | ']' | '<' | '>' | '*' | '_' | '@' | '$' => {
25                    escaped.push('\\');
26                    escaped.push(ch);
27                }
28                _ => escaped.push(ch),
29            }
30        }
31        escaped
32    }
33
34    fn escape_string(input: &str) -> String {
35        let mut escaped = String::with_capacity(input.len());
36        for ch in input.chars() {
37            match ch {
38                '\\' => escaped.push_str("\\\\"),
39                '"' => escaped.push_str("\\\""),
40                _ => escaped.push(ch),
41            }
42        }
43        escaped
44    }
45
46    /// Return the length of the longest consecutive backtick run in `s`.
47    fn longest_backtick_run(s: &str) -> usize {
48        let mut max = 0usize;
49        let mut cur = 0usize;
50        for ch in s.chars() {
51            if ch == '`' {
52                cur += 1;
53                if cur > max {
54                    max = cur;
55                }
56            } else {
57                cur = 0;
58            }
59        }
60        max
61    }
62}
63
64impl OutputFormat for Typst {
65    type Output = String;
66
67    fn text(&self, s: &str) -> Self::Output {
68        Self::escape_text(s)
69    }
70
71    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
72        items.join(delimiter)
73    }
74
75    fn finish(&self, output: Self::Output) -> String {
76        output
77    }
78
79    fn emph(&self, content: Self::Output) -> Self::Output {
80        if content.is_empty() {
81            return content;
82        }
83        format!("#emph[{content}]")
84    }
85
86    fn strong(&self, content: Self::Output) -> Self::Output {
87        if content.is_empty() {
88            return content;
89        }
90        format!("#strong[{content}]")
91    }
92
93    fn small_caps(&self, content: Self::Output) -> Self::Output {
94        if content.is_empty() {
95            return content;
96        }
97        format!("#smallcaps[{content}]")
98    }
99
100    fn superscript(&self, content: Self::Output) -> Self::Output {
101        if content.is_empty() {
102            return content;
103        }
104        format!("#super[{content}]")
105    }
106
107    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
108        if content.is_empty() {
109            return content;
110        }
111        let (open, close) = marks.for_depth(0);
112        format!("{open}{content}{close}")
113    }
114
115    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
116        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
117    }
118
119    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
120        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
121    }
122
123    fn wrap_punctuation(
124        &self,
125        wrap: &WrapPunctuation,
126        content: Self::Output,
127        marks: &QuoteMarks,
128    ) -> Self::Output {
129        match wrap {
130            WrapPunctuation::Parentheses => format!("({content})"),
131            WrapPunctuation::Brackets => format!("[{content}]"),
132            WrapPunctuation::Quotes => self.quote(content, marks),
133        }
134    }
135
136    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
137        content
138    }
139
140    fn annotation(&self, content: Self::Output) -> Self::Output {
141        if content.is_empty() {
142            return content;
143        }
144        format!("\n#block(class: \"citum-annotation\")[{}]", content)
145    }
146
147    fn citation(&self, ids: Vec<String>, content: Self::Output) -> Self::Output {
148        if content.is_empty() || ids.len() != 1 {
149            return content;
150        }
151
152        #[allow(clippy::unwrap_used, reason = "length checked")]
153        let id = ids.first().unwrap();
154        format!("#link(<{}>)[{}]", self.format_id(id), content)
155    }
156
157    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
158        if content.is_empty() {
159            return content;
160        }
161
162        if let Some(label) = url.strip_prefix('#') {
163            format!("#link(<{}>)[{}]", self.format_id(label), content)
164        } else {
165            format!(r#"#link("{}")[{}]"#, Self::escape_string(url), content)
166        }
167    }
168
169    fn format_id(&self, id: &str) -> String {
170        let mut normalized = String::with_capacity(id.len() + 4);
171        normalized.push_str("ref-");
172        for ch in id.chars() {
173            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.') {
174                normalized.push(ch);
175            } else {
176                normalized.push('-');
177            }
178        }
179        normalized
180    }
181
182    // ── Block-level body markup methods ────────────────────────────────────
183
184    fn paragraph(&self, content: Self::Output) -> Self::Output {
185        if content.is_empty() {
186            return content;
187        }
188        format!("{content}\n\n")
189    }
190
191    fn block_quote(&self, content: Self::Output) -> Self::Output {
192        if content.is_empty() {
193            return content;
194        }
195        let trimmed = content.trim_end();
196        format!("#quote(block: true)[\n{trimmed}\n]\n\n")
197    }
198
199    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
200        if items.is_empty() {
201            return String::new();
202        }
203        let body = items
204            .iter()
205            .map(|item| format!("- {}", item.trim()))
206            .collect::<Vec<_>>()
207            .join("\n");
208        format!("{body}\n\n")
209    }
210
211    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
212        if items.is_empty() {
213            return String::new();
214        }
215        let body = items
216            .iter()
217            .map(|item| format!("+ {}", item.trim()))
218            .collect::<Vec<_>>()
219            .join("\n");
220        format!("{body}\n\n")
221    }
222
223    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
224        let marks = "=".repeat(level.max(1) as usize);
225        format!("{marks} {content}\n\n")
226    }
227
228    fn code_block(&self, lang: Option<&str>, content: Self::Output) -> Self::Output {
229        let fence = "`".repeat(Self::longest_backtick_run(&content).max(2) + 1);
230        let lang_tag = lang.unwrap_or("");
231        format!("{fence}{lang_tag}\n{content}{fence}\n\n")
232    }
233
234    fn inline_code(&self, content: Self::Output) -> Self::Output {
235        let ticks = "`".repeat(Self::longest_backtick_run(&content) + 1);
236        format!("{ticks}{content}{ticks}")
237    }
238
239    fn strikeout(&self, content: Self::Output) -> Self::Output {
240        if content.is_empty() {
241            return content;
242        }
243        format!("#strike[{content}]")
244    }
245
246    fn hard_break(&self) -> Self::Output {
247        "\\\n".to_string()
248    }
249
250    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
251        self.join(entries, "\n\n")
252    }
253
254    fn entry(
255        &self,
256        id: &str,
257        content: Self::Output,
258        url: Option<&str>,
259        _metadata: &super::format::ProcEntryMetadata,
260    ) -> Self::Output {
261        let content = if let Some(u) = url {
262            self.link(u, content)
263        } else {
264            content
265        };
266
267        format!("{} <{}>", content, self.format_id(id))
268    }
269
270    /// Strip `#func(...)[...]` wrappers (function name, parenthesized
271    /// arguments — e.g. a `#link("url")` target — and the content group's
272    /// `[`/`]` delimiters), keeping the bracketed content visible. A literal
273    /// `[content]` not preceded by `#func` (i.e. from
274    /// [`Self::wrap_punctuation`]'s `WrapPunctuation::Brackets`) keeps its
275    /// brackets visible, since those are house-style punctuation, not
276    /// markup. Backslash-escaped characters are visible as themselves.
277    fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
278        let mut runs = RunBuilder::default();
279        let chars: Vec<(usize, char)> = fragment.char_indices().collect();
280        let mut i = 0;
281        let mut bracket_stack: Vec<bool> = Vec::new();
282        while let Some(&(pos, ch)) = chars.get(i) {
283            match ch {
284                '\\' => {
285                    if let Some(&(epos, echar)) = chars.get(i + 1) {
286                        runs.push_visible(epos, epos + echar.len_utf8());
287                    }
288                    i += 2;
289                }
290                '#' => i = consume_function_head(&chars, i, &mut bracket_stack),
291                '[' => {
292                    bracket_stack.push(false);
293                    runs.push_visible(pos, pos + 1);
294                    i += 1;
295                }
296                ']' => {
297                    if !bracket_stack.pop().unwrap_or(false) {
298                        runs.push_visible(pos, pos + 1);
299                    }
300                    i += 1;
301                }
302                _ => {
303                    runs.push_visible(pos, pos + ch.len_utf8());
304                    i += 1;
305                }
306            }
307        }
308        runs.finish()
309    }
310}
311
312/// Consume a `#ident(...)?[`-style function head starting at the `#` at
313/// `chars[i]`: the identifier and any parenthesized arguments are markup
314/// (invisible); if a content group `[` follows, push `true` onto
315/// `bracket_stack` so its matching `]` is later recognized as markup too.
316fn consume_function_head(
317    chars: &[(usize, char)],
318    i: usize,
319    bracket_stack: &mut Vec<bool>,
320) -> usize {
321    let mut j = i + 1;
322    while chars
323        .get(j)
324        .is_some_and(|&(_, c)| c.is_ascii_alphanumeric() || c == '_')
325    {
326        j += 1;
327    }
328    if chars.get(j).map(|&(_, c)| c) == Some('(') {
329        j = skip_balanced(chars, j, '(', ')', true);
330    }
331    if chars.get(j).map(|&(_, c)| c) == Some('[') {
332        bracket_stack.push(true);
333        j += 1;
334    }
335    j
336}
337
338#[cfg(test)]
339#[allow(
340    clippy::unwrap_used,
341    clippy::expect_used,
342    clippy::panic,
343    clippy::indexing_slicing,
344    reason = "Panicking is acceptable and often desired in tests."
345)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn visible_text_strips_emph_function_and_brackets() {
351        let fmt = Typst;
352        assert_eq!(fmt.visible_text("#emph[Title.]"), "Title.");
353    }
354
355    #[test]
356    fn visible_text_hides_link_target_keeps_content() {
357        let fmt = Typst;
358        assert_eq!(
359            fmt.visible_text(r#"#link("https://example.com/a.b")[Example]"#),
360            "Example"
361        );
362    }
363
364    #[test]
365    fn visible_text_handles_nested_functions() {
366        let fmt = Typst;
367        assert_eq!(fmt.visible_text("#strong[#emph[Title.]]"), "Title.");
368    }
369
370    #[test]
371    fn visible_text_keeps_literal_wrap_brackets_visible() {
372        let fmt = Typst;
373        // WrapPunctuation::Brackets: bare `[content]`, not a function call.
374        assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
375    }
376
377    #[test]
378    fn visible_text_keeps_escaped_punctuation() {
379        let fmt = Typst;
380        assert_eq!(fmt.visible_text(r"A \# B"), "A # B");
381    }
382}