From 472132b1585bef6bec6a296128f2ac72166034ca Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 19:44:55 +0100 Subject: [PATCH 1/4] math: refuse macro definitions and cap matrix cells RaTeX counts macro expansions (1,000) but not how large they grow, so a definition whose argument doubles on every call keeps the expander busy without bound. User macros have no use in an equation, so check_caps now refuses the definers (def, gdef, edef, xdef, let, futurelet, global, long, newcommand, renewcommand, providecommand) before RaTeX sees the source. The scan reads control words, verbatim text and comments the way RaTeX's lexer does. A matrix is laid out and written as rows times the widest row, so one long row in a tall matrix multiplied the output. check_caps now counts the padded cells of every environment from the source and refuses more than 2,500 in total; to_omml checks the parsed grid as well. Co-Authored-By: Claude Sonnet 5.5 --- crates/math/src/lib.rs | 232 ++++++++++++++++++++++++++++++++++++++-- crates/math/src/omml.rs | 3 + docs/decisions.md | 5 +- 3 files changed, 232 insertions(+), 8 deletions(-) diff --git a/crates/math/src/lib.rs b/crates/math/src/lib.rs index 5e1e99a41..e1f783dca 100644 --- a/crates/math/src/lib.rs +++ b/crates/math/src/lib.rs @@ -3,7 +3,7 @@ //! without calling RaTeX again. //! //! RaTeX is young (0.1.x), so it only ever runs inside this crate's entry points ([`layout`], -//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32) and runs under +//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32, no macro definitions, at most [`MAX_CELLS`] matrix cells) and runs under //! `catch_unwind`, so a stray panic becomes a [`MathError`]. See the RaTeX gate in //! `docs/decisions.md` and `tests/gate.rs`. //! @@ -33,6 +33,9 @@ pub const MAX_DEPTH: usize = 32; pub const MAX_EM: f32 = 10_000.0; /// Most items one equation may produce. pub const MAX_ITEMS: usize = 50_000; +/// Most matrix cells one equation may have, counting the empty cells that pad a short row to the +/// width of the widest one. A ragged matrix costs rows times the widest row, not what is typed. +pub const MAX_CELLS: usize = 2_500; /// The error for a source over [`MAX_SOURCE_BYTES`]. It is one shared value so that a cache can /// answer for such a source by its length alone, without reading it. @@ -223,20 +226,61 @@ fn parse(latex: &str) -> Result, MathError> { }) } -/// The size and nesting caps, checked before RaTeX sees the source. +/// Control words that define or rebind macros. User macros have no use in an equation, and +/// RaTeX caps only how many expansions run, not how large they grow, so a self-referencing +/// definition whose argument doubles on every call keeps the expander busy without bound. They are +/// refused before RaTeX sees the source. +const MACRO_DEFINERS: &[&str] = + &["def", "gdef", "edef", "xdef", "let", "futurelet", "global", "long", "newcommand", "renewcommand", "providecommand"]; + +/// One open `\begin…\end` environment, counted from the source text. +struct Grid { + /// Rows ended so far (by `\\` or `\cr`). + rows: usize, + /// Cells in the current row. + cells: usize, + /// Cells in the widest row so far. + widest: usize, +} + +impl Grid { + fn new() -> Grid { + Grid { rows: 0, cells: 1, widest: 0 } + } + fn end_row(&mut self) { + self.widest = self.widest.max(self.cells); + self.rows += 1; + self.cells = 1; + } + /// The padded grid RaTeX lays out and `to_omml` writes: every row as wide as the widest. + fn padded_cells(&self) -> usize { + (self.rows + 1).saturating_mul(self.widest.max(self.cells)) + } +} + +/// The size, nesting, macro and matrix caps, checked before RaTeX sees the source. fn check_caps(latex: &str) -> Result<(), MathError> { if latex.len() > MAX_SOURCE_BYTES { return Err(SOURCE_TOO_LONG.clone()); } + // `%` starts a comment, except inside the address of `\href` and `\url`, where RaTeX reads it + // as a character. Which one it is decides what the rest of the line means, so the two are not + // allowed together. + if latex.contains('%') && (latex.contains("\\href") || latex.contains("\\url")) { + return Err(MathError::new("A % can't be combined with a link in an equation.", latex.find('%').map(|i| i..i + 1))); + } let mut depth = 0usize; + let mut grids: Vec = Vec::new(); + let mut cells_total = 0usize; let mut chars = latex.char_indices().peekable(); while let Some((i, c)) = chars.next() { let open = match c { '\\' => { - // A control word, or an escaped character (`\{` is not a group). + // A control word (letters and `@`, as RaTeX's lexer reads it), or an escaped + // character (`\{` is not a group). let mut word = String::new(); while let Some(&(_, n)) = chars.peek() { - if n.is_ascii_alphabetic() { + if n.is_ascii_alphabetic() || n == '@' { word.push(n); chars.next(); } else { @@ -244,15 +288,69 @@ fn check_caps(latex: &str) -> Result<(), MathError> { } } if word.is_empty() { - chars.next(); + if let Some((_, n)) = chars.next() + && n == '\\' + && let Some(g) = grids.last_mut() + { + g.end_row(); + } continue; } + if MACRO_DEFINERS.contains(&word.as_str()) { + return Err(MathError::new("Macro definitions are not supported in equations.", Some(i..i + 1 + word.len()))); + } match word.as_str() { - "left" | "begin" => Some(true), - "right" | "end" => Some(false), + "verb" => { + // Verbatim text runs to the next copy of its delimiter, whatever it + // holds: no group, comment, row or definition starts inside it. + if chars.peek().is_some_and(|&(_, n)| n == '*') { + chars.next(); + } + if let Some((_, delim)) = chars.next() { + for (_, n) in chars.by_ref() { + if n == delim { + break; + } + } + } + None + } + "cr" => { + if let Some(g) = grids.last_mut() { + g.end_row(); + } + None + } + "begin" => { + if let Some(n) = alignat_columns(latex.get(i + 6..).unwrap_or_default()) + && n > MAX_ALIGNAT_PAIRS + { + return Err(too_many_cells(i..i + 6)); + } + grids.push(Grid::new()); + Some(true) + } + "end" => { + if let Some(g) = grids.pop() { + cells_total = cells_total.saturating_add(g.padded_cells()); + if cells_total > MAX_CELLS { + return Err(too_many_cells(i..i + 1 + word.len())); + } + } + Some(false) + } + "left" => Some(true), + "right" => Some(false), _ => None, } } + '&' | '@' => { + // `@` makes an arrow cell and an empty cell beside it in a `CD` diagram. + if let Some(g) = grids.last_mut() { + g.cells += if c == '@' { 2 } else { 1 }; + } + None + } '%' => { // A comment runs to the end of the line. for (_, n) in chars.by_ref() { @@ -277,9 +375,39 @@ fn check_caps(latex: &str) -> Result<(), MathError> { None => {} } } + // An environment left open is a syntax error for the parser to report, but it still counts. + for g in grids { + cells_total = cells_total.saturating_add(g.padded_cells()); + } + if cells_total > MAX_CELLS { + return Err(too_many_cells(latex.len()..latex.len())); + } Ok(()) } +/// Most column pairs `\begin{alignedat}{N}` may ask for. RaTeX allocates 2N column specs before +/// it lays anything out. +const MAX_ALIGNAT_PAIRS: usize = 50; + +/// N of `{alignat}{N}`, `{alignat*}{N}` or `{alignedat}{N}` at the start of `rest` (just after +/// `\begin`); `usize::MAX` when the count is present but too long to read. +fn alignat_columns(rest: &str) -> Option { + let rest = rest.trim_start().strip_prefix('{')?; + let (name, after) = rest.split_once('}')?; + if !matches!(name, "alignat" | "alignat*" | "alignedat") { + return None; + } + let digits = after.trim_start().strip_prefix('{')?.split('}').next()?.trim(); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some(digits.parse().unwrap_or(usize::MAX)) +} + +fn too_many_cells(span: Range) -> MathError { + MathError::new(format!("An equation can have at most {MAX_CELLS} matrix cells (rows times the widest row)."), Some(span)) +} + fn color(c: &Color) -> Option<[u8; 3]> { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; let rgb = [q(c.r), q(c.g), q(c.b)]; @@ -509,6 +637,96 @@ mod tests { } } + /// Runs `f` on its own thread and fails if it has not returned within `secs` seconds. + fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || { + let _ = tx.send(f()); + }) + .unwrap(); + match rx.recv_timeout(std::time::Duration::from_secs(secs)) { + Ok(v) => v, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("did not return within {secs} s"), + Err(e) => panic!("the worker thread died: {e}"), + } + } + + #[test] + fn macro_definitions_are_refused_before_the_expander_runs() { + let defs = [ + "\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\gdef\\a#1{\\a{#1#1#1#1}}\\a{x}", + "\\edef\\a#1{\\a{#1#1}}\\a{x}", + "\\xdef\\a#1{{#1}{#1}}\\a{x}", + "\\newcommand{\\a}[1]{\\a{#1#1}}\\a{x}", + "\\renewcommand{\\a}[1]{\\a{#1#1}}\\a{x}", + "\\providecommand{\\a}[1]{\\a{#1#1}}\\a{x}", + "\\let\\a\\frac \\a{1}{2}", + "\\futurelet\\a\\frac \\a{1}{2}", + "\\global\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\long\\def\\a#1{\\a{#1#1}}\\a{x}", + "{\\def\\a#1{\\a{#1#1}}}\\a{x}", + ]; + for src in defs { + // `check_caps` alone, so this cannot hang even if the refusal regresses. + let e = check_caps(src).unwrap_err(); + assert!(e.message.contains("Macro definitions"), "{src}: {e}"); + assert_eq!(e.span.as_ref().map(|r| &src[r.clone()]).map(|w| w.starts_with('\\')), Some(true)); + let s = src.to_string(); + let (c, l, o) = within(10, move || (check(&s).is_err(), layout(&s).is_err(), to_omml(&s, 11.0, None).is_err())); + assert!(c && l && o, "{src}"); + } + // Names that merely start with a definer, escaped backslashes and comments are not macros. + for ok in ["\\default", "\\letter", "\\\\def", "x % \\def\\a\n+1", "\\longrightarrow"] { + assert!(check_caps(ok).is_ok(), "{ok}"); + } + assert!(layout("\\longrightarrow x").is_ok()); + // Text the lexer reads differently from a plain scan must not hide a definition: a + // verbatim `%` is no comment, a `%` in a link address is no comment, `@` joins a word. + for src in [ + "\\verb|%|\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\verb\\x\\\\def\\a#1{\\a{#1#1}}\\a{x}", + "%\\verb|\n\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\href{a%b}{x}\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\url{a%}\\def\\a#1{\\a{#1#1}}\\a{x}", + ] { + assert!(check_caps(src).is_err(), "{src}"); + } + assert!(check_caps("\\verb|\\def| x").is_ok(), "verbatim text is not a definition"); + assert!(check_caps("\\def@x").is_ok(), "`\\def@x` is one unknown word to the lexer"); + } + + #[test] + fn ragged_matrices_are_capped_by_their_padded_size() { + let ragged = format!("\\begin{{matrix}}{}{}\\end{{matrix}}", "a\\\\".repeat(650), "a&".repeat(999) + "a"); + assert!(ragged.len() < MAX_SOURCE_BYTES); + let e = check_caps(&ragged).unwrap_err(); + assert!(e.message.contains("matrix cells"), "{e}"); + let r = ragged.clone(); + let (l, o) = within(10, move || (layout(&r).unwrap_err(), to_omml(&r, 11.0, None).unwrap_err())); + assert!(l.message.contains("matrix cells") && o.message.contains("matrix cells")); + // Without the environment words there is no grid to count; the parser rejects it. + // Ordinary and wide-but-regular matrices still work, and so does a trailing `\\`. + let wide = format!("\\begin{{pmatrix}}{}\\end{{pmatrix}}", vec!["a&".repeat(39) + "a"; 40].join("\\\\")); + assert!(wide.len() < MAX_SOURCE_BYTES); + assert!(layout(&wide).is_ok()); + assert!(to_omml(&wide, 11.0, None).is_ok()); + assert!(layout("\\begin{pmatrix} a & b \\\\ c & d \\\\ \\end{pmatrix}").is_ok()); + // Diagram arrows and `alignedat` column counts size a grid without any `&`. + let cd = format!("\\begin{{CD}}{}{}\\end{{CD}}", "A\\\\".repeat(900), "@>>>".repeat(300)); + assert!(cd.len() < MAX_SOURCE_BYTES); + assert!(check_caps(&cd).is_err_and(|e| e.message.contains("matrix cells"))); + let r = "\\begin{alignedat}{99999999}a\\end{alignedat}"; + assert!(check_caps(r).is_err_and(|e| e.message.contains("matrix cells"))); + assert!(check_caps("\\begin{alignedat}{2}a&b&c&d\\end{alignedat}").is_ok()); + assert!(layout("\\begin{CD}A @>f>> B \\\\ @VVV @VVV \\\\ C @>>> D\\end{CD}").is_ok()); + assert!(check_caps("\\operatorname{curl} 50\\%").is_ok()); + let two = format!("{0}{0}", "\\begin{matrix}a&b\\\\c&d\\end{matrix}"); + assert!(check_caps(&two).is_ok()); + } + #[test] fn an_injected_panic_is_an_error_not_a_crash() { INJECT_PANIC.with(|p| p.set(true)); diff --git a/crates/math/src/omml.rs b/crates/math/src/omml.rs index dc0141aed..7d5c66835 100644 --- a/crates/math/src/omml.rs +++ b/crates/math/src/omml.rs @@ -406,6 +406,9 @@ impl Writer { } ParseNode::Array { body, is_cd: None | Some(false), .. } => { let cols = body.iter().map(Vec::len).max().unwrap_or(0).max(1); + if body.len().saturating_mul(cols) > crate::MAX_CELLS { + return Err(MathError::new(format!("An equation can have at most {} matrix cells.", crate::MAX_CELLS), None)); + } self.out.push_str(&format!( "" )); diff --git a/docs/decisions.md b/docs/decisions.md index 09bc9b5cc..7ccc445c8 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -95,7 +95,10 @@ LaTeX subset: access. Indexing is not counted; the fuzz run covers it. - **Fuzzing.** 70,000 random token strings and 50,000 mutations of valid equations: no panic, no input slower than 5 ms. Self-referencing and exponential macros stop at the parser's - 1,000-expansion limit. Nesting deeper than 32 is refused ("Recursion limit exceeded"), and + 1,000-expansion limit, which counts expansions and not their size: a definition whose + argument doubles on every call never reaches it. The wrapper therefore refuses macro + definitions (`\def`, `\newcommand`, `\let` and the like) before RaTeX sees the source, and + caps the padded size of matrices at 2,500 cells. Nesting deeper than 32 is refused ("Recursion limit exceeded"), and the deepest accepted input needs under 256 KB of stack in a release build, well inside the smallest main-thread stack we run on (1 MB on Windows). Absurd sizes (`\rule{1e308em}{1em}`) lay out with huge dimensions, so the wrapper caps them. From d8ba397bb721ff16124b207ff3cedbb8bb5cc7d2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 4 Nov 2026 00:04:32 +0100 Subject: [PATCH 2/4] math: refuse an alignedat count hidden by a comment or spaces (review point: cap bypass) CodeRabbit found that \begin{alignedat}%{99999999} and {9 9} made the count parser return None, which skipped the 50-pair cap. The lookahead now drops comments and spaces as RaTeX's lexer does, and a matched alignat name with a count that is not a plain digit string is refused. Co-Authored-By: Claude Sonnet 5.5 --- crates/math/src/lib.rs | 45 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/math/src/lib.rs b/crates/math/src/lib.rs index e1f783dca..62033f2b8 100644 --- a/crates/math/src/lib.rs +++ b/crates/math/src/lib.rs @@ -390,16 +390,39 @@ fn check_caps(latex: &str) -> Result<(), MathError> { const MAX_ALIGNAT_PAIRS: usize = 50; /// N of `{alignat}{N}`, `{alignat*}{N}` or `{alignedat}{N}` at the start of `rest` (just after -/// `\begin`); `usize::MAX` when the count is present but too long to read. +/// `\begin`). `usize::MAX` when the count is present but is not a plain digit string RaTeX reads +/// the same way (too long, or hidden by a comment, inner spaces or a macro): refused, not skipped. +/// +/// RaTeX reads the environment name and the count through its lexer, which drops comments and +/// spaces, so they are dropped here too before the two groups are compared. fn alignat_columns(rest: &str) -> Option { - let rest = rest.trim_start().strip_prefix('{')?; - let (name, after) = rest.split_once('}')?; + // Enough for the longest name and any count that fits `usize`; a longer count is refused. + const WINDOW: usize = 64; + let mut kept = String::new(); + let mut chars = rest.chars(); + while kept.len() < WINDOW { + match chars.next() { + None => break, + Some('%') => { + for n in chars.by_ref() { + if n == '\n' { + break; + } + } + } + Some(c) if c.is_whitespace() => {} + Some(c) => kept.push(c), + } + } + let (name, after) = kept.strip_prefix('{')?.split_once('}')?; if !matches!(name, "alignat" | "alignat*" | "alignedat") { return None; } - let digits = after.trim_start().strip_prefix('{')?.split('}').next()?.trim(); + let Some(digits) = after.strip_prefix('{').and_then(|a| a.split_once('}')).map(|(d, _)| d) else { + return Some(usize::MAX); + }; if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return None; + return Some(usize::MAX); } Some(digits.parse().unwrap_or(usize::MAX)) } @@ -721,6 +744,18 @@ mod tests { let r = "\\begin{alignedat}{99999999}a\\end{alignedat}"; assert!(check_caps(r).is_err_and(|e| e.message.contains("matrix cells"))); assert!(check_caps("\\begin{alignedat}{2}a&b&c&d\\end{alignedat}").is_ok()); + // A comment, inner spaces or a macro in the count do not hide it from the cap. + for hidden in [ + "\\begin{alignedat}%\n{99999999}a\\end{alignedat}", + "\\begin %c\n {alignedat}{99999999}a\\end{alignedat}", + "\\begin{alignedat}{9 9}a\\end{alignedat}", + "\\begin{alignedat}{9%x\n9}a\\end{alignedat}", + "\\begin{align%x\nedat}{99}a\\end{alignedat}", + "\\begin{alignedat}{9\\relax9}a\\end{alignedat}", + ] { + assert!(check_caps(hidden).is_err_and(|e| e.message.contains("matrix cells")), "{hidden}"); + } + assert!(check_caps("\\begin{alignedat} {2} % two pairs\na&b&c&d\\end{alignedat}").is_ok()); assert!(layout("\\begin{CD}A @>f>> B \\\\ @VVV @VVV \\\\ C @>>> D\\end{CD}").is_ok()); assert!(check_caps("\\operatorname{curl} 50\\%").is_ok()); let two = format!("{0}{0}", "\\begin{matrix}a&b\\\\c&d\\end{matrix}"); From 86c5c8dd813e05b2f9912bd3dcc4a1e9e042e5a5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 4 Nov 2026 01:31:04 +0100 Subject: [PATCH 3/4] Math: count \newline as a row break in the matrix cell cap (#3327) RaTeX reads \newline as \\, but the source scan only counted \\ and \cr, so a matrix built with \newline skipped the 2,500 cell cap. Co-Authored-By: Claude Sonnet 5.5 --- crates/math/src/lib.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/math/src/lib.rs b/crates/math/src/lib.rs index 62033f2b8..3a5b310cf 100644 --- a/crates/math/src/lib.rs +++ b/crates/math/src/lib.rs @@ -235,7 +235,7 @@ const MACRO_DEFINERS: &[&str] = /// One open `\begin…\end` environment, counted from the source text. struct Grid { - /// Rows ended so far (by `\\` or `\cr`). + /// Rows ended so far (by `\\`, `\cr` or `\newline`). rows: usize, /// Cells in the current row. cells: usize, @@ -315,7 +315,8 @@ fn check_caps(latex: &str) -> Result<(), MathError> { } None } - "cr" => { + // RaTeX reads `\newline` as `\\`, so it ends a row like `\cr` does. + "cr" | "newline" => { if let Some(g) = grids.last_mut() { g.end_row(); } @@ -783,4 +784,20 @@ mod tests { let l = layout("").unwrap(); assert!(l.items.is_empty()); } + + #[test] + fn newline_rows_count_toward_the_matrix_cap() { + // RaTeX reads `\newline` as `\\`, so it ends a row like `\cr` does. + for brk in ["\\newline ", "\\cr ", "\\\\"] { + let src = format!("\\begin{{matrix}}{}{}\\end{{matrix}}", format!("a{brk}").repeat(250), "a&".repeat(700) + "a"); + assert!(src.len() < MAX_SOURCE_BYTES); + let e = check_caps(&src).unwrap_err(); + assert!(e.message.contains("matrix cells"), "{brk}: {e}"); + let s = src.clone(); + let (l, o) = within(10, move || (layout(&s).unwrap_err(), to_omml(&s, 11.0, None).unwrap_err())); + assert!(l.message.contains("matrix cells") && o.message.contains("matrix cells"), "{brk}"); + } + assert!(check_caps("\\begin{matrix}a&b\\newline c&d\\end{matrix}").is_ok()); + assert!(layout("\\begin{matrix}a&b\\newline c&d\\end{matrix}").is_ok()); + } } From 225496e5445eda6f0bd2cd7edf4cefc688b62f02 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 17:19:20 +0100 Subject: [PATCH 4/4] Math: refuse computed environment names in the alignat pair cap A name such as align\@char{97}t reads as alignat to the parser but split at the first } looked like another environment, so the pair limit was skipped. Names that contain a backslash or brace, or do not close in the window, are now refused like an unreadable count. Co-Authored-By: Claude Sonnet 5.5 --- crates/math/src/lib.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/math/src/lib.rs b/crates/math/src/lib.rs index 62033f2b8..fb01b2374 100644 --- a/crates/math/src/lib.rs +++ b/crates/math/src/lib.rs @@ -392,6 +392,7 @@ const MAX_ALIGNAT_PAIRS: usize = 50; /// N of `{alignat}{N}`, `{alignat*}{N}` or `{alignedat}{N}` at the start of `rest` (just after /// `\begin`). `usize::MAX` when the count is present but is not a plain digit string RaTeX reads /// the same way (too long, or hidden by a comment, inner spaces or a macro): refused, not skipped. +/// An environment name that is computed (contains `\` or `{`) is refused the same way. /// /// RaTeX reads the environment name and the count through its lexer, which drops comments and /// spaces, so they are dropped here too before the two groups are compared. @@ -414,7 +415,16 @@ fn alignat_columns(rest: &str) -> Option { Some(c) => kept.push(c), } } - let (name, after) = kept.strip_prefix('{')?.split_once('}')?; + let group = kept.strip_prefix('{')?; + // A name that does not close inside the window cannot be read, so it is refused. + let Some((name, after)) = group.split_once('}') else { + return Some(usize::MAX); + }; + // A computed name (`align\@char{97}t` reads as `alignat`) cannot be resolved here: refused + // too. Every real environment name is letters, optionally with a trailing `*`. + if name.contains(['\\', '{']) { + return Some(usize::MAX); + } if !matches!(name, "alignat" | "alignat*" | "alignedat") { return None; } @@ -755,6 +765,14 @@ mod tests { ] { assert!(check_caps(hidden).is_err_and(|e| e.message.contains("matrix cells")), "{hidden}"); } + // A computed environment name reads as `alignat` to the parser, so it is capped too. + for name in ["align\\@char{97}t", "alignedat\\relax", "al\\@char{105}gnat*"] { + let over = format!("\\begin{{{name}}}{{51}}a\\end{{alignat}}"); + assert!(check_caps(&over).is_err_and(|e| e.message.contains("matrix cells")), "{over}"); + } + let long = format!("\\begin{{{}}}{{51}}a\\end{{alignat}}", "a".repeat(80)); + assert!(check_caps(&long).is_err_and(|e| e.message.contains("matrix cells"))); + assert!(check_caps("\\begin{alignat}{50}a\\end{alignat}").is_ok()); assert!(check_caps("\\begin{alignedat} {2} % two pairs\na&b&c&d\\end{alignedat}").is_ok()); assert!(layout("\\begin{CD}A @>f>> B \\\\ @VVV @VVV \\\\ C @>>> D\\end{CD}").is_ok()); assert!(check_caps("\\operatorname{curl} 50\\%").is_ok());