citum_engine/render/
typst.rs1use super::format::OutputFormat;
9use citum_schema::template::WrapPunctuation;
10
11#[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 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 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}