//! Equation layout: the [`wordcraft_doc::math`] tree → positioned glyphs and rules. //! //! Follows the OpenType `MATH` model (the one Word's own math engine and Cambria Math were built //! around): font constants for script shifts, fraction and radical gaps, limits and rules; size //! variants and glyph assemblies for stretchy delimiters, radicals, big operators and wide //! accents; atom classes with TeX/Word inter-atom spacing. Without a math font the layout uses //! fixed proportions of the em. //! //! Coordinates: x right from the equation's left edge, y *up* from its baseline. use std::sync::Arc; use wordcraft_doc::math::{ Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, MathProps, ScriptKind, is_integral, math_alnum, parse_linear, }; use wordcraft_doc::props::{Rgb, TextColor}; use wordcraft_doc::resolve::ResolvedChar; use wordcraft_fonts::math::{C, Construction, MathTable, glyph_bounds}; use wordcraft_fonts::{FaceRef, FontDb}; /// Something to draw. #[derive(Clone, Debug)] pub enum MItem { Glyphs { face: FaceRef, size: f32, color: Rgb, synth_bold: bool, synth_italic: bool, /// Glyph id, x, y (baseline, up). glyphs: Vec<(u32, f32, f32)>, text: String, }, /// A filled rectangle; `y` is its bottom edge. Rect { x: f32, y: f32, w: f32, h: f32, color: Rgb, }, Line { x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dotted: bool, }, /// Drawn on screen only (placeholders for empty slots, the empty-equation hint). ScreenOnly(Box), } impl MItem { fn shift(&mut self, dx: f32, dy: f32) { match self { MItem::Glyphs { glyphs, .. } => { for g in glyphs { g.1 += dx; g.2 += dy; } } MItem::Rect { x, y, .. } => { *x += dx; *y += dy; } MItem::Line { x0, y0, x1, y1, .. } => { *x0 += dx; *x1 += dx; *y0 += dy; *y1 += dy; } MItem::ScreenOnly(i) => i.shift(dx, dy), } } } /// A caret position inside an equation, where it is drawn: `x`, the baseline `y` (up from the /// equation's baseline) and the caret's extent above (`a`) and below (`d`) it. #[derive(Clone, Debug, PartialEq)] pub struct Slot { pub path: Vec<(usize, usize)>, pub off: usize, pub x: f32, pub y: f32, pub a: f32, pub d: f32, } /// A laid-out equation. #[derive(Clone, Debug, Default)] pub struct MathLayout { pub width: f32, /// Extent above and below the baseline, points. pub ascent: f32, pub descent: f32, pub items: Vec, /// Every caret position, for drawing the caret and hit testing. pub slots: Vec, } impl MathLayout { /// Where the caret at (`path`, `off`) is drawn. pub fn slot(&self, path: &[(usize, usize)], off: usize) -> Option<&Slot> { self.slots .iter() .find(|s| s.path == path && s.off == off) .or_else(|| self.slots.iter().filter(|s| s.path == path).min_by_key(|s| s.off.abs_diff(off))) } /// The caret position nearest to (`x`, `y`) (y up from the baseline). pub fn hit(&self, x: f32, y: f32) -> Option<&Slot> { self.slots.iter().min_by(|a, b| slot_dist(a, x, y).total_cmp(&slot_dist(b, x, y))) } /// The extent of the argument at `path` (a numerator, a radicand…): left and right x, and /// how far it reaches above and below the equation's baseline. Spans its caret positions, /// within the equation. pub fn arg_extent(&self, path: &[(usize, usize)]) -> Option<(f32, f32, f32, f32)> { let mut slots = self.slots.iter().filter(|s| s.path == path); let first = slots.next()?; let init = (first.x, first.x, first.y + first.a, first.d - first.y); let (x0, x1, up, down) = slots.fold(init, |(x0, x1, up, down), s| (x0.min(s.x), x1.max(s.x), up.max(s.y + s.a), down.max(s.d - s.y))); Some((x0, x1, up.min(self.ascent), down.min(self.descent))) } } fn slot_dist(s: &Slot, x: f32, y: f32) -> f32 { let (top, bottom) = (s.y + s.a, s.y - s.d); let dy = if y > top { y - top } else if y < bottom { bottom - y } else { 0.0 }; // Inside a slot's height, prefer the innermost (shortest) argument. let h = (s.a + s.d).max(0.1); (s.x - x).abs() + dy * 3.0 + if dy == 0.0 { h * 0.05 } else { 0.0 } } /// Numbered rows (`#`) in an equation: they advance the automatic numbering. pub fn auto_numbers(math: &wordcraft_doc::math::Math, display: bool) -> u32 { if !display { return 0; } match math.nodes.as_slice() { [MNode::EqArr { rows }] => rows.iter().filter(|r| split_number(r).is_some()).count() as u32, _ => 0, } } /// A row's equation and number parts around its top-level `#`, and the units before the `#`. fn split_number(row: &[MNode]) -> Option<(Arg, Arg, usize)> { let mut eq = Vec::new(); let mut units = 0usize; for (i, n) in row.iter().enumerate() { if let MNode::Run(r) = n && !r.lit && let Some(b) = r.text.find('#') { let mut left = r.clone(); left.text = r.text.get(..b).unwrap_or("").to_string(); let mut right = r.clone(); right.text = r.text.get(b + 1..).unwrap_or("").to_string(); units += left.text.chars().count(); if !left.text.is_empty() { eq.push(MNode::Run(left)); } let mut num = Vec::new(); if !right.text.is_empty() { num.push(MNode::Run(right)); } num.extend(row.get(i + 1..).unwrap_or(&[]).iter().cloned()); return Some((eq, num, units)); } units += match n { MNode::Run(r) => r.text.chars().count(), _ => 1, }; eq.push(n.clone()); } None } /// Lay out an equation set at the size and colour of `rc`. `display`: an equation on a line of /// its own (bigger operators, limits above and below, full-size fractions). /// `avail` is the width of the line (numbered display equations fill it, wider display equations /// wrap to it as `props` says); `counter` counts the document's automatic equation numbers so far /// and advances past this equation's. pub fn layout_equation( math: &wordcraft_doc::math::Math, linear: &str, rc: &ResolvedChar, display: bool, avail: f32, props: &MathProps, counter: &mut u32, ) -> MathLayout { let parsed; let nodes: &Arg = if math.nodes.is_empty() && !math.linear { parsed = parse_linear(linear); &parsed } else { &math.nodes }; let (face, table) = wordcraft_fonts::math::resolve_math("Cambria Math"); let color = match rc.color { TextColor::Rgb(c) => c, TextColor::Auto => Rgb::BLACK, }; let base = rc.draw_size().clamp(1.0, 1638.0); let mut cx = Ctx { face, t: table, upem: face.upem.max(1.0) as f32, base, color, text_font: rc.font.clone(), nodes: 0, path: Vec::new(), node_stack: Vec::new(), }; let st = St { level: 0, display, cramped: false, upright: false }; let avail = if avail.is_finite() { avail.clamp(0.0, 1e5) } else { 0.0 }; let b = match nodes.as_slice() { [] => cx.hint(st), [MNode::EqArr { rows }] if display && rows.iter().any(|r| split_number(r).is_some()) => cx.numbered(rows, st, avail, counter), _ if display => cx.wrapped(nodes, st, avail, props), _ => cx.arg(nodes, st, 0), }; let mut slots = b.slots; let em = base; for s in &mut slots { if s.a <= 0.0 && s.d <= 0.0 { s.a = em * 0.75; s.d = em * 0.25; } } MathLayout { width: b.w.max(0.0), ascent: b.a.max(0.0), descent: b.d.max(0.0), items: b.items, slots } } /// Layout style: script level (0 = text size, 1 = script, 2 = script-script), display or inline, /// cramped (superscripts lowered: inside radicals, denominators, subscripts). #[derive(Clone, Copy, Debug)] struct St { level: u8, display: bool, cramped: bool, /// Letters default to upright (function names). upright: bool, } impl St { fn script(self) -> St { St { level: (self.level + 1).min(2), display: false, ..self } } fn cramp(self) -> St { St { cramped: true, ..self } } /// Numerator / denominator style. fn frac(self) -> St { if self.display { St { display: false, ..self } } else { self.script() } } } /// TeX atom classes (spacing). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] enum Cls { #[default] Ord, Op, Bin, Rel, Open, Close, Punct, Inner, } impl Cls { fn idx(self) -> usize { self as usize } } /// Inter-atom space in mu (1/18 em): TeX's table; negative = only outside scripts. const SPACING: [[i8; 8]; 8] = [ // Ord Op Bin Rel Open Close Punct Inner [0, 3, -4, -5, 0, 0, 0, -3], // Ord [3, 3, 0, -5, 0, 0, 0, -3], // Op [-4, -4, 0, 0, -4, 0, 0, -4], // Bin [-5, -5, 0, 0, -5, 0, 0, -5], // Rel [0, 0, 0, 0, 0, 0, 0, 0], // Open [0, 3, -4, -5, 0, 0, 0, -3], // Close [-3, -3, 0, -3, -3, -3, -3, -3], // Punct [-3, 3, -4, -5, -3, 0, -3, -3], // Inner ]; fn spacing_mu(l: Cls, r: Cls, st: St) -> f32 { let v = SPACING.get(l.idx()).and_then(|row| row.get(r.idx())).copied().unwrap_or(0); if v < 0 && st.level > 0 { 0.0 } else { v.unsigned_abs() as f32 } } /// A box under construction. #[derive(Clone, Debug, Default)] struct Bx { w: f32, a: f32, d: f32, items: Vec, /// Italic correction after the box (superscripts move right by it). ic: f32, first: Cls, last: Cls, /// The box is one glyph: (face, glyph, top-accent attachment x). glyph: Option<(FaceRef, u32, f32)>, /// Takes no part in inter-atom spacing (spaces). transparent: bool, /// Caret positions inside, relative to the box. slots: Vec, /// Text atoms: x of each character boundary (one more than the characters). cuts: Vec, /// A manual line break before this atom (`m:brk`; the value is its `m:alnAt`). brk: Option, } impl Bx { fn cls(mut self, c: Cls) -> Bx { self.first = c; self.last = c; self } /// Draw `o` at (dx, dy) into this box (extents grow; width is the caller's business). fn put(&mut self, o: Bx, dx: f32, dy: f32) { self.a = self.a.max(o.a + dy); self.d = self.d.max(o.d - dy); for mut it in o.items { it.shift(dx, dy); self.items.push(it); } for mut sl in o.slots { sl.x += dx; sl.y += dy; self.slots.push(sl); } } fn slot(&mut self, path: &[(usize, usize)], off: usize, x: f32) { self.slots.push(Slot { path: path.to_vec(), off, x, y: 0.0, a: 0.0, d: 0.0 }); } fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) { if w > 0.0 && h > 0.0 { self.items.push(MItem::Rect { x, y, w, h, color }); self.a = self.a.max(y + h); self.d = self.d.max(-y); } } } struct Ctx { face: FaceRef, t: Option>, upem: f32, base: f32, color: Rgb, text_font: String, /// Nodes laid out so far (caps work on hostile input). nodes: usize, /// The argument being laid out (for caret slots). path: Vec<(usize, usize)>, /// Index of the node being laid out in its argument, innermost last. node_stack: Vec, } const MAX_NODES: usize = 200_000; /// Constants as fractions of the em when there's no `MATH` table. fn fallback(c: C) -> f32 { match c { C::MathLeading => 0.15, C::AxisHeight => 0.25, C::AccentBaseHeight => 0.45, C::FlattenedAccentBaseHeight => 0.66, C::SubscriptShiftDown => 0.2, C::SubscriptTopMax => 0.36, C::SubscriptBaselineDropMin => 0.05, C::SuperscriptShiftUp => 0.36, C::SuperscriptShiftUpCramped => 0.29, C::SuperscriptBottomMin => 0.11, C::SuperscriptBaselineDropMax => 0.25, C::SubSuperscriptGapMin => 0.2, C::SuperscriptBottomMaxWithSubscript => 0.36, C::SpaceAfterScript => 0.05, C::UpperLimitGapMin => 0.1, C::UpperLimitBaselineRiseMin => 0.2, C::LowerLimitGapMin => 0.1, C::LowerLimitBaselineDropMin => 0.6, C::StackTopShiftUp => 0.44, C::StackTopDisplayStyleShiftUp => 0.68, C::StackBottomShiftDown => 0.34, C::StackBottomDisplayStyleShiftDown => 0.69, C::StackGapMin => 0.12, C::StackDisplayStyleGapMin => 0.28, C::StretchStackTopShiftUp => 0.2, C::StretchStackBottomShiftDown => 0.6, C::StretchStackGapAboveMin => 0.1, C::StretchStackGapBelowMin => 0.1, C::FractionNumeratorShiftUp => 0.39, C::FractionNumeratorDisplayStyleShiftUp => 0.68, C::FractionDenominatorShiftDown => 0.34, C::FractionDenominatorDisplayStyleShiftDown => 0.69, C::FractionNumeratorGapMin => 0.04, C::FractionNumDisplayStyleGapMin => 0.12, C::FractionRuleThickness => 0.05, C::FractionDenominatorGapMin => 0.04, C::FractionDenomDisplayStyleGapMin => 0.12, C::SkewedFractionHorizontalGap => 0.35, C::SkewedFractionVerticalGap => 0.1, C::OverbarVerticalGap => 0.15, C::OverbarRuleThickness => 0.05, C::OverbarExtraAscender => 0.05, C::UnderbarVerticalGap => 0.15, C::UnderbarRuleThickness => 0.05, C::UnderbarExtraDescender => 0.05, C::RadicalVerticalGap => 0.05, C::RadicalDisplayStyleVerticalGap => 0.15, C::RadicalRuleThickness => 0.05, C::RadicalExtraAscender => 0.05, C::RadicalKernBeforeDegree => 0.28, C::RadicalKernAfterDegree => -0.55, } } /// Characters drawn as operators with their own spacing class. fn class_of(c: char) -> Cls { match wordcraft_doc::math::math_class(c) { MClass::Ord => Cls::Ord, MClass::Op => Cls::Op, MClass::Bin => Cls::Bin, MClass::Rel => Cls::Rel, MClass::Open => Cls::Open, MClass::Close => Cls::Close, MClass::Punct => Cls::Punct, MClass::Inner => Cls::Inner, } } /// Accents drawn across the whole base (stretched horizontally). fn wide_accent(c: char) -> bool { matches!( c, '\u{20D6}' | '\u{20D7}' | '\u{20E1}' | '\u{20D0}' | '\u{20D1}' | '\u{2190}' | '\u{2192}' | '\u{2194}' | '\u{23DC}' | '\u{23DD}' | '\u{23DE}' | '\u{23DF}' | '\u{23B4}' | '\u{23B5}' ) } impl Ctx { fn scale(&self, level: u8) -> f32 { let (s1, s2) = match &self.t { Some(t) => (t.script_percent, t.script_script_percent), None => (70.0, 50.0), }; let s1 = if (30.0..=100.0).contains(&s1) { s1 } else { 70.0 }; let s2 = if (20.0..=100.0).contains(&s2) { s2 } else { 50.0 }; match level { 0 => 1.0, 1 => s1 / 100.0, _ => s2 / 100.0, } } fn size(&self, st: St) -> f32 { self.base * self.scale(st.level) } /// A constant in points at the style's size. fn k(&self, c: C, st: St) -> f32 { let size = self.size(st); match &self.t { Some(t) => t.get(c) * size / self.upem, None => fallback(c) * size, } } fn axis(&self, st: St) -> f32 { self.k(C::AxisHeight, st) } fn em(&self, st: St) -> f32 { self.size(st) } fn arg(&mut self, a: &[MNode], st: St, depth: usize) -> Bx { let atoms = self.row(a, st, depth, 0); let em = self.em(st); let mut b = compose(atoms.into_iter().map(|(b, _)| b).collect(), st, em); finalize(&mut b, &self.path, em); b } /// Child argument `c` of the node being laid out; an empty one shows a placeholder. fn sub(&mut self, c: usize, a: &[MNode], st: St, depth: usize) -> Bx { let me = self.node_stack.last().copied().unwrap_or(0); self.path.push((me, c)); let b = if a.is_empty() { self.placeholder(st) } else { self.arg(a, st, depth) }; self.path.pop(); b } /// The dotted box an empty argument shows on screen. fn placeholder(&mut self, st: St) -> Bx { let em = self.em(st); let (w, a, d, pad) = (em * 0.56, em * 0.7, em * 0.06, em * 0.05); let mut b = Bx { w: w + 2.0 * pad, a, d, ..Default::default() }; let (x0, x1, y0, y1) = (pad, pad + w, -d, a); let width = (em * 0.035).max(0.4); for ((ax, ay), (bx, by)) in [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] { let line = MItem::Line { x0: ax, y0: ay, x1: bx, y1: by, width, color: Rgb(0x60, 0x60, 0x60), dotted: true }; b.items.push(MItem::ScreenOnly(Box::new(line))); } let path = self.path.clone(); b.slot(&path, 0, pad); finalize(&mut b, &path, em); b } /// An empty equation: a grey prompt (screen only). fn hint(&mut self, st: St) -> Bx { let size = self.size(st); let rs = wordcraft_fonts::word::resolve(&self.text_font, false, false); let mut g = self.glyphs(rs.face, "Type an equation", size, 0, Rgb(0x80, 0x80, 0x80), (rs.synth_bold, rs.synth_italic)); g.items = std::mem::take(&mut g.items).into_iter().map(|i| MItem::ScreenOnly(Box::new(i))).collect(); g.glyph = None; g.cuts.clear(); g.slots.clear(); g.slot(&[], 0, 0.0); finalize(&mut g, &[], size); g } /// The atoms of a row with their alignment segment (`&` starts a new segment). `u0`: the /// unit offset of the row's first node (for caret slots). fn row(&mut self, a: &[MNode], st: St, depth: usize, u0: usize) -> Vec<(Bx, usize)> { let mut out = Vec::new(); if depth > MAX_DEPTH { return out; } let mut seg = 0usize; let mut u = u0; for (i, n) in a.iter().enumerate() { self.nodes += 1; if self.nodes > MAX_NODES { break; } let path = self.path.clone(); match n { MNode::Run(r) => { let first = out.len(); for part in self.run_atoms(r, st, u, &path) { match part { RunPart::Atom(b) => out.push((b, seg)), RunPart::Align(b) => { seg += 1; out.push((b, seg)); } } } if let Some((b, _)) = out.get_mut(first) { b.brk = r.brk; } u += r.text.chars().count(); } MNode::Nary { .. } | MNode::Func { .. } => { self.node_stack.push(i); let mut atoms = self.op_atoms(n, st, depth + 1); self.node_stack.pop(); if let Some(f) = atoms.first_mut() { f.slot(&path, u, 0.0); } if let Some(l) = atoms.last_mut() { let w = l.w; l.slot(&path, u + 1, w); } for b in atoms { out.push((b, seg)); } u += 1; } _ => { self.node_stack.push(i); let mut b = self.node(n, st, depth + 1); self.node_stack.pop(); let w = b.w; b.slot(&path, u, 0.0); b.slot(&path, u + 1, w); out.push((b, seg)); u += 1; } } } out } /// Glyph run for `text` in `face` at `size`: a box with ink extents. fn glyphs(&self, face: FaceRef, text: &str, size: f32, level: u8, color: Rgb, synth: (bool, bool)) -> Bx { let feats = if level > 0 { vec![wordcraft_fonts::feature_value(b"ssty", level as u32)] } else { Vec::new() }; let shaped = wordcraft_fonts::shape(&face, text, &feats, |c| c); let k = size / face.upem.max(1.0) as f32; let mut x = 0.0f32; let mut b = Bx::default(); let mut glyphs = Vec::with_capacity(shaped.len()); let mut last_gid = None; for g in &shaped { let gx = x + g.x_offset as f32 * k; let gy = g.y_offset as f32 * k; if let Some((x0, y0, x1, y1)) = glyph_bounds(&face, g.gid) { b.a = b.a.max(y1 * k + gy); b.d = b.d.max(-y0 * k - gy); let _ = (x0, x1); } glyphs.push((g.gid, gx, gy)); x += g.x_advance as f32 * k; last_gid = Some(g.gid); } b.w = x; // Character boundaries: where the first glyph of each character starts. let mut cuts = Vec::with_capacity(text.len() + 1); for (cb, _) in text.char_indices() { let mut gx = 0.0f32; let mut found = None; for g in &shaped { if g.cluster >= cb { found = Some(gx); break; } gx += g.x_advance as f32 * k; } cuts.push(found.unwrap_or(x)); } cuts.push(x); b.cuts = cuts; if let (Some(t), Some(g)) = (&self.t, last_gid) && face.id() == self.face.id() { b.ic = t.italics.get(&g).copied().unwrap_or(0.0) * k; } if shaped.len() == 1 && let Some(g) = last_gid { let attach = match (&self.t, glyph_bounds(&face, g)) { (Some(t), _) if face.id() == self.face.id() && t.top_accent.contains_key(&g) => t.top_accent.get(&g).copied().unwrap_or(0.0) * k, (_, Some((x0, _, x1, _))) => (x0 + x1) / 2.0 * k, _ => b.w / 2.0, }; b.glyph = Some((face, g, attach)); } if !glyphs.is_empty() { b.items.push(MItem::Glyphs { face, size, color, synth_bold: synth.0, synth_italic: synth.1, glyphs, text: text.to_string() }); } b } /// `c` in the math font, or in a fallback font when the math font lacks it (an n-ary /// operator, delimiter or accent the font doesn't cover would otherwise draw as a missing /// glyph box, #1273). fn char_glyphs(&self, c: char, size: f32, level: u8) -> Bx { let face = if self.face.covers(c) { self.face } else { FontDb::global().fallback_for(c, self.face.id()).map(|f| FaceRef::of(&f)).unwrap_or(self.face) }; self.glyphs(face, &c.to_string(), size, level, self.color, (false, false)) } /// A single glyph by id (size variants, assembly parts). fn glyph_id(&self, gid: u32, size: f32, color: Rgb) -> Bx { let face = self.face; let k = size / face.upem.max(1.0) as f32; let mut b = Bx { w: face.advance(gid) as f32 * k, ..Default::default() }; if let Some((_, y0, _, y1)) = glyph_bounds(&face, gid) { b.a = y1 * k; b.d = -y0 * k; } if let Some(t) = &self.t { b.ic = t.italics.get(&gid).copied().unwrap_or(0.0) * k; } b.items.push(MItem::Glyphs { face, size, color, synth_bold: false, synth_italic: false, glyphs: vec![(gid, 0.0, 0.0)], text: String::new() }); b } /// A text atom with caret slots at its character boundaries (`k0`: the unit offset of its /// first character). fn text_atom(&self, face: FaceRef, text: &str, size: f32, level: u8, color: Rgb, synth: (bool, bool), path: &[(usize, usize)], k0: usize) -> Bx { let mut b = self.glyphs(face, text, size, level, color, synth); let cuts = std::mem::take(&mut b.cuts); for (j, x) in cuts.iter().enumerate() { b.slot(path, k0 + j, *x); } b } fn run_atoms(&self, r: &MRun, st: St, u0: usize, path: &[(usize, usize)]) -> Vec { let mut out = Vec::new(); let size = r.size.filter(|s| s.is_finite()).unwrap_or(self.base).clamp(1.0, 1638.0) * self.scale(st.level); let color = r.color.unwrap_or(self.color); if r.nor { // Normal text: the text font, upright unless asked. let fam = r.font.clone().filter(|f| !f.eq_ignore_ascii_case("Cambria Math")).unwrap_or_else(|| self.text_font.clone()); let (bold, italic) = match r.sty { Some(MSty::Bold) => (true, false), Some(MSty::Italic) => (false, true), Some(MSty::BoldItalic) => (true, true), _ => (false, false), }; let rs = wordcraft_fonts::word::resolve(&fam, bold, italic); let mut b = self.text_atom(rs.face, &r.text, size, 0, color, (rs.synth_bold, rs.synth_italic), path, u0); b.glyph = None; out.push(RunPart::Atom(b.cls(Cls::Ord))); return out; } // Map characters (math alphabets, operators) and split into atoms by class. let mut cur = String::new(); let mut cur_k0 = 0usize; let mut cur_face: Option = None; let mut synth_italic = false; let flush = |cur: &mut String, k0: usize, face: Option, synth_italic: bool, out: &mut Vec| { if !cur.is_empty() { let f = face.unwrap_or(self.face); let b = self.text_atom(f, cur, size, st.level, color, (false, synth_italic), path, u0 + k0); out.push(RunPart::Atom(b.cls(Cls::Ord))); cur.clear(); } }; // A zero-width (or space) atom that takes no part in spacing, with its two slots. let blank = |w: f32, k: usize| { let mut b = Bx { w, transparent: true, ..Default::default() }; b.slot(path, u0 + k, 0.0); b.slot(path, u0 + k + 1, w); b }; for (k, c) in r.text.chars().enumerate() { if c == '&' && !r.lit { flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); out.push(RunPart::Align(blank(0.0, k))); continue; } let c = if r.lit { c } else { match c { '-' => '−', '*' => '∗', '\'' => '′', _ => c, } }; let is_letter = c.is_alphabetic(); let greek_cap = ('\u{391}'..='\u{3A9}').contains(&c); let (bold, italic) = match r.sty { Some(MSty::Plain) => (false, false), Some(MSty::Bold) => (true, false), Some(MSty::Italic) => (false, true), Some(MSty::BoldItalic) => (true, true), None => (false, is_letter && !greek_cap && !st.upright && r.scr == MScr::Roman && !r.lit), }; let mapped = math_alnum(c, bold, italic, r.scr); // Fall back to the plain letter (slanted) when the face lacks the math alphabet. let (ch, slant) = if mapped != c && !self.face.covers(mapped) { (c, italic) } else { (mapped, false) }; let face = if self.face.covers(ch) || ch.is_whitespace() { None } else { FontDb::global().fallback_for(ch, self.face.id()).map(|f| FaceRef::of(&f)) }; let cls = if r.lit { Cls::Ord } else { class_of(ch) }; if ch == ' ' || ch == '\u{2061}' || ch == '\u{2062}' || ch == '\u{2063}' || ch == '\u{200B}' { flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); // Word sets a typed space in an equation about 0.4 em wide. out.push(RunPart::Atom(blank(if ch == ' ' { size * 0.4 } else { 0.0 }, k))); continue; } let same_face = cur_face.map(|f| f.id()) == face.map(|f| f.id()); if cls != Cls::Ord || !same_face || slant != synth_italic { flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); } if cls != Cls::Ord { let f = face.unwrap_or(self.face); let b = self.text_atom(f, &ch.to_string(), size, st.level, color, (false, slant), path, u0 + k); out.push(RunPart::Atom(b.cls(cls))); continue; } if cur.is_empty() { cur_k0 = k; } cur_face = face; synth_italic = slant; cur.push(ch); } flush(&mut cur, cur_k0, cur_face, synth_italic, &mut out); out } /// N-ary operators and functions: the operator atom (class Op) then the operand. fn op_atoms(&mut self, n: &MNode, st: St, depth: usize) -> Vec { match n { MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { let e = self.sub(2, e, st, depth); let loc = lim_loc.unwrap_or(if is_integral(*chr) { LimLoc::SubSup } else { LimLoc::UndOvr }); let loc = if st.display { loc } else { LimLoc::SubSup }; let size = self.size(st); let base_gid = self.face.glyph_for(*chr); let mut target = 0.0f32; if st.display { target = match &self.t { Some(t) => t.display_operator_min_height * size / self.upem, None => size * 1.5, }; } if *grow { target = target.max(e.a + e.d); } let op = if base_gid == 0 { // The math font lacks the operator: a fallback font's glyph, display-sized. let s = if st.display { size * 1.4 } else { size }; self.char_glyphs(*chr, s, 0) } else if *grow && target > 0.0 && self.t.is_some() { self.stretch_v(base_gid, target, size).unwrap_or_else(|| self.glyph_id(base_gid, size, self.color)) } else if target > 0.0 && self.t.is_some() { // Word sets display operators about 1.2 em tall: the size variant nearest that. let want = target.min(size * 1.2) * self.upem / size; let (g, adv) = self .t .as_ref() .and_then(|t| t.vert.get(&base_gid)) .and_then(|c| c.variants.iter().min_by(|a, b| (a.1 - want).abs().total_cmp(&(b.1 - want).abs()))) .copied() .unwrap_or((base_gid, want)); // A font without a variant near that size: scale the nearest one. let f = if adv > 0.0 { (want / adv).clamp(0.6, 1.5) } else { 1.0 }; let f = if (0.9..=1.1).contains(&f) { 1.0 } else { f }; self.glyph_id(g, size * f, self.color) } else { self.glyph_id(base_gid, size, self.color) }; // Centre the operator on the math axis. let dy = self.axis(st) - (op.a - op.d) / 2.0; let mut ob = Bx { w: op.w, ..Default::default() }; let ic = op.ic; ob.put(op, 0.0, dy); let sub = if *sub_hide { None } else { Some(self.sub(0, sub, st.script().cramp(), depth)) }; let sup = if *sup_hide { None } else { Some(self.sub(1, sup, st.script(), depth)) }; let opb = match loc { LimLoc::UndOvr => self.limits(ob, sub, sup, ic, st, true), LimLoc::SubSup => { ob.ic = ic; self.attach_op(ob, sub, sup, st) } }; vec![opb.cls(Cls::Op), e] } MNode::Func { name, e } => { let name = self.sub(0, name, St { upright: true, ..st }, depth); let e = self.sub(1, e, St { upright: false, ..st }, depth); vec![name.cls(Cls::Op), e] } _ => Vec::new(), } } /// Limits under and over `base` (centred), spaced as for big operators (`big`) or as /// accents-like limits. fn limits(&self, base: Bx, under: Option, over: Option, ic: f32, st: St, big: bool) -> Bx { let w = base.w.max(under.as_ref().map_or(0.0, |b| b.w)).max(over.as_ref().map_or(0.0, |b| b.w)); let mut out = Bx { w, ..Default::default() }; let (ba, bd) = (base.a, base.d); let bw = base.w; out.put(base, (w - bw) / 2.0, 0.0); if let Some(o) = over { let rise = if big { self.k(C::UpperLimitBaselineRiseMin, st).max(self.k(C::UpperLimitGapMin, st) + o.d) } else { self.k(C::UpperLimitGapMin, st) + o.d }; let x = (w - o.w) / 2.0 + ic / 2.0; out.put(o, x, ba + rise); } if let Some(u) = under { let drop = if big { self.k(C::LowerLimitBaselineDropMin, st).max(self.k(C::LowerLimitGapMin, st) + u.a) } else { self.k(C::LowerLimitGapMin, st) + u.a }; let x = (w - u.w) / 2.0 - ic / 2.0; out.put(u, x, -bd - drop); } out } /// Limits beside a big operator: the superscript at its advance, the subscript tucked left /// under the slant by the italic correction. fn attach_op(&self, base: Bx, sub: Option, sup: Option, st: St) -> Bx { let ic = base.ic; let mut b = base; b.ic = 0.0; let (bw, bd) = (b.w, b.d); let mut out = self.attach(b, None, sup, st, false); if let Some(s) = sub { let v = self.k(C::SubscriptShiftDown, st).max(s.a - self.k(C::SubscriptTopMax, st)).max(bd + self.k(C::SubscriptBaselineDropMin, st)); let x = (bw - ic).max(0.0); let sw = s.w; out.put(s, x, -v); out.w = out.w.max(x + sw + self.k(C::SpaceAfterScript, st)); } out } /// Sub/superscripts after `base`. `char_base`: the base is a single character (no baseline drop). fn attach(&self, base: Bx, sub: Option, sup: Option, st: St, char_base: bool) -> Bx { let (bw, ba, bd, ic) = (base.w, base.a, base.d, base.ic); let (first, last) = (base.first, base.last); let mut out = Bx { w: bw, ..Default::default() }; out.put(base, 0.0, 0.0); let mut u = 0.0f32; let mut v = 0.0f32; if let Some(p) = &sup { let shift = if st.cramped { self.k(C::SuperscriptShiftUpCramped, st) } else { self.k(C::SuperscriptShiftUp, st) }; u = shift.max(p.d + self.k(C::SuperscriptBottomMin, st)); if !char_base { u = u.max(ba - self.k(C::SuperscriptBaselineDropMax, st)); } } if let Some(b) = &sub { v = self.k(C::SubscriptShiftDown, st).max(b.a - self.k(C::SubscriptTopMax, st)); if !char_base { v = v.max(bd + self.k(C::SubscriptBaselineDropMin, st)); } } if let (Some(p), Some(b)) = (&sup, &sub) { let gap = (u - p.d) - (b.a - v); let min = self.k(C::SubSuperscriptGapMin, st); if gap < min { v += min - gap; let lift = self.k(C::SuperscriptBottomMaxWithSubscript, st) - (u - p.d); if lift > 0.0 { u += lift; v -= lift; } } } let space = self.k(C::SpaceAfterScript, st); let mut w = bw; if let Some(p) = sup { let x = bw + ic; w = w.max(x + p.w); out.put(p, x, u); } if let Some(b) = sub { let x = bw; w = w.max(x + b.w); out.put(b, x, -v); } out.w = w + if w > bw { space } else { 0.0 }; out.first = first; out.last = last; out } /// A vertical size variant or assembly of `gid` at least `target` tall (`None` without a /// `MATH` table or construction for the glyph). fn stretch_v(&self, gid: u32, target: f32, size: f32) -> Option { let t = self.t.as_ref()?; let con = t.vert.get(&gid)?; self.stretch(con, t, target, size, true) } fn stretch_h(&self, gid: u32, target: f32, size: f32) -> Option { let t = self.t.as_ref()?; let con = t.horiz.get(&gid)?; self.stretch(con, t, target, size, false) } fn stretch(&self, con: &Construction, t: &MathTable, target: f32, size: f32, vertical: bool) -> Option { let k = size / self.upem; let target_fu = target / k; // The first variant big enough. if let Some((g, _)) = con.variants.iter().find(|(_, adv)| *adv >= target_fu - 1.0) { return Some(self.glyph_id(*g, size, self.color)); } if con.parts.is_empty() { return con.variants.last().map(|(g, _)| self.glyph_id(*g, size, self.color)); } // Assembly: repeat extenders until the parts span the target. let o = t.min_connector_overlap; let fixed: f32 = con.parts.iter().filter(|p| !p.extender).map(|p| p.full_advance).sum(); let ext: f32 = con.parts.iter().filter(|p| p.extender).map(|p| p.full_advance).sum(); let n_fixed = con.parts.iter().filter(|p| !p.extender).count(); let n_ext = con.parts.len() - n_fixed; let mut reps = 0usize; if n_ext > 0 { while reps < 100 { let n = n_fixed + n_ext * reps; let len = fixed + ext * reps as f32 - o * n.saturating_sub(1) as f32; if len >= target_fu { break; } reps += 1; } } let seq: Vec<_> = con.parts.iter().flat_map(|p| std::iter::repeat_n(*p, if p.extender { reps } else { 1 })).collect(); if seq.is_empty() { return None; } let joints = seq.len().saturating_sub(1); let max_len: f32 = seq.iter().map(|p| p.full_advance).sum::() - o * joints as f32; // Spread the excess over the joints as extra overlap (within each connector's length). let excess = (max_len - target_fu).max(0.0); let per = if joints > 0 { excess / joints as f32 } else { 0.0 }; let mut b = Bx::default(); let mut pos = 0.0f32; let mut glyphs = Vec::with_capacity(seq.len()); for (i, p) in seq.iter().enumerate() { if vertical { let y0 = glyph_bounds(&self.face, p.gid).map(|bb| bb.1).unwrap_or(0.0); glyphs.push((p.gid, 0.0, (pos - y0) * k)); b.w = b.w.max(self.face.advance(p.gid) as f32 * k); } else { glyphs.push((p.gid, pos * k, 0.0)); if let Some((_, y0, _, y1)) = glyph_bounds(&self.face, p.gid) { b.a = b.a.max(y1 * k); b.d = b.d.max(-y0 * k); } } if let Some(next) = seq.get(i + 1) { let room = p.end_connector.min(next.start_connector).max(o); let ov = (o + per).min(room); pos += p.full_advance - ov; } else { pos += p.full_advance; } } if vertical { b.a = pos * k; b.d = 0.0; } else { b.w = pos * k; } b.items.push(MItem::Glyphs { face: self.face, size, color: self.color, synth_bold: false, synth_italic: false, glyphs, text: String::new() }); Some(b) } /// A delimiter (or separator) glyph covering `a`/`d` around the axis (or the content exactly /// with `exact`). fn delimiter(&self, c: char, a: f32, d: f32, st: St, grow: bool, exact: bool) -> Bx { let size = self.size(st); let axis = self.axis(st); let mut glyph = self.char_glyphs(c, size, 0); glyph.glyph = None; if !grow { return glyph; } let target = if exact { a + d } else { 2.0 * (a - axis).max(d + axis) }; // Small content keeps the plain glyph where it sits. if glyph.a + glyph.d >= target - size * 0.02 { return glyph; } let gid = self.face.glyph_for(c); let Some(big) = (if gid != 0 { self.stretch_v(gid, target, size) } else { None }) else { return glyph }; let dy = if exact { (a - d) / 2.0 - (big.a - big.d) / 2.0 } else { axis - (big.a - big.d) / 2.0 }; let mut out = Bx { w: big.w, ..Default::default() }; out.put(big, 0.0, dy); out } fn node(&mut self, n: &MNode, st: St, depth: usize) -> Bx { if depth > MAX_DEPTH { return Bx::default(); } let d = depth + 1; let em = self.em(st); match n { MNode::Run(r) => { let atoms: Vec = self .run_atoms(r, st, 0, &self.path.clone()) .into_iter() .map(|p| match p { RunPart::Atom(b) | RunPart::Align(b) => b, }) .collect(); compose(atoms, st, em) } MNode::Frac { kind, num, den } => self.frac(*kind, num, den, st, d), MNode::Script { kind, base, sub, sup } => { let b = self.sub(0, base, st, d); let char_base = b.glyph.is_some(); let sub_b = if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup | ScriptKind::Pre) { Some(self.sub(1, sub, st.script().cramp(), d)) } else { None }; let sup_b = if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup | ScriptKind::Pre) { Some(self.sub(2, sup, st.script(), d)) } else { None }; if *kind == ScriptKind::Pre { // Pre-scripts: attach to an empty base, then put the base after them. let empty = Bx::default(); let mut pre = self.attach(empty, sub_b, sup_b, st, false); let pw = pre.w; let (f, l) = (b.first, b.last); pre.w = pw + b.w; pre.put(b, pw, 0.0); pre.first = f; pre.last = l; return pre; } self.attach(b, sub_b, sup_b, st, char_base) } MNode::Rad { deg, deg_hide, e } => self.radical(deg, *deg_hide, e, st, d), MNode::Nary { .. } | MNode::Func { .. } => { let atoms = self.op_atoms(n, st, d); compose(atoms, st, em) } MNode::Delim { beg, end, sep, grow, shp_match, elems } => { let parts: Vec = elems.iter().enumerate().map(|(k, e)| self.sub(k, e, st, d)).collect(); let a = parts.iter().map(|b| b.a).fold(0.0f32, f32::max); let dd = parts.iter().map(|b| b.d).fold(0.0f32, f32::max); let mut row: Vec = Vec::new(); if let Some(c) = beg { row.push(self.delimiter(*c, a, dd, st, *grow, *shp_match).cls(Cls::Open)); } let n = parts.len(); for (i, p) in parts.into_iter().enumerate() { row.push(p.cls(Cls::Inner)); if i + 1 < n && let Some(c) = sep { row.push(self.delimiter(*c, a, dd, st, *grow, *shp_match).cls(Cls::Ord)); } } if let Some(c) = end { row.push(self.delimiter(*c, a, dd, st, *grow, *shp_match).cls(Cls::Close)); } // No spacing inside the brackets. let mut out = hcat(row); out.first = if beg.is_some() { Cls::Open } else { Cls::Inner }; out.last = if end.is_some() { Cls::Close } else { Cls::Inner }; out } MNode::Lim { upper, e, lim } => { let base = self.sub(0, e, st, d); let ls = St { upright: false, ..st.script() }; let l = self.sub(1, lim, if *upper { ls } else { ls.cramp() }, d); let (f, la) = (base.first, base.last); let mut out = if *upper { self.limits(base, None, Some(l), 0.0, st, false) } else { self.limits(base, Some(l), None, 0.0, st, false) }; out.first = f; out.last = la; out } MNode::Acc { chr, e } => self.accent(*chr, e, st, d), MNode::Bar { top, e } => { let b = self.sub(0, e, st, d); let (w, a, dd) = (b.w, b.a, b.d); let mut out = Bx { w, ..Default::default() }; out.put(b, 0.0, 0.0); let color = self.color; if *top { let t = self.k(C::OverbarRuleThickness, st).max(0.4); let y = a + self.k(C::OverbarVerticalGap, st); out.rect(0.0, y, w, t, color); out.a = out.a.max(y + t + self.k(C::OverbarExtraAscender, st)); } else { let t = self.k(C::UnderbarRuleThickness, st).max(0.4); let y = -dd - self.k(C::UnderbarVerticalGap, st) - t; out.rect(0.0, y, w, t, color); out.d = out.d.max(-y + self.k(C::UnderbarExtraDescender, st)); } out } MNode::BorderBox { hide, strike, e } => { let b = self.sub(0, e, st, d); let t = self.k(C::FractionRuleThickness, st).max(0.4); let pad = em * 0.12; let (w, a, dd) = (b.w + 2.0 * pad, b.a + pad, b.d + pad); let mut out = Bx { w: w + 2.0 * t, ..Default::default() }; out.put(b, pad + t, 0.0); let color = self.color; let [ht, hb, hl, hr] = *hide; let (x0, x1, y0, y1) = (0.0, w + 2.0 * t, -dd - t, a + t); if !ht { out.rect(x0, a, x1 - x0, t, color); } if !hb { out.rect(x0, y0, x1 - x0, t, color); } if !hl { out.rect(x0, y0, t, y1 - y0, color); } if !hr { out.rect(x1 - t, y0, t, y1 - y0, color); } let [sh, sv, bltr, tlbr] = *strike; let mid = (a - dd) / 2.0; let line = |out: &mut Bx, p: (f32, f32, f32, f32)| { out.items.push(MItem::Line { x0: p.0, y0: p.1, x1: p.2, y1: p.3, width: t, color, dotted: false }) }; if sh { line(&mut out, (x0, mid, x1, mid)); } if sv { line(&mut out, ((x0 + x1) / 2.0, y0, (x0 + x1) / 2.0, y1)); } if bltr { line(&mut out, (x0, y0, x1, y1)); } if tlbr { line(&mut out, (x0, y1, x1, y0)); } out.a = out.a.max(y1); out.d = out.d.max(-y0); out } MNode::Boxed { e } => self.sub(0, e, st, d), MNode::GroupChr { chr, top, e } => { let b = self.sub(0, e, st, d); let size = self.size(st); let gid = self.face.glyph_for(*chr); let g = self.stretch_h(gid, b.w, size).unwrap_or_else(|| self.char_glyphs(*chr, size, 0)); let w = b.w.max(g.w); let (ba, bd, bw) = (b.a, b.d, b.w); let (ga, gd, gw) = (g.a, g.d, g.w); let mut out = Bx { w, ..Default::default() }; out.put(b, (w - bw) / 2.0, 0.0); if *top { let y = ba + self.k(C::StretchStackGapAboveMin, st) + gd; out.put(g, (w - gw) / 2.0, y); } else { let y = -bd - self.k(C::StretchStackGapBelowMin, st) - ga; out.put(g, (w - gw) / 2.0, y); } out } MNode::EqArr { rows } => self.eq_arr(rows, st, d), MNode::Matrix { rows, col_jc } => self.matrix(rows, col_jc, st, d), MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { let mut b = self.sub(0, e, st, d); if !show { b.items.clear(); } if *zero_wid { b.w = 0.0; } if *zero_asc { b.a = 0.0; } if *zero_desc { b.d = 0.0; } b.glyph = None; b } } } fn frac(&mut self, kind: FracKind, num: &[MNode], den: &[MNode], st: St, d: usize) -> Bx { let em = self.em(st); match kind { FracKind::Linear => { let n = self.sub(0, num, st, d); let dn = self.sub(1, den, st, d); let slash = self.glyphs(self.face, "/", self.size(st), st.level, self.color, (false, false)).cls(Cls::Ord); hcat(vec![n, slash, dn]) } FracKind::Skewed => { let n = self.sub(0, num, st.script(), d); let dn = self.sub(1, den, st.script().cramp(), d); let size = self.size(st); let slash = self.glyphs(self.face, "\u{2044}", size, 0, self.color, (false, false)); let gap = self.k(C::SkewedFractionHorizontalGap, st) / 2.0; let (nw, sw) = (n.w, slash.w); let mut out = Bx { w: n.w + gap + slash.w + gap + dn.w, ..Default::default() }; let up = self.axis(st) + self.k(C::SkewedFractionVerticalGap, st) / 2.0; out.put(n, 0.0, up.max(0.0)); out.put(slash, nw + gap, 0.0); out.put(dn, nw + gap + sw + gap, -self.k(C::SkewedFractionVerticalGap, st) / 2.0); out } FracKind::Bar | FracKind::NoBar => { let cs = st.frac(); let n = self.sub(0, num, St { cramped: st.cramped, ..cs }, d); let dn = self.sub(1, den, cs.cramp(), d); let axis = self.axis(st); let (u, v) = if kind == FracKind::Bar { let t = self.k(C::FractionRuleThickness, st); let (su, sd, gn, gd) = if st.display { ( self.k(C::FractionNumeratorDisplayStyleShiftUp, st), self.k(C::FractionDenominatorDisplayStyleShiftDown, st), self.k(C::FractionNumDisplayStyleGapMin, st), self.k(C::FractionDenomDisplayStyleGapMin, st), ) } else { ( self.k(C::FractionNumeratorShiftUp, st), self.k(C::FractionDenominatorShiftDown, st), self.k(C::FractionNumeratorGapMin, st), self.k(C::FractionDenominatorGapMin, st), ) }; (su.max(gn + t / 2.0 + axis + n.d), sd.max(gd + t / 2.0 - axis + dn.a)) } else { let (mut su, mut sd, gap) = if st.display { ( self.k(C::StackTopDisplayStyleShiftUp, st), self.k(C::StackBottomDisplayStyleShiftDown, st), self.k(C::StackDisplayStyleGapMin, st), ) } else { (self.k(C::StackTopShiftUp, st), self.k(C::StackBottomShiftDown, st), self.k(C::StackGapMin, st)) }; let g = (su - n.d) - (dn.a - sd); if g < gap { su += (gap - g) / 2.0; sd += (gap - g) / 2.0; } (su, sd) }; let pad = em * 0.08; let w = n.w.max(dn.w) + 2.0 * pad; let (nw, dw) = (n.w, dn.w); let mut out = Bx { w, ..Default::default() }; out.put(n, (w - nw) / 2.0, u); out.put(dn, (w - dw) / 2.0, -v); if kind == FracKind::Bar { let t = self.k(C::FractionRuleThickness, st).max(0.4); out.rect(pad * 0.5, axis - t / 2.0, w - pad, t, self.color); } out } } } fn radical(&mut self, deg: &[MNode], deg_hide: bool, e: &[MNode], st: St, d: usize) -> Bx { let b = self.sub(1, e, st.cramp(), d); let size = self.size(st); let t = self.k(C::RadicalRuleThickness, st).max(0.4); let mut gap = if st.display { self.k(C::RadicalDisplayStyleVerticalGap, st) } else { self.k(C::RadicalVerticalGap, st) }; let target = b.a + b.d + gap + t; let gid = self.face.glyph_for('√'); let g = self.stretch_v(gid, target, size).unwrap_or_else(|| { let mut g = self.glyphs(self.face, "√", size, 0, self.color, (false, false)); g.glyph = None; g }); let extra = (g.a + g.d) - target; if extra > 0.0 { gap += extra / 2.0; } // The sign's top meets the rule over the base. let top = b.a + gap + t; let dy = top - g.a; let (ga, gd, gw) = (g.a, g.d, g.w); let mut sign = Bx { w: gw, ..Default::default() }; sign.put(g, 0.0, dy); // Degree. let mut x0 = 0.0f32; let mut out = Bx::default(); if !deg_hide { let db = self.sub(0, deg, St { level: (st.level + 2).min(2), display: false, cramped: true, upright: st.upright }, d); let kb = self.k(C::RadicalKernBeforeDegree, st); let ka = self.k(C::RadicalKernAfterDegree, st); let raise = self.t.as_ref().map_or(60.0, |t| t.radical_degree_bottom_raise_percent) / 100.0 * (ga + gd); let y = (dy - gd) + raise; let dw = db.w; out.put(db, kb, y); x0 = (kb + dw + ka).max(0.0); } let bw = b.w; out.put(sign, x0, 0.0); out.rect(x0 + gw, top - t, bw, t, self.color); out.put(b, x0 + gw, 0.0); out.w = x0 + gw + bw; out.a = out.a.max(top + self.k(C::RadicalExtraAscender, st)); out } fn accent(&mut self, chr: char, e: &[MNode], st: St, d: usize) -> Bx { let b = self.sub(0, e, st.cramp(), d); let size = self.size(st); let color = self.color; // Overline-style accents are rules. if matches!(chr, '\u{305}' | '\u{AF}' | '\u{304}' | '\u{203E}') { let (w, a) = (b.w, b.a); let mut out = Bx { w, ..Default::default() }; let (f, l) = (b.first, b.last); out.put(b, 0.0, 0.0); let t = self.k(C::OverbarRuleThickness, st).max(0.4); let y = a + self.k(C::OverbarVerticalGap, st); out.rect(0.0, y, w, t, color); out.first = f; out.last = l; return out; } let gid = self.face.glyph_for(chr); // Combining marks have no advance: compare the base with the accent's ink. let ink = glyph_bounds(&self.face, gid).map(|(x0, _, x1, _)| (x1 - x0) * size / self.upem).unwrap_or(0.0); let narrow = gid != 0 && b.w <= ink * 1.2; let mut acc = if wide_accent(chr) && gid != 0 && !narrow { self.stretch_h(gid, b.w, size).unwrap_or_else(|| self.glyph_id(gid, size, color)) } else if gid != 0 { self.glyph_id(gid, size, color) } else { self.char_glyphs(chr, size, 0) }; acc.glyph = None; // Horizontal: the base's attachment point under the accent's. let base_x = b.glyph.map(|(_, _, ax)| ax).unwrap_or(b.w / 2.0); let acc_x = { let single = acc.items.first().and_then(|it| match it { MItem::Glyphs { glyphs, .. } if glyphs.len() == 1 => glyphs.first().map(|g| g.0), _ => None, }); let k = size / self.upem; match single { Some(g) => match self.t.as_ref().and_then(|t| t.top_accent.get(&g).copied()) { Some(v) => v * k, None => glyph_bounds(&self.face, g).map(|(x0, _, x1, _)| (x0 + x1) / 2.0 * k).unwrap_or(acc.w / 2.0), }, None => acc.w / 2.0, } }; // Vertical: accents sit on an x-height base; taller bases push them up. let abh = self.k(C::AccentBaseHeight, st); let raise = (b.a - abh).max(0.0); let (f, l, w, ic) = (b.first, b.last, b.w, b.ic); let glyph = b.glyph; let mut out = Bx { w, ..Default::default() }; out.put(b, 0.0, 0.0); let mut x = base_x - acc_x; if x < 0.0 && acc.w > w { x = (w - acc.w) / 2.0; } out.put(acc, x, raise); out.first = f; out.last = l; out.ic = ic; out.glyph = glyph.filter(|_| false); out } /// Rows stacked and centred on the math axis; `gap`: least space between rows, `min_skip`: /// least baseline-to-baseline distance. fn stack_rows(&self, rows: Vec<(Bx, f32)>, st: St, gap: f32, min_skip: f32) -> (Bx, Vec) { // (row box, x) → baselines going down. let mut ys = Vec::with_capacity(rows.len()); let mut y = 0.0f32; let mut prev_d: Option = None; for (b, _) in &rows { if let Some(pd) = prev_d { y -= (pd + gap + b.a).max(min_skip); } ys.push(y); prev_d = Some(b.d); } let top = rows.first().map_or(0.0, |(b, _)| b.a); let bottom = rows.last().map_or(0.0, |(b, _)| b.d) - ys.last().copied().unwrap_or(0.0); // Centre on the axis. let shift = self.axis(st) + (bottom - top) / 2.0; let mut out = Bx::default(); let mut bases = Vec::with_capacity(ys.len()); for ((b, x), yy) in rows.into_iter().zip(ys) { out.w = out.w.max(x + b.w); out.put(b, x, yy + shift); bases.push(yy + shift); } (out, bases) } fn eq_arr(&mut self, rows: &[Arg], st: St, d: usize) -> Bx { self.eq_rows(rows, st, d).0 } /// Equation-array rows aligned at `&`, and each row's baseline. fn eq_rows(&mut self, rows: &[Arg], st: St, d: usize) -> (Bx, Vec) { let em = self.em(st); let me = self.node_stack.last().copied().unwrap_or(0); // Each row: segments split at `&`. let mut segs_rows: Vec> = Vec::new(); let mut row_paths = Vec::new(); for (ri, r) in rows.iter().take(1000).enumerate() { self.path.push((me, ri)); row_paths.push(self.path.clone()); let atoms = if r.is_empty() { vec![(self.placeholder(st), 0)] } else { self.row(r, st, d, 0) }; self.path.pop(); let n = atoms.iter().map(|(_, s)| *s + 1).max().unwrap_or(1).min(64); let all: Vec = atoms.iter().map(|(b, _)| b.clone()).collect(); let gaps = gaps_between(&all, st, em); let mut segs: Vec> = vec![Vec::new(); n]; for (i, (mut b, s)) in atoms.into_iter().enumerate() { // Keep the spacing before an atom with the atom. let g = gaps.get(i).copied().unwrap_or(0.0); if g > 0.0 { let mut wrapped = Bx { w: g + b.w, a: b.a, d: b.d, first: b.first, last: b.last, ..Default::default() }; wrapped.put(b, g, 0.0); b = wrapped; } if let Some(seg) = segs.get_mut(s.min(n - 1)) { seg.push(b); } } segs_rows.push(segs.into_iter().map(hcat).collect()); } let ncols = segs_rows.iter().map(|r| r.len()).max().unwrap_or(1); // Rows without `&` are centred below, so they don't widen the aligned columns. let mut col_w = vec![0.0f32; ncols]; let mut lone_w = 0.0f32; for r in &segs_rows { if let [b] = r.as_slice() { lone_w = lone_w.max(b.w); continue; } for (i, b) in r.iter().enumerate() { if let Some(c) = col_w.get_mut(i) { *c = c.max(b.w); } } } let cols: f32 = col_w.iter().sum(); let total = cols.max(lone_w); let mut placed = Vec::new(); for (r, path) in segs_rows.into_iter().zip(row_paths) { let mut row = Bx::default(); let mut x = 0.0f32; // A row without `&` is centred in the array, as Word does (#2617). let single = r.len() == 1; for (i, b) in r.into_iter().enumerate() { let cw = col_w.get(i).copied().unwrap_or(0.0); let bw = b.w; let dx = if single || i % 2 == 1 { 0.0 } else { cw - bw }; row.put(b, x + dx, 0.0); x += if single { bw } else { cw }; row.w = row.w.max(x); } let rx = (total - if single { row.w } else { cols }) / 2.0; finalize(&mut row, &path, em); placed.push((row, rx.max(0.0))); } let (mut out, bases) = self.stack_rows(placed, st, em * 0.1, em * 1.1); out.w = out.w.max(total); (out, bases) } /// A display equation with numbers (`#`): the equation centred on the line, each row's /// number at the right margin. Automatic numbers (`#` alone) count up from `counter`. fn numbered(&mut self, rows: &[Arg], st: St, avail: f32, counter: &mut u32) -> Bx { let em = self.em(st); let parts: Vec<(Arg, Arg, usize)> = rows.iter().take(1000).map(|r| split_number(r).unwrap_or_else(|| (r.clone(), Vec::new(), usize::MAX))).collect(); self.node_stack.push(0); let eq_rows: Vec = parts.iter().map(|(e, _, _)| e.clone()).collect(); let (eq, bases) = self.eq_rows(&eq_rows, st, 1); // Numbers. let mut nums = Vec::new(); for (ri, (_, num, hash)) in parts.iter().enumerate() { if *hash == usize::MAX { nums.push(None); continue; } self.path.push((0, ri)); let path = self.path.clone(); // Every numbered equation counts; `#` alone shows the count. *counter = counter.saturating_add(1); let b = if num.is_empty() { let r = MRun { text: format!("({counter})"), sty: Some(MSty::Plain), ..Default::default() }; let mut b = compose( self.run_atoms(&r, St { upright: true, ..st }, 0, &path) .into_iter() .map(|p| match p { RunPart::Atom(b) | RunPart::Align(b) => b, }) .collect(), st, em, ); b.slots.clear(); b.slot(&path, hash + 1, 0.0); b } else { let atoms = self.row(num, st, 2, hash + 1); let mut b = compose(atoms.into_iter().map(|(b, _)| b).collect(), st, em); b.slot(&path, hash + 1, 0.0); b }; let mut b = b; finalize(&mut b, &path, em); self.path.pop(); nums.push(Some(b)); } self.node_stack.pop(); let num_w = nums.iter().flatten().map(|b| b.w).fold(0.0f32, f32::max); let gap = em; let mut x_eq = ((avail - eq.w) / 2.0).max(0.0); if x_eq + eq.w + gap + num_w > avail { x_eq = (avail - num_w - gap - eq.w).max(0.0); } let width = avail.max(x_eq + eq.w + gap + num_w); let mut out = Bx { w: width, ..Default::default() }; out.put(eq, x_eq, 0.0); for (b, y) in nums.into_iter().zip(bases) { if let Some(b) = b { let bw = b.w; out.put(b, width - bw, y); } } out } /// A display equation, broken into lines where it is wider than `avail` or a run asks for a /// manual break (`m:brk`). Breaks fall only at top-level binary operators and relations (never /// inside a fraction, radical, script or matrix), before or after the operator as `m:brkBin` /// says; lines are filled greedily and a term wider than the line overflows it. The first line /// starts at the left; the rest are indented by `m:wrapIndent`, set flush right with /// `m:wrapRight`, or aligned at an operator of the first line (`m:alnAt`). fn wrapped(&mut self, a: &[MNode], st: St, avail: f32, mp: &MathProps) -> Bx { let em = self.em(st); let atoms: Vec = self.row(a, st, 0, 0).into_iter().map(|(b, _)| b).collect(); let gaps = gaps_between(&atoms, st, em); let total: f32 = atoms.iter().map(|b| b.w).sum::() + gaps.iter().sum::(); let manual = atoms.iter().skip(1).any(|b| b.brk.is_some()); if atoms.len() < 2 || (!manual && total <= avail + 0.01) { let mut b = compose(atoms, st, em); finalize(&mut b, &self.path, em); return b; } let cls = classes(&atoms); let n = atoms.len(); let is_op = |i: usize| -> bool { atoms.get(i).is_some_and(|b| b.glyph.is_some() && !b.transparent) && cls.get(i).is_some_and(|c| matches!(c.0, Cls::Bin | Cls::Rel) && c.0 == c.1) }; // Chunk starts (after the first): the atom index → forced (`Some(alnAt)`) and the // operator repeated at the start of the line it begins. let mut starts: std::collections::BTreeMap, Option)> = std::collections::BTreeMap::new(); for i in 1..n { let op = is_op(i); let at = if op && mp.brk_bin != BrkBin::Before { i + 1 } else { i }; if at == 0 || at >= n { continue; } let repeat = (op && mp.brk_bin == BrkBin::Repeat).then_some(i); let forced = atoms.get(i).and_then(|b| b.brk); if op || forced.is_some() { let e = starts.entry(at).or_insert((None, None)); if forced.is_some() { e.0 = forced; } if repeat.is_some() { e.1 = repeat; } } } // Chunks: [s, e) with their width inside and the gap before them. let mut bounds: Vec = vec![0]; bounds.extend(starts.keys().copied()); bounds.push(n); let width_of = |s: usize, e: usize| -> f32 { (s..e).map(|i| atoms.get(i).map_or(0.0, |b| b.w) + if i > s { gaps.get(i).copied().unwrap_or(0.0) } else { 0.0 }).sum() }; // The operator glyphs a repeated operator shows at the end of one line and the start of // the next. let repeats: std::collections::HashMap, Bx)> = starts .values() .filter_map(|v| v.1) .filter_map(|i| { let b = atoms.get(i)?; let (end, start) = match op_char(b) { Some('−' | '-') => match mp.brk_bin_sub { BrkBinSub::MinusMinus => ('−', '−'), BrkBinSub::MinusPlus => ('−', '+'), BrkBinSub::PlusMinus => ('+', '−'), }, Some(c) => (c, c), None => return None, }; let end = (Some(end) != op_char(b)).then(|| self.op_glyph(b, end, st)).flatten(); Some((i, (end, self.op_glyph(b, start, st)?))) }) .collect(); let indent = if mp.wrap_indent.is_finite() { mp.wrap_indent.clamp(0.0, 1584.0) } else { 0.0 }.min(avail * 0.5); // Greedy fill: lines as lists of chunks (index into `bounds`). let mut lines: Vec<(usize, usize)> = Vec::new(); let mut cur_w = 0.0f32; let mut auto = false; for c in 0..bounds.len().saturating_sub(1) { let (s, e) = (bounds.get(c).copied().unwrap_or(0), bounds.get(c + 1).copied().unwrap_or(n)); let w = width_of(s, e); let lead = gaps.get(s).copied().unwrap_or(0.0); let info = starts.get(&s).copied(); let copy_w = info.and_then(|v| v.1).and_then(|i| repeats.get(&i)).map_or(0.0, |r| r.1.w + lead); let line_avail = if lines.len() <= 1 || mp.wrap_right { avail } else { avail - indent }; match lines.last_mut() { Some(l) if info.is_some_and(|v| v.0.is_none()) && cur_w + lead + w <= line_avail + 0.01 => { l.1 = c + 1; cur_w += lead + w; } Some(_) if info.is_some_and(|v| v.0.is_none()) => { auto = true; lines.push((c, c + 1)); cur_w = copy_w + w; } _ => { lines.push((c, c + 1)); cur_w = copy_w + w; } } } // Build each line's box. let ops: Vec = (0..n).map(is_op).collect(); let path = self.path.clone(); let mut atoms: Vec> = atoms.into_iter().map(Some).collect(); let mut boxes: Vec<(Bx, Option)> = Vec::with_capacity(lines.len()); let mut ops_x: Vec = Vec::new(); for (li, &(c0, c1)) in lines.iter().enumerate() { let (s, e) = (bounds.get(c0).copied().unwrap_or(0), bounds.get(c1).copied().unwrap_or(n)); let info = starts.get(&s).copied(); let next = lines.get(li + 1).and_then(|l| bounds.get(l.0)).and_then(|s| starts.get(s)).and_then(|v| v.1); let mut row = Bx::default(); let mut x = 0.0f32; let copy = info.and_then(|v| v.1).and_then(|i| repeats.get(&i)).map(|r| r.1.clone()); let has_copy = copy.is_some(); if let Some(cb) = copy { let w = cb.w; row.put(cb, 0.0, 0.0); x = w; } for i in s..e { let Some(mut b) = atoms.get_mut(i).and_then(Option::take) else { continue }; if i > s || has_copy { x += gaps.get(i).copied().unwrap_or(0.0); } if next == Some(i) && let Some((Some(end), _)) = repeats.get(&i) { let mut r = end.clone(); let k = if b.w > 0.0 { r.w / b.w } else { 1.0 }; r.slots = std::mem::take(&mut b.slots) .into_iter() .map(|mut sl| { sl.x *= k; sl }) .collect(); b = r; } if li == 0 && ops.get(i).copied().unwrap_or(false) { ops_x.push(x); } let w = b.w; row.put(b, x, 0.0); x += w; } row.w = x; finalize(&mut row, &path, em); boxes.push((row, info.and_then(|v| v.0))); } let widest = boxes.iter().map(|(b, _)| b.w).fold(0.0f32, f32::max); let right = if auto { avail.max(widest) } else { widest }; let mut placed = Vec::with_capacity(boxes.len()); for (li, (b, aln)) in boxes.into_iter().enumerate() { let x = if li == 0 { 0.0 } else if let Some(ax) = aln.filter(|a| *a > 0).and_then(|a| ops_x.get(a as usize - 1)) { *ax } else if mp.wrap_right { (right - b.w).max(0.0) } else { indent }; placed.push((b, x)); } // Stack the lines down from the first one's baseline. let (gap, skip) = (em * 0.2, em * 1.25); let mut out = Bx::default(); let mut y = 0.0f32; let mut prev_d: Option = None; for (b, x) in placed { if let Some(pd) = prev_d { y -= (pd + gap + b.a).max(skip); } prev_d = Some(b.d); out.w = out.w.max(x + b.w); out.put(b, x, y); } out.w = out.w.max(right); out } /// Operator `c` drawn like the operator atom `like` (its face, size and colour), without /// caret slots. fn op_glyph(&self, like: &Bx, c: char, st: St) -> Option { let MItem::Glyphs { face, size, color, synth_bold, synth_italic, .. } = like.items.iter().find(|i| matches!(i, MItem::Glyphs { .. }))? else { return None; }; let face = if face.covers(c) { *face } else { self.face }; let mut b = self.glyphs(face, &c.to_string(), *size, st.level, *color, (*synth_bold, *synth_italic)); b.cuts.clear(); Some(b.cls(like.first)) } fn matrix(&mut self, rows: &[Vec], col_jc: &[ColJc], st: St, d: usize) -> Bx { let em = self.em(st); let mut cells: Vec> = Vec::new(); let mut flat = 0usize; for r in rows.iter().take(1000) { let mut row = Vec::new(); for (ci, c) in r.iter().enumerate() { if ci < 64 { row.push(self.sub(flat, c, st, d)); } flat += 1; } cells.push(row); } let ncols = cells.iter().map(|r| r.len()).max().unwrap_or(0); let mut col_w = vec![0.0f32; ncols]; for r in &cells { for (i, b) in r.iter().enumerate() { if let Some(c) = col_w.get_mut(i) { *c = c.max(b.w); } } } let gap = em * 0.8; let mut placed = Vec::new(); for r in cells { let mut row = Bx::default(); let mut x = 0.0f32; for (i, b) in r.into_iter().enumerate() { let cw = col_w.get(i).copied().unwrap_or(0.0); let bw = b.w; let dx = match col_jc.get(i).copied().unwrap_or_default() { ColJc::Center => (cw - bw) / 2.0, ColJc::Left => 0.0, ColJc::Right => cw - bw, }; row.put(b, x + dx, 0.0); x += cw + gap; } row.w = (x - gap).max(0.0); placed.push((row, 0.0)); } let (mut out, _) = self.stack_rows(placed, st, em * 0.25, em * 1.2); out.w = col_w.iter().sum::() + gap * ncols.saturating_sub(1) as f32; out } } /// The character an operator atom draws. fn op_char(b: &Bx) -> Option { b.items.iter().find_map(|i| match i { MItem::Glyphs { text, .. } => { let mut cs = text.chars(); let c = cs.next()?; cs.next().is_none().then_some(c) } _ => None, }) } enum RunPart { Atom(Bx), /// An `&` alignment mark (a zero-width atom holding its caret slots). Align(Bx), } /// Give the caret slots of argument `path` in `b` the argument's height. fn finalize(b: &mut Bx, path: &[(usize, usize)], em: f32) { let (a, d) = (b.a.max(em * 0.72), b.d.max(em * 0.22)); for s in &mut b.slots { if s.path == path && s.a <= 0.0 && s.d <= 0.0 { s.a = a - s.y; s.d = d + s.y; } } } /// Spacing before each atom (TeX rules: a binary operator after nothing, an operator, a /// relation, an opening bracket or punctuation is ordinary, as is one before a relation, /// closing bracket or punctuation). fn gaps_between(atoms: &[Bx], st: St, em: f32) -> Vec { let cls = classes(atoms); let solid: Vec = (0..atoms.len()).filter(|i| atoms.get(*i).is_some_and(|b| !b.transparent)).collect(); let mut gaps = vec![0.0f32; atoms.len()]; for k in 1..solid.len() { let (Some(&p), Some(&i)) = (solid.get(k - 1), solid.get(k)) else { continue }; let (Some(l), Some(r)) = (cls.get(p), cls.get(i)) else { continue }; if let Some(g) = gaps.get_mut(i) { *g = spacing_mu(l.1, r.0, st) * em / 18.0; } } gaps } /// Each atom's classes (first, last) in context: a binary operator where TeX makes it ordinary /// (a unary minus) becomes ordinary. fn classes(atoms: &[Bx]) -> Vec<(Cls, Cls)> { let mut cls: Vec<(Cls, Cls)> = atoms.iter().map(|b| (b.first, b.last)).collect(); let solid: Vec = (0..atoms.len()).filter(|i| atoms.get(*i).is_some_and(|b| !b.transparent)).collect(); for (k, &i) in solid.iter().enumerate() { let prev = k.checked_sub(1).and_then(|p| solid.get(p)).and_then(|p| cls.get(*p)).map(|c| c.1); let next = solid.get(k + 1).and_then(|n| cls.get(*n)).map(|c| c.0); if let Some(c) = cls.get_mut(i) { if c.0 == Cls::Bin && matches!(prev, None | Some(Cls::Bin | Cls::Op | Cls::Rel | Cls::Open | Cls::Punct)) { c.0 = Cls::Ord; if c.1 == Cls::Bin { c.1 = Cls::Ord; } } if c.1 == Cls::Bin && matches!(next, None | Some(Cls::Rel | Cls::Close | Cls::Punct)) { c.1 = Cls::Ord; if c.0 == Cls::Bin { c.0 = Cls::Ord; } } } } cls } /// Atoms side by side with inter-atom spacing. fn compose(mut atoms: Vec, st: St, em: f32) -> Bx { if atoms.len() == 1 && let Some(b) = atoms.pop() { return b; } let gaps = gaps_between(&atoms, st, em); let first = atoms.iter().find(|b| !b.transparent).map(|b| b.first).unwrap_or_default(); let last = atoms.iter().rev().find(|b| !b.transparent).map(|b| b.last).unwrap_or_default(); let ic = atoms.last().map(|b| b.ic).unwrap_or(0.0); let mut out = Bx::default(); let mut x = 0.0f32; for (b, g) in atoms.into_iter().zip(gaps) { x += g; let w = b.w; out.put(b, x, 0.0); x += w; } out.w = x; out.first = first; out.last = last; out.ic = ic; out } /// Boxes side by side, no spacing. fn hcat(parts: Vec) -> Bx { let first = parts.first().map(|b| b.first).unwrap_or_default(); let last = parts.last().map(|b| b.last).unwrap_or_default(); let mut out = Bx::default(); let mut x = 0.0f32; for b in parts { let w = b.w; out.put(b, x, 0.0); x += w; } out.w = x; out.first = first; out.last = last; out } #[cfg(test)] mod tests { use super::*; use wordcraft_doc::math::Math; fn rc() -> ResolvedChar { wordcraft_doc::styles::StyleSheet::default().resolve_char(None, &Default::default()) } fn lay(lin: &str, display: bool) -> MathLayout { layout_equation(&Math::default(), lin, &rc(), display, 400.0, &MathProps::default(), &mut 0) } /// The glyphs of a laid-out equation by line: (baseline, [(x, text)]), top line first. fn lines(m: &MathLayout) -> Vec<(f32, Vec<(f32, String)>)> { let mut out: Vec<(f32, Vec<(f32, String)>)> = Vec::new(); for it in &m.items { if let MItem::Glyphs { glyphs, text, .. } = it && let Some(&(_, x, y)) = glyphs.first() { match out.iter_mut().find(|l| (l.0 - y).abs() < 0.5) { Some(l) => l.1.push((x, text.clone())), None => out.push((y, vec![(x, text.clone())])), } } } out.sort_by(|a, b| b.0.total_cmp(&a.0)); for l in &mut out { l.1.sort_by(|a, b| a.0.total_cmp(&b.0)); } out } fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) -> MathLayout { layout_equation(math, lin, &rc(), display, avail, mp, &mut 0) } #[test] fn eq_array_rows_without_ampersand_are_centred() { // Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above // "|a|, &n even"), whether or not the other rows have one. use wordcraft_doc::math::*; let run = |s: &str| vec![MNode::Run(MRun::new(s))]; let w_a = lay("a", true).width; for other in ["bbbbbb&c", "bbbbbbc"] { let math = Math { nodes: vec![MNode::EqArr { rows: vec![run("a"), run(other)] }], ..Default::default() }; let m = lay_in(&math, "", true, 400.0, &MathProps::default()); let ls = lines(&m); let xa = ls.first().and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); let x0 = ls.get(1).and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); let want = x0 + (m.width - x0 - w_a) / 2.0; assert!((xa - want).abs() < 1.0, "{other}: a at {xa}, want {want} ({ls:?}, width {})", m.width); // The lone row doesn't widen the aligned columns. assert!(x0.abs() < 0.5 && m.width < lay("bbbbbbc", true).width + 1.0, "{other}: {ls:?}, width {}", m.width); } // A lone row wider than the aligned columns sets the array's width; the aligned // rows are centred under it as one block. let math = Math { nodes: vec![MNode::EqArr { rows: vec![run("bbbbbbbbbb"), run("a&c")] }], ..Default::default() }; let m = lay_in(&math, "", true, 400.0, &MathProps::default()); let ls = lines(&m); let xw = ls.first().and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); let x0 = ls.get(1).and_then(|l| l.1.first()).map(|g| g.0).unwrap_or(f32::NAN); assert!(xw.abs() < 0.5 && (m.width - lay("bbbbbbbbbb", true).width).abs() < 1.0, "wide lone row: {ls:?}, width {}", m.width); let want = (m.width - w_a - lay("c", true).width) / 2.0; assert!(want > 1.0 && (x0 - want).abs() < 1.0, "aligned row at {x0}, want {want} ({ls:?})"); } #[test] fn long_display_equations_wrap_before_operators_and_indent() { let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w"; let mp = MathProps::default(); let full = lay_in(&Math::default(), sum, true, 1e4, &mp); assert_eq!(lines(&full).len(), 1, "fits on one line when there is room"); let avail = full.width * 0.45; assert!(avail > 2.0 * mp.wrap_indent, "room for the indent: {avail}"); let m = lay_in(&Math::default(), sum, true, avail, &mp); let ls = lines(&m); assert!(ls.len() >= 2, "{ls:?}"); assert!((m.width - avail).abs() < 0.01, "the wrapped equation fills the line: {} vs {avail}", m.width); for (k, (_, gs)) in ls.iter().enumerate() { let (x0, first) = gs.first().cloned().unwrap(); if k == 0 { // Math italic 𝑦 when a font has it; plain y where none does (FreeBSD CI has no fonts). assert!(x0.abs() < 0.01 && (first == "𝑦" || first == "y"), "{gs:?}"); } else { // Continuation lines start with the operator, one wrap indent in. assert!((x0 - mp.wrap_indent).abs() < 0.01 && first == "+", "line {k}: {gs:?}"); } for (x, t) in gs { assert!(*x < avail, "line {k}: {t} at {x} past {avail}"); } } // Breaks after the operator, and the operator repeated, as the settings say. let after = MathProps { brk_bin: BrkBin::After, ..Default::default() }; let ls = lines(&lay_in(&Math::default(), sum, true, avail, &after)); assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 != "+", "{ls:?}"); let repeat = MathProps { brk_bin: BrkBin::Repeat, wrap_right: true, ..Default::default() }; let m = lay_in(&Math::default(), sum, true, avail, &repeat); let ls = lines(&m); assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 == "+", "{ls:?}"); // Inline equations never wrap. assert_eq!(lines(&lay_in(&Math::default(), sum, false, avail, &mp)).len(), 1); // A term wider than the line overflows it rather than breaking inside. let frac = lay_in(&Math::default(), "y=(a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w)/2", true, avail, &mp); assert!(frac.width > avail && frac.items.iter().any(|i| matches!(i, MItem::Rect { .. })), "{}", frac.width); } #[test] fn manual_breaks_start_new_lines() { let mp = MathProps::default(); let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })]; let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp); let ls = lines(&m); assert_eq!(ls.len(), 2, "{ls:?}"); assert!((ls[1].1[0].0 - mp.wrap_indent).abs() < 0.01 && ls[1].1[0].1 == "+", "{ls:?}"); assert!(m.width < 400.0, "a manually broken equation is as wide as its lines"); // `m:alnAt`: the new line's operator sits under the first line's first operator. let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(1), ..MRun::new("+c") })]; let ls = lines(&lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp)); let eq_x = ls[0].1.iter().find(|g| g.1 == "=").unwrap().0; assert!((ls[1].1[0].0 - eq_x).abs() < 0.01, "{ls:?}"); } #[test] fn short_display_equations_stay_on_one_line() { let mp = MathProps::default(); let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp); assert_eq!(lines(&one).len(), 1); let plain = lay_in(&Math::default(), "a+b=c", false, 400.0, &mp); assert!(one.width < 400.0 && (one.width - plain.width).abs() < 0.01, "{} vs {}", one.width, plain.width); } #[test] fn fraction_is_stacked_with_a_rule() { let flat = lay("ab", true); let f = lay("a/b", true); assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}"); assert!(f.items.iter().any(|i| matches!(i, MItem::Rect { .. })), "fraction rule"); assert!(f.width < flat.width * 1.2, "stacked, not side by side"); } #[test] fn superscript_is_raised_and_smaller() { let m = lay("x^2", false); let sizes: Vec<(f32, f32)> = m .items .iter() .filter_map(|i| match i { MItem::Glyphs { size, glyphs, .. } => glyphs.first().map(|g| (*size, g.2)), _ => None, }) .collect(); assert_eq!(sizes.len(), 2, "{m:?}"); assert!(sizes[1].0 < sizes[0].0 && sizes[1].1 > 2.0, "{sizes:?}"); } #[test] fn operators_get_spaces_and_unary_minus_does_not() { let tight = lay("ab", false); let spaced = lay("a+b", false); let unary = lay("−b", false); let plus_w = lay("+", false).width; assert!(spaced.width > tight.width + plus_w + 2.0, "medium spaces around +"); assert!(unary.width < lay("b", false).width + plus_w + 1.0, "no space after a unary minus"); } #[test] fn display_operators_are_big_with_limits_over_and_under() { let d = lay("∑_(i=1)^n▒i", true); let t = lay("∑_(i=1)^n▒i", false); assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller"); assert!(d.width < t.width + 4.0, "limits stacked in display"); } #[test] fn delimiters_grow_around_tall_content() { let small = lay("(x)", true); let big = lay("((a)/(b))", true); let h = |m: &MathLayout| m.ascent + m.descent; assert!(h(&big) > h(&small) * 1.4, "{} vs {}", h(&big), h(&small)); } #[test] fn every_structure_lays_out() { use wordcraft_doc::math::*; let x = || vec![MNode::Run(MRun::new("x"))]; let nodes = vec![ MNode::Acc { chr: '\u{302}', e: x() }, MNode::Acc { chr: '\u{305}', e: x() }, MNode::Bar { top: false, e: x() }, MNode::BorderBox { hide: [false; 4], strike: [true; 4], e: x() }, MNode::Boxed { e: x() }, MNode::GroupChr { chr: '⏟', top: false, e: x() }, MNode::EqArr { rows: vec![vec![MNode::Run(MRun::new("x&=1"))], vec![MNode::Run(MRun::new("y&=22"))]] }, MNode::Matrix { rows: vec![vec![x(), x()], vec![x(), Vec::new()]], col_jc: vec![] }, MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: true, e: x() }, MNode::Lim { upper: false, e: vec![MNode::Run(MRun::plain("lim"))], lim: x() }, MNode::Rad { deg: x(), deg_hide: false, e: x() }, MNode::Script { kind: ScriptKind::Pre, base: x(), sub: x(), sup: x() }, MNode::Frac { kind: FracKind::Skewed, num: x(), den: x() }, MNode::Frac { kind: FracKind::Linear, num: x(), den: x() }, MNode::Frac { kind: FracKind::NoBar, num: x(), den: x() }, MNode::Delim { beg: None, end: Some('|'), sep: Some('|'), grow: true, shp_match: true, elems: vec![x(), x()] }, MNode::Run(MRun { text: "text".into(), nor: true, ..Default::default() }), MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }), ]; for n in nodes { let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(m.width > 0.0 && m.ascent + m.descent > 0.0, "{n:?} → {m:?}"); for it in &m.items { if let MItem::Glyphs { glyphs, .. } = it { assert!(glyphs.iter().all(|g| g.1.is_finite() && g.2.is_finite())); } } } } /// #1273: an n-ary operator, delimiter, accent or group character the math font lacks is /// drawn from a fallback font, not as a missing-glyph box. #[test] fn characters_the_math_font_lacks_fall_back() { let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math"); let Some(c) = ['⨌', '⫿', '⟅', 'ア', '中', '가'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some()) else { return; // Every candidate is in the math font, or no fallback font is installed. }; let x = || vec![MNode::Run(MRun::new("x"))]; let nodes = [ MNode::Nary { chr: c, lim_loc: None, grow: false, sub_hide: true, sup_hide: true, sub: vec![], sup: vec![], e: x() }, MNode::Delim { beg: Some(c), end: Some(c), sep: None, grow: true, shp_match: false, elems: vec![x()] }, MNode::Acc { chr: c, e: x() }, MNode::GroupChr { chr: c, top: true, e: x() }, ]; for display in [false, true] { for n in &nodes { let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), display, 400.0, &MathProps::default(), &mut 0); for it in &m.items { if let MItem::Glyphs { glyphs, .. } = it { assert!(glyphs.iter().all(|g| g.0 != 0), "{c:?} drew a missing glyph in {n:?}"); } } } } } #[test] fn caret_slots_cover_every_position() { use wordcraft_doc::math_edit::{MathPos, move_right, units}; let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); // Walking the caret through the equation, every position has a slot. let mut p = MathPos::default(); let mut seen = 0; loop { let s = m.slots.iter().find(|s| s.path == p.path && s.off == p.off); assert!(s.is_some(), "no slot for {p:?}"); seen += 1; match move_right(&math.nodes, &p) { Some(n) => p = n, None => break, } assert!(seen < 500); } assert_eq!(p.off, units(&math.nodes)); // Hit testing finds the denominator below the bar. let den = m.slots.iter().find(|s| s.path == vec![(1, 1)]).unwrap(); let hit = m.hit(den.x + 1.0, den.y).unwrap(); assert_eq!(hit.path, vec![(1, 1)]); } #[test] fn an_arguments_extent_spans_its_caret_positions() { let math = Math { nodes: wordcraft_doc::math::parse_linear("y=(a+b)/c"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); let (nx0, nx1, nup, ndown) = m.arg_extent(&[(1, 0)]).unwrap(); let (dx0, dx1, dup, ddown) = m.arg_extent(&[(1, 1)]).unwrap(); // The numerator sits above the baseline and the denominator below it, both inside the equation. assert!(nx0 > 0.0 && nx1 > nx0 && nx1 <= m.width, "{nx0} {nx1} {}", m.width); assert!(dx1 > dx0 && dx1 <= m.width); assert!(-ndown > 0.0 && nup <= m.ascent + 0.01, "numerator {nup} {ndown}"); assert!(dup < -ndown && ddown > 0.0 && ddown <= m.descent + 0.01, "denominator {dup} {ddown}"); // The whole equation's argument is as wide as the equation. let (x0, x1, ..) = m.arg_extent(&[]).unwrap(); assert!(x0.abs() < 0.01 && (x1 - m.width).abs() < 0.01, "{x0} {x1} {}", m.width); assert!(m.arg_extent(&[(9, 0)]).is_none()); } #[test] fn empty_slots_show_placeholders_on_screen_only() { let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items); assert!(m.slots.iter().any(|s| s.path == vec![(0, 0)] && s.off == 0)); let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(empty.width > 10.0 && empty.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_)))); } #[test] fn numbered_equations_fill_the_line_with_the_number_at_the_right() { let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!((m.width - 400.0).abs() < 0.01, "{}", m.width); let xs: Vec = m.items.iter().filter_map(|i| if let MItem::Glyphs { glyphs, .. } = i { glyphs.first().map(|g| g.1) } else { None }).collect(); let max = xs.iter().cloned().fold(0.0f32, f32::max); assert!(max > 350.0, "number at the right margin: {xs:?}"); let min = xs.iter().cloned().fold(f32::MAX, f32::min); assert!(min > 120.0 && min < 220.0, "equation centred: {xs:?}"); // Automatic numbers count. let auto = Math { nodes: wordcraft_doc::math::parse_linear("a=b#"), ..Default::default() }; let mut n = 4; let _ = layout_equation(&auto, "", &rc(), true, 400.0, &MathProps::default(), &mut n); assert_eq!(n, 5); assert_eq!(auto_numbers(&auto, true), 1); } #[test] fn math_alphabets() { assert_eq!(math_alnum('x', false, true, MScr::Roman), '𝑥'); assert_eq!(math_alnum('h', false, true, MScr::Roman), 'ℎ'); assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ'); assert_eq!(math_alnum('α', false, true, MScr::Roman), '𝛼'); assert_eq!(math_alnum('2', false, true, MScr::Roman), '2'); assert_eq!(math_alnum('A', true, false, MScr::Roman), '𝐀'); } #[test] fn eqarr_rows_without_an_align_marker_are_centred() { use wordcraft_doc::math::*; // A row without `&` must not widen the alignment columns; it centres across the // array instead of right-aligning at the first column's edge. let rows: Vec> = vec![vec![MNode::Run(MRun::new("mmm"))], vec![MNode::Run(MRun::new("x&yyyyyyyy"))]]; let math = Math { nodes: vec![MNode::EqArr { rows }], ..Default::default() }; let ls = lines(&lay_in(&math, "", true, 400.0, &MathProps::default())); assert_eq!(ls.len(), 2, "{ls:?}"); let left: Vec = ls.iter().map(|(_, gs)| gs.iter().map(|(x, _)| *x).fold(f32::MAX, |a, b| if b < a { b } else { a })).collect(); assert!(left.iter().all(|x| *x >= -0.5), "nothing hangs left of the array: {left:?}"); // The short row starts right of the aligned row's first segment. assert!(ls[0].1.first().is_some_and(|(x, _)| *x > left[1] + 1.0), "short row centred, not flush left: {ls:?}"); } #[test] fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() { use wordcraft_doc::math::*; // The `&`-less row is wider than the aligned columns: it must not hang left of the // origin, the array is as wide as it, and the aligned rows centre under it. let rows: Vec> = vec![vec![MNode::Run(MRun::new("bbbbbbbbbb"))], vec![MNode::Run(MRun::new("a&c"))]]; let math = Math { nodes: vec![MNode::EqArr { rows }], ..Default::default() }; let m = lay_in(&math, "", true, 400.0, &MathProps::default()); let ls = lines(&m); assert_eq!(ls.len(), 2, "{ls:?}"); let left: Vec = ls.iter().map(|(_, gs)| gs.iter().map(|(x, _)| *x).fold(f32::MAX, f32::min)).collect(); assert!(left.iter().all(|x| *x >= -0.5), "nothing hangs left of the array: {left:?}"); assert!(left[0].abs() < 0.5, "the wide row starts at the origin: {left:?}"); let narrow = lay_in( &Math { nodes: vec![MNode::EqArr { rows: vec![vec![MNode::Run(MRun::new("a&c"))]] }], ..Default::default() }, "", true, 400.0, &MathProps::default(), ); assert!(m.width > narrow.width + 1.0, "the array is as wide as its widest row: {} vs {}", m.width, narrow.width); assert!(left[1] > 1.0, "the aligned row is centred under the wide one: {left:?}"); } #[test] fn hostile_trees_never_panic() { use wordcraft_doc::math::*; // Deep nesting. let mut n = vec![MNode::Run(MRun::new("x"))]; for _ in 0..500 { n = vec![MNode::Frac { kind: FracKind::Bar, num: n.clone(), den: Vec::new() }]; } let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); // Empty everything. let empty = vec![ MNode::Nary { chr: '\0', lim_loc: None, grow: true, sub_hide: false, sup_hide: false, sub: vec![], sup: vec![], e: vec![] }, MNode::Delim { beg: Some('\u{10FFFF}'), end: None, sep: None, grow: true, shp_match: false, elems: vec![] }, MNode::Matrix { rows: vec![vec![]], col_jc: vec![] }, MNode::EqArr { rows: vec![] }, MNode::Run(MRun { text: String::new(), size: Some(f32::NAN), ..Default::default() }), ]; let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(m.width.is_finite() && m.ascent.is_finite()); // Breaks everywhere, odd wrap settings, no room at all. let runs: Vec = (0..2000).map(|i| MNode::Run(MRun { brk: (i % 3 == 0).then_some((i % 256) as u8), ..MRun::new("−+=") })).collect(); for avail in [0.0, f32::NAN, 1e9] { for brk_bin in [BrkBin::Before, BrkBin::After, BrkBin::Repeat] { let mp = MathProps { brk_bin, brk_bin_sub: BrkBinSub::PlusMinus, wrap_indent: f32::INFINITY, wrap_right: avail == 0.0 }; let m = layout_equation(&Math { nodes: runs.clone(), ..Default::default() }, "", &rc(), true, avail, &mp, &mut 0); assert!(m.width.is_finite() && m.descent.is_finite()); } } } }