Files
vangel76andClaude Sonnet 5.5 bbc6d94dbe Layout tests: install the pinned fonts in the layout entry points and font helpers
layout, layout_para, layout_equation and mark_text_width call test_fonts() first in test
builds, so the shared lay helper and everything that goes through them is covered. Tests and
helpers that look up fonts directly (the Times New Roman substitute test, the Symbol bullet
test, the Arabic/Myanmar/Thai face probes and the nbsp fixture) call it themselves. Audited by
running every layout test in its own process with a temporary guard that panicked on any
global font access before the pinned fonts were installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:32:22 +01:00

257 lines
8.2 KiB
Rust

//! 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};
mod bx;
mod glyphs;
mod nodes;
mod rows;
mod state;
mod structs;
use bx::St;
use state::Ctx;
/// 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<MItem>),
}
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<MItem>,
/// Every caret position, for drawing the caret and hit testing.
pub slots: Vec<Slot>,
}
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 {
#[cfg(test)]
crate::test_fonts();
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 }
}
#[cfg(test)]
mod tests;