citum_engine/render/
typst.rs1use super::format::{OutputFormat, QuoteMarks};
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!("#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 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}