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, QuoteMarks};
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!("#strong[{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, marks: &QuoteMarks) -> Self::Output {
105        if content.is_empty() {
106            return content;
107        }
108        let (open, close) = marks.for_depth(0);
109        format!("{open}{content}{close}")
110    }
111
112    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
113        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
114    }
115
116    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
117        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
118    }
119
120    fn wrap_punctuation(
121        &self,
122        wrap: &WrapPunctuation,
123        content: Self::Output,
124        marks: &QuoteMarks,
125    ) -> Self::Output {
126        match wrap {
127            WrapPunctuation::Parentheses => format!("({content})"),
128            WrapPunctuation::Brackets => format!("[{content}]"),
129            WrapPunctuation::Quotes => self.quote(content, marks),
130        }
131    }
132
133    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
134        content
135    }
136
137    fn annotation(&self, content: Self::Output) -> Self::Output {
138        if content.is_empty() {
139            return content;
140        }
141        format!("\n#block(class: \"citum-annotation\")[{}]", content)
142    }
143
144    fn citation(&self, ids: Vec<String>, content: Self::Output) -> Self::Output {
145        if content.is_empty() || ids.len() != 1 {
146            return content;
147        }
148
149        #[allow(clippy::unwrap_used, reason = "length checked")]
150        let id = ids.first().unwrap();
151        format!("#link(<{}>)[{}]", self.format_id(id), content)
152    }
153
154    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
155        if content.is_empty() {
156            return content;
157        }
158
159        if let Some(label) = url.strip_prefix('#') {
160            format!("#link(<{}>)[{}]", self.format_id(label), content)
161        } else {
162            format!(r#"#link("{}")[{}]"#, Self::escape_string(url), content)
163        }
164    }
165
166    fn format_id(&self, id: &str) -> String {
167        let mut normalized = String::with_capacity(id.len() + 4);
168        normalized.push_str("ref-");
169        for ch in id.chars() {
170            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.') {
171                normalized.push(ch);
172            } else {
173                normalized.push('-');
174            }
175        }
176        normalized
177    }
178
179    // ── Block-level body markup methods ────────────────────────────────────
180
181    fn paragraph(&self, content: Self::Output) -> Self::Output {
182        if content.is_empty() {
183            return content;
184        }
185        format!("{content}\n\n")
186    }
187
188    fn block_quote(&self, content: Self::Output) -> Self::Output {
189        if content.is_empty() {
190            return content;
191        }
192        let trimmed = content.trim_end();
193        format!("#quote(block: true)[\n{trimmed}\n]\n\n")
194    }
195
196    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
197        if items.is_empty() {
198            return String::new();
199        }
200        let body = items
201            .iter()
202            .map(|item| format!("- {}", item.trim()))
203            .collect::<Vec<_>>()
204            .join("\n");
205        format!("{body}\n\n")
206    }
207
208    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
209        if items.is_empty() {
210            return String::new();
211        }
212        let body = items
213            .iter()
214            .map(|item| format!("+ {}", item.trim()))
215            .collect::<Vec<_>>()
216            .join("\n");
217        format!("{body}\n\n")
218    }
219
220    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
221        let marks = "=".repeat(level.max(1) as usize);
222        format!("{marks} {content}\n\n")
223    }
224
225    fn code_block(&self, lang: Option<&str>, content: Self::Output) -> Self::Output {
226        let fence = "`".repeat(Self::longest_backtick_run(&content).max(2) + 1);
227        let lang_tag = lang.unwrap_or("");
228        format!("{fence}{lang_tag}\n{content}{fence}\n\n")
229    }
230
231    fn inline_code(&self, content: Self::Output) -> Self::Output {
232        let ticks = "`".repeat(Self::longest_backtick_run(&content) + 1);
233        format!("{ticks}{content}{ticks}")
234    }
235
236    fn strikeout(&self, content: Self::Output) -> Self::Output {
237        if content.is_empty() {
238            return content;
239        }
240        format!("#strike[{content}]")
241    }
242
243    fn hard_break(&self) -> Self::Output {
244        "\\\n".to_string()
245    }
246
247    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
248        self.join(entries, "\n\n")
249    }
250
251    fn entry(
252        &self,
253        id: &str,
254        content: Self::Output,
255        url: Option<&str>,
256        _metadata: &super::format::ProcEntryMetadata,
257    ) -> Self::Output {
258        let content = if let Some(u) = url {
259            self.link(u, content)
260        } else {
261            content
262        };
263
264        format!("{} <{}>", content, self.format_id(id))
265    }
266}