Skip to main content

sim_codec_doc/
latex.rs

1//! Safe LaTeX backend implementation over `codebook-tree-sitter-latex`.
2
3use std::collections::BTreeMap;
4
5use tree_sitter::Parser;
6
7use crate::backend::{
8    MarkupBackend, MarkupDecodeOptions, MarkupEncodeOptions, MarkupError, MarkupFidelity,
9    MarkupLoss,
10};
11use crate::markup::{BackendId, MarkupBlock, MarkupDoc, SourceDoc, Span};
12
13#[path = "latex_support.rs"]
14mod latex_support;
15
16#[path = "latex_writer.rs"]
17mod latex_writer;
18
19use latex_support::{
20    after_line, begin_environment_at, command_arg_anywhere, command_arg_at, command_arg_end,
21    find_unescaped, inline_plain_text, line_bounds, offset_block_span, paragraph_line,
22    parse_inlines, span, split_items, starts_command, table_rows, tex_math, trim_edge_newlines,
23    unescape_text,
24};
25use latex_writer::LatexEncoder;
26
27/// Safe LaTeX article-subset backend.
28#[derive(Clone, Debug, Default)]
29pub struct LatexBackend;
30
31impl MarkupBackend for LatexBackend {
32    fn id(&self) -> BackendId {
33        latex_id()
34    }
35
36    fn decode(
37        &self,
38        input: &str,
39        opts: &MarkupDecodeOptions,
40    ) -> Result<(MarkupDoc, MarkupFidelity), MarkupError> {
41        let mut decoder = LatexDecoder::new(input, opts);
42        decoder.check_syntax()?;
43        let blocks = decoder.parse_blocks();
44        let title = decoder.title.clone().or_else(|| {
45            blocks.iter().find_map(|block| match block {
46                MarkupBlock::Heading { level: 1, text, .. } => Some(inline_plain_text(text)),
47                _ => None,
48            })
49        });
50        let source = opts.preserve_source.then(|| SourceDoc {
51            backend: latex_id(),
52            text: input.to_owned(),
53        });
54        Ok((
55            MarkupDoc {
56                title,
57                blocks,
58                attrs: BTreeMap::new(),
59                source,
60            },
61            decoder.fidelity,
62        ))
63    }
64
65    fn encode(
66        &self,
67        doc: &MarkupDoc,
68        opts: &MarkupEncodeOptions,
69    ) -> Result<(String, MarkupFidelity), MarkupError> {
70        let mut encoder = LatexEncoder::new(opts);
71        let source = encoder.write_doc(doc);
72        if opts.fail_on_loss && !encoder.fidelity().dropped.is_empty() {
73            return Err(MarkupError::Encode(format!(
74                "latex encode dropped {} unsupported fragment(s)",
75                encoder.fidelity().dropped.len()
76            )));
77        }
78        Ok((source, encoder.into_fidelity()))
79    }
80}
81
82struct LatexDecoder<'a> {
83    input: &'a str,
84    preserve_raw: bool,
85    title: Option<String>,
86    fidelity: MarkupFidelity,
87}
88
89impl<'a> LatexDecoder<'a> {
90    fn new(input: &'a str, opts: &MarkupDecodeOptions) -> Self {
91        Self {
92            input,
93            preserve_raw: opts.preserve_raw,
94            title: None,
95            fidelity: MarkupFidelity::exact(latex_id()),
96        }
97    }
98
99    fn check_syntax(&mut self) -> Result<(), MarkupError> {
100        let mut parser = Parser::new();
101        let language = codebook_tree_sitter_latex::LANGUAGE.into();
102        parser
103            .set_language(&language)
104            .map_err(|err| MarkupError::Decode(format!("cannot load latex grammar: {err}")))?;
105        let tree = parser
106            .parse(self.input, None)
107            .ok_or_else(|| MarkupError::Decode("tree-sitter latex parse failed".to_owned()))?;
108        if tree.root_node().has_error() {
109            self.fidelity.warnings.push(
110                "latex syntax contains tree-sitter error nodes; unsupported source is preserved raw"
111                    .to_owned(),
112            );
113        }
114        Ok(())
115    }
116
117    fn parse_blocks(&mut self) -> Vec<MarkupBlock> {
118        let mut blocks = Vec::new();
119        let mut paragraph = PendingParagraph::default();
120        let mut cursor = 0;
121        while cursor < self.input.len() {
122            let (line_end, next_line) = line_bounds(self.input, cursor);
123            let line = &self.input[cursor..line_end];
124            let Some(view) = LineView::new(cursor, line) else {
125                self.flush_paragraph(&mut blocks, &mut paragraph);
126                cursor = next_line;
127                continue;
128            };
129            if view.trimmed.starts_with('%') {
130                cursor = next_line;
131                continue;
132            }
133            if self.handle_title(view.start, &mut blocks, &mut paragraph) {
134                cursor = after_line(self.input, command_arg_end(self.input, view.start));
135                continue;
136            }
137            if self.skip_document_wrapper(view.trimmed) {
138                self.flush_paragraph(&mut blocks, &mut paragraph);
139                cursor = next_line;
140                continue;
141            }
142            if let Some((block, next)) = self.heading(view.start) {
143                self.flush_paragraph(&mut blocks, &mut paragraph);
144                blocks.push(block);
145                cursor = after_line(self.input, next);
146                continue;
147            }
148            if let Some((block, next)) = self.display_math(view.start) {
149                self.flush_paragraph(&mut blocks, &mut paragraph);
150                blocks.push(block);
151                cursor = next;
152                continue;
153            }
154            if let Some((blocks_from_env, next)) = self.environment(view.start) {
155                self.flush_paragraph(&mut blocks, &mut paragraph);
156                blocks.extend(blocks_from_env);
157                cursor = next;
158                continue;
159            }
160            if let Some((raw, next)) = self.raw_command(view.start, line_end) {
161                self.flush_paragraph(&mut blocks, &mut paragraph);
162                if let Some(block) = raw {
163                    blocks.push(block);
164                }
165                cursor = next;
166                continue;
167            }
168            if let Some((text, end)) = paragraph_line(self.input, view.start, view.end) {
169                paragraph.push(text, view.start, end);
170            }
171            cursor = next_line;
172        }
173        self.flush_paragraph(&mut blocks, &mut paragraph);
174        blocks
175    }
176
177    fn handle_title(
178        &mut self,
179        start: usize,
180        blocks: &mut Vec<MarkupBlock>,
181        paragraph: &mut PendingParagraph,
182    ) -> bool {
183        if let Some(arg) = command_arg_at(self.input, start, "\\title") {
184            self.flush_paragraph(blocks, paragraph);
185            let inlines =
186                parse_inlines(arg.text(self.input), self.preserve_raw, &mut self.fidelity);
187            self.title = Some(inline_plain_text(&inlines));
188            return true;
189        }
190        false
191    }
192
193    fn skip_document_wrapper(&self, trimmed: &str) -> bool {
194        trimmed.starts_with("\\begin{document}")
195            || trimmed.starts_with("\\end{document}")
196            || trimmed.starts_with("\\maketitle")
197    }
198
199    fn heading(&mut self, start: usize) -> Option<(MarkupBlock, usize)> {
200        let (command, level) = [
201            ("\\subsubsection", 3),
202            ("\\subsection", 2),
203            ("\\section", 1),
204        ]
205        .into_iter()
206        .find(|(command, _)| starts_command(self.input, start, command))?;
207        let arg = command_arg_at(self.input, start, command)?;
208        Some((
209            MarkupBlock::Heading {
210                level,
211                text: parse_inlines(arg.text(self.input), self.preserve_raw, &mut self.fidelity),
212                id: None,
213                span: Some(span(start, arg.next)),
214            },
215            arg.next,
216        ))
217    }
218
219    fn display_math(&mut self, start: usize) -> Option<(MarkupBlock, usize)> {
220        let (content_start, close, after) = if self.input[start..].starts_with("\\[") {
221            let content_start = start + 2;
222            let close = find_unescaped(self.input, content_start, "\\]")?;
223            (content_start, close, close + 2)
224        } else if self.input[start..].starts_with("$$") {
225            let content_start = start + 2;
226            let close = find_unescaped(self.input, content_start, "$$")?;
227            (content_start, close, close + 2)
228        } else {
229            return None;
230        };
231        Some((
232            MarkupBlock::MathBlock {
233                source: tex_math(&self.input[content_start..close]),
234                span: Some(span(start, after)),
235            },
236            after,
237        ))
238    }
239
240    fn environment(&mut self, start: usize) -> Option<(Vec<MarkupBlock>, usize)> {
241        let env = begin_environment_at(self.input, start)?;
242        let blocks = match env.name {
243            "itemize" => vec![self.list(false, &env)],
244            "enumerate" => vec![self.list(true, &env)],
245            "verbatim" => vec![MarkupBlock::CodeBlock {
246                lang: None,
247                code: trim_edge_newlines(env.content(self.input)).to_owned(),
248                span: Some(span(start, env.after_end)),
249            }],
250            "tabular" => vec![self.table(&env)],
251            "figure" => self.figure(&env).into_iter().collect(),
252            "equation" | "equation*" => vec![MarkupBlock::MathBlock {
253                source: tex_math(env.content(self.input)),
254                span: Some(span(start, env.after_end)),
255            }],
256            "quote" => vec![MarkupBlock::Quote {
257                blocks: self.blocks_from_fragment(env.content(self.input), env.content_start),
258                span: Some(span(start, env.after_end)),
259            }],
260            _ => self
261                .raw_block(
262                    self.input[start..env.after_end].to_owned(),
263                    "environment",
264                    "unsupported latex environment is preserved but not executed",
265                    Some(span(start, env.after_end)),
266                )
267                .into_iter()
268                .collect(),
269        };
270        Some((blocks, env.after_end))
271    }
272
273    fn raw_command(
274        &mut self,
275        start: usize,
276        line_end: usize,
277    ) -> Option<(Option<MarkupBlock>, usize)> {
278        let (path, reason) = if starts_command(self.input, start, "\\input") {
279            ("input", "latex input is preserved but not resolved")
280        } else if starts_command(self.input, start, "\\include") {
281            ("include", "latex include is preserved but not resolved")
282        } else if starts_command(self.input, start, "\\bibliography")
283            || starts_command(self.input, start, "\\addbibresource")
284        {
285            (
286                "bibliography",
287                "latex bibliography command is preserved but not read",
288            )
289        } else if starts_command(self.input, start, "\\documentclass")
290            || starts_command(self.input, start, "\\usepackage")
291        {
292            (
293                "preamble",
294                "latex preamble command is preserved as raw source",
295            )
296        } else if self.input[start..].starts_with('\\') {
297            (
298                "command",
299                "unsupported latex command is preserved but not executed",
300            )
301        } else {
302            return None;
303        };
304        let end = command_arg_end(self.input, start).max(line_end);
305        let text = self.input[start..end].trim_end().to_owned();
306        let raw = self.raw_block(text, path, reason, Some(span(start, end)));
307        Some((raw, after_line(self.input, end)))
308    }
309
310    fn list(&mut self, ordered: bool, env: &Environment<'_>) -> MarkupBlock {
311        let items = split_items(env.content(self.input))
312            .into_iter()
313            .map(|item| {
314                let content = item.trim();
315                if content.is_empty() {
316                    Vec::new()
317                } else {
318                    vec![MarkupBlock::Paragraph {
319                        content: parse_inlines(content, self.preserve_raw, &mut self.fidelity),
320                        span: None,
321                    }]
322                }
323            })
324            .collect();
325        MarkupBlock::List {
326            ordered,
327            items,
328            span: Some(span(env.start, env.after_end)),
329        }
330    }
331
332    fn table(&mut self, env: &Environment<'_>) -> MarkupBlock {
333        let rows = table_rows(env.content(self.input))
334            .into_iter()
335            .map(|row| {
336                row.into_iter()
337                    .map(|cell| parse_inlines(cell.trim(), self.preserve_raw, &mut self.fidelity))
338                    .collect::<Vec<_>>()
339            })
340            .collect::<Vec<_>>();
341        let mut iter = rows.into_iter();
342        let header = iter.next().unwrap_or_default();
343        MarkupBlock::Table {
344            header,
345            rows: iter.collect(),
346            span: Some(span(env.start, env.after_end)),
347        }
348    }
349
350    fn figure(&mut self, env: &Environment<'_>) -> Option<MarkupBlock> {
351        let content = env.content(self.input);
352        let src = command_arg_anywhere(content, "\\includegraphics")
353            .map(|arg| unescape_text(arg.text(content)))
354            .unwrap_or_default();
355        let caption = command_arg_anywhere(content, "\\caption")
356            .map(|arg| parse_inlines(arg.text(content), self.preserve_raw, &mut self.fidelity))
357            .unwrap_or_default();
358        if src.is_empty() && caption.is_empty() {
359            return self.raw_block(
360                self.input[env.start..env.after_end].to_owned(),
361                "figure",
362                "unsupported latex figure is preserved as raw source",
363                Some(span(env.start, env.after_end)),
364            );
365        }
366        Some(MarkupBlock::Figure {
367            src,
368            caption,
369            span: Some(span(env.start, env.after_end)),
370        })
371    }
372
373    fn blocks_from_fragment(&mut self, source: &str, offset: usize) -> Vec<MarkupBlock> {
374        let mut decoder = LatexDecoder {
375            input: source,
376            preserve_raw: self.preserve_raw,
377            title: None,
378            fidelity: MarkupFidelity::exact(latex_id()),
379        };
380        let mut blocks = decoder.parse_blocks();
381        self.fidelity
382            .preserved_raw
383            .extend(decoder.fidelity.preserved_raw);
384        self.fidelity.dropped.extend(decoder.fidelity.dropped);
385        self.fidelity.warnings.extend(decoder.fidelity.warnings);
386        for block in &mut blocks {
387            offset_block_span(block, offset);
388        }
389        blocks
390    }
391
392    fn flush_paragraph(&mut self, blocks: &mut Vec<MarkupBlock>, paragraph: &mut PendingParagraph) {
393        if let Some((text, start, end)) = paragraph.take() {
394            blocks.push(MarkupBlock::Paragraph {
395                content: parse_inlines(&text, self.preserve_raw, &mut self.fidelity),
396                span: Some(span(start, end)),
397            });
398        }
399    }
400
401    fn raw_block(
402        &mut self,
403        text: String,
404        path: &str,
405        reason: &str,
406        span: Option<Span>,
407    ) -> Option<MarkupBlock> {
408        self.fidelity.warnings.push(reason.to_owned());
409        if self.preserve_raw {
410            self.fidelity.preserved_raw.push(text.clone());
411            Some(MarkupBlock::Raw {
412                backend: latex_id(),
413                text,
414                span,
415            })
416        } else {
417            self.fidelity.dropped.push(MarkupLoss {
418                path: path.to_owned(),
419                reason: reason.to_owned(),
420            });
421            None
422        }
423    }
424}
425
426#[derive(Default)]
427struct PendingParagraph {
428    text: String,
429    start: Option<usize>,
430    end: usize,
431}
432
433impl PendingParagraph {
434    fn push(&mut self, text: &str, start: usize, end: usize) {
435        if text.trim().is_empty() {
436            return;
437        }
438        if !self.text.is_empty() {
439            self.text.push(' ');
440        }
441        self.text.push_str(text.trim());
442        self.start.get_or_insert(start);
443        self.end = end;
444    }
445
446    fn take(&mut self) -> Option<(String, usize, usize)> {
447        if self.text.trim().is_empty() {
448            self.text.clear();
449            self.start = None;
450            return None;
451        }
452        Some((std::mem::take(&mut self.text), self.start.take()?, self.end))
453    }
454}
455
456#[derive(Clone, Copy)]
457struct LineView<'a> {
458    trimmed: &'a str,
459    start: usize,
460    end: usize,
461}
462
463impl<'a> LineView<'a> {
464    fn new(line_start: usize, line: &'a str) -> Option<Self> {
465        let leading = line.len() - line.trim_start().len();
466        let trailing = line.len() - line.trim_end().len();
467        let start = line_start + leading;
468        let end = line_start + line.len() - trailing;
469        (start < end).then_some(Self {
470            trimmed: &line[leading..line.len() - trailing],
471            start,
472            end,
473        })
474    }
475}
476
477#[derive(Clone, Copy)]
478struct BracedArg {
479    content_start: usize,
480    content_end: usize,
481    next: usize,
482}
483
484impl BracedArg {
485    fn text<'a>(&self, input: &'a str) -> &'a str {
486        &input[self.content_start..self.content_end]
487    }
488}
489
490struct Environment<'a> {
491    name: &'a str,
492    start: usize,
493    content_start: usize,
494    content_end: usize,
495    after_end: usize,
496}
497
498impl<'a> Environment<'a> {
499    fn content<'b>(&self, input: &'b str) -> &'b str {
500        &input[self.content_start..self.content_end]
501    }
502}
503
504fn latex_id() -> BackendId {
505    BackendId::new("latex")
506}