1use std::borrow::Cow;
9use std::ops::Range;
10
11use super::format::{OutputFormat, QuoteMarks};
12use super::visible_scan::{RunBuilder, skip_balanced};
13use citum_schema::template::WrapPunctuation;
14
15#[derive(Debug, Clone, Default)]
17pub struct Latex;
18
19impl Latex {
20 fn escape_href_target(url: &str) -> String {
28 url.replace('\\', r"\textbackslash{}")
29 .replace('%', r"\%")
30 .replace('#', r"\#")
31 }
32}
33
34impl OutputFormat for Latex {
35 type Output = String;
36
37 fn text(&self, s: &str) -> Self::Output {
38 let mut res = String::with_capacity(s.len() + 10);
39 for c in s.chars() {
40 match c {
41 '\\' => res.push_str(r"\textbackslash{}"),
42 '{' => res.push_str(r"\{"),
43 '}' => res.push_str(r"\}"),
44 '$' => res.push_str(r"\$"),
45 '&' => res.push_str(r"\&"),
46 '#' => res.push_str(r"\#"),
47 '_' => res.push_str(r"\_"),
48 '%' => res.push_str(r"\%"),
49 '~' => res.push_str(r"\textasciitilde{}"),
50 '^' => res.push_str(r"\textasciicircum{}"),
51 _ => res.push(c),
52 }
53 }
54 res
55 }
56
57 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
58 items.join(&self.text(delimiter))
59 }
60
61 fn finish(&self, output: Self::Output) -> String {
62 let mut result = String::with_capacity(output.len() + 4);
66 let mut prev = '\0';
67 for c in output.chars() {
68 if c == '&' && prev != '\\' {
69 result.push_str(r"\&");
70 } else {
71 result.push(c);
72 }
73 prev = c;
74 }
75 result
76 }
77
78 fn emph(&self, content: Self::Output) -> Self::Output {
79 format!(r"\emph{{{content}}}")
80 }
81
82 fn strong(&self, content: Self::Output) -> Self::Output {
83 format!(r"\textbf{{{content}}}")
84 }
85
86 fn small_caps(&self, content: Self::Output) -> Self::Output {
87 format!(r"\textsc{{{content}}}")
88 }
89
90 fn superscript(&self, content: Self::Output) -> Self::Output {
91 format!(r"\textsuperscript{{{content}}}")
92 }
93
94 fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
95 let (open, close) = marks.for_depth(0);
96 format!("{open}{content}{close}")
97 }
98
99 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
100 format!("{}{}{}", self.text(prefix), content, self.text(suffix))
101 }
102
103 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
104 format!("{}{}{}", self.text(prefix), content, self.text(suffix))
105 }
106
107 fn wrap_punctuation(
108 &self,
109 wrap: &WrapPunctuation,
110 content: Self::Output,
111 marks: &QuoteMarks,
112 ) -> Self::Output {
113 match wrap {
114 WrapPunctuation::Parentheses => format!("({content})"),
115 WrapPunctuation::Brackets => format!("[{content}]"),
116 WrapPunctuation::Quotes => self.quote(content, marks),
117 }
118 }
119
120 fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
121 content
124 }
125
126 fn annotation(&self, content: Self::Output) -> Self::Output {
127 if content.is_empty() {
128 return content;
129 }
130 format!(
131 "\n\\begin{{citumannotation}}\n{}\n\\end{{citumannotation}}",
132 content
133 )
134 }
135
136 fn link(&self, url: &str, content: Self::Output) -> Self::Output {
137 let target = Self::escape_href_target(url);
138 format!(r"\href{{{target}}}{{{content}}}")
139 }
140
141 fn paragraph(&self, content: Self::Output) -> Self::Output {
144 if content.is_empty() {
145 return content;
146 }
147 format!("{content}\n\n")
148 }
149
150 fn block_quote(&self, content: Self::Output) -> Self::Output {
151 if content.is_empty() {
152 return content;
153 }
154 let trimmed = content.trim_end();
155 format!("\\begin{{quote}}\n{trimmed}\n\\end{{quote}}\n\n")
156 }
157
158 fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
159 if items.is_empty() {
160 return String::new();
161 }
162 let body = items
163 .iter()
164 .map(|item| format!(" \\item {}", item.trim()))
165 .collect::<Vec<_>>()
166 .join("\n");
167 format!("\\begin{{itemize}}\n{body}\n\\end{{itemize}}\n\n")
168 }
169
170 fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
171 if items.is_empty() {
172 return String::new();
173 }
174 let body = items
175 .iter()
176 .map(|item| format!(" \\item {}", item.trim()))
177 .collect::<Vec<_>>()
178 .join("\n");
179 format!("\\begin{{enumerate}}\n{body}\n\\end{{enumerate}}\n\n")
180 }
181
182 fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
183 let cmd = match level {
184 1 => "\\section",
185 2 => "\\subsection",
186 3 => "\\subsubsection",
187 _ => "\\paragraph",
188 };
189 format!("{cmd}{{{content}}}\n\n")
190 }
191
192 fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
193 let cmd = match level {
194 1 => "\\section*",
195 2 => "\\subsection*",
196 3 => "\\subsubsection*",
197 _ => "\\paragraph*",
198 };
199 format!("{cmd}{{{content}}}\n\n")
200 }
201
202 fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
203 format!("\\begin{{verbatim}}\n{content}\\end{{verbatim}}\n\n")
204 }
205
206 fn inline_code(&self, content: Self::Output) -> Self::Output {
207 format!("\\texttt{{{}}}", self.text(&content))
209 }
210
211 fn strikeout(&self, content: Self::Output) -> Self::Output {
212 if content.is_empty() {
213 return content;
214 }
215 format!("\\sout{{{content}}}")
216 }
217
218 fn hard_break(&self) -> Self::Output {
219 "\\\\\n".to_string()
220 }
221
222 fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
223 entries.join("\\par\\vspace{0.5em}")
224 }
225
226 fn entry(
227 &self,
228 _id: &str,
229 content: Self::Output,
230 _url: Option<&str>,
231 _metadata: &super::format::ProcEntryMetadata,
232 ) -> Self::Output {
233 format!("\\noindent\\hangindent=2em\\hangafter=1 {content}")
234 }
235
236 fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
248 let mut runs = RunBuilder::default();
249 let chars: Vec<(usize, char)> = fragment.char_indices().collect();
250 let mut i = 0;
251 while let Some(&(pos, ch)) = chars.get(i) {
252 if ch == '\\' {
253 let (escape, next_i) = scan_backslash_escape(&chars, i);
254 if let LatexEscape::Punct(_) = escape
255 && let Some(&(epos, echar)) = chars.get(i + 1)
256 {
257 runs.push_visible(epos, epos + echar.len_utf8());
258 }
259 i = next_i;
260 continue;
261 }
262 if ch == '{' || ch == '}' {
263 i += 1;
264 continue;
265 }
266 runs.push_visible(pos, pos + ch.len_utf8());
267 i += 1;
268 }
269 runs.finish()
270 }
271
272 fn visible_text<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
279 let chars: Vec<(usize, char)> = fragment.char_indices().collect();
280 let mut i = 0;
281 let mut owned = String::with_capacity(fragment.len());
282 let mut any_markup = false;
283 while let Some(&(_, ch)) = chars.get(i) {
284 if ch == '\\' {
285 any_markup = true;
286 let (escape, next_i) = scan_backslash_escape(&chars, i);
287 match escape {
288 LatexEscape::Punct(c) => owned.push(c),
289 LatexEscape::Command { synth: Some(c) } => owned.push(c),
290 LatexEscape::Command { synth: None } | LatexEscape::Bare => {}
291 }
292 i = next_i;
293 continue;
294 }
295 if ch == '{' || ch == '}' {
296 any_markup = true;
297 i += 1;
298 continue;
299 }
300 owned.push(ch);
301 i += 1;
302 }
303 if any_markup {
304 Cow::Owned(owned)
305 } else {
306 Cow::Borrowed(fragment)
307 }
308 }
309}
310
311enum LatexEscape {
313 Punct(char),
315 Command { synth: Option<char> },
318 Bare,
320}
321
322fn scan_backslash_escape(chars: &[(usize, char)], i: usize) -> (LatexEscape, usize) {
326 match chars.get(i + 1).map(|&(_, c)| c) {
327 Some(c @ ('{' | '}' | '$' | '&' | '#' | '_' | '%')) => (LatexEscape::Punct(c), i + 2),
328 Some(c) if c.is_ascii_alphabetic() => {
329 let mut j = i + 1;
330 let mut command = String::new();
331 while let Some(&(_, cc)) = chars.get(j) {
332 if !cc.is_ascii_alphabetic() {
333 break;
334 }
335 command.push(cc);
336 j += 1;
337 }
338 let synth = match command.as_str() {
339 "textbackslash" => Some('\\'),
340 "textasciitilde" => Some('~'),
341 "textasciicircum" => Some('^'),
342 _ => None,
343 };
344 let end = if command == "href" {
345 skip_balanced(chars, j, '{', '}', false)
346 } else {
347 j
348 };
349 (LatexEscape::Command { synth }, end)
350 }
351 _ => (LatexEscape::Bare, i + 1),
352 }
353}
354
355#[cfg(test)]
356#[allow(
357 clippy::unwrap_used,
358 clippy::expect_used,
359 clippy::panic,
360 clippy::indexing_slicing,
361 reason = "Panicking is acceptable and often desired in tests."
362)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn visible_text_strips_emph_command_and_braces() {
368 let fmt = Latex;
369 assert_eq!(fmt.visible_text(r"\emph{Title.}"), "Title.");
370 }
371
372 #[test]
373 fn visible_text_keeps_escaped_punctuation() {
374 let fmt = Latex;
375 assert_eq!(fmt.visible_text(r"Smith \& Jones"), "Smith & Jones");
376 }
377
378 #[test]
379 fn visible_text_hides_href_target_keeps_content() {
380 let fmt = Latex;
381 assert_eq!(
382 fmt.visible_text(r"\href{https://example.com/a.b}{Example}"),
383 "Example"
384 );
385 }
386
387 #[test]
388 fn visible_text_handles_nested_commands() {
389 let fmt = Latex;
390 assert_eq!(fmt.visible_text(r"\textbf{\emph{Title.}}"), "Title.");
391 }
392
393 #[test]
394 fn visible_text_is_borrowed_when_no_markup() {
395 let fmt = Latex;
396 assert_eq!(fmt.visible_text("Plain text."), "Plain text.");
397 }
398
399 #[test]
400 fn visible_text_synthesizes_escaped_backslash() {
401 let fmt = Latex;
402 assert_eq!(fmt.visible_text(r"C:\textbackslash{}Users"), r"C:\Users");
403 }
404
405 #[test]
406 fn visible_text_synthesizes_escaped_tilde() {
407 let fmt = Latex;
408 assert_eq!(
409 fmt.visible_text(r"Title\textasciitilde{}Subtitle"),
410 "Title~Subtitle"
411 );
412 }
413
414 #[test]
415 fn visible_text_synthesizes_escaped_caret() {
416 let fmt = Latex;
417 assert_eq!(fmt.visible_text(r"x\textasciicircum{}2"), "x^2");
418 }
419
420 #[test]
421 fn visible_text_synthesized_char_is_seen_as_the_trailing_char() {
422 let fmt = Latex;
426 let rendered = fmt.emph(r"Title\textasciitilde{}".to_string());
427 assert_eq!(fmt.visible_text(&rendered).chars().last(), Some('~'));
428 }
429
430 #[test]
431 fn visible_runs_does_not_claim_a_byte_range_for_synthesized_chars() {
432 let fmt = Latex;
436 let runs = fmt.visible_runs(r"\textasciitilde{}");
437 assert!(runs.is_empty(), "expected no visible runs, got {runs:?}");
438 }
439}