1use 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#[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 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 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 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
316fn 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 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}