typlite/writer/
latex.rs

1//! LaTeX writer implementation
2
3use std::path::Path;
4
5use cmark_writer::ast::Node;
6use ecow::EcoString;
7use tinymist_std::path::unix_slash;
8
9use crate::Result;
10use crate::common::{
11    CenterNode, ExternalFrameNode, FigureNode, FormatWriter, HighlightNode, InlineNode, ListState,
12    VerbatimNode,
13};
14
15/// LaTeX writer implementation
16pub struct LaTeXWriter {
17    list_state: Option<ListState>,
18}
19
20impl Default for LaTeXWriter {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl LaTeXWriter {
27    pub fn new() -> Self {
28        Self { list_state: None }
29    }
30
31    fn write_inline_nodes(&mut self, nodes: &[Node], output: &mut EcoString) -> Result<()> {
32        for node in nodes {
33            self.write_node(node, output)?;
34        }
35        Ok(())
36    }
37
38    /// Write the document to LaTeX format
39    fn write_node(&mut self, node: &Node, output: &mut EcoString) -> Result<()> {
40        match node {
41            Node::Document(blocks) => {
42                for block in blocks {
43                    self.write_node(block, output)?;
44                }
45            }
46            Node::Paragraph(inlines) => {
47                self.write_inline_nodes(inlines, output)?;
48                output.push_str("\n\n");
49            }
50            Node::Heading {
51                level,
52                content,
53                heading_type: _,
54            } => {
55                if *level > 4 {
56                    return Err(format!("heading level {level} is not supported in LaTeX").into());
57                }
58
59                output.push('\\');
60                match level {
61                    1 => output.push_str("chapter{"),
62                    2 => output.push_str("section{"),
63                    3 => output.push_str("subsection{"),
64                    4 => output.push_str("subsubsection{"),
65                    _ => return Err(format!("Heading level {level} is not supported").into()),
66                }
67
68                self.write_inline_nodes(content, output)?;
69                output.push_str("}\n\n");
70            }
71            Node::BlockQuote(content) => {
72                output.push_str("\\begin{quote}\n");
73                for block in content {
74                    self.write_node(block, output)?;
75                }
76                output.push_str("\\end{quote}\n");
77            }
78            Node::CodeBlock {
79                language,
80                content,
81                block_type: _,
82            } => {
83                if let Some(lang) = language {
84                    if !lang.is_empty() {
85                        output.push_str("\\begin{lstlisting}[language=");
86                        output.push_str(lang);
87                        output.push_str("]\n");
88                    } else {
89                        output.push_str("\\begin{verbatim}\n");
90                    }
91                } else {
92                    output.push_str("\\begin{verbatim}\n");
93                }
94
95                output.push_str(content);
96
97                if language.as_ref().is_none_or(|lang| lang.is_empty()) {
98                    output.push_str("\n\\end{verbatim}");
99                } else {
100                    output.push_str("\n\\end{lstlisting}");
101                }
102                output.push_str("\n\n");
103            }
104            Node::OrderedList { start: _, items } => {
105                let previous_state = self.list_state;
106                self.list_state = Some(ListState::Ordered);
107
108                output.push_str("\\begin{enumerate}\n");
109                for item in items {
110                    match item {
111                        cmark_writer::ast::ListItem::Ordered { content, .. }
112                        | cmark_writer::ast::ListItem::Unordered { content } => {
113                            output.push_str("\\item ");
114                            for block in content {
115                                match block {
116                                    // For paragraphs, we want inline content rather than creating a
117                                    // new paragraph
118                                    Node::Paragraph(inlines) => {
119                                        self.write_inline_nodes(inlines, output)?;
120                                    }
121                                    _ => self.write_node(block, output)?,
122                                }
123                            }
124                            output.push('\n');
125                        }
126                        _ => {}
127                    }
128                }
129                output.push_str("\\end{enumerate}\n\n");
130
131                self.list_state = previous_state;
132            }
133            Node::UnorderedList(items) => {
134                let previous_state = self.list_state;
135                self.list_state = Some(ListState::Unordered);
136
137                output.push_str("\\begin{itemize}\n");
138                for item in items {
139                    match item {
140                        cmark_writer::ast::ListItem::Ordered { content, .. }
141                        | cmark_writer::ast::ListItem::Unordered { content } => {
142                            output.push_str("\\item ");
143                            for block in content {
144                                match block {
145                                    // For paragraphs, we want inline content rather than creating a
146                                    // new paragraph
147                                    Node::Paragraph(inlines) => {
148                                        self.write_inline_nodes(inlines, output)?;
149                                    }
150                                    _ => self.write_node(block, output)?,
151                                }
152                            }
153                            output.push('\n');
154                        }
155                        _ => {}
156                    }
157                }
158                output.push_str("\\end{itemize}\n\n");
159
160                self.list_state = previous_state;
161            }
162            Node::Table {
163                headers,
164                rows,
165                alignments: _,
166            } => {
167                // Calculate column count
168                let col_count = headers
169                    .len()
170                    .max(rows.iter().map(|row| row.len()).max().unwrap_or(0));
171
172                output.push_str("\\begin{table}[htbp]\n");
173                output.push_str("\\centering\n");
174                output.push_str("\\begin{tabular}{");
175
176                // Add column format (centered alignment)
177                for _ in 0..col_count {
178                    output.push('c');
179                }
180                output.push_str("}\n\\hline\n");
181
182                // Process header
183                if !headers.is_empty() {
184                    for (i, cell) in headers.iter().enumerate() {
185                        if i > 0 {
186                            output.push_str(" & ");
187                        }
188                        self.write_node(cell, output)?;
189                    }
190                    output.push_str(" \\\\\n\\hline\n");
191                }
192
193                // Process all rows
194                for row in rows {
195                    for (i, cell) in row.iter().enumerate() {
196                        if i > 0 {
197                            output.push_str(" & ");
198                        }
199                        self.write_node(cell, output)?;
200                    }
201                    output.push_str(" \\\\\n");
202                }
203
204                // Close table environment
205                output.push_str("\\hline\n");
206                output.push_str("\\end{tabular}\n");
207                output.push_str("\\end{table}\n\n");
208            }
209            node if node.is_custom_type::<FigureNode>() => {
210                let figure_node = node.as_custom_type::<FigureNode>().unwrap();
211                // Start figure environment
212                output.push_str("\\begin{figure}[htbp]\n\\centering\n");
213
214                // Handle the body content (typically an image)
215                match &*figure_node.body {
216                    Node::Paragraph(content) => {
217                        for node in content {
218                            // Special handling for image nodes in figures
219                            if let Node::Image {
220                                url,
221                                title: _,
222                                alt: _,
223                            } = node
224                            {
225                                // Path to the image file
226                                let path = unix_slash(Path::new(url.as_str()));
227
228                                // Write includegraphics command
229                                output.push_str("\\includegraphics[width=0.8\\textwidth]{");
230                                output.push_str(&path);
231                                output.push_str("}\n");
232                            } else {
233                                // For non-image content, just render it normally
234                                self.write_node(node, output)?;
235                            }
236                        }
237                    }
238                    // Directly handle the node if it's not in a paragraph
239                    node => self.write_node(node, output)?,
240                }
241
242                // Add caption if present
243                if !figure_node.caption.is_empty() {
244                    output.push_str("\\caption{");
245                    output.push_str(&escape_latex(&figure_node.caption));
246                    output.push_str("}\n");
247                }
248
249                // Close figure environment
250                output.push_str("\\end{figure}\n\n");
251            }
252            node if node.is_custom_type::<ExternalFrameNode>() => {
253                let external_frame = node.as_custom_type::<ExternalFrameNode>().unwrap();
254                // Handle externally stored frames
255                let path = unix_slash(&external_frame.file_path);
256
257                output.push_str("\\begin{figure}[htbp]\n");
258                output.push_str("\\centering\n");
259                output.push_str("\\includegraphics[width=0.8\\textwidth]{");
260                output.push_str(&path);
261                output.push_str("}\n");
262
263                if !external_frame.alt_text.is_empty() {
264                    output.push_str("\\caption{");
265                    output.push_str(&escape_latex(&external_frame.alt_text));
266                    output.push_str("}\n");
267                }
268
269                output.push_str("\\end{figure}\n\n");
270            }
271            node if node.is_custom_type::<CenterNode>() => {
272                let center_node = node.as_custom_type::<CenterNode>().unwrap();
273                output.push_str("\\begin{center}\n");
274                self.write_node(&center_node.node, output)?;
275                output.push_str("\\end{center}\n\n");
276            }
277            node if node.is_custom_type::<HighlightNode>() => {
278                let highlight_node = node.as_custom_type::<HighlightNode>().unwrap();
279                output.push_str("\\colorbox{yellow}{");
280                for child in &highlight_node.content {
281                    self.write_node(child, output)?;
282                }
283                output.push_str("}");
284            }
285            node if node.is_custom_type::<InlineNode>() => {
286                let inline_node = node.as_custom_type::<InlineNode>().unwrap();
287                // Process all child nodes inline
288                for child in &inline_node.content {
289                    self.write_node(child, output)?;
290                }
291            }
292            node if node.is_custom_type::<VerbatimNode>() => {
293                let inline_node = node.as_custom_type::<VerbatimNode>().unwrap();
294                output.push_str(&inline_node.content);
295            }
296            Node::Text(text) => {
297                output.push_str(&escape_latex(text));
298            }
299            Node::Emphasis(content) => {
300                output.push_str("\\textit{");
301                self.write_inline_nodes(content, output)?;
302                output.push_str("}");
303            }
304            Node::Strong(content) => {
305                output.push_str("\\textbf{");
306                self.write_inline_nodes(content, output)?;
307                output.push_str("}");
308            }
309            Node::Strikethrough(content) => {
310                output.push_str("\\sout{");
311                self.write_inline_nodes(content, output)?;
312                output.push_str("}");
313            }
314            Node::Link {
315                url,
316                title: _,
317                content,
318            } => {
319                output.push_str("\\href{");
320                output.push_str(url);
321                output.push_str("}{");
322                self.write_inline_nodes(content, output)?;
323                output.push_str("}");
324            }
325            Node::Image { url, title: _, alt } => {
326                let alt_text = if !alt.is_empty() {
327                    let mut alt_str = EcoString::new();
328                    self.write_inline_nodes(alt, &mut alt_str)?;
329                    alt_str
330                } else {
331                    "".into()
332                };
333
334                let path = unix_slash(Path::new(&url.as_str()));
335
336                output.push_str("\\begin{figure}\n");
337                output.push_str("\\centering\n");
338                output.push_str("\\includegraphics[width=0.8\\textwidth]{");
339                output.push_str(&path);
340                output.push_str("}\n");
341
342                if !alt_text.is_empty() {
343                    output.push_str("\\caption{");
344                    output.push_str(&alt_text);
345                    output.push_str("}\n");
346                }
347
348                output.push_str("\\end{figure}\n\n");
349            }
350            Node::InlineCode(code) => {
351                output.push_str("\\texttt{");
352                output.push_str(&escape_latex(code));
353                output.push_str("}");
354            }
355            Node::HardBreak => {
356                output.push_str("\\\\\n");
357            }
358            Node::SoftBreak => {
359                output.push(' ');
360            }
361            Node::ThematicBreak => {
362                output.push_str("\\hrule\n\n");
363            }
364            Node::HtmlElement(element) => {
365                for child in &element.children {
366                    self.write_node(child, output)?;
367                }
368            }
369            _ => {}
370        }
371
372        Ok(())
373    }
374}
375
376/// Escape LaTeX special characters in a string
377fn escape_latex(text: &str) -> String {
378    text.replace('&', "\\&")
379        .replace('%', "\\%")
380        .replace('$', "\\$")
381        .replace('#', "\\#")
382        .replace('_', "\\_")
383        .replace('{', "\\{")
384        .replace('}', "\\}")
385        .replace('~', "\\textasciitilde{}")
386        .replace('^', "\\textasciicircum{}")
387        .replace('\\', "\\textbackslash{}")
388}
389
390impl FormatWriter for LaTeXWriter {
391    fn write_eco(&mut self, document: &Node, output: &mut EcoString) -> Result<()> {
392        // Write the document content
393        self.write_node(document, output)?;
394        Ok(())
395    }
396
397    fn write_vec(&mut self, document: &Node) -> Result<Vec<u8>> {
398        let mut output = EcoString::new();
399        self.write_eco(document, &mut output)?;
400        Ok(output.as_str().as_bytes().to_vec())
401    }
402}