1use std::ops::Range;
9
10use super::format::{OutputFormat, QuoteMarks};
11use super::visible_scan::{RunBuilder, skip_balanced};
12use citum_schema::template::WrapPunctuation;
13
14#[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 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 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 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
312fn 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 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}