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 super::format::OutputFormat;
9use citum_schema::template::WrapPunctuation;
10
11/// Typst renderer.
12#[derive(Debug, Clone, Default)]
13pub struct Typst;
14
15impl Typst {
16    fn escape_text(input: &str) -> String {
17        let mut escaped = String::with_capacity(input.len());
18        for ch in input.chars() {
19            match ch {
20                '\\' => escaped.push_str("\\\\"),
21                '#' | '[' | ']' | '<' | '>' | '*' | '_' | '@' | '$' => {
22                    escaped.push('\\');
23                    escaped.push(ch);
24                }
25                _ => escaped.push(ch),
26            }
27        }
28        escaped
29    }
30
31    fn escape_string(input: &str) -> String {
32        let mut escaped = String::with_capacity(input.len());
33        for ch in input.chars() {
34            match ch {
35                '\\' => escaped.push_str("\\\\"),
36                '"' => escaped.push_str("\\\""),
37                _ => escaped.push(ch),
38            }
39        }
40        escaped
41    }
42
43    /// Return the length of the longest consecutive backtick run in `s`.
44    fn longest_backtick_run(s: &str) -> usize {
45        let mut max = 0usize;
46        let mut cur = 0usize;
47        for ch in s.chars() {
48            if ch == '`' {
49                cur += 1;
50                if cur > max {
51                    max = cur;
52                }
53            } else {
54                cur = 0;
55            }
56        }
57        max
58    }
59}
60
61impl OutputFormat for Typst {
62    type Output = String;
63
64    fn text(&self, s: &str) -> Self::Output {
65        Self::escape_text(s)
66    }
67
68    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
69        items.join(delimiter)
70    }
71
72    fn finish(&self, output: Self::Output) -> String {
73        output
74    }
75
76    fn emph(&self, content: Self::Output) -> Self::Output {
77        if content.is_empty() {
78            return content;
79        }
80        format!("#emph[{content}]")
81    }
82
83    fn strong(&self, content: Self::Output) -> Self::Output {
84        if content.is_empty() {
85            return content;
86        }
87        format!("*{content}*")
88    }
89
90    fn small_caps(&self, content: Self::Output) -> Self::Output {
91        if content.is_empty() {
92            return content;
93        }
94        format!("#smallcaps[{content}]")
95    }
96
97    fn superscript(&self, content: Self::Output) -> Self::Output {
98        if content.is_empty() {
99            return content;
100        }
101        format!("#super[{content}]")
102    }
103
104    fn quote(&self, content: Self::Output) -> Self::Output {
105        if content.is_empty() {
106            return content;
107        }
108        format!("\u{201C}{content}\u{201D}")
109    }
110
111    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
112        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
113    }
114
115    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
116        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
117    }
118
119    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
120        match wrap {
121            WrapPunctuation::Parentheses => format!("({content})"),
122            WrapPunctuation::Brackets => format!("[{content}]"),
123            WrapPunctuation::Quotes => format!("\u{201C}{content}\u{201D}"),
124        }
125    }
126
127    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
128        content
129    }
130
131    fn annotation(&self, content: Self::Output) -> Self::Output {
132        if content.is_empty() {
133            return content;
134        }
135        format!("\n#block(class: \"citum-annotation\")[{}]", content)
136    }
137
138    fn citation(&self, ids: Vec<String>, content: Self::Output) -> Self::Output {
139        if content.is_empty() || ids.len() != 1 {
140            return content;
141        }
142
143        #[allow(clippy::unwrap_used, reason = "length checked")]
144        let id = ids.first().unwrap();
145        format!("#link(<{}>)[{}]", self.format_id(id), content)
146    }
147
148    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
149        if content.is_empty() {
150            return content;
151        }
152
153        if let Some(label) = url.strip_prefix('#') {
154            format!("#link(<{}>)[{}]", self.format_id(label), content)
155        } else {
156            format!(r#"#link("{}")[{}]"#, Self::escape_string(url), content)
157        }
158    }
159
160    fn format_id(&self, id: &str) -> String {
161        let mut normalized = String::with_capacity(id.len() + 4);
162        normalized.push_str("ref-");
163        for ch in id.chars() {
164            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.') {
165                normalized.push(ch);
166            } else {
167                normalized.push('-');
168            }
169        }
170        normalized
171    }
172
173    // ── Block-level body markup methods ────────────────────────────────────
174
175    fn paragraph(&self, content: Self::Output) -> Self::Output {
176        if content.is_empty() {
177            return content;
178        }
179        format!("{content}\n\n")
180    }
181
182    fn block_quote(&self, content: Self::Output) -> Self::Output {
183        if content.is_empty() {
184            return content;
185        }
186        let trimmed = content.trim_end();
187        format!("#quote(block: true)[\n{trimmed}\n]\n\n")
188    }
189
190    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
191        if items.is_empty() {
192            return String::new();
193        }
194        let body = items
195            .iter()
196            .map(|item| format!("- {}", item.trim()))
197            .collect::<Vec<_>>()
198            .join("\n");
199        format!("{body}\n\n")
200    }
201
202    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
203        if items.is_empty() {
204            return String::new();
205        }
206        let body = items
207            .iter()
208            .map(|item| format!("+ {}", item.trim()))
209            .collect::<Vec<_>>()
210            .join("\n");
211        format!("{body}\n\n")
212    }
213
214    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
215        let marks = "=".repeat(level.max(1) as usize);
216        format!("{marks} {content}\n\n")
217    }
218
219    fn code_block(&self, lang: Option<&str>, content: Self::Output) -> Self::Output {
220        let fence = "`".repeat(Self::longest_backtick_run(&content).max(2) + 1);
221        let lang_tag = lang.unwrap_or("");
222        format!("{fence}{lang_tag}\n{content}{fence}\n\n")
223    }
224
225    fn inline_code(&self, content: Self::Output) -> Self::Output {
226        let ticks = "`".repeat(Self::longest_backtick_run(&content) + 1);
227        format!("{ticks}{content}{ticks}")
228    }
229
230    fn strikeout(&self, content: Self::Output) -> Self::Output {
231        if content.is_empty() {
232            return content;
233        }
234        format!("#strike[{content}]")
235    }
236
237    fn hard_break(&self) -> Self::Output {
238        "\\\n".to_string()
239    }
240
241    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
242        self.join(entries, "\n\n")
243    }
244
245    fn entry(
246        &self,
247        id: &str,
248        content: Self::Output,
249        url: Option<&str>,
250        _metadata: &super::format::ProcEntryMetadata,
251    ) -> Self::Output {
252        let content = if let Some(u) = url {
253            self.link(u, content)
254        } else {
255            content
256        };
257
258        format!("{} <{}>", content, self.format_id(id))
259    }
260}