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