1use 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#[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}