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