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