Skip to main content

ravenrs_sheet/math/
mod.rs

1//! GridCraft equations. LaTeX source is laid out by RaTeX (`ratex-parser`, `ratex-layout`; MIT)
2//! into a [`MathList`]: plain glyph, rule and path data that the UI and the PDF writer paint
3//! without calling RaTeX again.
4//!
5//! RaTeX is young (0.1.x), so it only ever runs inside this crate's entry points ([`layout`],
6//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32) and runs under
7//! `catch_unwind`, so a stray panic becomes a [`MathError`]. See the RaTeX gate in
8//! `docs/decisions.md` and `tests/gate.rs`.
9//!
10//! Units are em: one em is the equation's font size. Coordinates run right and down from the
11//! top-left of the list's box; the baseline is at `y = height`.
12#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
13#![forbid(unsafe_code)]
14
15mod omml;
16
17use std::ops::Range;
18use std::panic::{AssertUnwindSafe, catch_unwind};
19
20use ratex_font::FontId;
21use ratex_types::color::Color;
22use ratex_types::display_item::{DisplayItem, DisplayList};
23use ratex_types::path_command::PathCommand;
24
25pub use omml::to_omml;
26
27/// Longest source accepted, in bytes.
28pub const MAX_SOURCE_BYTES: usize = 4096;
29/// Deepest nesting of `{…}`, `\left…\right` and `\begin…\end` accepted.
30pub const MAX_DEPTH: usize = 32;
31/// Largest coordinate or size accepted in a layout, in em. `\rule{1e308em}{1em}` lays out; this
32/// stops it from reaching a painter.
33pub const MAX_EM: f32 = 10_000.0;
34/// Most items one equation may produce.
35pub const MAX_ITEMS: usize = 50_000;
36
37/// The error for a source over [`MAX_SOURCE_BYTES`]. It is one shared value so that a cache can
38/// answer for such a source by its length alone, without reading it.
39pub static SOURCE_TOO_LONG: std::sync::LazyLock<MathError> =
40    std::sync::LazyLock::new(|| MathError::new(format!("An equation can be at most {MAX_SOURCE_BYTES} bytes long."), None));
41
42/// Why an equation could not be laid out.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct MathError {
45    /// The offending source, as a byte range on char boundaries, when known.
46    pub span: Option<Range<usize>>,
47    pub message: String,
48}
49
50impl MathError {
51    fn new(message: impl Into<String>, span: Option<Range<usize>>) -> MathError {
52        MathError { span, message: message.into() }
53    }
54
55    /// The 1-based character position of the error in `source`, when known.
56    pub fn char_pos(&self, source: &str) -> Option<usize> {
57        let start = self.span.as_ref()?.start;
58        Some(source.char_indices().take_while(|(i, _)| *i < start).count() + 1)
59    }
60}
61
62impl std::fmt::Display for MathError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.write_str(&self.message)
65    }
66}
67
68impl std::error::Error for MathError {}
69
70/// A laid-out equation.
71#[derive(Clone, Debug, Default, PartialEq)]
72pub struct MathList {
73    pub items: Vec<Item>,
74    /// Advance width, in em.
75    pub width: f32,
76    /// Height above the baseline, in em.
77    pub height: f32,
78    /// Depth below the baseline, in em.
79    pub depth: f32,
80}
81
82impl MathList {
83    /// Total height (height plus depth), in em.
84    pub fn total_height(&self) -> f32 {
85        self.height + self.depth
86    }
87}
88
89#[derive(Clone, Debug, PartialEq)]
90pub enum Item {
91    Glyph(Glyph),
92    Rule(Rule),
93    Path(Path),
94}
95
96/// The typeface a glyph asks for. Painters map it onto the fonts they have.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
98pub enum Face {
99    #[default]
100    Roman,
101    Italic,
102    Bold,
103    BoldItalic,
104    Sans,
105    SansBold,
106    SansItalic,
107    Mono,
108    /// A large operator or delimiter (TeX's Size1 to Size4 fonts): drawn stretched to the glyph's
109    /// box rather than at the run's font size.
110    Large(u8),
111}
112
113impl Face {
114    pub fn is_italic(self) -> bool {
115        matches!(self, Face::Italic | Face::BoldItalic | Face::SansItalic)
116    }
117    pub fn is_bold(self) -> bool {
118        matches!(self, Face::Bold | Face::BoldItalic | Face::SansBold)
119    }
120}
121
122/// One character.
123#[derive(Clone, Debug, PartialEq)]
124pub struct Glyph {
125    /// Left edge of the glyph's box.
126    pub x: f32,
127    /// The glyph's baseline.
128    pub y: f32,
129    /// Font size, in em of the equation (1 for the main line, smaller in scripts).
130    pub size: f32,
131    /// The character to draw.
132    pub ch: char,
133    /// A plainer character to draw when no font has `ch` (`R` for `ℝ`), before giving up.
134    pub alt: Option<char>,
135    pub face: Face,
136    /// The glyph's box (TeX metrics), already scaled by `size`: advance, height above and depth
137    /// below the baseline. A painter without the glyph draws a `[?]` box this size.
138    pub width: f32,
139    pub height: f32,
140    pub depth: f32,
141    /// An explicit colour (`\color`); `None` is the equation's own ("Automatic") colour.
142    pub color: Option<[u8; 3]>,
143}
144
145/// A filled rectangle (fraction bars, radical vinculums, table rules, box borders).
146#[derive(Clone, Debug, PartialEq)]
147pub struct Rule {
148    pub x: f32,
149    /// Top edge.
150    pub y: f32,
151    pub width: f32,
152    pub height: f32,
153    pub color: Option<[u8; 3]>,
154}
155
156/// A vector outline (stretchy braces, arrows, radicals), relative to `(x, y)`.
157#[derive(Clone, Debug, PartialEq)]
158pub struct Path {
159    pub x: f32,
160    pub y: f32,
161    pub commands: Vec<PathCmd>,
162    pub fill: bool,
163    pub color: Option<[u8; 3]>,
164}
165
166#[derive(Clone, Copy, Debug, PartialEq)]
167pub enum PathCmd {
168    MoveTo(f32, f32),
169    LineTo(f32, f32),
170    QuadTo(f32, f32, f32, f32),
171    CubicTo(f32, f32, f32, f32, f32, f32),
172    Close,
173}
174
175/// Lays `latex` out. Errors carry the offending span when RaTeX reports one.
176pub fn layout(latex: &str) -> Result<MathList, MathError> {
177    check_caps(latex)?;
178    guarded(|| {
179        let nodes = parse(latex)?;
180        let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default());
181        convert(&ratex_layout::to_display_list(&lb))
182    })
183}
184
185/// Whether `latex` parses, without laying it out (cheap enough to run on every keystroke).
186pub fn check(latex: &str) -> Result<(), MathError> {
187    check_caps(latex)?;
188    guarded(|| parse(latex).map(|_| ()))
189}
190
191/// The equation gallery (Insert › Equation): GridCraft's own examples, as (name, LaTeX).
192pub const GALLERY: &[(&str, &str)] = &[
193    ("Quadratic formula", "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"),
194    ("Pythagoras", "a^2 + b^2 = c^2"),
195    ("Binomial theorem", "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}"),
196    ("Area of a circle", "A = \\pi r^2"),
197    ("Euler's identity", "e^{i\\pi} + 1 = 0"),
198    ("2\u{00D7}2 matrix", "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}"),
199];
200
201/// Runs `f` so that a panic inside it becomes an error.
202fn guarded<T>(f: impl FnOnce() -> Result<T, MathError>) -> Result<T, MathError> {
203    catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(MathError::new("This equation could not be laid out.", None)))
204}
205
206#[cfg(test)]
207thread_local! {
208    /// Test hook: makes the next guarded parse panic, to prove the guard holds.
209    static INJECT_PANIC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
210}
211
212fn parse(latex: &str) -> Result<Vec<ratex_parser::ParseNode>, MathError> {
213    #[cfg(test)]
214    if INJECT_PANIC.with(|p| p.replace(false)) {
215        panic!("injected");
216    }
217    ratex_parser::parse(latex).map_err(|e| {
218        let span = e.loc.and_then(|l| {
219            let (s, t) = (l.start.min(latex.len()), l.end.min(latex.len()));
220            (latex.is_char_boundary(s) && latex.is_char_boundary(t)).then_some(s..t.max(s))
221        });
222        MathError::new(e.message, span)
223    })
224}
225
226/// The size and nesting caps, checked before RaTeX sees the source.
227fn check_caps(latex: &str) -> Result<(), MathError> {
228    if latex.len() > MAX_SOURCE_BYTES {
229        return Err(SOURCE_TOO_LONG.clone());
230    }
231    let mut depth = 0usize;
232    let mut chars = latex.char_indices().peekable();
233    while let Some((i, c)) = chars.next() {
234        let open = match c {
235            '\\' => {
236                // A control word, or an escaped character (`\{` is not a group).
237                let mut word = String::new();
238                while let Some(&(_, n)) = chars.peek() {
239                    if n.is_ascii_alphabetic() {
240                        word.push(n);
241                        chars.next();
242                    } else {
243                        break;
244                    }
245                }
246                if word.is_empty() {
247                    chars.next();
248                    continue;
249                }
250                match word.as_str() {
251                    "left" | "begin" => Some(true),
252                    "right" | "end" => Some(false),
253                    _ => None,
254                }
255            }
256            '%' => {
257                // A comment runs to the end of the line.
258                for (_, n) in chars.by_ref() {
259                    if n == '\n' {
260                        break;
261                    }
262                }
263                None
264            }
265            '{' => Some(true),
266            '}' => Some(false),
267            _ => None,
268        };
269        match open {
270            Some(true) => {
271                depth += 1;
272                if depth > MAX_DEPTH {
273                    return Err(MathError::new(format!("Equations can nest at most {MAX_DEPTH} levels deep."), Some(i..i + c.len_utf8())));
274                }
275            }
276            Some(false) => depth = depth.saturating_sub(1),
277            None => {}
278        }
279    }
280    Ok(())
281}
282
283fn color(c: &Color) -> Option<[u8; 3]> {
284    let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
285    let rgb = [q(c.r), q(c.g), q(c.b)];
286    (rgb != [0, 0, 0]).then_some(rgb)
287}
288
289/// A finite value within the caps, as `f32`.
290fn em(v: f64) -> Result<f32, MathError> {
291    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)) }
292}
293
294fn convert(dl: &DisplayList) -> Result<MathList, MathError> {
295    if dl.items.len() > MAX_ITEMS {
296        return Err(MathError::new("This equation is too large to draw.", None));
297    }
298    let mut items = Vec::with_capacity(dl.items.len());
299    for it in &dl.items {
300        items.push(match it {
301            DisplayItem::GlyphPath { x, y, scale, font, char_code, color: c } => {
302                Item::Glyph(glyph(em(*x)?, em(*y)?, em(*scale)?, font, *char_code, color(c))?)
303            }
304            DisplayItem::Line { x, y, width, thickness, color: c, .. } => {
305                let t = em(*thickness)?;
306                Item::Rule(Rule { x: em(*x)?, y: em(*y)? - t / 2.0, width: em(*width)?, height: t, color: color(c) })
307            }
308            DisplayItem::Rect { x, y, width, height, color: c } => {
309                Item::Rule(Rule { x: em(*x)?, y: em(*y)?, width: em(*width)?, height: em(*height)?, color: color(c) })
310            }
311            DisplayItem::Path { x, y, commands, fill, color: c } => {
312                let mut cmds = Vec::with_capacity(commands.len());
313                for pc in commands {
314                    cmds.push(match *pc {
315                        PathCommand::MoveTo { x, y } => PathCmd::MoveTo(em(x)?, em(y)?),
316                        PathCommand::LineTo { x, y } => PathCmd::LineTo(em(x)?, em(y)?),
317                        PathCommand::QuadTo { x1, y1, x, y } => PathCmd::QuadTo(em(x1)?, em(y1)?, em(x)?, em(y)?),
318                        PathCommand::CubicTo { x1, y1, x2, y2, x, y } => PathCmd::CubicTo(em(x1)?, em(y1)?, em(x2)?, em(y2)?, em(x)?, em(y)?),
319                        PathCommand::Close => PathCmd::Close,
320                    });
321                }
322                Item::Path(Path { x: em(*x)?, y: em(*y)?, commands: cmds, fill: *fill, color: color(c) })
323            }
324        });
325    }
326    Ok(MathList { items, width: em(dl.width)?.max(0.0), height: em(dl.height)?, depth: em(dl.depth)? })
327}
328
329fn glyph(x: f32, y: f32, size: f32, font: &str, code: u32, color: Option<[u8; 3]>) -> Result<Glyph, MathError> {
330    let fid = FontId::parse(font).unwrap_or(FontId::MainRegular);
331    let face = match fid {
332        FontId::MainBold | FontId::FrakturBold => Face::Bold,
333        FontId::MainItalic | FontId::MathItalic => Face::Italic,
334        FontId::MainBoldItalic | FontId::MathBoldItalic => Face::BoldItalic,
335        FontId::SansSerifRegular => Face::Sans,
336        FontId::SansSerifBold => Face::SansBold,
337        FontId::SansSerifItalic => Face::SansItalic,
338        FontId::TypewriterRegular => Face::Mono,
339        FontId::Size1Regular => Face::Large(1),
340        FontId::Size2Regular => Face::Large(2),
341        FontId::Size3Regular => Face::Large(3),
342        FontId::Size4Regular => Face::Large(4),
343        _ => Face::Roman,
344    };
345    let raw = char::from_u32(code).unwrap_or('\u{FFFD}');
346    // Blackboard, script and Fraktur letters sit at ASCII slots in TeX's fonts; draw the Unicode
347    // letter, with the ASCII one as the fallback.
348    let (ch, alt) = match (fid, raw) {
349        (FontId::AmsRegular, c @ ('A'..='Z' | 'k')) => (styled(c, Style::DoubleStruck), Some(c)),
350        (FontId::CaligraphicRegular | FontId::ScriptRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Script), Some(c)),
351        (FontId::FrakturRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Fraktur), Some(c)),
352        (FontId::FrakturBold, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::BoldFraktur), Some(c)),
353        (_, c) if (0x1D400..=0x1D7FF).contains(&code) => (c, Some(ratex_font::katex_ttf_glyph_char(fid, code))),
354        (_, c) => (c, None),
355    };
356    let m = ratex_font::font_and_metric_for_mathematical_alphanumeric(code)
357        .and_then(|(f, mc)| ratex_font::get_char_metrics(f, mc))
358        .or_else(|| ratex_font::get_char_metrics(fid, code))
359        .or_else(|| ratex_font::get_char_metrics_with_fallback(fid, code));
360    let (w, h, d) = m.map_or((0.5, 0.7, 0.0), |m| (m.width + m.italic, m.height, m.depth));
361    Ok(Glyph { x, y, size, ch, alt, face, width: em(w)? * size, height: em(h)? * size, depth: em(d)? * size, color })
362}
363
364#[derive(Clone, Copy)]
365enum Style {
366    DoubleStruck,
367    Script,
368    Fraktur,
369    BoldFraktur,
370}
371
372/// The Mathematical Alphanumeric Symbols letter for `c` (Unicode 15, chapter 22.2), using the
373/// Letterlike Symbols block where Unicode put a letter before the alphanumeric block existed.
374fn styled(c: char, style: Style) -> char {
375    let letterlike = match (style, c) {
376        (Style::DoubleStruck, 'C') => Some('\u{2102}'),
377        (Style::DoubleStruck, 'H') => Some('\u{210D}'),
378        (Style::DoubleStruck, 'N') => Some('\u{2115}'),
379        (Style::DoubleStruck, 'P') => Some('\u{2119}'),
380        (Style::DoubleStruck, 'Q') => Some('\u{211A}'),
381        (Style::DoubleStruck, 'R') => Some('\u{211D}'),
382        (Style::DoubleStruck, 'Z') => Some('\u{2124}'),
383        (Style::Script, 'B') => Some('\u{212C}'),
384        (Style::Script, 'E') => Some('\u{2130}'),
385        (Style::Script, 'F') => Some('\u{2131}'),
386        (Style::Script, 'H') => Some('\u{210B}'),
387        (Style::Script, 'I') => Some('\u{2110}'),
388        (Style::Script, 'L') => Some('\u{2112}'),
389        (Style::Script, 'M') => Some('\u{2133}'),
390        (Style::Script, 'R') => Some('\u{211B}'),
391        (Style::Script, 'e') => Some('\u{212F}'),
392        (Style::Script, 'g') => Some('\u{210A}'),
393        (Style::Script, 'o') => Some('\u{2134}'),
394        (Style::Fraktur, 'C') => Some('\u{212D}'),
395        (Style::Fraktur, 'H') => Some('\u{210C}'),
396        (Style::Fraktur, 'I') => Some('\u{2111}'),
397        (Style::Fraktur, 'R') => Some('\u{211C}'),
398        (Style::Fraktur, 'Z') => Some('\u{2128}'),
399        _ => None,
400    };
401    if let Some(l) = letterlike {
402        return l;
403    }
404    let (upper, lower) = match style {
405        Style::DoubleStruck => (0x1D538, 0x1D552),
406        Style::Script => (0x1D49C, 0x1D4B6),
407        Style::Fraktur => (0x1D504, 0x1D51E),
408        Style::BoldFraktur => (0x1D56C, 0x1D586),
409    };
410    let code = match c {
411        'A'..='Z' => upper + (c as u32 - 'A' as u32),
412        'a'..='z' => lower + (c as u32 - 'a' as u32),
413        _ => return c,
414    };
415    char::from_u32(code).unwrap_or(c)
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    fn glyphs(l: &MathList) -> String {
423        l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.ch) } else { None }).collect()
424    }
425
426    #[test]
427    fn boxes_of_a_simple_line() {
428        let l = layout("x+1").unwrap();
429        assert_eq!(glyphs(&l), "x+1");
430        assert!(l.width > 1.5 && l.width < 3.0, "{}", l.width);
431        assert!(l.height > 0.4 && l.height < 1.0);
432        let Some(Item::Glyph(x)) = l.items.first() else { panic!("a glyph first") };
433        assert_eq!(x.face, Face::Italic, "a letter is a math italic");
434        assert!((x.y - l.height).abs() < 1e-4, "on the baseline");
435        assert!(x.width > 0.3 && x.height > 0.3);
436        let one = l.items.iter().find_map(|i| if let Item::Glyph(g) = i { (g.ch == '1').then_some(g) } else { None }).unwrap();
437        assert_eq!(one.face, Face::Roman);
438    }
439
440    #[test]
441    fn fractions_roots_and_scripts() {
442        let f = layout("\\frac{a}{b}").unwrap();
443        let rule = f.items.iter().find_map(|i| if let Item::Rule(r) = i { Some(r) } else { None }).expect("a fraction bar");
444        assert!(rule.width > 0.3 && rule.height > 0.0 && rule.height < 0.1);
445        let (a, b) = match &f.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.clone()) } else { None }).collect::<Vec<_>>()[..] {
446            [a, b] => (a.clone(), b.clone()),
447            other => panic!("{other:?}"),
448        };
449        assert!(a.y < rule.y && b.y > rule.y + rule.height, "numerator above, denominator below");
450        assert!(f.depth > 0.3);
451        let s = layout("x^2").unwrap();
452        let g: Vec<&Glyph> = s.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g) } else { None }).collect();
453        assert!(g[1].size < 1.0 && g[1].y < g[0].y, "the exponent is smaller and raised");
454        let r = layout("\\sqrt{x}").unwrap();
455        assert!(glyphs(&r).contains('x'));
456        assert!(r.items.len() >= 2);
457        let big = layout("\\displaystyle\\sum_{i=1}^n i").unwrap();
458        assert!(big.items.iter().any(|i| matches!(i, Item::Glyph(Glyph { ch: '∑', face: Face::Large(_), .. }))));
459    }
460
461    #[test]
462    fn styled_letters_map_to_unicode_with_a_plain_fallback() {
463        let l = layout("\\mathbb{R}\\mathbb{Q}\\mathcal{L}\\mathfrak{g}").unwrap();
464        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();
465        assert_eq!(g, vec![('ℝ', Some('R')), ('ℚ', Some('Q')), ('ℒ', Some('L')), ('𝔤', Some('g'))]);
466        assert_eq!(styled('A', Style::DoubleStruck), '𝔸');
467        assert_eq!(styled('b', Style::Script), '𝒷');
468        assert_eq!(styled('7', Style::Fraktur), '7');
469    }
470
471    #[test]
472    fn colours_other_than_black_are_kept() {
473        let l = layout("{\\color{red}x}y").unwrap();
474        let c: Vec<Option<[u8; 3]>> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.color) } else { None }).collect();
475        assert_eq!(c, vec![Some([255, 0, 0]), None]);
476    }
477
478    #[test]
479    fn errors_point_at_the_source() {
480        let e = layout("a + \\frac{1}{").unwrap_err();
481        assert!(!e.message.is_empty());
482        let e = layout("x + \\foo").unwrap_err();
483        assert_eq!(e.span, Some(4..8), "{e:?}");
484        assert_eq!(e.char_pos("x + \\foo"), Some(5));
485        let e = layout("é + \\foo").unwrap_err();
486        assert_eq!(e.char_pos("é + \\foo"), Some(5), "positions count characters, not bytes");
487        assert!(check("\\frac{a}{b}").is_ok());
488        assert!(check("\\frac{a}{").is_err());
489    }
490
491    #[test]
492    fn hostile_input_is_refused_without_panicking() {
493        let e = layout(&"x".repeat(MAX_SOURCE_BYTES + 1)).unwrap_err();
494        assert!(e.message.contains("4096"));
495        assert!(layout(&"x".repeat(MAX_SOURCE_BYTES)).is_ok());
496        let deep = format!("{}x{}", "{".repeat(33), "}".repeat(33));
497        assert_eq!(layout(&deep).unwrap_err().span, Some(32..33));
498        assert!(layout(&format!("{}x{}", "\\left(".repeat(40), "\\right)".repeat(40))).is_err());
499        // Escaped braces and comments are not groups.
500        assert!(check_caps(&"\\{".repeat(100)).is_ok());
501        assert!(check_caps(&format!("x % {}\n", "{".repeat(100))).is_ok());
502        assert!(layout("\\rule{99999999em}{1em}").unwrap_err().message.contains("too large"));
503        assert!(layout("\\kern99999999em x").is_err());
504        assert!(layout("\\def\\a{\\a\\a}\\a").is_err());
505        for s in ["", " ", "\\", "{", "}", "^", "_", "&", "\\\\", "\u{0}", "\u{FFFF}", "\u{1F600}", "$", "#", "\\begin{matrix}", "\\end{matrix}"] {
506            let _ = layout(s);
507            let _ = check(s);
508            let _ = to_omml(s, 11.0, None);
509        }
510    }
511
512    #[test]
513    fn an_injected_panic_is_an_error_not_a_crash() {
514        INJECT_PANIC.with(|p| p.set(true));
515        let e = layout("x").unwrap_err();
516        assert_eq!(e.message, "This equation could not be laid out.");
517        assert!(layout("x").is_ok(), "the next call is unaffected");
518    }
519
520    #[test]
521    fn the_gallery_lays_out_and_converts() {
522        for (name, src) in GALLERY {
523            assert!(layout(src).is_ok_and(|l| !l.items.is_empty()), "{name}");
524            assert!(to_omml(src, 11.0, None).is_ok(), "{name}");
525        }
526    }
527
528    #[test]
529    fn empty_source_is_an_empty_list() {
530        let l = layout("").unwrap();
531        assert!(l.items.is_empty());
532    }
533}