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 super::format::{OutputFormat, QuoteMarks};
9use citum_schema::template::WrapPunctuation;
10
11/// LaTeX renderer.
12#[derive(Debug, Clone, Default)]
13pub struct Latex;
14
15impl Latex {
16    /// Escapes characters that break a LaTeX `\href{...}` URL argument.
17    ///
18    /// Minimal set for the audit finding: a bare `%` starts a LaTeX comment
19    /// and truncates the rest of the line, `#` breaks `{}`-grouping (macro
20    /// parameter syntax), and `\` would otherwise be read as a control
21    /// sequence. `\` is escaped first so the backslash-introducing escapes
22    /// for `%`/`#` are not themselves re-escaped.
23    fn escape_href_target(url: &str) -> String {
24        url.replace('\\', r"\textbackslash{}")
25            .replace('%', r"\%")
26            .replace('#', r"\#")
27    }
28}
29
30impl OutputFormat for Latex {
31    type Output = String;
32
33    fn text(&self, s: &str) -> Self::Output {
34        let mut res = String::with_capacity(s.len() + 10);
35        for c in s.chars() {
36            match c {
37                '\\' => res.push_str(r"\textbackslash{}"),
38                '{' => res.push_str(r"\{"),
39                '}' => res.push_str(r"\}"),
40                '$' => res.push_str(r"\$"),
41                '&' => res.push_str(r"\&"),
42                '#' => res.push_str(r"\#"),
43                '_' => res.push_str(r"\_"),
44                '%' => res.push_str(r"\%"),
45                '~' => res.push_str(r"\textasciitilde{}"),
46                '^' => res.push_str(r"\textasciicircum{}"),
47                _ => res.push(c),
48            }
49        }
50        res
51    }
52
53    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
54        items.join(&self.text(delimiter))
55    }
56
57    fn finish(&self, output: Self::Output) -> String {
58        // Escape any bare & not already preceded by backslash.
59        // Locale terms (e.g. the & from AndOptions::Symbol) bypass text() and
60        // arrive here unescaped; this final pass makes the output valid LaTeX.
61        let mut result = String::with_capacity(output.len() + 4);
62        let mut prev = '\0';
63        for c in output.chars() {
64            if c == '&' && prev != '\\' {
65                result.push_str(r"\&");
66            } else {
67                result.push(c);
68            }
69            prev = c;
70        }
71        result
72    }
73
74    fn emph(&self, content: Self::Output) -> Self::Output {
75        format!(r"\emph{{{content}}}")
76    }
77
78    fn strong(&self, content: Self::Output) -> Self::Output {
79        format!(r"\textbf{{{content}}}")
80    }
81
82    fn small_caps(&self, content: Self::Output) -> Self::Output {
83        format!(r"\textsc{{{content}}}")
84    }
85
86    fn superscript(&self, content: Self::Output) -> Self::Output {
87        format!(r"\textsuperscript{{{content}}}")
88    }
89
90    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
91        let (open, close) = marks.for_depth(0);
92        format!("{open}{content}{close}")
93    }
94
95    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
96        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
97    }
98
99    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
100        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
101    }
102
103    fn wrap_punctuation(
104        &self,
105        wrap: &WrapPunctuation,
106        content: Self::Output,
107        marks: &QuoteMarks,
108    ) -> Self::Output {
109        match wrap {
110            WrapPunctuation::Parentheses => format!("({content})"),
111            WrapPunctuation::Brackets => format!("[{content}]"),
112            WrapPunctuation::Quotes => self.quote(content, marks),
113        }
114    }
115
116    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
117        // In LaTeX, we could use custom commands if we wanted semantic tagging
118        // For now, just return content
119        content
120    }
121
122    fn annotation(&self, content: Self::Output) -> Self::Output {
123        if content.is_empty() {
124            return content;
125        }
126        format!(
127            "\n\\begin{{citumannotation}}\n{}\n\\end{{citumannotation}}",
128            content
129        )
130    }
131
132    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
133        let target = Self::escape_href_target(url);
134        format!(r"\href{{{target}}}{{{content}}}")
135    }
136
137    // ── Block-level body markup methods ────────────────────────────────────
138
139    fn paragraph(&self, content: Self::Output) -> Self::Output {
140        if content.is_empty() {
141            return content;
142        }
143        format!("{content}\n\n")
144    }
145
146    fn block_quote(&self, content: Self::Output) -> Self::Output {
147        if content.is_empty() {
148            return content;
149        }
150        let trimmed = content.trim_end();
151        format!("\\begin{{quote}}\n{trimmed}\n\\end{{quote}}\n\n")
152    }
153
154    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
155        if items.is_empty() {
156            return String::new();
157        }
158        let body = items
159            .iter()
160            .map(|item| format!("  \\item {}", item.trim()))
161            .collect::<Vec<_>>()
162            .join("\n");
163        format!("\\begin{{itemize}}\n{body}\n\\end{{itemize}}\n\n")
164    }
165
166    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
167        if items.is_empty() {
168            return String::new();
169        }
170        let body = items
171            .iter()
172            .map(|item| format!("  \\item {}", item.trim()))
173            .collect::<Vec<_>>()
174            .join("\n");
175        format!("\\begin{{enumerate}}\n{body}\n\\end{{enumerate}}\n\n")
176    }
177
178    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
179        let cmd = match level {
180            1 => "\\section",
181            2 => "\\subsection",
182            3 => "\\subsubsection",
183            _ => "\\paragraph",
184        };
185        format!("{cmd}{{{content}}}\n\n")
186    }
187
188    fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
189        let cmd = match level {
190            1 => "\\section*",
191            2 => "\\subsection*",
192            3 => "\\subsubsection*",
193            _ => "\\paragraph*",
194        };
195        format!("{cmd}{{{content}}}\n\n")
196    }
197
198    fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
199        format!("\\begin{{verbatim}}\n{content}\\end{{verbatim}}\n\n")
200    }
201
202    fn inline_code(&self, content: Self::Output) -> Self::Output {
203        // \texttt is not verbatim; escape LaTeX specials in the raw code content.
204        format!("\\texttt{{{}}}", self.text(&content))
205    }
206
207    fn strikeout(&self, content: Self::Output) -> Self::Output {
208        if content.is_empty() {
209            return content;
210        }
211        format!("\\sout{{{content}}}")
212    }
213
214    fn hard_break(&self) -> Self::Output {
215        "\\\\\n".to_string()
216    }
217
218    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
219        entries.join("\\par\\vspace{0.5em}")
220    }
221
222    fn entry(
223        &self,
224        _id: &str,
225        content: Self::Output,
226        _url: Option<&str>,
227        _metadata: &super::format::ProcEntryMetadata,
228    ) -> Self::Output {
229        format!("\\noindent\\hangindent=2em\\hangafter=1 {content}")
230    }
231}