Skip to main content

citum_engine/render/
latex.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! LaTeX output format.
7
8use 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/// LaTeX renderer.
16#[derive(Debug, Clone, Default)]
17pub struct Latex;
18
19impl Latex {
20    /// Escapes characters that break a LaTeX `\href{...}` URL argument.
21    ///
22    /// Minimal set for the audit finding: a bare `%` starts a LaTeX comment
23    /// and truncates the rest of the line, `#` breaks `{}`-grouping (macro
24    /// parameter syntax), and `\` would otherwise be read as a control
25    /// sequence. `\` is escaped first so the backslash-introducing escapes
26    /// for `%`/`#` are not themselves re-escaped.
27    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        // Escape any bare & not already preceded by backslash.
63        // Locale terms (e.g. the & from AndOptions::Symbol) bypass text() and
64        // arrive here unescaped; this final pass makes the output valid LaTeX.
65        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        // In LaTeX, we could use custom commands if we wanted semantic tagging
122        // For now, just return content
123        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    // ── Block-level body markup methods ────────────────────────────────────
142
143    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        // \texttt is not verbatim; escape LaTeX specials in the raw code content.
208        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    /// Strip LaTeX commands (`\emph`, `\textbf`, ...) and their brace
237    /// delimiters, keeping brace *contents* visible. A backslash-escaped
238    /// punctuation mark (`\&`, `\_`, `\%`, `\#`, `\$`, `\{`, `\}`) is visible
239    /// as the escaped character itself. `\href{target}{content}` is
240    /// special-cased so the URL target stays invisible — it must not
241    /// participate in separator/dedup decisions — while `content` does.
242    ///
243    /// Named commands that stand in for a single visible character
244    /// (`\textbackslash{}`, `\textasciitilde{}`, `\textasciicircum{}`) have
245    /// no backing byte range here — see [`Self::visible_text`], which
246    /// synthesizes them for boundary decisions.
247    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    /// As [`Self::visible_runs`], but additionally synthesizes the single
273    /// visible character that `\textbackslash{}`/`\textasciitilde{}`/
274    /// `\textasciicircum{}` stand in for (`\`, `~`, `^`), since those have no
275    /// backing byte range in the raw fragment. Used for read-only boundary
276    /// decisions (first/last visible char); `visible_runs` stays raw-byte
277    /// accurate for `cleanup_dangling_punctuation`'s in-place raw edits.
278    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
311/// Outcome of classifying the backslash escape at `chars[i]`.
312enum LatexEscape {
313    /// An escaped punctuation mark (`\&`, `\_`, ...); visible as the escaped char.
314    Punct(char),
315    /// A named ascii-alpha command. `synth` holds the single character it
316    /// stands in for (`\textbackslash{}` → `\`, etc.), when applicable.
317    Command { synth: Option<char> },
318    /// A lone backslash with no recognized continuation.
319    Bare,
320}
321
322/// Classify the `\` at `chars[i]`, returning the outcome and the index just
323/// past it — past the command name (and past `\href`'s target brace group),
324/// or past the escaped char for punctuation escapes.
325fn 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        // A field ending in an escaped tilde must expose that tilde as the
423        // last visible char, not disappear (the gap this fixes: the raw
424        // fragment's last char is `}`, not `~`).
425        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        // visible_runs stays raw-byte accurate (used for in-place raw edits
433        // in cleanup_dangling_punctuation) — the synthesized char has no
434        // backing byte, so it must not appear as a run.
435        let fmt = Latex;
436        let runs = fmt.visible_runs(r"\textasciitilde{}");
437        assert!(runs.is_empty(), "expected no visible runs, got {runs:?}");
438    }
439}