Files
gridcraft/crates/math/src/lib.rs
T

822 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GridCraft equations. LaTeX source is laid out by RaTeX (`ratex-parser`, `ratex-layout`; MIT)
//! into a [`MathList`]: plain glyph, rule and path data that the UI and the PDF writer paint
//! 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, 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`.
//!
//! Units are em: one em is the equation's font size. Coordinates run right and down from the
//! top-left of the list's box; the baseline is at `y = height`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod omml;
use std::ops::Range;
use std::panic::{AssertUnwindSafe, catch_unwind};
use ratex_font::FontId;
use ratex_types::color::Color;
use ratex_types::display_item::{DisplayItem, DisplayList};
use ratex_types::path_command::PathCommand;
pub use omml::to_omml;
/// Longest source accepted, in bytes.
pub const MAX_SOURCE_BYTES: usize = 4096;
/// Deepest nesting of `{…}`, `\left…\right` and `\begin…\end` accepted.
pub const MAX_DEPTH: usize = 32;
/// Largest coordinate or size accepted in a layout, in em. `\rule{1e308em}{1em}` lays out; this
/// stops it from reaching a painter.
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.
pub static SOURCE_TOO_LONG: std::sync::LazyLock<MathError> =
std::sync::LazyLock::new(|| MathError::new(format!("An equation can be at most {MAX_SOURCE_BYTES} bytes long."), None));
/// Why an equation could not be laid out.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MathError {
/// The offending source, as a byte range on char boundaries, when known.
pub span: Option<Range<usize>>,
pub message: String,
}
impl MathError {
fn new(message: impl Into<String>, span: Option<Range<usize>>) -> MathError {
MathError { span, message: message.into() }
}
/// The 1-based character position of the error in `source`, when known.
pub fn char_pos(&self, source: &str) -> Option<usize> {
let start = self.span.as_ref()?.start;
Some(source.char_indices().take_while(|(i, _)| *i < start).count() + 1)
}
}
impl std::fmt::Display for MathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for MathError {}
/// A laid-out equation.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MathList {
pub items: Vec<Item>,
/// Advance width, in em.
pub width: f32,
/// Height above the baseline, in em.
pub height: f32,
/// Depth below the baseline, in em.
pub depth: f32,
}
impl MathList {
/// Total height (height plus depth), in em.
pub fn total_height(&self) -> f32 {
self.height + self.depth
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Item {
Glyph(Glyph),
Rule(Rule),
Path(Path),
}
/// The typeface a glyph asks for. Painters map it onto the fonts they have.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Face {
#[default]
Roman,
Italic,
Bold,
BoldItalic,
Sans,
SansBold,
SansItalic,
Mono,
/// A large operator or delimiter (TeX's Size1 to Size4 fonts): drawn stretched to the glyph's
/// box rather than at the run's font size.
Large(u8),
}
impl Face {
pub fn is_italic(self) -> bool {
matches!(self, Face::Italic | Face::BoldItalic | Face::SansItalic)
}
pub fn is_bold(self) -> bool {
matches!(self, Face::Bold | Face::BoldItalic | Face::SansBold)
}
}
/// One character.
#[derive(Clone, Debug, PartialEq)]
pub struct Glyph {
/// Left edge of the glyph's box.
pub x: f32,
/// The glyph's baseline.
pub y: f32,
/// Font size, in em of the equation (1 for the main line, smaller in scripts).
pub size: f32,
/// The character to draw.
pub ch: char,
/// A plainer character to draw when no font has `ch` (`R` for `ℝ`), before giving up.
pub alt: Option<char>,
pub face: Face,
/// The glyph's box (TeX metrics), already scaled by `size`: advance, height above and depth
/// below the baseline. A painter without the glyph draws a `[?]` box this size.
pub width: f32,
pub height: f32,
pub depth: f32,
/// An explicit colour (`\color`); `None` is the equation's own ("Automatic") colour.
pub color: Option<[u8; 3]>,
}
/// A filled rectangle (fraction bars, radical vinculums, table rules, box borders).
#[derive(Clone, Debug, PartialEq)]
pub struct Rule {
pub x: f32,
/// Top edge.
pub y: f32,
pub width: f32,
pub height: f32,
pub color: Option<[u8; 3]>,
}
/// A vector outline (stretchy braces, arrows, radicals), relative to `(x, y)`.
#[derive(Clone, Debug, PartialEq)]
pub struct Path {
pub x: f32,
pub y: f32,
pub commands: Vec<PathCmd>,
pub fill: bool,
pub color: Option<[u8; 3]>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PathCmd {
MoveTo(f32, f32),
LineTo(f32, f32),
QuadTo(f32, f32, f32, f32),
CubicTo(f32, f32, f32, f32, f32, f32),
Close,
}
/// Lays `latex` out. Errors carry the offending span when RaTeX reports one.
pub fn layout(latex: &str) -> Result<MathList, MathError> {
check_caps(latex)?;
guarded(|| {
let nodes = parse(latex)?;
let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default());
convert(&ratex_layout::to_display_list(&lb))
})
}
/// Whether `latex` parses, without laying it out (cheap enough to run on every keystroke).
pub fn check(latex: &str) -> Result<(), MathError> {
check_caps(latex)?;
guarded(|| parse(latex).map(|_| ()))
}
/// The equation gallery (Insert › Equation): GridCraft's own examples, as (name, LaTeX).
pub const GALLERY: &[(&str, &str)] = &[
("Quadratic formula", "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"),
("Pythagoras", "a^2 + b^2 = c^2"),
("Binomial theorem", "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}"),
("Area of a circle", "A = \\pi r^2"),
("Euler's identity", "e^{i\\pi} + 1 = 0"),
("2\u{00D7}2 matrix", "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}"),
];
/// Runs `f` so that a panic inside it becomes an error.
fn guarded<T>(f: impl FnOnce() -> Result<T, MathError>) -> Result<T, MathError> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(MathError::new("This equation could not be laid out.", None)))
}
#[cfg(test)]
thread_local! {
/// Test hook: makes the next guarded parse panic, to prove the guard holds.
static INJECT_PANIC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn parse(latex: &str) -> Result<Vec<ratex_parser::ParseNode>, MathError> {
#[cfg(test)]
if INJECT_PANIC.with(|p| p.replace(false)) {
panic!("injected");
}
ratex_parser::parse(latex).map_err(|e| {
let span = e.loc.and_then(|l| {
let (s, t) = (l.start.min(latex.len()), l.end.min(latex.len()));
(latex.is_char_boundary(s) && latex.is_char_boundary(t)).then_some(s..t.max(s))
});
MathError::new(e.message, span)
})
}
/// 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 `\\`, `\cr` or `\newline`).
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<Grid> = 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 (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() || n == '@' {
word.push(n);
chars.next();
} else {
break;
}
}
if word.is_empty() {
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() {
"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
}
// RaTeX reads `\newline` as `\\`, so it ends a row like `\cr` does.
"cr" | "newline" => {
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() {
if n == '\n' {
break;
}
}
None
}
'{' => Some(true),
'}' => Some(false),
_ => None,
};
match open {
Some(true) => {
depth += 1;
if depth > MAX_DEPTH {
return Err(MathError::new(format!("Equations can nest at most {MAX_DEPTH} levels deep."), Some(i..i + c.len_utf8())));
}
}
Some(false) => depth = depth.saturating_sub(1),
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 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.
fn alignat_columns(rest: &str) -> Option<usize> {
// 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 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;
}
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 Some(usize::MAX);
}
Some(digits.parse().unwrap_or(usize::MAX))
}
fn too_many_cells(span: Range<usize>) -> 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)];
(rgb != [0, 0, 0]).then_some(rgb)
}
/// A finite value within the caps, as `f32`.
fn em(v: f64) -> Result<f32, MathError> {
if v.is_finite() && v.abs() <= f64::from(MAX_EM) { Ok(v as f32) } else { Err(MathError::new("This equation is too large to draw.", None)) }
}
fn convert(dl: &DisplayList) -> Result<MathList, MathError> {
if dl.items.len() > MAX_ITEMS {
return Err(MathError::new("This equation is too large to draw.", None));
}
let mut items = Vec::with_capacity(dl.items.len());
for it in &dl.items {
items.push(match it {
DisplayItem::GlyphPath { x, y, scale, font, char_code, color: c } => {
Item::Glyph(glyph(em(*x)?, em(*y)?, em(*scale)?, font, *char_code, color(c))?)
}
DisplayItem::Line { x, y, width, thickness, color: c, .. } => {
let t = em(*thickness)?;
Item::Rule(Rule { x: em(*x)?, y: em(*y)? - t / 2.0, width: em(*width)?, height: t, color: color(c) })
}
DisplayItem::Rect { x, y, width, height, color: c } => {
Item::Rule(Rule { x: em(*x)?, y: em(*y)?, width: em(*width)?, height: em(*height)?, color: color(c) })
}
DisplayItem::Path { x, y, commands, fill, color: c } => {
let mut cmds = Vec::with_capacity(commands.len());
for pc in commands {
cmds.push(match *pc {
PathCommand::MoveTo { x, y } => PathCmd::MoveTo(em(x)?, em(y)?),
PathCommand::LineTo { x, y } => PathCmd::LineTo(em(x)?, em(y)?),
PathCommand::QuadTo { x1, y1, x, y } => PathCmd::QuadTo(em(x1)?, em(y1)?, em(x)?, em(y)?),
PathCommand::CubicTo { x1, y1, x2, y2, x, y } => PathCmd::CubicTo(em(x1)?, em(y1)?, em(x2)?, em(y2)?, em(x)?, em(y)?),
PathCommand::Close => PathCmd::Close,
});
}
Item::Path(Path { x: em(*x)?, y: em(*y)?, commands: cmds, fill: *fill, color: color(c) })
}
});
}
Ok(MathList { items, width: em(dl.width)?.max(0.0), height: em(dl.height)?, depth: em(dl.depth)? })
}
fn glyph(x: f32, y: f32, size: f32, font: &str, code: u32, color: Option<[u8; 3]>) -> Result<Glyph, MathError> {
let fid = FontId::parse(font).unwrap_or(FontId::MainRegular);
let face = match fid {
FontId::MainBold | FontId::FrakturBold => Face::Bold,
FontId::MainItalic | FontId::MathItalic => Face::Italic,
FontId::MainBoldItalic | FontId::MathBoldItalic => Face::BoldItalic,
FontId::SansSerifRegular => Face::Sans,
FontId::SansSerifBold => Face::SansBold,
FontId::SansSerifItalic => Face::SansItalic,
FontId::TypewriterRegular => Face::Mono,
FontId::Size1Regular => Face::Large(1),
FontId::Size2Regular => Face::Large(2),
FontId::Size3Regular => Face::Large(3),
FontId::Size4Regular => Face::Large(4),
_ => Face::Roman,
};
let raw = char::from_u32(code).unwrap_or('\u{FFFD}');
// Blackboard, script and Fraktur letters sit at ASCII slots in TeX's fonts; draw the Unicode
// letter, with the ASCII one as the fallback.
let (ch, alt) = match (fid, raw) {
(FontId::AmsRegular, c @ ('A'..='Z' | 'k')) => (styled(c, Style::DoubleStruck), Some(c)),
(FontId::CaligraphicRegular | FontId::ScriptRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Script), Some(c)),
(FontId::FrakturRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Fraktur), Some(c)),
(FontId::FrakturBold, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::BoldFraktur), Some(c)),
(_, c) if (0x1D400..=0x1D7FF).contains(&code) => (c, Some(ratex_font::katex_ttf_glyph_char(fid, code))),
(_, c) => (c, None),
};
let m = ratex_font::font_and_metric_for_mathematical_alphanumeric(code)
.and_then(|(f, mc)| ratex_font::get_char_metrics(f, mc))
.or_else(|| ratex_font::get_char_metrics(fid, code))
.or_else(|| ratex_font::get_char_metrics_with_fallback(fid, code));
let (w, h, d) = m.map_or((0.5, 0.7, 0.0), |m| (m.width + m.italic, m.height, m.depth));
Ok(Glyph { x, y, size, ch, alt, face, width: em(w)? * size, height: em(h)? * size, depth: em(d)? * size, color })
}
#[derive(Clone, Copy)]
enum Style {
DoubleStruck,
Script,
Fraktur,
BoldFraktur,
}
/// The Mathematical Alphanumeric Symbols letter for `c` (Unicode 15, chapter 22.2), using the
/// Letterlike Symbols block where Unicode put a letter before the alphanumeric block existed.
fn styled(c: char, style: Style) -> char {
let letterlike = match (style, c) {
(Style::DoubleStruck, 'C') => Some('\u{2102}'),
(Style::DoubleStruck, 'H') => Some('\u{210D}'),
(Style::DoubleStruck, 'N') => Some('\u{2115}'),
(Style::DoubleStruck, 'P') => Some('\u{2119}'),
(Style::DoubleStruck, 'Q') => Some('\u{211A}'),
(Style::DoubleStruck, 'R') => Some('\u{211D}'),
(Style::DoubleStruck, 'Z') => Some('\u{2124}'),
(Style::Script, 'B') => Some('\u{212C}'),
(Style::Script, 'E') => Some('\u{2130}'),
(Style::Script, 'F') => Some('\u{2131}'),
(Style::Script, 'H') => Some('\u{210B}'),
(Style::Script, 'I') => Some('\u{2110}'),
(Style::Script, 'L') => Some('\u{2112}'),
(Style::Script, 'M') => Some('\u{2133}'),
(Style::Script, 'R') => Some('\u{211B}'),
(Style::Script, 'e') => Some('\u{212F}'),
(Style::Script, 'g') => Some('\u{210A}'),
(Style::Script, 'o') => Some('\u{2134}'),
(Style::Fraktur, 'C') => Some('\u{212D}'),
(Style::Fraktur, 'H') => Some('\u{210C}'),
(Style::Fraktur, 'I') => Some('\u{2111}'),
(Style::Fraktur, 'R') => Some('\u{211C}'),
(Style::Fraktur, 'Z') => Some('\u{2128}'),
_ => None,
};
if let Some(l) = letterlike {
return l;
}
let (upper, lower) = match style {
Style::DoubleStruck => (0x1D538, 0x1D552),
Style::Script => (0x1D49C, 0x1D4B6),
Style::Fraktur => (0x1D504, 0x1D51E),
Style::BoldFraktur => (0x1D56C, 0x1D586),
};
let code = match c {
'A'..='Z' => upper + (c as u32 - 'A' as u32),
'a'..='z' => lower + (c as u32 - 'a' as u32),
_ => return c,
};
char::from_u32(code).unwrap_or(c)
}
#[cfg(test)]
mod tests {
use super::*;
fn glyphs(l: &MathList) -> String {
l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.ch) } else { None }).collect()
}
#[test]
fn boxes_of_a_simple_line() {
let l = layout("x+1").unwrap();
assert_eq!(glyphs(&l), "x+1");
assert!(l.width > 1.5 && l.width < 3.0, "{}", l.width);
assert!(l.height > 0.4 && l.height < 1.0);
let Some(Item::Glyph(x)) = l.items.first() else { panic!("a glyph first") };
assert_eq!(x.face, Face::Italic, "a letter is a math italic");
assert!((x.y - l.height).abs() < 1e-4, "on the baseline");
assert!(x.width > 0.3 && x.height > 0.3);
let one = l.items.iter().find_map(|i| if let Item::Glyph(g) = i { (g.ch == '1').then_some(g) } else { None }).unwrap();
assert_eq!(one.face, Face::Roman);
}
#[test]
fn fractions_roots_and_scripts() {
let f = layout("\\frac{a}{b}").unwrap();
let rule = f.items.iter().find_map(|i| if let Item::Rule(r) = i { Some(r) } else { None }).expect("a fraction bar");
assert!(rule.width > 0.3 && rule.height > 0.0 && rule.height < 0.1);
let (a, b) = match &f.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.clone()) } else { None }).collect::<Vec<_>>()[..] {
[a, b] => (a.clone(), b.clone()),
other => panic!("{other:?}"),
};
assert!(a.y < rule.y && b.y > rule.y + rule.height, "numerator above, denominator below");
assert!(f.depth > 0.3);
let s = layout("x^2").unwrap();
let g: Vec<&Glyph> = s.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g) } else { None }).collect();
assert!(g[1].size < 1.0 && g[1].y < g[0].y, "the exponent is smaller and raised");
let r = layout("\\sqrt{x}").unwrap();
assert!(glyphs(&r).contains('x'));
assert!(r.items.len() >= 2);
let big = layout("\\displaystyle\\sum_{i=1}^n i").unwrap();
assert!(big.items.iter().any(|i| matches!(i, Item::Glyph(Glyph { ch: '∑', face: Face::Large(_), .. }))));
}
#[test]
fn styled_letters_map_to_unicode_with_a_plain_fallback() {
let l = layout("\\mathbb{R}\\mathbb{Q}\\mathcal{L}\\mathfrak{g}").unwrap();
let g: Vec<(char, Option<char>)> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some((g.ch, g.alt)) } else { None }).collect();
assert_eq!(g, vec![('ℝ', Some('R')), ('ℚ', Some('Q')), ('ℒ', Some('L')), ('𝔤', Some('g'))]);
assert_eq!(styled('A', Style::DoubleStruck), '𝔸');
assert_eq!(styled('b', Style::Script), '𝒷');
assert_eq!(styled('7', Style::Fraktur), '7');
}
#[test]
fn colours_other_than_black_are_kept() {
let l = layout("{\\color{red}x}y").unwrap();
let c: Vec<Option<[u8; 3]>> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.color) } else { None }).collect();
assert_eq!(c, vec![Some([255, 0, 0]), None]);
}
#[test]
fn errors_point_at_the_source() {
let e = layout("a + \\frac{1}{").unwrap_err();
assert!(!e.message.is_empty());
let e = layout("x + \\foo").unwrap_err();
assert_eq!(e.span, Some(4..8), "{e:?}");
assert_eq!(e.char_pos("x + \\foo"), Some(5));
let e = layout("é + \\foo").unwrap_err();
assert_eq!(e.char_pos("é + \\foo"), Some(5), "positions count characters, not bytes");
assert!(check("\\frac{a}{b}").is_ok());
assert!(check("\\frac{a}{").is_err());
}
#[test]
fn hostile_input_is_refused_without_panicking() {
let e = layout(&"x".repeat(MAX_SOURCE_BYTES + 1)).unwrap_err();
assert!(e.message.contains("4096"));
assert!(layout(&"x".repeat(MAX_SOURCE_BYTES)).is_ok());
let deep = format!("{}x{}", "{".repeat(33), "}".repeat(33));
assert_eq!(layout(&deep).unwrap_err().span, Some(32..33));
assert!(layout(&format!("{}x{}", "\\left(".repeat(40), "\\right)".repeat(40))).is_err());
// Escaped braces and comments are not groups.
assert!(check_caps(&"\\{".repeat(100)).is_ok());
assert!(check_caps(&format!("x % {}\n", "{".repeat(100))).is_ok());
assert!(layout("\\rule{99999999em}{1em}").unwrap_err().message.contains("too large"));
assert!(layout("\\kern99999999em x").is_err());
assert!(layout("\\def\\a{\\a\\a}\\a").is_err());
for s in ["", " ", "\\", "{", "}", "^", "_", "&", "\\\\", "\u{0}", "\u{FFFF}", "\u{1F600}", "$", "#", "\\begin{matrix}", "\\end{matrix}"] {
let _ = layout(s);
let _ = check(s);
let _ = to_omml(s, 11.0, None);
}
}
/// Runs `f` on its own thread and fails if it has not returned within `secs` seconds.
fn within<T: Send + 'static>(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());
// 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}");
}
// 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());
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));
let e = layout("x").unwrap_err();
assert_eq!(e.message, "This equation could not be laid out.");
assert!(layout("x").is_ok(), "the next call is unaffected");
}
#[test]
fn the_gallery_lays_out_and_converts() {
for (name, src) in GALLERY {
assert!(layout(src).is_ok_and(|l| !l.items.is_empty()), "{name}");
assert!(to_omml(src, 11.0, None).is_ok(), "{name}");
}
}
#[test]
fn empty_source_is_an_empty_list() {
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());
}
}