//! Math expressions: a LaTeX subset typeset into boxes and written as SVG (text in the bundled //! serif, rules and radicals as paths). Covers fractions, super/subscripts, square and n-th roots, //! big operators with limits, Greek letters, common symbols, `\text{}`, `\mathrm{}`, spacing //! commands and `\left( … \right)`. use std::fmt::Write as _; use designcraft_fonts::{FontDb, FontFace}; /// A positioned piece of the formula (baseline-relative: y grows downwards). #[derive(Clone, Debug, PartialEq)] enum Elem { Text { x: f64, y: f64, s: String, size: f64, italic: bool, }, Rule { x: f64, y: f64, w: f64, h: f64, }, /// A polyline (radical sign), stroke width `w`. Line { pts: Vec<(f64, f64)>, w: f64, }, } /// A laid-out box: width, height above and depth below the baseline. #[derive(Clone, Debug, Default)] struct MBox { w: f64, asc: f64, desc: f64, els: Vec, } impl MBox { fn shifted(mut self, dx: f64, dy: f64) -> Vec { for e in &mut self.els { match e { Elem::Text { x, y, .. } | Elem::Rule { x, y, .. } => { *x += dx; *y += dy; } Elem::Line { pts, .. } => { for p in pts { p.0 += dx; p.1 += dy; } } } } self.els } } #[derive(Clone, Debug, PartialEq)] enum Node { /// A character run: identifiers italic, numbers and symbols upright. Sym(String, Class), Group(Vec), Frac(Box, Box), Sqrt(Option>, Box), Scripts { base: Box, sup: Option>, sub: Option>, }, Text(String), Space(f64), /// A big operator (∑ ∏ ∫): drawn larger, limits as scripts. Big(String), /// `\left( … \right)`: delimiters sized to the content. Fenced(String, Box, String), } #[derive(Clone, Copy, Debug, PartialEq)] enum Class { Ident, Number, /// Binary operator (+ − × ·): medium space both sides. Bin, /// Relation (= < ≤ →): thick space both sides. Rel, Punct, Plain, } fn symbol(name: &str) -> Option<(&'static str, Class)> { use Class::*; Some(match name { "alpha" => ("α", Ident), "beta" => ("β", Ident), "gamma" => ("γ", Ident), "delta" => ("δ", Ident), "epsilon" | "varepsilon" => ("ε", Ident), "zeta" => ("ζ", Ident), "eta" => ("η", Ident), "theta" => ("θ", Ident), "iota" => ("ι", Ident), "kappa" => ("κ", Ident), "lambda" => ("λ", Ident), "mu" => ("μ", Ident), "nu" => ("ν", Ident), "xi" => ("ξ", Ident), "pi" => ("π", Ident), "rho" => ("ρ", Ident), "sigma" => ("σ", Ident), "tau" => ("τ", Ident), "phi" | "varphi" => ("φ", Ident), "chi" => ("χ", Ident), "psi" => ("ψ", Ident), "omega" => ("ω", Ident), "Gamma" => ("Γ", Plain), "Delta" => ("Δ", Plain), "Theta" => ("Θ", Plain), "Lambda" => ("Λ", Plain), "Pi" => ("Π", Plain), "Sigma" => ("Σ", Plain), "Phi" => ("Φ", Plain), "Psi" => ("Ψ", Plain), "Omega" => ("Ω", Plain), "times" => ("×", Bin), "cdot" => ("·", Bin), "pm" => ("±", Bin), "mp" => ("∓", Bin), "div" => ("÷", Bin), "le" | "leq" => ("≤", Rel), "ge" | "geq" => ("≥", Rel), "neq" | "ne" => ("≠", Rel), "approx" => ("≈", Rel), "equiv" => ("≡", Rel), "to" | "rightarrow" => ("→", Rel), "leftarrow" => ("←", Rel), "Rightarrow" => ("⇒", Rel), "in" => ("∈", Rel), "infty" => ("∞", Plain), "partial" => ("∂", Plain), "nabla" => ("∇", Plain), "ldots" | "dots" => ("…", Plain), "cdots" => ("⋯", Plain), "prime" => ("′", Plain), "degree" => ("°", Plain), _ => return None, }) } fn big_op(name: &str) -> Option<&'static str> { Some(match name { "sum" => "∑", "prod" => "∏", "int" => "∫", "oint" => "∮", _ => return None, }) } struct Parser<'a> { s: &'a [char], i: usize, } impl Parser<'_> { fn peek(&self) -> Option { self.s.get(self.i).copied() } fn skip_ws(&mut self) { while self.peek().is_some_and(char::is_whitespace) { self.i += 1; } } fn command(&mut self) -> String { let mut name = String::new(); if let Some(c) = self.peek() && !c.is_ascii_alphabetic() { self.i += 1; return c.to_string(); } while let Some(c) = self.peek().filter(char::is_ascii_alphabetic) { name.push(c); self.i += 1; } name } /// Raw text of a `{…}` argument. fn raw_group(&mut self) -> String { self.skip_ws(); if self.peek() != Some('{') { return self .peek() .map(|c| { self.i += 1; c.to_string() }) .unwrap_or_default(); } self.i += 1; let mut depth = 1; let mut out = String::new(); while let Some(c) = self.peek() { self.i += 1; match c { '{' => depth += 1, '}' => { depth -= 1; if depth == 0 { break; } } _ => {} } out.push(c); } out } fn arg(&mut self) -> Node { self.skip_ws(); if self.peek() == Some('{') { self.i += 1; let g = self.list(Some('}')); Node::Group(g) } else { self.atom().unwrap_or(Node::Group(vec![])) } } fn delimiter(&mut self) -> String { self.skip_ws(); match self.peek() { Some('\\') => { self.i += 1; match self.command().as_str() { "{" => "{".into(), "}" => "}".into(), "|" => "‖".into(), "langle" => "⟨".into(), "rangle" => "⟩".into(), _ => String::new(), } } Some('.') => { self.i += 1; String::new() } Some(c) => { self.i += 1; c.to_string() } None => String::new(), } } fn atom(&mut self) -> Option { self.skip_ws(); let c = self.peek()?; self.i += 1; Some(match c { '{' => Node::Group(self.list(Some('}'))), '\\' => { let name = self.command(); match name.as_str() { "frac" | "dfrac" | "tfrac" => { let a = self.arg(); let b = self.arg(); Node::Frac(Box::new(a), Box::new(b)) } "sqrt" => { self.skip_ws(); let idx = (self.peek() == Some('[')).then(|| { self.i += 1; let mut s = String::new(); while let Some(c) = self.peek() { self.i += 1; if c == ']' { break; } s.push(c); } Box::new(parse(&s)) }); let a = self.arg(); Node::Sqrt(idx, Box::new(a)) } "text" | "mathrm" | "textrm" | "operatorname" => Node::Text(self.raw_group()), "sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => Node::Text(name), "," => Node::Space(3.0 / 18.0), ":" | ">" => Node::Space(4.0 / 18.0), ";" => Node::Space(5.0 / 18.0), "quad" => Node::Space(1.0), "qquad" => Node::Space(2.0), "!" => Node::Space(-3.0 / 18.0), " " => Node::Space(0.25), "left" => { let open = self.delimiter(); let body = self.list_until_right(); let close = self.delimiter(); Node::Fenced(open, Box::new(Node::Group(body)), close) } "{" => Node::Sym("{".into(), Class::Punct), "}" => Node::Sym("}".into(), Class::Punct), "%" | "$" | "#" | "&" | "_" => Node::Sym(name, Class::Plain), n => match (big_op(n), symbol(n)) { (Some(op), _) => Node::Big(op.into()), (None, Some((s, cl))) => Node::Sym(s.into(), cl), _ => Node::Text(format!("\\{n}")), }, } } c if c.is_ascii_digit() || c == '.' => { let mut s = c.to_string(); while let Some(d) = self.peek().filter(|d| d.is_ascii_digit() || *d == '.') { s.push(d); self.i += 1; } Node::Sym(s, Class::Number) } c if c.is_alphabetic() => Node::Sym(c.to_string(), Class::Ident), '+' => Node::Sym("+".into(), Class::Bin), '-' => Node::Sym("−".into(), Class::Bin), '*' => Node::Sym("∗".into(), Class::Bin), '=' | '<' | '>' => Node::Sym(c.to_string(), Class::Rel), ',' | ';' => Node::Sym(c.to_string(), Class::Punct), '\'' => Node::Sym("′".into(), Class::Plain), c => Node::Sym(c.to_string(), Class::Plain), }) } fn list(&mut self, end: Option) -> Vec { let mut out: Vec = Vec::new(); loop { self.skip_ws(); match self.peek() { None => break, Some(c) if Some(c) == end => { self.i += 1; break; } Some('^') | Some('_') => { let up = self.peek() == Some('^'); self.i += 1; let a = self.arg(); let base = out.pop().unwrap_or(Node::Group(vec![])); let node = match base { Node::Scripts { base, sup, sub } => { if up { Node::Scripts { base, sup: Some(Box::new(a)), sub } } else { Node::Scripts { base, sup, sub: Some(Box::new(a)) } } } b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, }; out.push(node); } Some(_) => { if let Some(a) = self.atom() { out.push(a); } } } } out } fn list_until_right(&mut self) -> Vec { let mut out = Vec::new(); loop { self.skip_ws(); if self.peek().is_none() { break; } if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) { self.i += 6; break; } let mut p = Parser { s: self.s, i: self.i }; let one = p.list_one(); self.i = p.i; out.extend(one); } out } /// One atom plus any scripts on it. fn list_one(&mut self) -> Vec { let mut out = Vec::new(); if let Some(a) = self.atom() { out.push(a); } loop { self.skip_ws(); match self.peek() { Some('^') | Some('_') => { let up = self.peek() == Some('^'); self.i += 1; let a = self.arg(); let base = out.pop().unwrap_or(Node::Group(vec![])); out.push(match base { Node::Scripts { base, sub, .. } if up => Node::Scripts { base, sup: Some(Box::new(a)), sub }, Node::Scripts { base, sup, .. } => Node::Scripts { base, sup, sub: Some(Box::new(a)) }, b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, }); } _ => break, } } out } } fn parse(src: &str) -> Node { let chars: Vec = src.chars().collect(); let mut p = Parser { s: &chars, i: 0 }; Node::Group(p.list(None)) } /// Typesetting fonts and metrics. struct Fonts { upright: std::sync::Arc, italic: std::sync::Arc, fallback: std::sync::Arc, } impl Fonts { fn face(&self, c: char, italic: bool) -> &FontFace { let f = if italic { &self.italic } else { &self.upright }; if f.glyph_for(c) != 0 { f } else { &self.fallback } } fn width(&self, s: &str, size: f64, italic: bool) -> f64 { s.chars() .map(|c| { let f = self.face(c, italic); f.advance(f.glyph_for(c)) / f.units_per_em().max(1.0) * size }) .sum() } } const FAMILY: &str = designcraft_fonts::DEFAULT_FAMILY; const FALLBACK: &str = "Source Sans 3"; fn text_box(f: &Fonts, s: &str, size: f64, italic: bool) -> MBox { let w = f.width(s, size, italic); MBox { w, asc: size * 0.72, desc: size * 0.22, els: vec![Elem::Text { x: 0.0, y: 0.0, s: s.to_string(), size, italic }] } } fn hbox(parts: Vec<(MBox, f64)>) -> MBox { // (box, space before) laid out along the baseline. let mut out = MBox::default(); let mut x = 0.0; for (b, before) in parts { x += before; out.asc = out.asc.max(b.asc); out.desc = out.desc.max(b.desc); let w = b.w; out.els.extend(b.shifted(x, 0.0)); x += w; } out.w = x.max(0.0); out } fn layout(f: &Fonts, n: &Node, size: f64) -> MBox { let axis = size * 0.25; match n { Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), Node::Text(s) => text_box(f, s, size, false), Node::Space(em) => MBox { w: em * size, ..Default::default() }, Node::Group(list) => { let mut parts = Vec::new(); let mut prev: Option = None; for (k, item) in list.iter().enumerate() { let cl = match item { Node::Sym(_, c) => Some(*c), Node::Scripts { base, .. } => match &**base { Node::Sym(_, c) => Some(*c), _ => None, }, _ => None, }; // A leading + or − (or after another operator) is a sign, not a binary operator. let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { Some(Class::Plain) } else { cl }; let gap = |c: Option| match c { Some(Class::Bin) => size * 4.0 / 18.0, Some(Class::Rel) => size * 5.0 / 18.0, _ => 0.0, }; let mut before = gap(cl).max(gap(prev)); if k == 0 { before = 0.0; } if prev == Some(Class::Punct) { before = before.max(size * 3.0 / 18.0); } parts.push((layout(f, item, size), before)); prev = cl; } hbox(parts) } Node::Frac(a, b) => { let s2 = size * 0.85; let (na, nb) = (layout(f, a, s2), layout(f, b, s2)); let w = na.w.max(nb.w) + size * 0.2; let t = (size * 0.06).max(0.4); let gap = size * 0.15; let num_base = -(axis + t / 2.0 + gap + na.desc); let den_base = -axis + t / 2.0 + gap + nb.asc; let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); let (wa, wb) = (na.w, nb.w); out.els.extend(na.shifted((w - wa) / 2.0, num_base)); out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); out } Node::Sqrt(idx, a) => { let inner = layout(f, a, size); let t = (size * 0.05).max(0.4); let gap = size * 0.12; let top = inner.asc + gap + t; let bottom = inner.desc; let hook = size * 0.55; let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; // The radical: a short rise, the long stroke down, then up to the bar. out.els.push(Elem::Line { pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], w: t, }); let iw = inner.w; let _ = iw; out.els.extend(inner.shifted(hook, 0.0)); if let Some(i) = idx { let ib = layout(f, i, size * 0.55); let (ia, iw2) = (ib.asc, ib.w); out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); out.asc = out.asc.max(axis + size * 0.1 + ia); } out } Node::Big(op) => { let mut b = text_box(f, op, size * 1.5, false); b.asc = size * 1.05; b.desc = size * 0.35; for e in &mut b.els { if let Elem::Text { y, .. } = e { // Centre the glyph on the math axis. *y = size * 0.32; } } b } Node::Scripts { base, sup, sub } => { let big = matches!(&**base, Node::Big(_)); let b = layout(f, base, size); let s2 = size * 0.7; let (bw, basc, bdesc) = (b.w, b.asc, b.desc); let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; if big { // Limits above and below. let mut w = bw; if let Some(sp) = sup { let sb = layout(f, sp, s2); let y = -basc - size * 0.1 - sb.desc; w = w.max(sb.w); let (sw, sa) = (sb.w, sb.asc); out.els.extend(sb.shifted((bw - sw) / 2.0, y)); out.asc = out.asc.max(-y + sa); } if let Some(sbn) = sub { let sb = layout(f, sbn, s2); let y = bdesc + size * 0.1 + sb.asc; w = w.max(sb.w); let (sw, sd) = (sb.w, sb.desc); out.els.extend(sb.shifted((bw - sw) / 2.0, y)); out.desc = out.desc.max(y + sd); } // Centre the operator over the widest limit. let dx = (w - bw) / 2.0; let els = std::mem::take(&mut out.els); out = MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; return out; } let mut w = bw; if let Some(sp) = sup { let sb = layout(f, sp, s2); let y = -(size * 0.42).max(basc - sb.asc * 0.6); w = w.max(bw + sb.w + size * 0.05); let sa = sb.asc; out.els.extend(sb.shifted(bw + size * 0.05, y)); out.asc = out.asc.max(-y + sa); } if let Some(sbn) = sub { let sb = layout(f, sbn, s2); let y = (size * 0.2).max(bdesc * 0.8); w = w.max(bw + sb.w + size * 0.05); let sd = sb.desc; out.els.extend(sb.shifted(bw + size * 0.05, y)); out.desc = out.desc.max(y + sd); } out.w = w; out } Node::Fenced(open, body, close) => { let inner = layout(f, body, size); let h = (inner.asc + inner.desc).max(size); let k = h / size; let ds = size * k.max(1.0); let mk = |s: &str| -> MBox { if s.is_empty() { return MBox::default(); } let mut b = text_box(f, s, ds, false); // Scaled delimiters centred on the content. let mid = (inner.desc - inner.asc) / 2.0; for e in &mut b.els { if let Elem::Text { y, .. } = e { *y = mid + ds * 0.25; } } b.asc = inner.asc.max(size * 0.72); b.desc = inner.desc.max(size * 0.22); b }; let (l, r) = (mk(open), mk(close)); hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) } } } /// Typeset `latex` at `size` points: SVG bytes and the size in points. pub fn to_svg(latex: &str, size: f64) -> (Vec, (f64, f64)) { let db = FontDb::global(); let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") }; let b = layout(&f, &parse(latex), size); let pad = size * 0.1; let (w, h) = (b.w + 2.0 * pad, b.asc + b.desc + 2.0 * pad); let base = pad + b.asc; let mut svg = String::new(); let _ = write!(svg, ""); let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); for e in &b.els { match e { Elem::Text { x, y, s, size, italic } => { // Per character, so each uses a face that has it. let mut cx = *x; for c in s.chars() { let face = f.face(c, *italic); let fam = if std::ptr::eq(face, &*f.fallback) { FALLBACK } else { FAMILY }; let it = *italic && !std::ptr::eq(face, &*f.fallback); let _ = write!( svg, "{}", pad + cx, base + y, if it { " font-style=\"italic\"" } else { "" }, esc(&c.to_string()) ); cx += f.width(&c.to_string(), *size, *italic); } } Elem::Rule { x, y, w: rw, h: rh } => { let _ = write!(svg, "", pad + x, base + y); } Elem::Line { pts, w: lw } => { let d: Vec = pts.iter().map(|(x, y)| format!("{:.3},{:.3}", pad + x, base + y)).collect(); let _ = write!( svg, "", d.join(" ") ); } } } svg.push_str(""); (svg.into_bytes(), (w, h)) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_the_common_constructs() { let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}"); let Node::Group(v) = n else { panic!() }; assert!(matches!(&v[0], Node::Frac(_, _))); let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha"); let Node::Group(v) = n else { panic!() }; assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_)))); assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel))); assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident))); let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x"); let Node::Group(v) = n else { panic!() }; assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")")); assert!(v.contains(&Node::Text("if ".into()))); } #[test] fn layout_sizes_and_svg() { let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0); let s = String::from_utf8(svg).unwrap(); assert!(s.starts_with("E<") && s.contains(">2<")); assert!(w > 30.0 && h > 10.0, "{w} {h}"); // A fraction is taller than its numerator alone; a big operator with limits taller still. let (_, (_, h1)) = to_svg("x", 12.0); let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0); let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0); assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}"); // The SVG parses and rasterises. let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); assert!(designcraft_images::svg_tree(&svg).is_some()); } }