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, realize_wrap};
12use super::visible_scan::{RunBuilder, skip_balanced};
13use crate::values::ScriptClass;
14use citum_schema::template::WrapPunctuation;
15
16/// LaTeX renderer.
17#[derive(Debug, Clone, Default)]
18pub struct Latex;
19
20impl Latex {
21    /// Escapes characters that break a LaTeX `\href{...}` URL argument.
22    ///
23    /// Minimal set for the audit finding: a bare `%` starts a LaTeX comment
24    /// and truncates the rest of the line, `#` breaks `{}`-grouping (macro
25    /// parameter syntax), and `\` would otherwise be read as a control
26    /// sequence. `\` is escaped first so the backslash-introducing escapes
27    /// for `%`/`#` are not themselves re-escaped.
28    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        // Escape any bare & not already preceded by backslash.
64        // Locale terms (e.g. the & from AndOptions::Symbol) bypass text() and
65        // arrive here unescaped; this final pass makes the output valid LaTeX.
66        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        // In LaTeX, we could use custom commands if we wanted semantic tagging
126        // For now, just return content
127        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    // ── Block-level body markup methods ────────────────────────────────────
146
147    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        // \texttt is not verbatim; escape LaTeX specials in the raw code content.
212        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    /// Strip LaTeX commands (`\emph`, `\textbf`, ...) and their brace
241    /// delimiters, keeping brace *contents* visible. A backslash-escaped
242    /// punctuation mark (`\&`, `\_`, `\%`, `\#`, `\$`, `\{`, `\}`) is visible
243    /// as the escaped character itself. `\href{target}{content}` is
244    /// special-cased so the URL target stays invisible — it must not
245    /// participate in separator/dedup decisions — while `content` does.
246    ///
247    /// Named commands that stand in for a single visible character
248    /// (`\textbackslash{}`, `\textasciitilde{}`, `\textasciicircum{}`) have
249    /// no backing byte range here — see [`Self::visible_text`], which
250    /// synthesizes them for boundary decisions.
251    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    /// As [`Self::visible_runs`], but additionally synthesizes the single
277    /// visible character that `\textbackslash{}`/`\textasciitilde{}`/
278    /// `\textasciicircum{}` stand in for (`\`, `~`, `^`), since those have no
279    /// backing byte range in the raw fragment. Used for read-only boundary
280    /// decisions (first/last visible char); `visible_runs` stays raw-byte
281    /// accurate for `cleanup_dangling_punctuation`'s in-place raw edits.
282    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
315/// Outcome of classifying the backslash escape at `chars[i]`.
316enum LatexEscape {
317    /// An escaped punctuation mark (`\&`, `\_`, ...); visible as the escaped char.
318    Punct(char),
319    /// A named ascii-alpha command. `synth` holds the single character it
320    /// stands in for (`\textbackslash{}` → `\`, etc.), when applicable.
321    Command { synth: Option<char> },
322    /// A lone backslash with no recognized continuation.
323    Bare,
324}
325
326/// Classify the `\` at `chars[i]`, returning the outcome and the index just
327/// past it — past the command name (and past `\href`'s target brace group),
328/// or past the escaped char for punctuation escapes.
329fn 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        // A field ending in an escaped tilde must expose that tilde as the
427        // last visible char, not disappear (the gap this fixes: the raw
428        // fragment's last char is `}`, not `~`).
429        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        // visible_runs stays raw-byte accurate (used for in-place raw edits
437        // in cleanup_dangling_punctuation) — the synthesized char has no
438        // backing byte, so it must not appear as a run.
439        let fmt = Latex;
440        let runs = fmt.visible_runs(r"\textasciitilde{}");
441        assert!(runs.is_empty(), "expected no visible runs, got {runs:?}");
442    }
443}