1#![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
27pub const MAX_SOURCE_BYTES: usize = 4096;
29pub const MAX_DEPTH: usize = 32;
31pub const MAX_EM: f32 = 10_000.0;
34pub const MAX_ITEMS: usize = 50_000;
36
37pub 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#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct MathError {
45 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 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#[derive(Clone, Debug, Default, PartialEq)]
72pub struct MathList {
73 pub items: Vec<Item>,
74 pub width: f32,
76 pub height: f32,
78 pub depth: f32,
80}
81
82impl MathList {
83 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#[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 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#[derive(Clone, Debug, PartialEq)]
124pub struct Glyph {
125 pub x: f32,
127 pub y: f32,
129 pub size: f32,
131 pub ch: char,
133 pub alt: Option<char>,
135 pub face: Face,
136 pub width: f32,
139 pub height: f32,
140 pub depth: f32,
141 pub color: Option<[u8; 3]>,
143}
144
145#[derive(Clone, Debug, PartialEq)]
147pub struct Rule {
148 pub x: f32,
149 pub y: f32,
151 pub width: f32,
152 pub height: f32,
153 pub color: Option<[u8; 3]>,
154}
155
156#[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
175pub 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
185pub fn check(latex: &str) -> Result<(), MathError> {
187 check_caps(latex)?;
188 guarded(|| parse(latex).map(|_| ()))
189}
190
191pub 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
201fn 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 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
226fn 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 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 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
289fn 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 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
372fn 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 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}