//! 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) 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; /// 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 = 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>, pub message: String, } impl MathError { fn new(message: impl Into, span: Option>) -> 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 { 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, /// 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, 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, 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 { 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(f: impl FnOnce() -> Result) -> Result { 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 = const { std::cell::Cell::new(false) }; } fn parse(latex: &str) -> Result, 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) }) } /// The size and nesting 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()); } let mut depth = 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). let mut word = String::new(); while let Some(&(_, n)) = chars.peek() { if n.is_ascii_alphabetic() { word.push(n); chars.next(); } else { break; } } if word.is_empty() { chars.next(); continue; } match word.as_str() { "left" | "begin" => Some(true), "right" | "end" => Some(false), _ => 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 => {} } } Ok(()) } 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 { 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 { 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 { 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::>()[..] { [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)> = 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> = 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); } } #[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()); } }