Skip to main content

carta_readers/
latex.rs

1//! LaTeX reader: parses a LaTeX source document into the document model.
2//!
3//! LaTeX has no line-oriented block grammar the way lightweight markup does; a source file is a
4//! stream of ordinary text, control sequences (`\name` / `\<symbol>`), grouping braces, math shifts,
5//! and environments (`\begin{env}` … `\end{env}`). Parsing is a single character-level recursive
6//! descent over the source: `Parser::parse_blocks` recognises block-level constructs (sectioning
7//! commands, environments, paragraphs) and `Parser::parse_inlines` the inline run inside each,
8//! accumulating a text buffer that flushes to [`Inline::Str`] at every space, break, or markup
9//! boundary.
10//!
11//! Only the body between `\begin{document}` and `\end{document}` is rendered when a document
12//! environment is present; the preamble is scanned for metadata (`\title`, `\author`, `\date`) and
13//! macro definitions but otherwise dropped. A construct that has no faithful model representation
14//! degrades: an unknown command is dropped (or passed through verbatim under `raw_tex`), and an
15//! unknown environment becomes a classed [`Block::Div`] (or a raw block under `raw_tex`).
16
17use std::collections::BTreeMap;
18use std::rc::Rc;
19
20use carta_ast::{
21    Alignment, Attr, Block, Caption, Cell, Citation, CitationMode, ColSpec, ColWidth, Document,
22    Format, Inline, ListAttributes, ListNumberDelim, ListNumberStyle, MathType, MetaValue,
23    QuoteType, Row, Table, TableBody, TableFoot, TableHead, Target, slug, slug_gfm, to_plain_text,
24};
25use carta_core::{Extension, Extensions, Reader, ReaderOptions, Result};
26
27use crate::heading_ids::{IdRegistry, IdScheme};
28
29/// Parses LaTeX source into the document model.
30#[derive(Debug, Default, Clone, Copy)]
31pub struct LatexReader;
32
33impl Reader for LatexReader {
34    fn read(&self, input: &str, options: &ReaderOptions) -> Result<Document> {
35        let ext = options.extensions;
36        let (preamble, body) = split_document(input);
37
38        // Section levels depend on which top-level sectioning command the document uses, so the whole
39        // body is scanned once before any header is emitted.
40        let base_level = base_section_level(body);
41
42        let mut parser = Parser {
43            frames: Vec::new(),
44            ext,
45            smart: ext.contains(Extension::Smart),
46            meta: BTreeMap::new(),
47            macros: Rc::new(BTreeMap::new()),
48            ids: IdRegistry::default(),
49            base_level,
50            in_figure: false,
51            in_float: false,
52            expand_depth: 0,
53            total_expansions: 0,
54            last_ws_had_newline: false,
55        };
56
57        // The preamble contributes metadata and macro definitions but no blocks.
58        if let Some(preamble) = preamble {
59            parser.set_source(preamble);
60            let _ = parser.parse_blocks(&Stop::Eof);
61        }
62
63        parser.set_source(body);
64        let blocks = parser.parse_blocks(&Stop::Env("document"));
65
66        Ok(Document {
67            meta: parser
68                .meta
69                .into_iter()
70                .map(|(k, v)| (k.into(), v))
71                .collect(),
72            blocks,
73            ..Document::default()
74        })
75    }
76}
77
78/// Splits the source into an optional preamble and the body to render. With a `\begin{document}`,
79/// the preamble is everything before it and the body is the text up to a matching `\end{document}`;
80/// without one, the whole source is the body and there is no preamble.
81fn split_document(input: &str) -> (Option<&str>, &str) {
82    // The `\begin{document}`/`\end{document}` needles end in `}`, so a plain substring search cannot
83    // latch onto a longer control word.
84    if let Some(begin) = input.find("\\begin{document}") {
85        let after = &input[begin + "\\begin{document}".len()..];
86        let body = match after.find("\\end{document}") {
87            Some(end) => &after[..end],
88            None => after,
89        };
90        (Some(&input[..begin]), body)
91    } else {
92        (None, input)
93    }
94}
95
96/// The header level the top-most sectioning command in `body` maps to. `\part` shifts every section
97/// down by two levels and `\chapter` by one, so that whichever appears highest becomes level one.
98fn base_section_level(body: &str) -> i32 {
99    if has_command(body, "part") {
100        -1
101    } else {
102        i32::from(!has_command(body, "chapter"))
103    }
104}
105
106/// Whether the control word `\name` (a backslash, the exact letters, then a non-letter) occurs in
107/// `text`. A trailing `*` counts, so the starred form is found too.
108fn has_command(text: &str, name: &str) -> bool {
109    let bytes = text.as_bytes();
110    let pat = format!("\\{name}");
111    let mut from = 0;
112    while let Some(rel) = text[from..].find(&pat) {
113        let start = from + rel;
114        let after = start + pat.len();
115        let next = bytes.get(after).copied();
116        match next {
117            Some(b) if b.is_ascii_alphabetic() => {}
118            _ => return true,
119        }
120        from = after;
121    }
122    false
123}
124
125/// When to stop a block-parsing loop.
126enum Stop<'a> {
127    /// End of input.
128    Eof,
129    /// A matching `\end{name}` (or end of input).
130    Env(&'a str),
131    /// A `\item`, a matching `\end{name}`, or end of input — used inside a list environment.
132    Item(&'a str),
133}
134
135/// A user-defined macro: its argument count and replacement text, with `#1`…`#9` marking parameters.
136/// When `optional_default` is set, the first parameter is optional and takes this value unless the
137/// call supplies a `[…]` argument.
138#[derive(Clone)]
139struct Macro {
140    args: usize,
141    optional_default: Option<String>,
142    body: String,
143}
144
145/// One level of input the cursor reads from: a character buffer and a position within it. The
146/// bottom frame holds the source text; each macro expansion pushes a new frame that is read to
147/// exhaustion, then popped, so the expansion's characters are consumed in place of the invocation.
148struct Frame {
149    chars: Vec<char>,
150    pos: usize,
151}
152
153/// The character cursor and parse state.
154#[allow(clippy::struct_excessive_bools)]
155struct Parser {
156    /// The input-frame stack: the bottom frame is the source text, and each frame above it is a
157    /// pending macro expansion still being read. Never empty; the bottom frame is never popped.
158    frames: Vec<Frame>,
159    ext: Extensions,
160    smart: bool,
161    meta: BTreeMap<String, MetaValue>,
162    /// User macro definitions, shared with sub-parsers by reference; a sub-parser that defines its own
163    /// macro copies the table on write, so definitions never leak across scopes.
164    macros: Rc<BTreeMap<String, Macro>>,
165    ids: IdRegistry,
166    /// The level offset for sectioning commands (see [`base_section_level`]).
167    base_level: i32,
168    /// Whether the current context is a figure body, where an image carries no alt text.
169    in_figure: bool,
170    /// Whether the current context is a float body, where `\caption` ends a paragraph so it can be
171    /// hoisted out as the float's caption.
172    in_float: bool,
173    /// Current macro-expansion nesting depth (the number of live expansion frames), bounded to stop
174    /// runaway recursive expansions.
175    expand_depth: u32,
176    /// Total macro expansions performed by this parser, bounded to stop a branching macro from doing
177    /// exponential work while staying under the nesting cap.
178    total_expansions: u32,
179    /// Whether the most recently consumed inter-word whitespace contained a newline, so the gap
180    /// renders as a soft break rather than a plain space.
181    last_ws_had_newline: bool,
182}
183
184/// Where an inline run ends.
185#[derive(Clone, Copy, PartialEq, Eq)]
186enum InlineStop {
187    /// A closing `}`.
188    Group,
189    /// A closing `]`.
190    Bracket,
191    /// A block boundary: a blank line, a sectioning command, an environment, or `\par`.
192    Paragraph,
193    /// A closing single smart quote `'`.
194    QuoteSingle,
195    /// A closing double smart quote `''`.
196    QuoteDouble,
197}
198
199/// Bounds macro-expansion nesting depth (how deeply expansions may recurse into one another).
200const MAX_EXPAND_DEPTH: u32 = 200;
201
202/// Bounds the total number of expansions one parser may perform (its overall work budget).
203const MAX_TOTAL_EXPANSIONS: u32 = 100_000;
204
205impl Parser {
206    /// Replaces the frame stack with a single bottom frame over `source`, resetting the cursor and
207    /// the expansion nesting depth. Macro definitions and the total-expansion budget carry over.
208    fn set_source(&mut self, source: &str) {
209        self.frames = vec![Frame {
210            chars: source.chars().collect(),
211            pos: 0,
212        }];
213        self.expand_depth = 0;
214    }
215
216    /// Pops expansion frames that have been read to exhaustion, decrementing the nesting depth for
217    /// each. The bottom frame (the source) is never popped, so at least one frame always remains.
218    fn drop_exhausted_frames(&mut self) {
219        while self.frames.len() > 1 {
220            match self.frames.last() {
221                Some(frame) if frame.pos >= frame.chars.len() => {
222                    self.frames.pop();
223                    self.expand_depth = self.expand_depth.saturating_sub(1);
224                }
225                _ => break,
226            }
227        }
228    }
229
230    fn cur(&self) -> Option<char> {
231        self.frames
232            .iter()
233            .rev()
234            .find_map(|frame| frame.chars.get(frame.pos).copied())
235    }
236
237    fn at(&self, offset: usize) -> Option<char> {
238        let mut remaining = offset;
239        for frame in self.frames.iter().rev() {
240            let available = frame.chars.len().saturating_sub(frame.pos);
241            if remaining < available {
242                return frame.chars.get(frame.pos + remaining).copied();
243            }
244            remaining -= available;
245        }
246        None
247    }
248
249    fn bump(&mut self) -> Option<char> {
250        self.drop_exhausted_frames();
251        let frame = self.frames.last_mut()?;
252        let c = frame.chars.get(frame.pos).copied();
253        if c.is_some() {
254            frame.pos += 1;
255        }
256        c
257    }
258
259    /// Advances the cursor by `n` characters, crossing frame boundaries as needed.
260    fn advance_chars(&mut self, n: usize) {
261        for _ in 0..n {
262            self.bump();
263        }
264    }
265
266    fn eof(&self) -> bool {
267        self.cur().is_none()
268    }
269
270    /// Whether the cursor is at the literal string `s`.
271    fn looking_at(&self, s: &str) -> bool {
272        s.chars().enumerate().all(|(i, c)| self.at(i) == Some(c))
273    }
274
275    /// Consumes the literal `s` if the cursor is at it.
276    fn eat(&mut self, s: &str) -> bool {
277        if self.looking_at(s) {
278            self.advance_chars(s.chars().count());
279            true
280        } else {
281            false
282        }
283    }
284
285    // --- Block level -----------------------------------------------------------------------------
286
287    fn parse_blocks(&mut self, stop: &Stop) -> Vec<Block> {
288        let mut blocks = Vec::new();
289        loop {
290            self.skip_block_ws();
291            if self.at_stop(stop) {
292                break;
293            }
294            if let Some(mut produced) = self.parse_block_construct() {
295                blocks.append(&mut produced);
296            } else {
297                let para = self.parse_paragraph();
298                if !para.is_empty() {
299                    blocks.push(Block::Para(para));
300                } else if !self.advance_over_stray() {
301                    break;
302                }
303            }
304        }
305        blocks
306    }
307
308    /// Whether the current position satisfies `stop`.
309    fn at_stop(&self, stop: &Stop) -> bool {
310        match stop {
311            Stop::Eof => self.eof(),
312            Stop::Env(name) => self.eof() || self.at_end_env(name),
313            Stop::Item(name) => self.eof() || self.at_end_env(name) || self.at_control_word("item"),
314        }
315    }
316
317    /// Whether the cursor is at `\end{name}` (tolerating spaces inside the braces).
318    fn at_end_env(&self, name: &str) -> bool {
319        match self.peek_env_after("\\end") {
320            Some(env) => env == name,
321            None => false,
322        }
323    }
324
325    /// Whether the cursor is at the control word `\name` followed by a non-letter.
326    fn at_control_word(&self, name: &str) -> bool {
327        if self.cur() != Some('\\') {
328            return false;
329        }
330        for (i, c) in name.chars().enumerate() {
331            if self.at(1 + i) != Some(c) {
332                return false;
333            }
334        }
335        match self.at(1 + name.chars().count()) {
336            Some(c) => !c.is_ascii_alphabetic(),
337            None => true,
338        }
339    }
340
341    /// The environment name in `\begin{env}` / `\end{env}` when the cursor is at `prefix`
342    /// (`"\\begin"` or `"\\end"`), skipping a `*` and spaces around the braces.
343    fn peek_env_after(&self, prefix: &str) -> Option<String> {
344        let mut i = 0;
345        for c in prefix.chars() {
346            if self.at(i) != Some(c) {
347                return None;
348            }
349            i += 1;
350        }
351        while self.at(i) == Some(' ') {
352            i += 1;
353        }
354        if self.at(i) != Some('{') {
355            return None;
356        }
357        i += 1;
358        let mut name = String::new();
359        while let Some(c) = self.at(i) {
360            if c == '}' {
361                return Some(name);
362            }
363            name.push(c);
364            i += 1;
365        }
366        None
367    }
368
369    /// Consumes a single stray character so a stalled loop makes progress. Returns `false` at EOF.
370    fn advance_over_stray(&mut self) -> bool {
371        // A dangling `\end{env}` for an environment we are not inside is skipped whole.
372        if self.looking_at("\\end")
373            && let Some(env) = self.peek_env_after("\\end")
374        {
375            self.consume_env_marker("\\end");
376            let _ = env;
377            return true;
378        }
379        // A control word that cannot begin a block (a stray `\item` or `\par`) is dropped whole,
380        // along with an immediately following optional argument.
381        if self.cur() == Some('\\') && self.at(1).is_some_and(|c| c.is_ascii_alphabetic()) {
382            self.bump();
383            while self.cur().is_some_and(|c| c.is_ascii_alphabetic()) {
384                self.bump();
385            }
386            let _ = self.read_optional_raw();
387            return true;
388        }
389        self.bump().is_some()
390    }
391
392    /// Skips whitespace, blank lines, and comments between blocks.
393    fn skip_block_ws(&mut self) {
394        loop {
395            match self.cur() {
396                Some(c) if c.is_whitespace() => {
397                    self.bump();
398                }
399                Some('%') => self.skip_comment(),
400                _ => break,
401            }
402        }
403    }
404
405    /// Skips a comment: `%` to the end of the line, but not the newline itself.
406    fn skip_comment(&mut self) {
407        while let Some(c) = self.cur() {
408            if c == '\n' {
409                break;
410            }
411            self.bump();
412        }
413    }
414
415    /// Recognises a block-level construct at the cursor. Returns `None` when the cursor is at inline
416    /// content that a paragraph should collect.
417    fn parse_block_construct(&mut self) -> Option<Vec<Block>> {
418        if self.cur() != Some('\\') {
419            return None;
420        }
421        // `\begin{env}` — an environment, unless it is a math environment (which is inline content).
422        if let Some(env) = self.peek_env_after("\\begin") {
423            if math_env(&env) {
424                return None;
425            }
426            return Some(self.parse_environment(&env));
427        }
428        // A macro that expands is read from its expansion frame, then re-examined.
429        if self.try_expand_macro() {
430            return self.parse_block_construct();
431        }
432        let name = self.peek_control_word()?;
433        if let Some(level) = section_intrinsic(&name) {
434            return Some(vec![self.parse_section(level)]);
435        }
436        match name.as_str() {
437            "title" | "author" | "date" | "subtitle" => {
438                self.consume_control_word();
439                self.capture_meta(&name);
440                Some(Vec::new())
441            }
442            "newcommand"
443            | "renewcommand"
444            | "providecommand"
445            | "DeclareRobustCommand"
446            | "def"
447            | "let" => Some(self.parse_macro_definition(&name)),
448            // Commands that emit no block: layout and float directives, package/setup declarations,
449            // and the bibliography, are consumed with their arguments and dropped.
450            "par" | "maketitle" | "tableofcontents" | "listoffigures" | "listoftables"
451            | "frontmatter" | "mainmatter" | "backmatter" | "appendix" | "clearpage"
452            | "cleardoublepage" | "newpage" | "pagebreak" | "noindent" | "bigskip" | "medskip"
453            | "smallskip" | "centering" | "raggedright" | "raggedleft" | "printindex"
454            | "printbibliography" | "documentclass" | "usepackage" | "RequirePackage"
455            | "pagestyle" | "thispagestyle" | "pagenumbering" | "setlength" | "setcounter"
456            | "addtocounter" | "geometry" | "hypersetup" | "bibliographystyle" | "include"
457            | "input" | "graphicspath" | "definecolor" | "newtheorem" | "theoremstyle"
458            | "captionsetup" | "bibliography" => {
459                self.consume_control_word();
460                self.skip_command_args(&name);
461                Some(Vec::new())
462            }
463            _ => None,
464        }
465    }
466
467    /// The control word at the cursor (letters after a `\`), without consuming it. `None` for a
468    /// control symbol or a non-command position.
469    fn peek_control_word(&self) -> Option<String> {
470        if self.cur() != Some('\\') {
471            return None;
472        }
473        let mut i = 1;
474        let mut name = String::new();
475        while let Some(c) = self.at(i) {
476            if c.is_ascii_alphabetic() {
477                name.push(c);
478                i += 1;
479            } else {
480                break;
481            }
482        }
483        if name.is_empty() { None } else { Some(name) }
484    }
485
486    /// Consumes a `\name` control word and the spaces that follow it (which LaTeX absorbs).
487    fn consume_control_word(&mut self) -> String {
488        self.bump(); // backslash
489        let mut name = String::new();
490        while let Some(c) = self.cur() {
491            if c.is_ascii_alphabetic() {
492                name.push(c);
493                self.bump();
494            } else {
495                break;
496            }
497        }
498        // A trailing `*` variant marker binds to the command name.
499        while matches!(self.cur(), Some(' ' | '\t')) {
500            self.bump();
501        }
502        name
503    }
504
505    /// Consumes a `\begin`/`\end` marker together with its `{env}` (and any `*` / spaces).
506    fn consume_env_marker(&mut self, prefix: &str) {
507        self.eat(prefix);
508        while self.cur() == Some(' ') {
509            self.bump();
510        }
511        if self.cur() == Some('{') {
512            self.bump();
513            while let Some(c) = self.cur() {
514                self.bump();
515                if c == '}' {
516                    break;
517                }
518            }
519        }
520    }
521
522    // --- Sectioning ------------------------------------------------------------------------------
523
524    fn parse_section(&mut self, intrinsic: i32) -> Block {
525        self.consume_control_word();
526        let starred = self.cur() == Some('*');
527        if starred {
528            self.bump();
529            while matches!(self.cur(), Some(' ' | '\t')) {
530                self.bump();
531            }
532        }
533        // An optional `[short title]` is ignored.
534        let _ = self.read_optional_raw();
535        let mut label = None;
536        let title = self.parse_group_inlines_capturing_label(&mut label);
537        // A `\label` immediately following the heading names it too.
538        self.skip_block_ws();
539        if let Some(id) = self.peek_env_arg_after_label() {
540            label = Some(id);
541        }
542
543        let level = (intrinsic - self.base_level + 1).max(1);
544        let id = match label {
545            Some(id) => {
546                self.ids.reserve_native(&id);
547                id
548            }
549            None => self.assign_id(&to_plain_text(&title)),
550        };
551        let mut classes = Vec::new();
552        if starred {
553            classes.push("unnumbered".into());
554        }
555        Block::Header(
556            level,
557            Box::new(Attr {
558                id: id.into(),
559                classes,
560                attributes: Vec::new(),
561            }),
562            title,
563        )
564    }
565
566    /// If the cursor is at `\label{id}`, consumes it and returns the identifier.
567    fn peek_env_arg_after_label(&mut self) -> Option<String> {
568        if self.at_control_word("label") {
569            self.consume_control_word();
570            return self.read_group_raw();
571        }
572        None
573    }
574
575    /// Parses a braced inline group, capturing the identifier of any `\label` inside it into `label`.
576    fn parse_group_inlines_capturing_label(&mut self, label: &mut Option<String>) -> Vec<Inline> {
577        if self.cur() != Some('{') {
578            return Vec::new();
579        }
580        self.bump();
581        let inlines = self.parse_inlines(InlineStop::Group);
582        if self.cur() == Some('}') {
583            self.bump();
584        }
585        // Pull a label out of the collected inlines: a `\label` renders to an empty span carrying a
586        // `label` attribute, which becomes the header id rather than an inline span.
587        let mut kept = Vec::new();
588        for inline in inlines {
589            if let Inline::Span(attr, content) = &inline
590                && content.is_empty()
591                && attr.attributes.iter().any(|(k, _)| k == "label")
592            {
593                *label = Some(attr.id.to_string());
594                continue;
595            }
596            kept.push(inline);
597        }
598        kept
599    }
600
601    /// Derives a heading identifier from its title text. The slug shape follows the active extension,
602    /// but a section always disambiguates natively: an empty slug becomes `section` and a repeat
603    /// increments a numeric suffix until unused (also avoiding any reserved `\label`).
604    fn assign_id(&mut self, text: &str) -> String {
605        let Some(scheme) = IdScheme::select(self.ext, false) else {
606            return String::new();
607        };
608        let base = match scheme {
609            IdScheme::Plain => slug(text),
610            IdScheme::Gfm => slug_gfm(text),
611        };
612        self.ids.assign_native(base)
613    }
614
615    // --- Environments ----------------------------------------------------------------------------
616
617    fn parse_environment(&mut self, env: &str) -> Vec<Block> {
618        self.consume_env_marker("\\begin");
619        // Some environments take a leading argument or options.
620        match env {
621            "itemize" | "enumerate" => {
622                let _ = self.read_optional_raw();
623                vec![self.parse_list(env)]
624            }
625            "description" => {
626                let _ = self.read_optional_raw();
627                vec![self.parse_description()]
628            }
629            "quote" | "quotation" | "verse" => {
630                let inner = self.parse_blocks(&Stop::Env(env));
631                self.consume_env_marker("\\end");
632                vec![Block::BlockQuote(inner)]
633            }
634            "center" | "flushleft" | "flushright" => {
635                let inner = self.parse_blocks(&Stop::Env(env));
636                self.consume_env_marker("\\end");
637                vec![Block::Div(
638                    Box::new(Attr {
639                        id: carta_ast::Text::default(),
640                        classes: vec![env.into()],
641                        attributes: Vec::new(),
642                    }),
643                    inner,
644                )]
645            }
646            "minipage" => {
647                // Positional options precede the mandatory width; none affect the content.
648                while self.read_optional_raw().is_some() {}
649                let _ = self.read_group_raw();
650                let inner = self.parse_blocks(&Stop::Env(env));
651                self.consume_env_marker("\\end");
652                vec![Block::Div(
653                    Box::new(Attr {
654                        id: carta_ast::Text::default(),
655                        classes: vec!["minipage".into()],
656                        attributes: Vec::new(),
657                    }),
658                    inner,
659                )]
660            }
661            "verbatim" | "verbatim*" | "Verbatim" | "lstlisting" | "minted" | "alltt"
662            | "lstinputlisting" => self.parse_verbatim_env(env),
663            "comment" => {
664                self.skip_to_end_env(env);
665                Vec::new()
666            }
667            "figure" | "figure*" | "wrapfigure" | "SCfigure" | "marginfigure" => {
668                vec![self.parse_figure(env)]
669            }
670            "table" | "table*" => self.parse_table_float(env),
671            "tabular" | "tabular*" | "tabularx" | "array" | "longtable" | "supertabular"
672            | "tabulary" => {
673                vec![self.parse_tabular(env)]
674            }
675            "abstract" => {
676                let inner = self.parse_blocks(&Stop::Env(env));
677                self.consume_env_marker("\\end");
678                self.meta
679                    .insert("abstract".to_owned(), MetaValue::MetaBlocks(inner));
680                Vec::new()
681            }
682            "document" => {
683                let inner = self.parse_blocks(&Stop::Env(env));
684                self.consume_env_marker("\\end");
685                inner
686            }
687            _ => {
688                if self.ext.contains(Extension::RawTex) {
689                    self.parse_raw_env(env)
690                } else {
691                    let inner = self.parse_blocks(&Stop::Env(env));
692                    self.consume_env_marker("\\end");
693                    vec![Block::Div(
694                        Box::new(Attr {
695                            id: carta_ast::Text::default(),
696                            classes: vec![env.into()],
697                            attributes: Vec::new(),
698                        }),
699                        inner,
700                    )]
701                }
702            }
703        }
704    }
705
706    /// Captures an unknown environment verbatim as a raw LaTeX block (under `raw_tex`).
707    fn parse_raw_env(&mut self, env: &str) -> Vec<Block> {
708        let mut raw = format!("\\begin{{{env}}}");
709        while !self.eof() {
710            if self.at_end_env(env) {
711                break;
712            }
713            if let Some(c) = self.bump() {
714                raw.push(c);
715            }
716        }
717        // Append the closing marker as written.
718        raw.push_str("\\end{");
719        raw.push_str(env);
720        raw.push('}');
721        self.consume_env_marker("\\end");
722        vec![Block::RawBlock(Format("latex".into()), raw.into())]
723    }
724
725    /// Skips to and consumes the matching `\end{env}`.
726    fn skip_to_end_env(&mut self, env: &str) {
727        while !self.eof() {
728            if self.at_end_env(env) {
729                break;
730            }
731            self.bump();
732        }
733        self.consume_env_marker("\\end");
734    }
735
736    // --- Lists -----------------------------------------------------------------------------------
737
738    fn parse_list(&mut self, env: &str) -> Block {
739        let items = self.parse_items(env);
740        if env == "enumerate" {
741            Block::OrderedList(
742                ListAttributes {
743                    start: 1,
744                    style: ListNumberStyle::DefaultStyle,
745                    delim: ListNumberDelim::DefaultDelim,
746                },
747                items,
748            )
749        } else {
750            Block::BulletList(items)
751        }
752    }
753
754    /// Reads the `\item` entries of an itemize/enumerate environment.
755    fn parse_items(&mut self, env: &str) -> Vec<Vec<Block>> {
756        let mut items: Vec<Vec<Block>> = Vec::new();
757        loop {
758            self.skip_block_ws();
759            if self.eof() || self.at_end_env(env) {
760                break;
761            }
762            if self.at_control_word("item") {
763                self.consume_control_word();
764                let _ = self.read_optional_raw(); // custom marker, dropped
765                let blocks = self.parse_blocks(&Stop::Item(env));
766                items.push(blocks);
767            } else if !self.advance_over_stray() {
768                break;
769            }
770        }
771        self.consume_env_marker("\\end");
772        items
773    }
774
775    fn parse_description(&mut self) -> Block {
776        let mut entries: Vec<(Vec<Inline>, Vec<Vec<Block>>)> = Vec::new();
777        loop {
778            self.skip_block_ws();
779            if self.eof() || self.at_end_env("description") {
780                break;
781            }
782            if self.at_control_word("item") {
783                self.consume_control_word();
784                let term = self.read_optional_inlines().unwrap_or_default();
785                let blocks = self.parse_blocks(&Stop::Item("description"));
786                entries.push((term, vec![blocks]));
787            } else if !self.advance_over_stray() {
788                break;
789            }
790        }
791        self.consume_env_marker("\\end");
792        Block::DefinitionList(entries)
793    }
794
795    // --- Verbatim --------------------------------------------------------------------------------
796
797    fn parse_verbatim_env(&mut self, env: &str) -> Vec<Block> {
798        let mut classes = Vec::new();
799        let mut attributes = Vec::new();
800        // `lstlisting`/`Verbatim` take `[key=value,…]` options; `minted` takes `{language}`.
801        if matches!(env, "lstlisting" | "Verbatim" | "lstinputlisting") {
802            if let Some(opts) = self.read_optional_raw() {
803                for (k, v) in parse_key_values(&opts) {
804                    if k == "language" && !v.is_empty() {
805                        classes.push(v.to_lowercase().into());
806                    }
807                    attributes.push((k, v));
808                }
809            }
810        } else if env == "minted" {
811            let _ = self.read_optional_raw();
812            if let Some(lang) = self.read_group_raw()
813                && !lang.is_empty()
814            {
815                classes.push(lang.to_lowercase().into());
816            }
817        }
818        let content = self.read_verbatim_body(env);
819        vec![Block::CodeBlock(
820            Box::new(Attr {
821                id: carta_ast::Text::default(),
822                classes,
823                attributes: attributes
824                    .into_iter()
825                    .map(|(k, v)| (k.into(), v.into()))
826                    .collect(),
827            }),
828            content.into(),
829        )]
830    }
831
832    /// Reads a verbatim environment body verbatim, stopping before `\end{env}`.
833    fn read_verbatim_body(&mut self, env: &str) -> String {
834        let closing = format!("\\end{{{env}}}");
835        let mut body = String::new();
836        while !self.eof() {
837            if self.looking_at(&closing) {
838                break;
839            }
840            if let Some(c) = self.bump() {
841                body.push(c);
842            }
843        }
844        self.consume_env_marker("\\end");
845        body.trim_matches('\n').to_owned()
846    }
847
848    // --- Figures & tables ------------------------------------------------------------------------
849
850    fn parse_figure(&mut self, env: &str) -> Block {
851        let _ = self.read_optional_raw(); // float placement
852        if env == "wrapfigure" {
853            let _ = self.read_group_raw(); // placement
854            let _ = self.read_group_raw(); // width
855        }
856        let was_in_figure = self.in_figure;
857        self.in_figure = true;
858        let (blocks, caption, id) = self.collect_float(env);
859        self.in_figure = was_in_figure;
860        Block::Figure(
861            Box::new(Attr {
862                id: id.unwrap_or_default().into(),
863                classes: Vec::new(),
864                attributes: Vec::new(),
865            }),
866            Box::new(Caption {
867                short: None,
868                long: caption,
869            }),
870            blocks.into_iter().map(demote_image_para).collect(),
871        )
872    }
873
874    fn parse_table_float(&mut self, env: &str) -> Vec<Block> {
875        let _ = self.read_optional_raw();
876        let (mut blocks, caption, id) = self.collect_float(env);
877        if !caption.is_empty()
878            && let Some(Block::Table(table)) =
879                blocks.iter_mut().find(|b| matches!(b, Block::Table(_)))
880        {
881            table.caption = Caption {
882                short: None,
883                long: caption,
884            };
885            if let Some(id) = id {
886                table.attr.id = id.into();
887            }
888        }
889        blocks
890    }
891
892    /// Parses a float body, pulling out a `\caption` (as caption blocks) and a `\label` (as an id).
893    fn collect_float(&mut self, env: &str) -> (Vec<Block>, Vec<Block>, Option<String>) {
894        let mut blocks = Vec::new();
895        let mut caption = Vec::new();
896        let mut id = None;
897        let was_in_float = self.in_float;
898        self.in_float = true;
899        loop {
900            self.skip_block_ws();
901            if self.eof() || self.at_end_env(env) {
902                break;
903            }
904            if self.at_control_word("caption") {
905                self.consume_control_word();
906                let _ = self.read_optional_raw();
907                let inlines = self.parse_group_inlines_capturing_label(&mut id);
908                caption = vec![Block::Plain(inlines)];
909                continue;
910            }
911            if self.at_control_word("centering")
912                || self.at_control_word("small")
913                || self.at_control_word("footnotesize")
914            {
915                self.consume_control_word();
916                continue;
917            }
918            if self.at_control_word("label") {
919                self.consume_control_word();
920                id = self.read_group_raw();
921                continue;
922            }
923            if let Some(mut produced) = self.parse_block_construct() {
924                blocks.append(&mut produced);
925            } else {
926                let para = self.parse_paragraph();
927                if !para.is_empty() {
928                    blocks.push(Block::Para(para));
929                } else if !self.advance_over_stray() {
930                    break;
931                }
932            }
933        }
934        self.consume_env_marker("\\end");
935        self.in_float = was_in_float;
936        (blocks, caption, id)
937    }
938
939    fn parse_tabular(&mut self, env: &str) -> Block {
940        if env == "tabular*" || env == "tabularx" || env == "tabulary" {
941            let _ = self.read_group_raw(); // width
942        }
943        let _ = self.read_optional_raw(); // vertical position
944        let spec = self.read_group_raw().unwrap_or_default();
945        let aligns = parse_column_spec(&spec);
946        let body = self.read_environment_source(env);
947        self.consume_env_marker("\\end");
948        build_table(self, &aligns, &body)
949    }
950
951    /// Reads a math environment as a single math inline. `math`/`displaymath` carry the body alone;
952    /// the aligned and numbered environments carry their `\begin`/`\end` wrapper inside the formula.
953    fn read_math_environment(&mut self, env: &str) -> Inline {
954        self.consume_env_marker("\\begin");
955        let body = self.read_environment_source(env);
956        self.consume_env_marker("\\end");
957        match env {
958            "math" => Inline::Math(MathType::InlineMath, body.trim().into()),
959            "displaymath" => Inline::Math(MathType::DisplayMath, body.trim().into()),
960            _ => {
961                // The environment markers are re-emitted on their own lines around the body, with
962                // trailing whitespace stripped but leading indentation of the first line preserved.
963                let content = body.trim_end().trim_start_matches(['\n', '\r']);
964                Inline::Math(
965                    MathType::DisplayMath,
966                    format!("\\begin{{{env}}}\n{content}\n\\end{{{env}}}").into(),
967                )
968            }
969        }
970    }
971
972    /// Reads the raw source of an environment body up to (but not consuming) its `\end{env}`.
973    fn read_environment_source(&mut self, env: &str) -> String {
974        let closing = format!("\\end{{{env}}}");
975        let mut out = String::new();
976        while !self.eof() {
977            if self.looking_at(&closing) {
978                break;
979            }
980            if let Some(c) = self.bump() {
981                out.push(c);
982            }
983        }
984        out
985    }
986
987    // --- Paragraph & inline ----------------------------------------------------------------------
988
989    fn parse_paragraph(&mut self) -> Vec<Inline> {
990        let inlines = self.parse_inlines(InlineStop::Paragraph);
991        trim_inlines(inlines)
992    }
993
994    fn parse_inlines(&mut self, stop: InlineStop) -> Vec<Inline> {
995        let mut out = Vec::new();
996        let mut buf = String::new();
997        loop {
998            let Some(c) = self.cur() else {
999                break;
1000            };
1001            match stop {
1002                InlineStop::Group if c == '}' => break,
1003                InlineStop::Bracket if c == ']' => break,
1004                InlineStop::QuoteSingle if c == '\'' && self.quote_closes(1) => break,
1005                InlineStop::QuoteDouble
1006                    if c == '\'' && self.at(1) == Some('\'') && self.quote_closes(2) =>
1007                {
1008                    break;
1009                }
1010                _ => {}
1011            }
1012            match c {
1013                ' ' | '\t' | '\n' | '\r' | '%' => {
1014                    let had_blank = self.consume_inline_ws();
1015                    if had_blank && matches!(stop, InlineStop::Paragraph) {
1016                        break;
1017                    }
1018                    flush_buf(&mut buf, &mut out);
1019                    if !self.eof() {
1020                        let ws = if had_blank || self.last_ws_had_newline {
1021                            Inline::SoftBreak
1022                        } else {
1023                            Inline::Space
1024                        };
1025                        push_whitespace(&mut out, ws);
1026                    }
1027                }
1028                '\\' => {
1029                    if matches!(stop, InlineStop::Paragraph) && self.inline_break_ahead() {
1030                        break;
1031                    }
1032                    if let Some(env) = self.peek_env_after("\\begin")
1033                        && math_env(&env)
1034                    {
1035                        let math = self.read_math_environment(&env);
1036                        emit(&mut out, &mut buf, math);
1037                        continue;
1038                    }
1039                    if self.try_expand_macro() {
1040                        continue;
1041                    }
1042                    // A font-switch command applies to the remainder of the enclosing group.
1043                    if let Some(word) = self.peek_control_word()
1044                        && let Some(switch) = switch_kind(&word)
1045                    {
1046                        self.apply_switch(switch, stop, &mut out, &mut buf);
1047                        break;
1048                    }
1049                    // A control sequence flushes the text buffer itself only when it emits an inline;
1050                    // an accent or symbol appends to the buffer so it joins the surrounding word.
1051                    self.exec_control(&mut out, &mut buf);
1052                }
1053                '{' => {
1054                    self.bump();
1055                    let inner = self.parse_inlines(InlineStop::Group);
1056                    if self.cur() == Some('}') {
1057                        self.bump();
1058                    }
1059                    // An empty group leaves the surrounding word intact; a non-empty one becomes a
1060                    // grouping span placed after the buffered text.
1061                    if let Some(span) = group_span(inner) {
1062                        emit(&mut out, &mut buf, span);
1063                    }
1064                }
1065                '}' => {
1066                    // A stray close brace outside a group is treated as a literal.
1067                    buf.push('}');
1068                    self.bump();
1069                }
1070                '$' => {
1071                    flush_buf(&mut buf, &mut out);
1072                    let math = self.read_dollar_math();
1073                    out.push(math);
1074                }
1075                '~' => {
1076                    buf.push('\u{a0}');
1077                    self.bump();
1078                }
1079                '-' => {
1080                    self.read_dashes(&mut buf);
1081                }
1082                '`' if self.smart => {
1083                    flush_buf(&mut buf, &mut out);
1084                    self.read_open_quote(&mut out);
1085                }
1086                '\'' => {
1087                    self.read_apostrophe(&mut buf);
1088                }
1089                _ => {
1090                    buf.push(c);
1091                    self.bump();
1092                }
1093            }
1094        }
1095        flush_buf(&mut buf, &mut out);
1096        out
1097    }
1098
1099    /// Whether `\`-introduced content at the cursor starts a new block, ending a paragraph.
1100    fn inline_break_ahead(&self) -> bool {
1101        if let Some(env) = self.peek_env_after("\\begin") {
1102            return !math_env(&env);
1103        }
1104        if self.looking_at("\\end") && self.peek_env_after("\\end").is_some() {
1105            return true;
1106        }
1107        if let Some(word) = self.peek_control_word() {
1108            if section_intrinsic(&word).is_some() {
1109                return true;
1110            }
1111            if self.in_float && word == "caption" {
1112                return true;
1113            }
1114            return matches!(word.as_str(), "item" | "par");
1115        }
1116        false
1117    }
1118
1119    /// Whether a smart-quote delimiter at `offset` from the cursor closes an open quote — it does
1120    /// when the character after it is not alphanumeric.
1121    fn quote_closes(&self, offset: usize) -> bool {
1122        match self.at(offset) {
1123            Some(c) => !c.is_alphanumeric(),
1124            None => true,
1125        }
1126    }
1127
1128    /// Consumes a run of whitespace and comments. Returns whether it spanned a blank line. Records
1129    /// whether the run contained a newline in `last_ws_had_newline`.
1130    fn consume_inline_ws(&mut self) -> bool {
1131        let mut newlines = 0u32;
1132        loop {
1133            match self.cur() {
1134                Some('\n') => {
1135                    newlines += 1;
1136                    self.bump();
1137                }
1138                Some(' ' | '\t' | '\r') => {
1139                    self.bump();
1140                }
1141                Some('%') => self.skip_comment(),
1142                _ => break,
1143            }
1144        }
1145        self.last_ws_had_newline = newlines > 0;
1146        newlines >= 2
1147    }
1148
1149    fn read_dashes(&mut self, buf: &mut String) {
1150        let mut count = 0;
1151        while self.cur() == Some('-') {
1152            count += 1;
1153            self.bump();
1154        }
1155        while count >= 3 {
1156            buf.push('\u{2014}');
1157            count -= 3;
1158        }
1159        if count == 2 {
1160            buf.push('\u{2013}');
1161        } else if count == 1 {
1162            buf.push('-');
1163        }
1164    }
1165
1166    fn read_apostrophe(&mut self, buf: &mut String) {
1167        if self.cur() == Some('\'') && self.at(1) == Some('\'') {
1168            buf.push('\u{201d}');
1169            self.bump();
1170            self.bump();
1171        } else {
1172            buf.push('\u{2019}');
1173            self.bump();
1174        }
1175    }
1176
1177    /// Opens a smart quote at a `` ` ``, reading its content up to the matching close.
1178    fn read_open_quote(&mut self, out: &mut Vec<Inline>) {
1179        if self.at(1) == Some('`') {
1180            self.bump();
1181            self.bump();
1182            let inner = self.parse_inlines(InlineStop::QuoteDouble);
1183            if self.cur() == Some('\'') && self.at(1) == Some('\'') {
1184                self.bump();
1185                self.bump();
1186                out.push(Inline::Quoted(QuoteType::DoubleQuote, inner));
1187            } else {
1188                out.push(Inline::Str("\u{201c}".into()));
1189                out.extend(inner);
1190            }
1191        } else {
1192            self.bump();
1193            let inner = self.parse_inlines(InlineStop::QuoteSingle);
1194            if self.cur() == Some('\'') {
1195                self.bump();
1196                out.push(Inline::Quoted(QuoteType::SingleQuote, inner));
1197            } else {
1198                out.push(Inline::Str("\u{2018}".into()));
1199                out.extend(inner);
1200            }
1201        }
1202    }
1203
1204    // --- Inline commands -------------------------------------------------------------------------
1205
1206    /// Dispatches a control sequence in inline context, appending inlines to `out` or text to `buf`.
1207    fn exec_control(&mut self, out: &mut Vec<Inline>, buf: &mut String) {
1208        // A control symbol: a backslash followed by a single non-letter.
1209        if self.at(1).is_some_and(|c| !c.is_ascii_alphabetic()) {
1210            self.exec_control_symbol(out, buf);
1211            return;
1212        }
1213        let name = self.consume_control_word();
1214        self.exec_named(&name, out, buf);
1215    }
1216
1217    fn exec_control_symbol(&mut self, out: &mut Vec<Inline>, buf: &mut String) {
1218        self.bump(); // backslash
1219        let Some(c) = self.bump() else {
1220            return;
1221        };
1222        match c {
1223            '\\' => {
1224                // A hard line break, with an optional `*` and `[dimen]` that are discarded. It
1225                // absorbs any spacing on either side.
1226                if self.cur() == Some('*') {
1227                    self.bump();
1228                }
1229                let _ = self.read_optional_raw();
1230                flush_buf(buf, out);
1231                while matches!(out.last(), Some(Inline::Space | Inline::SoftBreak)) {
1232                    out.pop();
1233                }
1234                out.push(Inline::LineBreak);
1235            }
1236            '[' => {
1237                let text = self.read_math_body("\\]");
1238                emit(out, buf, Inline::Math(MathType::DisplayMath, text.into()));
1239            }
1240            '(' => {
1241                let text = self.read_math_body("\\)");
1242                emit(out, buf, Inline::Math(MathType::InlineMath, text.into()));
1243            }
1244            // An explicit inter-word space is a non-breaking space.
1245            ' ' | '\n' | '\t' => buf.push('\u{a0}'),
1246            // A thin space.
1247            ',' => buf.push('\u{2006}'),
1248            '&' | '%' | '#' | '$' | '_' | '{' | '}' => buf.push(c),
1249            '~' => self.read_accent_symbol(Accent::Tilde, buf),
1250            '^' => self.read_accent_symbol(Accent::Circumflex, buf),
1251            '\'' => self.read_accent_symbol(Accent::Acute, buf),
1252            '`' => self.read_accent_symbol(Accent::Grave, buf),
1253            '"' => self.read_accent_symbol(Accent::Diaeresis, buf),
1254            '=' => self.read_accent_symbol(Accent::Macron, buf),
1255            '.' => self.read_accent_symbol(Accent::DotAbove, buf),
1256            // Discretionary/zero-width spacing and escaped delimiters that carry no text.
1257            '-' | '/' | ';' | ':' | '!' | '@' | ')' | ']' => {}
1258            other => buf.push(other),
1259        }
1260    }
1261
1262    /// Applies a font-switch command (`\bf`, `\em`, …) to the remainder of the enclosing group.
1263    fn apply_switch(
1264        &mut self,
1265        switch: Switch,
1266        stop: InlineStop,
1267        out: &mut Vec<Inline>,
1268        buf: &mut String,
1269    ) {
1270        self.consume_control_word();
1271        flush_buf(buf, out);
1272        let rest = self.parse_inlines(stop);
1273        if matches!(switch, Switch::Code) {
1274            out.push(switch.wrap(rest));
1275        } else {
1276            out.extend(extract_spaces(rest, |i| switch.wrap(i)));
1277        }
1278    }
1279
1280    #[allow(clippy::too_many_lines)]
1281    fn exec_named(&mut self, name: &str, out: &mut Vec<Inline>, buf: &mut String) {
1282        // Wrapping formatters. Most pull surrounding spacing out of the wrapper; underline keeps it.
1283        if let Some(wrap) = inline_wrapper(name) {
1284            let inner = self.parse_group_inlines();
1285            if matches!(name, "underline" | "uline") {
1286                emit(out, buf, wrap(inner));
1287            } else {
1288                emit_all(out, buf, extract_spaces(inner, wrap));
1289            }
1290            return;
1291        }
1292        // Accent commands spelled as control words apply to their argument's base character.
1293        if let Some(accent) = word_accent(name) {
1294            self.read_accent_symbol(accent, buf);
1295            return;
1296        }
1297        // Font family/shape/series switches wrap their argument in a single-class span.
1298        if let Some(class) = font_span_class(name) {
1299            let inner = self.parse_group_inlines();
1300            emit_all(out, buf, extract_spaces(inner, |i| span_class(i, class)));
1301            return;
1302        }
1303        match name {
1304            "textcolor" | "colorbox" => {
1305                let color = self.read_group_raw().unwrap_or_default();
1306                let inner = self.parse_group_inlines();
1307                let property = if name == "colorbox" {
1308                    "background-color"
1309                } else {
1310                    "color"
1311                };
1312                let attr = Attr {
1313                    id: carta_ast::Text::default(),
1314                    classes: Vec::new(),
1315                    attributes: vec![(
1316                        "style".into(),
1317                        format!("{property}: {}", color.trim()).into(),
1318                    )],
1319                };
1320                emit(out, buf, Inline::Span(Box::new(attr), inner));
1321            }
1322            "texttt" | "lstinline" => {
1323                if name == "lstinline" {
1324                    let _ = self.read_optional_raw();
1325                }
1326                let inner = self.parse_group_inlines();
1327                emit(
1328                    out,
1329                    buf,
1330                    Inline::Code(Box::default(), to_plain_text(&inner).into()),
1331                );
1332            }
1333            "verb" => {
1334                if let Some(code) = self.read_verb() {
1335                    emit(out, buf, Inline::Code(Box::default(), code.into()));
1336                }
1337            }
1338            "footnote" | "footnotetext" | "thanks" => {
1339                let _ = self.read_optional_raw();
1340                let blocks = self.parse_group_blocks();
1341                emit(out, buf, Inline::Note(blocks));
1342            }
1343            "url" | "nolinkurl" => {
1344                if let Some(url) = self.read_group_raw() {
1345                    let url = unescape_url(&url);
1346                    emit(
1347                        out,
1348                        buf,
1349                        Inline::Link(
1350                            Box::new(Attr {
1351                                id: carta_ast::Text::default(),
1352                                classes: vec!["uri".into()],
1353                                attributes: Vec::new(),
1354                            }),
1355                            vec![Inline::Str(url.clone().into())],
1356                            Box::new(Target {
1357                                url: url.into(),
1358                                title: carta_ast::Text::default(),
1359                            }),
1360                        ),
1361                    );
1362                }
1363            }
1364            "href" => {
1365                let url = self
1366                    .read_group_raw()
1367                    .map(|u| unescape_url(&u))
1368                    .unwrap_or_default();
1369                let text = self.parse_group_inlines();
1370                emit(
1371                    out,
1372                    buf,
1373                    Inline::Link(
1374                        Box::default(),
1375                        text,
1376                        Box::new(Target {
1377                            url: url.into(),
1378                            title: carta_ast::Text::default(),
1379                        }),
1380                    ),
1381                );
1382            }
1383            "includegraphics" => {
1384                let opts = self.read_optional_raw().unwrap_or_default();
1385                let path = self.read_group_raw().unwrap_or_default();
1386                let attributes = image_attributes(&opts);
1387                let alt = if self.in_figure {
1388                    Vec::new()
1389                } else {
1390                    vec![Inline::Str("image".into())]
1391                };
1392                emit(
1393                    out,
1394                    buf,
1395                    Inline::Image(
1396                        Box::new(Attr {
1397                            id: carta_ast::Text::default(),
1398                            classes: Vec::new(),
1399                            attributes: attributes
1400                                .into_iter()
1401                                .map(|(k, v)| (k.into(), v.into()))
1402                                .collect(),
1403                        }),
1404                        alt,
1405                        Box::new(Target {
1406                            url: path.into(),
1407                            title: carta_ast::Text::default(),
1408                        }),
1409                    ),
1410                );
1411            }
1412            "label" => {
1413                if let Some(id) = self.read_group_raw() {
1414                    emit(
1415                        out,
1416                        buf,
1417                        Inline::Span(
1418                            Box::new(Attr {
1419                                id: id.clone().into(),
1420                                classes: Vec::new(),
1421                                attributes: vec![("label".into(), id.into())],
1422                            }),
1423                            Vec::new(),
1424                        ),
1425                    );
1426                }
1427            }
1428            "ref" | "eqref" | "autoref" | "cref" | "Cref" => {
1429                if let Some(target) = self.read_group_raw() {
1430                    emit(out, buf, reference_link(name, &target));
1431                }
1432            }
1433            "cite" | "citep" | "citet" | "citealp" | "citealt" | "citeauthor" | "citeyear"
1434            | "parencite" | "textcite" | "footcite" | "autocite" => {
1435                flush_buf(buf, out);
1436                self.read_citation(name, out);
1437            }
1438            "textsuperscript" | "textsubscript" => {
1439                let inner = self.parse_group_inlines();
1440                let wrap: fn(Vec<Inline>) -> Inline = if name == "textsubscript" {
1441                    Inline::Subscript
1442                } else {
1443                    Inline::Superscript
1444                };
1445                emit_all(out, buf, extract_spaces(inner, wrap));
1446            }
1447            "mbox" | "hbox" => {
1448                let inner = self.parse_group_inlines();
1449                emit_all(out, buf, inner);
1450            }
1451            "ensuremath" => {
1452                let body = self.read_group_raw().unwrap_or_default();
1453                emit(
1454                    out,
1455                    buf,
1456                    Inline::Math(MathType::InlineMath, body.trim().into()),
1457                );
1458            }
1459            "footnotemark" | "protect" | "noindent" | "indent" | "bigskip" | "medskip"
1460            | "smallskip" | "centering" | "hfill" | "hrulefill" | "dotfill" | "par"
1461            | "displaystyle" | "scriptsize" | "small" | "footnotesize" | "large" | "Large"
1462            | "LARGE" | "huge" | "Huge" | "normalsize" | "rmfamily" | "sffamily" | "ttfamily"
1463            | "mdseries" | "upshape" | "normalfont" | "sc" | "rm" | "sf" | "boldmath"
1464            | "unboldmath" | "clearpage" | "newpage" | "nolinebreak" | "sloppy" | "raggedright"
1465            | "item" => {
1466                // Font-switch and spacing commands with no argument contribute nothing. A stray
1467                // `\item` outside any list is likewise dropped (list items are handled elsewhere).
1468            }
1469            "linebreak" => {
1470                let _ = self.read_optional_raw();
1471                emit(out, buf, Inline::LineBreak);
1472            }
1473            "newline" => emit(out, buf, Inline::LineBreak),
1474            "hspace" | "vspace" | "hskip" | "vskip" | "setlength" | "vphantom" | "hphantom"
1475            | "phantom" | "rule" | "settowidth" => {
1476                self.skip_command_args(name);
1477            }
1478            _ => {
1479                if let Some(text) = symbol_text(name) {
1480                    buf.push_str(text);
1481                } else if self.ext.contains(Extension::RawTex) {
1482                    let raw = self.reconstruct_command(name);
1483                    emit(
1484                        out,
1485                        buf,
1486                        Inline::RawInline(Format("latex".into()), raw.into()),
1487                    );
1488                } else {
1489                    // Unknown command: drop it along with any adjacent bracket/brace arguments.
1490                    self.skip_adjacent_arguments();
1491                }
1492            }
1493        }
1494    }
1495
1496    /// Rebuilds an unknown command's source, including any immediately following optional and braced
1497    /// arguments, for verbatim passthrough.
1498    fn reconstruct_command(&mut self, name: &str) -> String {
1499        let mut raw = format!("\\{name}");
1500        loop {
1501            match self.cur() {
1502                Some('[') => {
1503                    if let Some(opt) = self.read_optional_raw() {
1504                        raw.push('[');
1505                        raw.push_str(&opt);
1506                        raw.push(']');
1507                    } else {
1508                        break;
1509                    }
1510                }
1511                Some('{') => {
1512                    if let Some(arg) = self.read_group_raw() {
1513                        raw.push('{');
1514                        raw.push_str(&arg);
1515                        raw.push('}');
1516                    } else {
1517                        break;
1518                    }
1519                }
1520                _ => break,
1521            }
1522        }
1523        raw
1524    }
1525
1526    fn read_citation(&mut self, name: &str, out: &mut Vec<Inline>) {
1527        // With one bracketed argument it is the note that follows the key; with two, the first
1528        // precedes the key and the second follows it.
1529        let opt1 = self.read_optional_raw();
1530        let opt2 = self.read_optional_raw();
1531        let keys_raw = self.read_group_raw().unwrap_or_default();
1532        let (prefix_raw, suffix_raw) = match (&opt1, &opt2) {
1533            (Some(pre), Some(post)) => (Some(pre.as_str()), Some(post.as_str())),
1534            (Some(post), None) => (None, Some(post.as_str())),
1535            _ => (None, None),
1536        };
1537        let prefix = prefix_raw
1538            .map(|s| self.parse_fragment(s))
1539            .unwrap_or_default();
1540        let suffix = suffix_raw
1541            .map(|s| self.parse_fragment(s))
1542            .unwrap_or_default();
1543        let mode = if matches!(name, "citet" | "textcite" | "citeauthor") {
1544            CitationMode::AuthorInText
1545        } else {
1546            CitationMode::NormalCitation
1547        };
1548        let mut citations = Vec::new();
1549        for key in keys_raw.split(',') {
1550            let key = key.trim();
1551            if key.is_empty() {
1552                continue;
1553            }
1554            citations.push(Citation {
1555                id: key.into(),
1556                prefix: Vec::new(),
1557                suffix: Vec::new(),
1558                mode: mode.clone(),
1559                note_num: 0,
1560                hash: 0,
1561            });
1562        }
1563        if citations.is_empty() {
1564            return;
1565        }
1566        if let Some(first) = citations.first_mut() {
1567            first.prefix = prefix;
1568        }
1569        if let Some(last) = citations.last_mut() {
1570            last.suffix = suffix;
1571        }
1572        let mut raw = format!("\\{name}");
1573        for opt in [&opt1, &opt2].into_iter().flatten() {
1574            raw.push('[');
1575            raw.push_str(opt);
1576            raw.push(']');
1577        }
1578        raw.push('{');
1579        raw.push_str(&keys_raw);
1580        raw.push('}');
1581        out.push(Inline::Cite(
1582            citations,
1583            vec![Inline::RawInline(Format("latex".into()), raw.into())],
1584        ));
1585    }
1586
1587    /// Reads `\verb<delim>…<delim>` (or `\verb*…`) verbatim.
1588    fn read_verb(&mut self) -> Option<String> {
1589        if self.cur() == Some('*') {
1590            self.bump();
1591        }
1592        let delim = self.bump()?;
1593        let mut code = String::new();
1594        while let Some(c) = self.cur() {
1595            self.bump();
1596            if c == delim {
1597                break;
1598            }
1599            code.push(c);
1600        }
1601        Some(code)
1602    }
1603
1604    /// Reads a braced group as blocks (used for footnote bodies).
1605    /// A sub-parser over `source` that inherits the shared context (extensions, smart mode, macro
1606    /// table, section base level, expansion depth) but starts with fresh cursor and output state
1607    /// (metadata and heading ids). It never inherits float context.
1608    fn child(&self, source: &str, in_figure: bool) -> Parser {
1609        Parser {
1610            frames: vec![Frame {
1611                chars: source.chars().collect(),
1612                pos: 0,
1613            }],
1614            ext: self.ext,
1615            smart: self.smart,
1616            meta: BTreeMap::new(),
1617            macros: Rc::clone(&self.macros),
1618            ids: IdRegistry::default(),
1619            base_level: self.base_level,
1620            in_figure,
1621            in_float: false,
1622            expand_depth: self.expand_depth,
1623            total_expansions: 0,
1624            last_ws_had_newline: false,
1625        }
1626    }
1627
1628    fn parse_group_blocks(&mut self) -> Vec<Block> {
1629        if self.cur() != Some('{') {
1630            return Vec::new();
1631        }
1632        // Slice out the balanced group and parse it as a sub-document so paragraph breaks work.
1633        let source = self.read_group_raw().unwrap_or_default();
1634        let mut sub = self.child(&source, self.in_figure);
1635        sub.parse_blocks(&Stop::Eof)
1636    }
1637
1638    fn parse_group_inlines(&mut self) -> Vec<Inline> {
1639        if self.cur() != Some('{') {
1640            return Vec::new();
1641        }
1642        self.bump();
1643        let inner = self.parse_inlines(InlineStop::Group);
1644        if self.cur() == Some('}') {
1645            self.bump();
1646        }
1647        inner
1648    }
1649
1650    // --- Math ------------------------------------------------------------------------------------
1651
1652    fn read_dollar_math(&mut self) -> Inline {
1653        if self.cur() == Some('$') && self.at(1) == Some('$') {
1654            self.bump();
1655            self.bump();
1656            Inline::Math(MathType::DisplayMath, self.read_math_body("$$").into())
1657        } else {
1658            self.bump();
1659            Inline::Math(MathType::InlineMath, self.read_math_body("$").into())
1660        }
1661    }
1662
1663    /// Reads math source up to and consuming `close`, then trims surrounding whitespace.
1664    fn read_math_body(&mut self, close: &str) -> String {
1665        let mut text = String::new();
1666        while !self.eof() {
1667            if self.looking_at(close) {
1668                self.advance_chars(close.chars().count());
1669                break;
1670            }
1671            // A backslash escape keeps its following character, so `\$` does not end `$` math.
1672            if self.cur() == Some('\\') {
1673                if let Some(c) = self.bump() {
1674                    text.push(c);
1675                }
1676                if let Some(c) = self.bump() {
1677                    text.push(c);
1678                }
1679                continue;
1680            }
1681            if let Some(c) = self.bump() {
1682                text.push(c);
1683            }
1684        }
1685        text.trim().to_owned()
1686    }
1687
1688    // --- Accents & arguments ---------------------------------------------------------------------
1689
1690    fn read_accent_symbol(&mut self, accent: Accent, buf: &mut String) {
1691        let base = self.read_accent_argument();
1692        buf.push_str(&apply_accent(accent, base.as_deref()));
1693    }
1694
1695    /// Reads an accent's argument: a braced group, a control sequence (e.g. `\i` for a dotless i), or
1696    /// the next single character. The result is the accent's base text.
1697    fn read_accent_argument(&mut self) -> Option<String> {
1698        while matches!(self.cur(), Some(' ' | '\t')) {
1699            self.bump();
1700        }
1701        match self.cur() {
1702            Some('{') => {
1703                let raw = self.read_group_raw().unwrap_or_default();
1704                Some(resolve_accent_base(raw.trim()))
1705            }
1706            Some('\\') if self.at(1).is_some_and(|c| c.is_ascii_alphabetic()) => {
1707                let word = self.consume_control_word();
1708                Some(symbol_text(&word).map_or(word.clone(), str::to_owned))
1709            }
1710            Some('\\') => {
1711                self.bump();
1712                self.bump().map(|c| c.to_string())
1713            }
1714            _ => self.bump().map(|c| c.to_string()),
1715        }
1716    }
1717
1718    fn read_group_raw(&mut self) -> Option<String> {
1719        while matches!(self.cur(), Some(' ' | '\t')) {
1720            self.bump();
1721        }
1722        if self.cur() != Some('{') {
1723            return None;
1724        }
1725        self.bump();
1726        let mut depth = 1;
1727        let mut s = String::new();
1728        while let Some(c) = self.cur() {
1729            match c {
1730                '{' => {
1731                    depth += 1;
1732                    s.push(c);
1733                    self.bump();
1734                }
1735                '}' => {
1736                    depth -= 1;
1737                    self.bump();
1738                    if depth == 0 {
1739                        break;
1740                    }
1741                    s.push('}');
1742                }
1743                '\\' => {
1744                    s.push(c);
1745                    self.bump();
1746                    if let Some(n) = self.cur() {
1747                        s.push(n);
1748                        self.bump();
1749                    }
1750                }
1751                _ => {
1752                    s.push(c);
1753                    self.bump();
1754                }
1755            }
1756        }
1757        Some(s)
1758    }
1759
1760    fn read_optional_raw(&mut self) -> Option<String> {
1761        if self.cur() != Some('[') {
1762            return None;
1763        }
1764        self.bump();
1765        let mut depth = 0;
1766        let mut s = String::new();
1767        while let Some(c) = self.cur() {
1768            match c {
1769                '{' => {
1770                    depth += 1;
1771                    s.push(c);
1772                    self.bump();
1773                }
1774                '}' => {
1775                    if depth > 0 {
1776                        depth -= 1;
1777                    }
1778                    s.push(c);
1779                    self.bump();
1780                }
1781                ']' if depth == 0 => {
1782                    self.bump();
1783                    break;
1784                }
1785                _ => {
1786                    s.push(c);
1787                    self.bump();
1788                }
1789            }
1790        }
1791        Some(s)
1792    }
1793
1794    fn read_optional_inlines(&mut self) -> Option<Vec<Inline>> {
1795        if self.cur() != Some('[') {
1796            return None;
1797        }
1798        self.bump();
1799        let inner = self.parse_inlines(InlineStop::Bracket);
1800        if self.cur() == Some(']') {
1801            self.bump();
1802        }
1803        Some(inner)
1804    }
1805
1806    /// Consumes the arguments of a command whose output is dropped: any optional `[…]` groups
1807    /// followed by the number of braced groups the command name is known to take.
1808    fn skip_command_args(&mut self, name: &str) {
1809        while self.cur() == Some('[') {
1810            let _ = self.read_optional_raw();
1811        }
1812        for _ in 0..command_arg_count(name) {
1813            while self.cur() == Some('[') {
1814                let _ = self.read_optional_raw();
1815            }
1816            if self.read_group_raw().is_none() {
1817                break;
1818            }
1819        }
1820    }
1821
1822    /// Consumes the optional and braced argument groups directly following a command, stopping at the
1823    /// first space or other token. Used to swallow an unknown command's arguments.
1824    fn skip_adjacent_arguments(&mut self) {
1825        loop {
1826            match self.cur() {
1827                Some('[') => {
1828                    if self.read_optional_raw().is_none() {
1829                        break;
1830                    }
1831                }
1832                Some('{') => {
1833                    if self.read_group_raw().is_none() {
1834                        break;
1835                    }
1836                }
1837                _ => break,
1838            }
1839        }
1840    }
1841
1842    /// Captures a `\title`/`\author`/`\date`-family command's argument as document metadata.
1843    fn capture_meta(&mut self, name: &str) {
1844        let _ = self.read_optional_raw();
1845        if name == "author" {
1846            // Authors are `\and`-separated and stored as a list of inline sequences, one per author.
1847            let raw = self.read_group_raw().unwrap_or_default();
1848            let authors: Vec<MetaValue> = split_on_command(&raw, "and")
1849                .into_iter()
1850                .filter(|part| !part.trim().is_empty())
1851                .map(|part| MetaValue::MetaInlines(self.parse_fragment(part.trim())))
1852                .collect();
1853            self.meta
1854                .insert("author".to_owned(), MetaValue::MetaList(authors));
1855            return;
1856        }
1857        let inlines = self.parse_group_inlines();
1858        self.meta
1859            .insert(name.to_owned(), MetaValue::MetaInlines(inlines));
1860    }
1861
1862    /// Parses a self-contained fragment of LaTeX source into inlines with a fresh sub-parser.
1863    fn parse_fragment(&self, source: &str) -> Vec<Inline> {
1864        parse_cell_inlines(self, source)
1865    }
1866
1867    // --- Macros ----------------------------------------------------------------------------------
1868
1869    /// Parses a macro definition. With macro expansion enabled the definition is recorded for later
1870    /// expansion and contributes no block; with it disabled the definition is left in the output as a
1871    /// raw LaTeX block, preserving its source verbatim.
1872    fn parse_macro_definition(&mut self, name: &str) -> Vec<Block> {
1873        // The verbatim-capture branch below runs only with `Extension::LatexMacros` disabled, when no
1874        // expansion frame is ever pushed, so exactly one frame is live throughout this call and `start`
1875        // indexes the same buffer as the final position.
1876        let start = self.frames.last().map_or(0, |frame| frame.pos);
1877        self.consume_control_word();
1878        if self.cur() == Some('*') {
1879            self.bump();
1880        }
1881        if name == "def" {
1882            self.parse_def();
1883        } else if name == "let" {
1884            // `\let\a\b` and `\let\a=\b` bind one name to another; the operands are consumed but the
1885            // binding itself is not modelled.
1886            let _ = self.take_defined_name();
1887            if self.cur() == Some('=') {
1888                self.bump();
1889            }
1890            if self.peek_control_word().is_some() {
1891                self.consume_control_word();
1892            }
1893        } else if let Some(macro_name) = self.take_defined_name() {
1894            let args = self
1895                .read_optional_raw()
1896                .and_then(|s| s.trim().parse::<usize>().ok())
1897                .unwrap_or(0);
1898            let optional_default = self.read_optional_raw();
1899            let body = self.read_group_raw().unwrap_or_default();
1900            Rc::make_mut(&mut self.macros).insert(
1901                macro_name,
1902                Macro {
1903                    args,
1904                    optional_default,
1905                    body,
1906                },
1907            );
1908        }
1909        if self.ext.contains(Extension::LatexMacros) {
1910            return Vec::new();
1911        }
1912        let raw: String = self
1913            .frames
1914            .last()
1915            .and_then(|frame| frame.chars.get(start..frame.pos))
1916            .unwrap_or_default()
1917            .iter()
1918            .collect();
1919        vec![Block::RawBlock(Format("latex".into()), raw.into())]
1920    }
1921
1922    /// Reads a `\newcommand`-style target name, whether written `{\name}` or bare `\name`.
1923    fn take_defined_name(&mut self) -> Option<String> {
1924        while matches!(self.cur(), Some(' ' | '\t')) {
1925            self.bump();
1926        }
1927        if self.cur() == Some('{') {
1928            let raw = self.read_group_raw()?;
1929            Some(raw.trim().trim_start_matches('\\').to_owned())
1930        } else if self.cur() == Some('\\') {
1931            Some(self.consume_control_word())
1932        } else {
1933            None
1934        }
1935    }
1936
1937    fn parse_def(&mut self) {
1938        let Some(macro_name) = self.take_defined_name() else {
1939            return;
1940        };
1941        // Skip a simple parameter text (e.g. `#1#2`) up to the opening brace.
1942        let mut args = 0usize;
1943        while let Some(c) = self.cur() {
1944            if c == '{' {
1945                break;
1946            }
1947            if c == '#' {
1948                args += 1;
1949                self.bump();
1950                self.bump();
1951            } else {
1952                self.bump();
1953            }
1954        }
1955        let body = self.read_group_raw().unwrap_or_default();
1956        Rc::make_mut(&mut self.macros).insert(
1957            macro_name,
1958            Macro {
1959                args,
1960                optional_default: None,
1961                body,
1962            },
1963        );
1964    }
1965
1966    /// If the cursor is at a user macro invocation, pushes its expansion as a new input frame for the
1967    /// cursor to read next. Returns whether an expansion occurred.
1968    fn try_expand_macro(&mut self) -> bool {
1969        if !self.ext.contains(Extension::LatexMacros)
1970            || self.expand_depth >= MAX_EXPAND_DEPTH
1971            || self.total_expansions >= MAX_TOTAL_EXPANSIONS
1972        {
1973            return false;
1974        }
1975        let Some(name) = self.peek_control_word() else {
1976            return false;
1977        };
1978        let macros = Rc::clone(&self.macros);
1979        let Some(mac) = macros.get(&name) else {
1980            return false;
1981        };
1982        self.consume_control_word();
1983        let mut args = Vec::new();
1984        let mut mandatory = mac.args;
1985        if let Some(default) = &mac.optional_default {
1986            let first = if self.cur() == Some('[') {
1987                self.read_optional_raw().unwrap_or_default()
1988            } else {
1989                default.clone()
1990            };
1991            args.push(first);
1992            mandatory = mandatory.saturating_sub(1);
1993        }
1994        for _ in 0..mandatory {
1995            match self.read_macro_arg() {
1996                Some(a) => args.push(a),
1997                None => args.push(String::new()),
1998            }
1999        }
2000        // Arguments are consumed from the stream before the expansion frame is pushed, so `#n`
2001        // captures the invocation's own arguments and the cursor resumes at the source position where
2002        // consumption stopped once the expansion frame is exhausted.
2003        let expanded = substitute_macro(&mac.body, &args);
2004        if !expanded.is_empty() {
2005            self.frames.push(Frame {
2006                chars: expanded.chars().collect(),
2007                pos: 0,
2008            });
2009            self.expand_depth += 1;
2010        }
2011        self.total_expansions += 1;
2012        true
2013    }
2014
2015    /// Reads a single macro argument: a braced group, or the next single token.
2016    fn read_macro_arg(&mut self) -> Option<String> {
2017        while matches!(self.cur(), Some(' ' | '\t' | '\n')) {
2018            self.bump();
2019        }
2020        if self.cur() == Some('{') {
2021            self.read_group_raw()
2022        } else if self.cur() == Some('\\') {
2023            Some(format!("\\{}", self.consume_control_word()))
2024        } else {
2025            self.bump().map(|c| c.to_string())
2026        }
2027    }
2028}
2029
2030// --- Free helpers --------------------------------------------------------------------------------
2031
2032/// Appends the accumulated text buffer as a single [`Inline::Str`] and clears it.
2033fn flush_buf(buf: &mut String, out: &mut Vec<Inline>) {
2034    if !buf.is_empty() {
2035        out.push(Inline::Str(std::mem::take(buf).into()));
2036    }
2037}
2038
2039/// Flushes the pending text buffer, then appends an inline — so buffered text stays ahead of it.
2040fn emit(out: &mut Vec<Inline>, buf: &mut String, inline: Inline) {
2041    flush_buf(buf, out);
2042    out.push(inline);
2043}
2044
2045/// Flushes the pending text buffer, then appends a sequence of inlines.
2046fn emit_all(out: &mut Vec<Inline>, buf: &mut String, inlines: Vec<Inline>) {
2047    flush_buf(buf, out);
2048    out.extend(inlines);
2049}
2050
2051/// Appends a whitespace break, coalescing runs of spacing (which arise around dropped commands)
2052/// into a single break, with a soft line break taking precedence over a plain space.
2053fn push_whitespace(out: &mut Vec<Inline>, ws: Inline) {
2054    match out.last() {
2055        // A trailing plain space is promoted when the new break is soft.
2056        Some(Inline::Space) if matches!(ws, Inline::SoftBreak) => {
2057            if let Some(last) = out.last_mut() {
2058                *last = Inline::SoftBreak;
2059            }
2060        }
2061        // Any existing break or space already separates; further spacing is swallowed.
2062        Some(Inline::LineBreak | Inline::SoftBreak | Inline::Space) => {}
2063        _ => out.push(ws),
2064    }
2065}
2066
2067/// Drops leading and trailing spacing inlines from a paragraph's content.
2068fn trim_inlines(mut inlines: Vec<Inline>) -> Vec<Inline> {
2069    while matches!(inlines.first(), Some(Inline::Space | Inline::SoftBreak)) {
2070        inlines.remove(0);
2071    }
2072    while matches!(inlines.last(), Some(Inline::Space | Inline::SoftBreak)) {
2073        inlines.pop();
2074    }
2075    inlines
2076}
2077
2078/// The intrinsic nesting level of a sectioning command, before the document-wide level offset:
2079/// `\part` is -1, `\chapter` 0, `\section` 1, and so on down to `\subparagraph` at 5.
2080fn section_intrinsic(name: &str) -> Option<i32> {
2081    match name {
2082        "part" => Some(-1),
2083        "chapter" => Some(0),
2084        "section" => Some(1),
2085        "subsection" => Some(2),
2086        "subsubsection" => Some(3),
2087        "paragraph" => Some(4),
2088        "subparagraph" => Some(5),
2089        _ => None,
2090    }
2091}
2092
2093/// Whether `env` typesets mathematics and so is inline content rather than a block environment.
2094fn math_env(env: &str) -> bool {
2095    matches!(
2096        env,
2097        "math"
2098            | "displaymath"
2099            | "equation"
2100            | "equation*"
2101            | "align"
2102            | "align*"
2103            | "alignat"
2104            | "alignat*"
2105            | "gather"
2106            | "gather*"
2107            | "multline"
2108            | "multline*"
2109            | "flalign"
2110            | "flalign*"
2111            | "eqnarray"
2112            | "eqnarray*"
2113            | "split"
2114            | "cases"
2115    )
2116}
2117
2118/// The formatter that wraps a braced group's inlines, for the simple font/emphasis commands.
2119fn inline_wrapper(name: &str) -> Option<fn(Vec<Inline>) -> Inline> {
2120    match name {
2121        "emph" | "textit" | "textsl" | "italic" | "emphasize" => Some(Inline::Emph),
2122        "textbf" | "strong" => Some(Inline::Strong),
2123        "underline" | "uline" => Some(Inline::Underline),
2124        "textsc" => Some(Inline::SmallCaps),
2125        "sout" | "st" => Some(Inline::Strikeout),
2126        _ => None,
2127    }
2128}
2129
2130/// The CSS class a font-family/shape/series command wraps its argument in, or `None` for a command
2131/// that is not one of these span-producing font switches.
2132fn font_span_class(name: &str) -> Option<&'static str> {
2133    match name {
2134        "textrm" | "textnormal" => Some("roman"),
2135        "textsf" => Some("sans-serif"),
2136        "textup" => Some("upright"),
2137        "textmd" => Some("medium"),
2138        _ => None,
2139    }
2140}
2141
2142/// Wraps inlines in a formatter, but lifts leading and trailing spacing out of the wrapper so it
2143/// stays between words rather than inside the formatted run.
2144fn extract_spaces<F: FnOnce(Vec<Inline>) -> Inline>(
2145    mut inner: Vec<Inline>,
2146    wrap: F,
2147) -> Vec<Inline> {
2148    let leading =
2149        matches!(inner.first(), Some(Inline::Space | Inline::SoftBreak)).then(|| inner.remove(0));
2150    let trailing = if matches!(inner.last(), Some(Inline::Space | Inline::SoftBreak)) {
2151        inner.pop()
2152    } else {
2153        None
2154    };
2155    let mut result = Vec::new();
2156    result.extend(leading);
2157    result.push(wrap(inner));
2158    result.extend(trailing);
2159    result
2160}
2161
2162/// Wraps inlines in a span carrying a single class.
2163fn span_class(inlines: Vec<Inline>, class: &str) -> Inline {
2164    Inline::Span(
2165        Box::new(Attr {
2166            id: carta_ast::Text::default(),
2167            classes: vec![class.into()],
2168            attributes: Vec::new(),
2169        }),
2170        inlines,
2171    )
2172}
2173
2174/// A cross-reference command becomes a link to the anchor, showing the raw target text.
2175fn reference_link(name: &str, target: &str) -> Inline {
2176    let kind = match name {
2177        "autoref" | "cref" => "ref+label",
2178        "Cref" => "ref+Label",
2179        other => other,
2180    };
2181    Inline::Link(
2182        Box::new(Attr {
2183            id: carta_ast::Text::default(),
2184            classes: Vec::new(),
2185            attributes: vec![
2186                ("reference-type".into(), kind.into()),
2187                ("reference".into(), target.into()),
2188            ],
2189        }),
2190        vec![Inline::Str(format!("[{target}]").into())],
2191        Box::new(Target {
2192            url: format!("#{target}").into(),
2193            title: carta_ast::Text::default(),
2194        }),
2195    )
2196}
2197
2198/// A font-switch command that formats the remainder of its enclosing group.
2199#[derive(Clone, Copy)]
2200enum Switch {
2201    Strong,
2202    Emph,
2203    SmallCaps,
2204    Code,
2205}
2206
2207impl Switch {
2208    fn wrap(self, inner: Vec<Inline>) -> Inline {
2209        match self {
2210            Switch::Strong => Inline::Strong(inner),
2211            Switch::Emph => Inline::Emph(inner),
2212            Switch::SmallCaps => Inline::SmallCaps(inner),
2213            Switch::Code => Inline::Code(Box::default(), to_plain_text(&inner).into()),
2214        }
2215    }
2216}
2217
2218fn switch_kind(name: &str) -> Option<Switch> {
2219    Some(match name {
2220        "bf" | "bfseries" => Switch::Strong,
2221        "it" | "itshape" | "em" | "sl" | "slshape" => Switch::Emph,
2222        "scshape" => Switch::SmallCaps,
2223        "tt" => Switch::Code,
2224        _ => return None,
2225    })
2226}
2227
2228/// Wraps a bare brace group's content in a null-attribute span to preserve its grouping. An empty
2229/// group vanishes; a group that already is a single null-attribute span is not wrapped again.
2230fn group_span(inner: Vec<Inline>) -> Option<Inline> {
2231    match inner.first() {
2232        None => None,
2233        Some(Inline::Span(attr, _))
2234            if inner.len() == 1
2235                && attr.id.is_empty()
2236                && attr.classes.is_empty()
2237                && attr.attributes.is_empty() =>
2238        {
2239            inner.into_iter().next()
2240        }
2241        _ => Some(Inline::Span(Box::default(), inner)),
2242    }
2243}
2244
2245/// A combining diacritic requested by an accent command.
2246#[derive(Clone, Copy)]
2247enum Accent {
2248    Acute,
2249    Grave,
2250    Circumflex,
2251    Tilde,
2252    Diaeresis,
2253    Macron,
2254    DotAbove,
2255    Cedilla,
2256    Caron,
2257    Breve,
2258    DoubleAcute,
2259    Ring,
2260    Ogonek,
2261}
2262
2263/// The accent for a control-word accent command (`\c`, `\v`, `\u`, `\H`, `\r`, `\k`). The
2264/// control-symbol accents (`\'`, `` \` ``, `\^`, …) are dispatched separately.
2265fn word_accent(name: &str) -> Option<Accent> {
2266    match name {
2267        "c" => Some(Accent::Cedilla),
2268        "v" => Some(Accent::Caron),
2269        "u" => Some(Accent::Breve),
2270        "H" => Some(Accent::DoubleAcute),
2271        "r" => Some(Accent::Ring),
2272        "k" => Some(Accent::Ogonek),
2273        _ => None,
2274    }
2275}
2276
2277/// The standalone combining mark for an accent, used when no precomposed character exists.
2278fn combining_mark(accent: Accent) -> char {
2279    match accent {
2280        Accent::Acute => '\u{301}',
2281        Accent::Grave => '\u{300}',
2282        Accent::Circumflex => '\u{302}',
2283        Accent::Tilde => '\u{303}',
2284        Accent::Diaeresis => '\u{308}',
2285        Accent::Macron => '\u{304}',
2286        Accent::DotAbove => '\u{307}',
2287        Accent::Cedilla => '\u{327}',
2288        Accent::Caron => '\u{30c}',
2289        Accent::Breve => '\u{306}',
2290        Accent::DoubleAcute => '\u{30b}',
2291        Accent::Ring => '\u{30a}',
2292        Accent::Ogonek => '\u{328}',
2293    }
2294}
2295
2296/// Resolves an accent's braced argument to its base text: a control sequence such as `\i` becomes its
2297/// glyph, and plain text is returned unchanged.
2298fn resolve_accent_base(raw: &str) -> String {
2299    if let Some(rest) = raw.strip_prefix('\\') {
2300        let word: String = rest.chars().take_while(char::is_ascii_alphabetic).collect();
2301        if !word.is_empty() {
2302            return symbol_text(&word).map_or(word, str::to_owned);
2303        }
2304    }
2305    raw.to_owned()
2306}
2307
2308/// Applies an accent to its base text, producing the precomposed character when one exists and
2309/// otherwise the base followed by the standalone combining mark. An empty argument yields nothing.
2310fn apply_accent(accent: Accent, base: Option<&str>) -> String {
2311    let base = base.unwrap_or("");
2312    let mut chars = base.chars();
2313    let Some(first) = chars.next() else {
2314        return String::new();
2315    };
2316    let rest: String = chars.collect();
2317    match combine_accent(accent, first) {
2318        Some(composed) => format!("{composed}{rest}"),
2319        None => format!("{first}{}{rest}", combining_mark(accent)),
2320    }
2321}
2322
2323/// The precomposed character for a base letter and accent, for the common Latin-1/Latin-A cases.
2324#[allow(clippy::too_many_lines)]
2325fn combine_accent(accent: Accent, base: char) -> Option<char> {
2326    let table: &[(char, char)] = match accent {
2327        Accent::Acute => &[
2328            ('a', 'á'),
2329            ('e', 'é'),
2330            ('i', 'í'),
2331            ('o', 'ó'),
2332            ('u', 'ú'),
2333            ('y', 'ý'),
2334            ('c', 'ć'),
2335            ('n', 'ń'),
2336            ('s', 'ś'),
2337            ('z', 'ź'),
2338            ('r', 'ŕ'),
2339            ('l', 'ĺ'),
2340            ('A', 'Á'),
2341            ('E', 'É'),
2342            ('I', 'Í'),
2343            ('O', 'Ó'),
2344            ('U', 'Ú'),
2345            ('Y', 'Ý'),
2346            ('C', 'Ć'),
2347            ('N', 'Ń'),
2348            ('S', 'Ś'),
2349            ('Z', 'Ź'),
2350        ],
2351        Accent::Grave => &[
2352            ('a', 'à'),
2353            ('e', 'è'),
2354            ('i', 'ì'),
2355            ('o', 'ò'),
2356            ('u', 'ù'),
2357            ('A', 'À'),
2358            ('E', 'È'),
2359            ('I', 'Ì'),
2360            ('O', 'Ò'),
2361            ('U', 'Ù'),
2362        ],
2363        Accent::Circumflex => &[
2364            ('a', 'â'),
2365            ('e', 'ê'),
2366            ('i', 'î'),
2367            ('o', 'ô'),
2368            ('u', 'û'),
2369            ('A', 'Â'),
2370            ('E', 'Ê'),
2371            ('I', 'Î'),
2372            ('O', 'Ô'),
2373            ('U', 'Û'),
2374        ],
2375        Accent::Tilde => &[
2376            ('a', 'ã'),
2377            ('o', 'õ'),
2378            ('n', 'ñ'),
2379            ('A', 'Ã'),
2380            ('O', 'Õ'),
2381            ('N', 'Ñ'),
2382        ],
2383        Accent::Diaeresis => &[
2384            ('a', 'ä'),
2385            ('e', 'ë'),
2386            ('i', 'ï'),
2387            ('o', 'ö'),
2388            ('u', 'ü'),
2389            ('y', 'ÿ'),
2390            ('A', 'Ä'),
2391            ('E', 'Ë'),
2392            ('I', 'Ï'),
2393            ('O', 'Ö'),
2394            ('U', 'Ü'),
2395        ],
2396        Accent::Macron => &[
2397            ('a', 'ā'),
2398            ('e', 'ē'),
2399            ('i', 'ī'),
2400            ('o', 'ō'),
2401            ('u', 'ū'),
2402            ('A', 'Ā'),
2403            ('E', 'Ē'),
2404            ('I', 'Ī'),
2405            ('O', 'Ō'),
2406            ('U', 'Ū'),
2407        ],
2408        Accent::DotAbove => &[('e', 'ė'), ('z', 'ż'), ('E', 'Ė'), ('Z', 'Ż')],
2409        Accent::Cedilla => &[
2410            ('c', 'ç'),
2411            ('s', 'ş'),
2412            ('t', 'ţ'),
2413            ('g', 'ģ'),
2414            ('C', 'Ç'),
2415            ('S', 'Ş'),
2416            ('T', 'Ţ'),
2417        ],
2418        Accent::Caron => &[
2419            ('c', 'č'),
2420            ('s', 'š'),
2421            ('z', 'ž'),
2422            ('r', 'ř'),
2423            ('e', 'ě'),
2424            ('n', 'ň'),
2425            ('d', 'ď'),
2426            ('t', 'ť'),
2427            ('l', 'ľ'),
2428            ('C', 'Č'),
2429            ('S', 'Š'),
2430            ('Z', 'Ž'),
2431            ('R', 'Ř'),
2432            ('E', 'Ě'),
2433            ('N', 'Ň'),
2434        ],
2435        Accent::Breve => &[
2436            ('a', 'ă'),
2437            ('e', 'ĕ'),
2438            ('g', 'ğ'),
2439            ('i', 'ĭ'),
2440            ('o', 'ŏ'),
2441            ('u', 'ŭ'),
2442            ('A', 'Ă'),
2443            ('G', 'Ğ'),
2444        ],
2445        Accent::DoubleAcute => &[('o', 'ő'), ('u', 'ű'), ('O', 'Ő'), ('U', 'Ű')],
2446        Accent::Ring => &[('a', 'å'), ('u', 'ů'), ('A', 'Å'), ('U', 'Ů')],
2447        Accent::Ogonek => &[
2448            ('a', 'ą'),
2449            ('e', 'ę'),
2450            ('i', 'į'),
2451            ('u', 'ų'),
2452            ('A', 'Ą'),
2453            ('E', 'Ę'),
2454        ],
2455    };
2456    table.iter().find(|(b, _)| *b == base).map(|(_, c)| *c)
2457}
2458
2459/// The literal text a symbol or named-glyph command produces.
2460fn symbol_text(name: &str) -> Option<&'static str> {
2461    let text = match name {
2462        "LaTeX" => "LaTeX",
2463        "TeX" => "TeX",
2464        "ldots" | "dots" => "\u{2026}",
2465        "textbackslash" => "\\",
2466        "textasciitilde" => "~",
2467        "textasciicircum" => "^",
2468        "textless" => "<",
2469        "textgreater" => ">",
2470        "textbullet" => "\u{2022}",
2471        "textquoteright" => "\u{2019}",
2472        "textquoteleft" => "\u{2018}",
2473        "textquotedblright" => "\u{201d}",
2474        "textquotedblleft" => "\u{201c}",
2475        "textregistered" => "\u{ae}",
2476        "textcopyright" | "copyright" => "\u{a9}",
2477        "textdegree" => "\u{b0}",
2478        "textdagger" => "\u{2020}",
2479        "S" | "textsection" => "\u{a7}",
2480        "P" | "textparagraph" => "\u{b6}",
2481        "pounds" | "textsterling" => "\u{a3}",
2482        "euro" => "\u{20ac}",
2483        "textyen" => "\u{a5}",
2484        "guillemotleft" => "\u{ab}",
2485        "guillemotright" => "\u{bb}",
2486        "aa" => "\u{e5}",
2487        "AA" => "\u{c5}",
2488        "ae" => "\u{e6}",
2489        "AE" => "\u{c6}",
2490        "oe" => "\u{153}",
2491        "OE" => "\u{152}",
2492        "o" => "\u{f8}",
2493        "O" => "\u{d8}",
2494        "ss" => "\u{df}",
2495        "l" => "\u{142}",
2496        "L" => "\u{141}",
2497        "i" => "\u{131}",
2498        "j" => "\u{237}",
2499        "textquotesingle" => "'",
2500        "textquotedbl" => "\"",
2501        "slash" => "/",
2502        "&" => "&",
2503        _ => return None,
2504    };
2505    Some(text)
2506}
2507
2508/// The number of trailing braced arguments a dropped command consumes.
2509fn command_arg_count(name: &str) -> usize {
2510    match name {
2511        "setlength" | "addtolength" | "setcounter" | "addtocounter" | "settowidth"
2512        | "definecolor" | "rule" | "newtheorem" => 2,
2513        "hspace" | "vspace" | "hskip" | "vskip" | "vphantom" | "hphantom" | "phantom"
2514        | "raisebox" | "pagestyle" | "thispagestyle" | "pagenumbering" | "documentclass"
2515        | "usepackage" | "RequirePackage" | "geometry" | "hypersetup" | "bibliographystyle"
2516        | "include" | "input" | "graphicspath" | "theoremstyle" | "captionsetup"
2517        | "bibliography" => 1,
2518        _ => 0,
2519    }
2520}
2521
2522/// Splits raw source on a `\name` control word at brace depth zero, returning the between-parts.
2523fn split_on_command(raw: &str, name: &str) -> Vec<String> {
2524    let marker: Vec<char> = format!("\\{name}").chars().collect();
2525    let chars: Vec<char> = raw.chars().collect();
2526    let mut parts = Vec::new();
2527    let mut current = String::new();
2528    let mut depth = 0i32;
2529    let mut i = 0;
2530    while let Some(&c) = chars.get(i) {
2531        match c {
2532            '{' => depth += 1,
2533            '}' => {
2534                if depth > 0 {
2535                    depth -= 1;
2536                }
2537            }
2538            '\\' if depth == 0 => {
2539                let matches_marker = marker
2540                    .iter()
2541                    .enumerate()
2542                    .all(|(k, m)| chars.get(i + k) == Some(m));
2543                let after = chars.get(i + marker.len());
2544                let is_word_boundary = after.is_none_or(|c| !c.is_ascii_alphabetic());
2545                if matches_marker && is_word_boundary {
2546                    parts.push(std::mem::take(&mut current));
2547                    i += marker.len();
2548                    continue;
2549                }
2550            }
2551            _ => {}
2552        }
2553        current.push(c);
2554        i += 1;
2555    }
2556    parts.push(current);
2557    parts
2558}
2559
2560/// Converts a paragraph made up solely of images (and spacing) into a plain block, matching how a
2561/// bare image line reads inside a figure.
2562fn demote_image_para(block: Block) -> Block {
2563    if let Block::Para(inlines) = &block {
2564        let has_image = inlines.iter().any(|i| matches!(i, Inline::Image(..)));
2565        let only_images = inlines.iter().all(|i| {
2566            matches!(
2567                i,
2568                Inline::Image(..) | Inline::Space | Inline::SoftBreak | Inline::LineBreak
2569            )
2570        });
2571        if has_image
2572            && only_images
2573            && let Block::Para(inlines) = block
2574        {
2575            return Block::Plain(inlines);
2576        }
2577    }
2578    block
2579}
2580
2581/// Substitutes `#1`…`#9` in a macro body with the given argument strings.
2582fn substitute_macro(body: &str, args: &[String]) -> String {
2583    let mut out = String::new();
2584    let mut chars = body.chars().peekable();
2585    while let Some(c) = chars.next() {
2586        if c == '#' {
2587            match chars.peek() {
2588                Some(d) if matches!(d, '1'..='9') => {
2589                    let idx = (*d as usize) - ('1' as usize);
2590                    chars.next();
2591                    if let Some(arg) = args.get(idx) {
2592                        out.push_str(arg);
2593                    }
2594                    continue;
2595                }
2596                _ => {}
2597            }
2598        }
2599        out.push(c);
2600    }
2601    out
2602}
2603
2604/// Removes a backslash before a character that LaTeX escapes in URLs.
2605fn unescape_url(url: &str) -> String {
2606    let mut out = String::new();
2607    let mut chars = url.chars();
2608    while let Some(c) = chars.next() {
2609        if c == '\\' {
2610            match chars.clone().next() {
2611                Some(n) if matches!(n, '%' | '#' | '_' | '&' | '{' | '}' | '$' | '~') => {
2612                    out.push(n);
2613                    chars.next();
2614                }
2615                _ => out.push('\\'),
2616            }
2617        } else {
2618            out.push(c);
2619        }
2620    }
2621    out
2622}
2623
2624/// Parses a `key=value, key, …` option list into ordered attribute pairs. A bare key gets an empty
2625/// value.
2626fn parse_key_values(text: &str) -> Vec<(String, String)> {
2627    let mut pairs = Vec::new();
2628    for part in split_top_level(text, ',') {
2629        let part = part.trim();
2630        if part.is_empty() {
2631            continue;
2632        }
2633        match part.split_once('=') {
2634            Some((k, v)) => pairs.push((k.trim().to_owned(), v.trim().to_owned())),
2635            None => pairs.push((part.to_owned(), String::new())),
2636        }
2637    }
2638    pairs
2639}
2640
2641/// Builds an image's attribute list from its bracketed options, keeping only the sizing keys and
2642/// expressing a fraction of the text block as a percentage.
2643fn image_attributes(opts: &str) -> Vec<(String, String)> {
2644    let mut attrs = Vec::new();
2645    for (key, value) in parse_key_values(opts) {
2646        if key != "width" && key != "height" {
2647            continue;
2648        }
2649        let value = latex_length_to_percent(&value).unwrap_or(value);
2650        attrs.push((key, value));
2651    }
2652    attrs
2653}
2654
2655/// Converts a length given as a fraction of the text block (`0.5\textwidth`) into a percentage
2656/// (`50%`). Returns `None` for absolute lengths or a value that lacks a leading digit.
2657fn latex_length_to_percent(value: &str) -> Option<String> {
2658    let value = value.trim();
2659    let number = ["\\textwidth", "\\linewidth", "\\textheight"]
2660        .into_iter()
2661        .find_map(|unit| value.strip_suffix(unit))?
2662        .trim();
2663    if !number.starts_with(|c: char| c.is_ascii_digit()) {
2664        return None;
2665    }
2666    let (int_part, frac_part) = number.split_once('.').unwrap_or((number, ""));
2667    if int_part.is_empty()
2668        || !int_part.bytes().all(|b| b.is_ascii_digit())
2669        || !frac_part.bytes().all(|b| b.is_ascii_digit())
2670    {
2671        return None;
2672    }
2673    // Multiply by 100 by shifting the decimal point two places to the right.
2674    let mut digits = format!("{int_part}{frac_part}");
2675    let point = int_part.len() + 2;
2676    while digits.len() < point {
2677        digits.push('0');
2678    }
2679    let (whole, frac) = digits.split_at_checked(point)?;
2680    let whole = whole.trim_start_matches('0');
2681    let whole = if whole.is_empty() { "0" } else { whole };
2682    let frac = frac.trim_end_matches('0');
2683    Some(if frac.is_empty() {
2684        format!("{whole}%")
2685    } else {
2686        format!("{whole}.{frac}%")
2687    })
2688}
2689
2690/// Splits `text` on `sep`, ignoring separators nested inside `{…}`.
2691fn split_top_level(text: &str, sep: char) -> Vec<String> {
2692    let mut parts = Vec::new();
2693    let mut depth = 0i32;
2694    let mut current = String::new();
2695    for c in text.chars() {
2696        match c {
2697            '{' => {
2698                depth += 1;
2699                current.push(c);
2700            }
2701            '}' => {
2702                if depth > 0 {
2703                    depth -= 1;
2704                }
2705                current.push(c);
2706            }
2707            _ if c == sep && depth == 0 => {
2708                parts.push(std::mem::take(&mut current));
2709            }
2710            _ => current.push(c),
2711        }
2712    }
2713    parts.push(current);
2714    parts
2715}
2716
2717/// Parses a tabular column specification into per-column alignments, skipping rules (`|`), inter-
2718/// column material (`@{…}`, `!{…}`, `>{…}`, `<{…}`), and paragraph-column widths.
2719fn parse_column_spec(spec: &str) -> Vec<Alignment> {
2720    let mut aligns = Vec::new();
2721    let chars: Vec<char> = spec.chars().collect();
2722    let mut i = 0;
2723    while let Some(&c) = chars.get(i) {
2724        match c {
2725            'l' => aligns.push(Alignment::AlignLeft),
2726            'r' => aligns.push(Alignment::AlignRight),
2727            'c' => aligns.push(Alignment::AlignCenter),
2728            'p' | 'm' | 'b' | 'X' => {
2729                aligns.push(Alignment::AlignLeft);
2730                i = skip_brace_group(&chars, i + 1);
2731                continue;
2732            }
2733            '@' | '!' | '>' | '<' => {
2734                i = skip_brace_group(&chars, i + 1);
2735                continue;
2736            }
2737            '*' => {
2738                // `*{n}{cols}` repetition is not expanded; its groups are skipped.
2739                i = skip_brace_group(&chars, i + 1);
2740                i = skip_brace_group(&chars, i);
2741                continue;
2742            }
2743            _ => {}
2744        }
2745        i += 1;
2746    }
2747    aligns
2748}
2749
2750/// Returns the index just past a `{…}` group starting at or after `start` (skipping leading spaces).
2751fn skip_brace_group(chars: &[char], start: usize) -> usize {
2752    let mut i = start;
2753    while matches!(chars.get(i), Some(' ')) {
2754        i += 1;
2755    }
2756    if chars.get(i) != Some(&'{') {
2757        return i;
2758    }
2759    let mut depth = 0i32;
2760    while let Some(&c) = chars.get(i) {
2761        match c {
2762            '{' => depth += 1,
2763            '}' => {
2764                depth -= 1;
2765                if depth == 0 {
2766                    return i + 1;
2767                }
2768            }
2769            _ => {}
2770        }
2771        i += 1;
2772    }
2773    i
2774}
2775
2776/// Builds a table from a column spec and the raw tabular body: rows are split on `\\`, cells on `&`.
2777/// The rows before the first interior horizontal rule become the header; the rest form one body.
2778fn build_table(parser: &Parser, aligns: &[Alignment], body: &str) -> Block {
2779    let col_specs: Vec<ColSpec> = aligns
2780        .iter()
2781        .map(|a| ColSpec {
2782            align: a.clone(),
2783            width: ColWidth::ColWidthDefault,
2784        })
2785        .collect();
2786    let ncols = col_specs.len();
2787
2788    // The first row becomes a header exactly when a horizontal rule immediately follows it.
2789    let mut rows: Vec<Row> = Vec::new();
2790    let mut rule_pending = false;
2791    let mut first_row_ruled = false;
2792    for chunk in split_top_level(body, '\n').join(" ").split("\\\\") {
2793        let (leading_rule, content) = strip_leading_rules(chunk);
2794        if content.trim().is_empty() {
2795            rule_pending |= leading_rule;
2796            continue;
2797        }
2798        rule_pending |= leading_rule;
2799        if rows.len() == 1 && rule_pending {
2800            first_row_ruled = true;
2801        }
2802        rule_pending = false;
2803        rows.push(build_row(parser, &strip_rules(&content), ncols));
2804    }
2805    // A rule trailing the sole row also makes that row a header.
2806    if rows.len() == 1 && rule_pending {
2807        first_row_ruled = true;
2808    }
2809
2810    let (head_rows, body_rows) = if first_row_ruled && !rows.is_empty() {
2811        let body = rows.split_off(1);
2812        (rows, body)
2813    } else {
2814        (Vec::new(), rows)
2815    };
2816
2817    let table = Table {
2818        attr: Attr::default(),
2819        caption: Caption::default(),
2820        col_specs,
2821        head: TableHead {
2822            attr: Attr::default(),
2823            rows: head_rows,
2824        },
2825        bodies: vec![TableBody {
2826            attr: Attr::default(),
2827            row_head_columns: 0,
2828            head: Vec::new(),
2829            body: body_rows,
2830        }],
2831        foot: TableFoot::default(),
2832    };
2833    Block::Table(Box::new(table))
2834}
2835
2836/// Builds a table row from raw cell source, splitting on `&` and padding to `ncols` columns.
2837/// A `\multicolumn{n}{align}{content}` field yields a single cell spanning `n` columns.
2838fn build_row(parser: &Parser, source: &str, ncols: usize) -> Row {
2839    let mut cells = Vec::new();
2840    let mut span_total: i32 = 0;
2841    for cell_src in split_top_level(source, '&') {
2842        let trimmed = cell_src.trim();
2843        let (align, col_span, content_src) = match parse_multicolumn(trimmed) {
2844            Some((n, align, content)) => (align, n, content),
2845            None => (Alignment::AlignDefault, 1, trimmed.to_owned()),
2846        };
2847        let inlines = parse_cell_inlines(parser, content_src.trim());
2848        let content = if inlines.is_empty() {
2849            Vec::new()
2850        } else {
2851            vec![Block::Plain(inlines)]
2852        };
2853        cells.push(Cell {
2854            attr: Attr::default(),
2855            align,
2856            row_span: 1,
2857            col_span,
2858            content,
2859        });
2860        span_total += col_span.max(1);
2861    }
2862    while span_total < i32::try_from(ncols).unwrap_or(i32::MAX) {
2863        cells.push(Cell {
2864            attr: Attr::default(),
2865            align: Alignment::AlignDefault,
2866            row_span: 1,
2867            col_span: 1,
2868            content: Vec::new(),
2869        });
2870        span_total += 1;
2871    }
2872    Row {
2873        attr: Attr::default(),
2874        cells,
2875    }
2876}
2877
2878/// Parses a `\multicolumn{n}{align}{content}` cell, returning the span, cell alignment, and the
2879/// raw content. Returns `None` when the field is not a multicolumn.
2880fn parse_multicolumn(src: &str) -> Option<(i32, Alignment, String)> {
2881    let rest = src.strip_prefix("\\multicolumn")?;
2882    let chars: Vec<char> = rest.chars().collect();
2883    let (span, next) = read_brace_group(&chars, 0)?;
2884    let (align_spec, next) = read_brace_group(&chars, next)?;
2885    let (content, _) = read_brace_group(&chars, next)?;
2886    let span: i32 = span.trim().parse().ok()?;
2887    if span < 1 {
2888        return None;
2889    }
2890    let align = parse_column_spec(&align_spec)
2891        .into_iter()
2892        .next()
2893        .unwrap_or(Alignment::AlignDefault);
2894    Some((span, align, content))
2895}
2896
2897/// Reads a balanced `{…}` group at or after `start` (skipping leading spaces), returning its inner
2898/// content and the index just past the closing brace.
2899fn read_brace_group(chars: &[char], start: usize) -> Option<(String, usize)> {
2900    let mut i = start;
2901    while matches!(chars.get(i), Some(' ')) {
2902        i += 1;
2903    }
2904    if chars.get(i) != Some(&'{') {
2905        return None;
2906    }
2907    let mut depth = 0i32;
2908    let mut content = String::new();
2909    while let Some(&c) = chars.get(i) {
2910        match c {
2911            '{' => {
2912                depth += 1;
2913                if depth > 1 {
2914                    content.push(c);
2915                }
2916            }
2917            '}' => {
2918                depth -= 1;
2919                if depth == 0 {
2920                    return Some((content, i + 1));
2921                }
2922                content.push(c);
2923            }
2924            _ => content.push(c),
2925        }
2926        i += 1;
2927    }
2928    None
2929}
2930
2931/// Strips leading whitespace and horizontal-rule commands from a row chunk, returning whether a
2932/// header-separating rule was present and the remaining content.
2933fn strip_leading_rules(chunk: &str) -> (bool, String) {
2934    let chars: Vec<char> = chunk.chars().collect();
2935    let mut i = 0;
2936    let mut header_boundary = false;
2937    loop {
2938        while matches!(chars.get(i), Some(c) if c.is_whitespace()) {
2939            i += 1;
2940        }
2941        match rule_command_at(&chars, i) {
2942            Some((end, header)) => {
2943                header_boundary |= header;
2944                i = end;
2945            }
2946            None => break,
2947        }
2948    }
2949    (
2950        header_boundary,
2951        chars.get(i..).unwrap_or(&[]).iter().collect(),
2952    )
2953}
2954
2955/// If a horizontal-rule command (`\hline`, `\toprule`, …) begins at `chars[start]`, returns the index
2956/// just past the command name and all its bracketed arguments, together with whether the rule marks a
2957/// header boundary. A dashed or custom rule (`\hdashline`, `\specialrule`) is removed from the source
2958/// but does not separate the header row from the body.
2959fn rule_command_at(chars: &[char], start: usize) -> Option<(usize, bool)> {
2960    if chars.get(start) != Some(&'\\') {
2961        return None;
2962    }
2963    let mut j = start + 1;
2964    let mut name = String::new();
2965    while let Some(&d) = chars.get(j) {
2966        if d.is_ascii_alphabetic() {
2967            name.push(d);
2968            j += 1;
2969        } else {
2970            break;
2971        }
2972    }
2973    if !is_rule_command(&name) {
2974        return None;
2975    }
2976    let header_boundary = !matches!(name.as_str(), "hdashline" | "specialrule");
2977    while matches!(chars.get(j), Some('{' | '[' | '(')) {
2978        j = skip_rule_argument(chars, j);
2979    }
2980    Some((j, header_boundary))
2981}
2982
2983fn is_rule_command(name: &str) -> bool {
2984    matches!(
2985        name,
2986        "hline"
2987            | "toprule"
2988            | "midrule"
2989            | "bottomrule"
2990            | "cmidrule"
2991            | "cline"
2992            | "hdashline"
2993            | "specialrule"
2994    )
2995}
2996
2997/// Returns the index just past a bracketed argument (`{…}`, `[…]`, or `(…)`) starting at `start`.
2998fn skip_rule_argument(chars: &[char], start: usize) -> usize {
2999    let close = match chars.get(start) {
3000        Some('{') => '}',
3001        Some('[') => ']',
3002        Some('(') => ')',
3003        _ => return start,
3004    };
3005    let mut i = start + 1;
3006    while let Some(&c) = chars.get(i) {
3007        i += 1;
3008        if c == close {
3009            break;
3010        }
3011    }
3012    i
3013}
3014
3015/// Parses a single table cell's source into inlines using a fresh sub-parser.
3016fn parse_cell_inlines(parser: &Parser, source: &str) -> Vec<Inline> {
3017    let mut sub = parser.child(source, false);
3018    trim_inlines(sub.parse_inlines(InlineStop::Paragraph))
3019}
3020
3021/// Removes horizontal-rule commands (`\hline`, `\toprule`, …, `\cline{…}`) from a table row source.
3022fn strip_rules(row: &str) -> String {
3023    let mut out = String::new();
3024    let chars: Vec<char> = row.chars().collect();
3025    let mut i = 0;
3026    while let Some(&c) = chars.get(i) {
3027        if let Some((end, _)) = rule_command_at(&chars, i) {
3028            i = end;
3029            continue;
3030        }
3031        out.push(c);
3032        i += 1;
3033    }
3034    out
3035}
3036
3037#[cfg(test)]
3038mod tests {
3039    use super::*;
3040
3041    /// Reader defaults for LaTeX: smart punctuation, header identifiers from title text, and macro
3042    /// expansion. Mirrors the format's default extension set so unit tests observe the same behavior
3043    /// as an ordinary conversion.
3044    fn latex_defaults() -> Extensions {
3045        Extensions::from_list(&[
3046            Extension::Smart,
3047            Extension::AutoIdentifiers,
3048            Extension::LatexMacros,
3049        ])
3050    }
3051
3052    fn parse(input: &str) -> Vec<Block> {
3053        parse_ext(input, latex_defaults())
3054    }
3055
3056    fn parse_ext(input: &str, extensions: Extensions) -> Vec<Block> {
3057        let mut options = ReaderOptions::default();
3058        options.extensions = extensions;
3059        LatexReader
3060            .read(input, &options)
3061            .expect("latex reader does not fail")
3062            .blocks
3063    }
3064
3065    fn attr(attributes: Vec<(String, String)>) -> Attr {
3066        Attr {
3067            id: carta_ast::Text::default(),
3068            classes: Vec::new(),
3069            attributes: attributes
3070                .into_iter()
3071                .map(|(k, v)| (k.into(), v.into()))
3072                .collect(),
3073        }
3074    }
3075
3076    // `#0` is not a parameter reference: only `#1`…`#9` are. A `#0` in a macro body is emitted
3077    // verbatim rather than triggering an out-of-range parameter lookup.
3078    #[test]
3079    fn substitute_macro_preserves_non_parameter_hash_zero() {
3080        let expanded = substitute_macro("a#0b", &["Y".to_owned()]);
3081        assert_eq!(expanded, "a#0b");
3082    }
3083
3084    // A numbered display environment is captured as display math whose body preserves the full
3085    // `\begin{env}` … `\end{env}` source, so downstream numbering markup survives untouched.
3086    #[test]
3087    fn equation_environment_is_display_math_with_verbatim_body() {
3088        let blocks = parse("\\begin{equation}\n  f(x) = x + 1\n\\end{equation}\n");
3089        assert_eq!(
3090            blocks,
3091            vec![Block::Para(vec![Inline::Math(
3092                MathType::DisplayMath,
3093                "\\begin{equation}\n  f(x) = x + 1\n\\end{equation}"
3094                    .to_owned()
3095                    .into(),
3096            )])],
3097        );
3098    }
3099
3100    // A cross-reference becomes a link to the target anchor whose visible text is the bracketed
3101    // label, tagged with the reference kind so a caller can recognize it. A `~` tie before the
3102    // command is a non-breaking space.
3103    #[test]
3104    fn ref_becomes_tagged_link_with_bracketed_label() {
3105        let blocks = parse("See Section~\\ref{sec:intro}.\n");
3106        assert_eq!(
3107            blocks,
3108            vec![Block::Para(vec![
3109                Inline::Str("See".to_owned().into()),
3110                Inline::Space,
3111                Inline::Str("Section\u{a0}".to_owned().into()),
3112                Inline::Link(
3113                    Box::new(attr(vec![
3114                        ("reference-type".to_owned(), "ref".to_owned()),
3115                        ("reference".to_owned(), "sec:intro".to_owned()),
3116                    ])),
3117                    vec![Inline::Str("[sec:intro]".to_owned().into())],
3118                    Box::new(Target {
3119                        url: "#sec:intro".to_owned().into(),
3120                        title: carta_ast::Text::default(),
3121                    }),
3122                ),
3123                Inline::Str(".".to_owned().into()),
3124            ])],
3125        );
3126    }
3127
3128    // The reference kind distinguishes the name-carrying variants: `\cref` and `\autoref` request a
3129    // lowercase label, `\Cref` an uppercase one.
3130    #[test]
3131    fn cref_variants_carry_their_reference_kind() {
3132        let kinds = |input: &str| match parse(input).into_iter().next() {
3133            Some(Block::Para(inlines)) => inlines
3134                .into_iter()
3135                .filter_map(|inline| match inline {
3136                    Inline::Link(attr, _, _) => attr
3137                        .attributes
3138                        .into_iter()
3139                        .find(|(k, _)| k == "reference-type")
3140                        .map(|(_, v)| v),
3141                    _ => None,
3142                })
3143                .collect::<Vec<_>>(),
3144            _ => Vec::new(),
3145        };
3146        assert_eq!(kinds("\\cref{a}"), vec!["ref+label".to_owned()]);
3147        assert_eq!(kinds("\\autoref{a}"), vec!["ref+label".to_owned()]);
3148        assert_eq!(kinds("\\Cref{a}"), vec!["ref+Label".to_owned()]);
3149    }
3150
3151    // A `width` given as a fraction of `\textwidth` is recorded as a percentage attribute on the
3152    // image; the default alt text is `image`.
3153    #[test]
3154    fn includegraphics_textwidth_fraction_becomes_percent_width() {
3155        let blocks = parse("x \\includegraphics[width=0.5\\textwidth]{diagram.png} y\n");
3156        assert_eq!(
3157            blocks,
3158            vec![Block::Para(vec![
3159                Inline::Str("x".to_owned().into()),
3160                Inline::Space,
3161                Inline::Image(
3162                    Box::new(attr(vec![("width".to_owned(), "50%".to_owned())])),
3163                    vec![Inline::Str("image".to_owned().into())],
3164                    Box::new(Target {
3165                        url: "diagram.png".to_owned().into(),
3166                        title: carta_ast::Text::default(),
3167                    }),
3168                ),
3169                Inline::Space,
3170                Inline::Str("y".to_owned().into()),
3171            ])],
3172        );
3173    }
3174
3175    // With macro expansion off, a definition is preserved verbatim as a raw block rather than
3176    // recorded for expansion, so no expansion silently changes the text.
3177    #[test]
3178    fn macro_definition_preserved_verbatim_when_expansion_disabled() {
3179        let ext = Extensions::from_list(&[Extension::Smart, Extension::AutoIdentifiers]);
3180        let blocks = parse_ext("\\newcommand{\\foo}{bar}\n", ext);
3181        assert_eq!(
3182            blocks,
3183            vec![Block::RawBlock(
3184                Format("latex".to_owned().into()),
3185                "\\newcommand{\\foo}{bar}".to_owned().into(),
3186            )],
3187        );
3188    }
3189
3190    /// The concatenated plain text of every paragraph a source parses to.
3191    fn plain_text(input: &str) -> String {
3192        parse(input)
3193            .iter()
3194            .filter_map(|block| match block {
3195                Block::Para(inlines) => Some(to_plain_text(inlines)),
3196                _ => None,
3197            })
3198            .collect::<Vec<_>>()
3199            .join("\n")
3200    }
3201
3202    #[test]
3203    fn simple_macro_expands_to_its_body() {
3204        assert_eq!(plain_text("\\newcommand{\\x}{Y}\n\n\\x\n"), "Y");
3205    }
3206
3207    #[test]
3208    fn macro_optional_argument_defaults_when_absent() {
3209        assert_eq!(
3210            plain_text("\\newcommand{\\x}[2][d]{#1#2}\n\n\\x{B}\n"),
3211            "dB"
3212        );
3213        assert_eq!(
3214            plain_text("\\newcommand{\\x}[2][d]{#1#2}\n\n\\x[A]{B}\n"),
3215            "AB",
3216        );
3217    }
3218
3219    #[test]
3220    fn macro_body_invoking_another_macro_expands_fully() {
3221        assert_eq!(
3222            plain_text("\\newcommand{\\a}{\\b}\n\\newcommand{\\b}{Z}\n\n\\a\n"),
3223            "Z",
3224        );
3225    }
3226
3227    // A long sequence of nested invocations all expand: the nesting depth is released after each
3228    // invocation, so it does not accumulate across the sequence and hit the nesting cap.
3229    #[test]
3230    fn nested_invocations_do_not_accumulate_depth() {
3231        let mut source = String::from("\\newcommand{\\a}{\\b}\n\\newcommand{\\b}{Z}\n\n");
3232        for _ in 0..300 {
3233            source.push_str("\\a ");
3234        }
3235        assert_eq!(plain_text(&source).matches('Z').count(), 300);
3236    }
3237
3238    // More than 200 sequential invocations all expand: expansion is not capped by a total count.
3239    #[test]
3240    fn many_sequential_invocations_all_expand() {
3241        let mut source = String::from("\\newcommand{\\hi}{Hello}\n\n");
3242        for _ in 0..300 {
3243            source.push_str("\\hi ");
3244        }
3245        assert_eq!(plain_text(&source).matches("Hello").count(), 300);
3246    }
3247
3248    // A self-recursive macro is stopped by the nesting-depth guard and returns without panicking.
3249    #[test]
3250    fn self_recursive_macro_terminates() {
3251        let _ = parse("\\newcommand{\\x}{\\x}\n\n\\x\n");
3252    }
3253
3254    // A macro whose expansion ends mid-construct is completed by the following source text: the
3255    // command reads its argument across the frame boundary, matching the flattened source.
3256    #[test]
3257    fn expansion_completed_by_following_source_matches_flattened() {
3258        assert_eq!(
3259            parse("\\newcommand{\\bo}{\\textbf}\n\n\\bo{word}\n"),
3260            parse("\\textbf{word}\n"),
3261        );
3262    }
3263
3264    // An expansion frame that empties right before `\end{...}` pops cleanly at the environment
3265    // boundary, matching the flattened source.
3266    #[test]
3267    fn expansion_ending_at_environment_boundary_matches_flattened() {
3268        assert_eq!(
3269            parse("\\newcommand{\\c}{content}\n\n\\begin{quote}\\c\\end{quote}\n"),
3270            parse("\\begin{quote}content\\end{quote}\n"),
3271        );
3272    }
3273}