Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/math.rs

55.0 KiB, 262 runs

created by r1870400018:36109, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Mathematics layout: TeX's mlist-to-hlist, simplified to the embedded text faces.
2//!
3//! A maths expression is an [`Atom`] tree -- symbols with a spacing [`Class`], rows, fractions and
4//! scripts -- and [`layout`] sets it into the box-glue-penalty stream the rest of the engine draws.
5//! The tree is measured into an internal box carrying, for each drawable, an x offset from the box's
6//! left and a `rel` offset from the maths baseline; [`layout`] then flattens that into one [`Node::HBox`]
7//! of leaves seated by glue and lifted by each leaf's own [`Leaf::with_shift`]. A fraction bar is a
8//! [`LeafKind::Rule`](crate::ir::LeafKind); a numerator is raised and a script lowered by the shift, so
9//! nothing bottoms out in a nested box -- which [`place_line`](crate::driver) would draw as a bare
10//! rectangle. The one returned HBox is unwrapped by the caller: [`doc`](crate::doc) weaves its leaves
11//! into a paragraph line for inline maths, or centres them on their own line for a display equation.
12//!
13//! The MATH-font boundary, stated plainly. New Computer Modern Math carries a real OpenType MATH table, and
14//! this module now sets to it: the maths italic alphabet, the axis height, the fraction rule thickness,
15//! the display-style fraction shifts and gaps, the script shifts and their minima, and the two script
16//! scale-down percentages are all read from the font, and a delimiter or a radical grows to its content
17//! through the font's vertical glyph variants. The plain-TeX guesses -- a quarter-em axis, half-em
18//! shifts, seven-tenths and one-half script sizes -- survive only as the fallback for a text face with
19//! no MATH table. The inter-atom spacing is still TeX's reduced `mu` table rather than the font's, and a
20//! delimiter taller than the largest pre-drawn variant is not yet assembled from repeating pieces; big
21//! operators, matrices and a maths parser are later work. See the crate's phase notes.
22
23use crate::theme::Theme;
24use crate::font::ShapedText;
25use crate::ir::{
26 BoxNode,
27 Dims,
28 DrawOp,
29 Glue,
30 Graphic,
31 Leaf,
32 Node,
33 Sp,
34};
35use crate::mathtable::MathTable;
36
37use oxedyne_fe2o3_core::prelude::*;
38use oxedyne_fe2o3_font::{
39 face::Role,
40 font::Font,
41 set::FontSet,
42 shape::Dir,
43};
44use oxedyne_fe2o3_graphics::{
45 colour::Rgba,
46 path::Path,
47 transform::Transform,
48};
49
50use std::sync::{
51 Arc,
52 OnceLock,
53};
54
55// The maths font: New Computer Modern Math, embedded in `crate::fonts` -- Typst's own default for an
56// equation, so a variable, an operator and a radical wear the letterforms the oracle sets. It carries the
57// Mathematical Italic block, the upright operators and the large symbols, and an OpenType MATH table of
58// layout constants and grown-delimiter variants, which this module reads for its shifts, gaps and variants
59// (see the header). Until 2026-09-23 this was Latin Modern Math; some tuning notes below still name it.
60const MATH_FONT: &[u8] = crate::fonts::MATH;
61
62/// The parsed maths font, built once and shared. Parsing the face is the costly part, so it is cached
63/// behind a [`OnceLock`]; a lost race merely parses twice and keeps the first.
64fn math_font() -> Outcome<Arc<Font>> {
65 static MATH: OnceLock<Arc<Font>> = OnceLock::new();
66 if let Some(f) = MATH.get() {
67 return Ok(f.clone());
68 }
69 let f = Arc::new(res!(Font::new(MATH_FONT.to_vec())));
70 let _ = MATH.set(f.clone());
71 Ok(f)
72}
73
74/// The maths font's OpenType MATH table, parsed once and shared -- the layout constants and the grown
75/// delimiter and radical variants. `None` if the font carries no such table, in which case the layout
76/// falls back to the plain-TeX approximations.
77fn math_table() -> Outcome<Option<Arc<MathTable>>> {
78 static TABLE: OnceLock<Option<Arc<MathTable>>> = OnceLock::new();
79 if let Some(t) = TABLE.get() {
80 return Ok(t.clone());
81 }
82 let parsed = res!(MathTable::parse(MATH_FONT)).map(Arc::new);
83 let _ = TABLE.set(parsed.clone());
84 Ok(parsed)
85}
86
87/// The glyph id a single character shapes to in the maths font, for looking the character up in the
88/// MATH table's variants.
89fn glyph_id(font: &Arc<Font>, size: Sp, ch: &str) -> Outcome<u32> {
90 let run = res!(font.shape(ch, size.to_pt() as f32, Dir::Ltr));
91 match run.glyphs.first() {
92 Some(g) => Ok(g.id),
93 None => Err(err!("The maths font shaped {:?} to no glyph.", ch; Bug)),
94 }
95}
96
97/// One glyph's outline flipped into the engine's y-down frame, with its ink bounding box. The outline
98/// comes from the font y up with the baseline at zero; the flip leaves the baseline at zero and the ink
99/// above the baseline at negative y. Returns the flipped path and its extent above and below and its
100/// width, so a caller can seat a grown delimiter or a radical by its ink.
101fn glyph_ink(font: &Arc<Font>, gid: u32, size: Sp) -> Outcome<(Path, Sp, Sp, Sp)> {
102 let raw = res!(font.outline(0, gid, size.to_pt() as f32));
103 let path = res!(raw.transform(&Transform::scale(1.0, -1.0)));
104 match path.bounds(&Transform::IDENTITY) {
105 Some(b) => {
106 // y down: the top of the ink is the least y, the bottom the greatest.
107 let height = if b.y0 < 0.0 { Sp::from_pt((-b.y0) as f64) } else { Sp::ZERO };
108 let depth = if b.y1 > 0.0 { Sp::from_pt(b.y1 as f64) } else { Sp::ZERO };
109 let width = Sp::from_pt(b.x1.max(0.0) as f64);
110 Ok((path, height, depth, width))
111 },
112 None => Ok((path, Sp::ZERO, Sp::ZERO, Sp::ZERO)), // a blank glyph, nothing to seat
113 }
114}
115
116/// An atom's spacing class, after TeX. The class of the atoms either side of a gap fixes the space
117/// set there; the class also decides, for a symbol, whether it is set upright or in the maths italic.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum Class {
120 Ord, // an ordinary atom: a variable or a number
121 Op, // a large operator or a function name (sin, log)
122 Bin, // a binary operator (+ -)
123 Rel, // a relation (= < >)
124 Open, // an opening delimiter
125 Close, // a closing delimiter
126 Punct, // punctuation
127}
128
129/// An accent riding above (or a rule below) a base: the dot of a derivative, the bar of a mean, the
130/// hat of an estimate, the vinculum of an overline. A glyph accent is a mark centred over the base; a
131/// rule accent is a bar drawn the width of the base, over it (`overline`) or under it (`underline`).
132#[derive(Clone, Debug)]
133pub enum Accent {
134 Over(String), // a mark centred above the base (dot, hat, tilde, ...)
135 OverRule, // a bar the width of the base, above it (an overline)
136 UnderRule, // a bar the width of the base, below it (an underline)
137}
138
139/// How a [`Atom::Matrix`] aligns and delimits its grid. A plain matrix centres each column and wraps
140/// the grid in its delimiters; `cases` left-aligns and opens a single brace; an alignment block (a
141/// multi-line display equation broken at `\` and aligned at `&`) alternates right- then left-aligned
142/// columns with no gap at the alignment point, and carries no delimiters.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum MatKind {
145 Matrix,
146 Cases,
147 Align,
148}
149
150/// A node of the maths tree. `Sym` is the leaf: its text and its class. The recursive variants build
151/// rows, fractions, scripts, accents and grids; grouping `{ ... }` is a [`Atom::Row`].
152#[derive(Clone, Debug)]
153pub enum Atom {
154 Sym(String, Class),
155 Text(String), // an upright roman run: a quoted string, a `text()`/`upright()`, `d` of a differential
156 Space(i32), // a fixed horizontal space, in thousandths of an em (a `quad`, a `thin`, an `#h(..)`)
157 Row(Vec<Atom>),
158 Frac {
159 num: Box<Atom>,
160 den: Box<Atom>,
161 },
162 Script {
163 base: Box<Atom>,
164 sup: Option<Box<Atom>>,
165 sub: Option<Box<Atom>>,
166 },
167 Sqrt(Box<Atom>), // a square root: the radical sign and a vinculum over the radicand
168 Fence {
169 left: char, // the opening delimiter, grown to the body
170 body: Box<Atom>,
171 right: char, // the closing delimiter, grown to the body
172 },
173 Accent {
174 base: Box<Atom>,
175 mark: Accent,
176 },
177 Matrix {
178 rows: Vec<Vec<Atom>>,
179 left: Option<char>, // an opening delimiter grown to the grid, or none
180 right: Option<char>, // a closing delimiter grown to the grid, or none
181 kind: MatKind,
182 },
183 Binom {
184 top: Box<Atom>, // the upper term, `binom(top, bottom)`
185 bottom: Box<Atom>, // the lower term
186 },
187 Display(Box<Atom>), // force display style on the inner atom, Typst's `math.display(x)`
188}
189
190impl Atom {
191 pub fn sym<S: Into<String>>(text: S, class: Class) -> Self {
192 Atom::Sym(text.into(), class)
193 }
194
195 /// A variable: ordinary, and set italic when it is a single letter.
196 pub fn var<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Ord) }
197
198 /// A number: ordinary, but set upright because its text is not a single letter.
199 pub fn num<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Ord) }
200
201 pub fn op<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Op) }
202 pub fn bin<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Bin) }
203 pub fn rel<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Rel) }
204 pub fn open<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Open) }
205 pub fn close<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Close) }
206 pub fn punct<S: Into<String>>(text: S) -> Self { Atom::Sym(text.into(), Class::Punct) }
207
208 pub fn row(items: Vec<Atom>) -> Self { Atom::Row(items) }
209
210 pub fn frac(num: Atom, den: Atom) -> Self {
211 Atom::Frac { num: Box::new(num), den: Box::new(den) }
212 }
213
214 pub fn sup(base: Atom, sup: Atom) -> Self {
215 Atom::Script { base: Box::new(base), sup: Some(Box::new(sup)), sub: None }
216 }
217
218 pub fn sub(base: Atom, sub: Atom) -> Self {
219 Atom::Script { base: Box::new(base), sup: None, sub: Some(Box::new(sub)) }
220 }
221
222 pub fn subsup(base: Atom, sub: Atom, sup: Atom) -> Self {
223 Atom::Script { base: Box::new(base), sup: Some(Box::new(sup)), sub: Some(Box::new(sub)) }
224 }
225
226 /// A square root over the radicand.
227 pub fn sqrt(radicand: Atom) -> Self {
228 Atom::Sqrt(Box::new(radicand))
229 }
230
231 /// A body between a pair of delimiters that grow to it, such as parentheses around a tall fraction.
232 pub fn fence(left: char, body: Atom, right: char) -> Self {
233 Atom::Fence { left, body: Box::new(body), right }
234 }
235
236 /// An upright roman run: a quoted string or a `text()`/`upright()`, never set in the maths italic.
237 pub fn text<S: Into<String>>(text: S) -> Self { Atom::Text(text.into()) }
238
239 /// A fixed horizontal space, its width in thousandths of an em (1000 = one em).
240 pub fn space(milli_em: i32) -> Self { Atom::Space(milli_em) }
241
242 /// A base with an accent riding above it, or a rule over or under it.
243 pub fn accent(base: Atom, mark: Accent) -> Self {
244 Atom::Accent { base: Box::new(base), mark }
245 }
246
247 /// A grid of atoms: a matrix, a `cases`, or a multi-line alignment block.
248 pub fn matrix(rows: Vec<Vec<Atom>>, left: Option<char>, right: Option<char>, kind: MatKind) -> Self {
249 Atom::Matrix { rows, left, right, kind }
250 }
251
252 /// A binomial coefficient `binom(top, bottom)`: the two terms stacked without a rule, in parentheses.
253 pub fn binom(top: Atom, bottom: Atom) -> Self {
254 Atom::Binom { top: Box::new(top), bottom: Box::new(bottom) }
255 }
256
257 /// Forces display style on the inner atom, Typst's `math.display(x)`.
258 pub fn display(inner: Atom) -> Self {
259 Atom::Display(Box::new(inner))
260 }
261}
262
263/// A size level, TeX's styles collapsed to the three that change the type size: running maths, a
264/// script, and a script of a script. A fraction sets its parts one level down; a script sets its
265/// scripts one level down; the smallest level does not shrink further.
266#[derive(Clone, Copy, Debug, PartialEq, Eq)]
267enum Level {
268 Text,
269 Script,
270 ScriptScript,
271}
272
273impl Level {
274 fn smaller(self) -> Level {
275 match self {
276 Level::Text => Level::Script,
277 Level::Script => Level::ScriptScript,
278 Level::ScriptScript => Level::ScriptScript,
279 }
280 }
281}
282
283/// The type size at a level: the body size at text, and the two smaller sizes from the maths font's
284/// own `scriptPercentScaleDown` and `scriptScriptPercentScaleDown`. The Computer Modern maths faces use
285/// 70 and 50, the plain-TeX ratios, but a font may choose otherwise; the font's word is taken when it has a
286/// MATH table.
287fn size_for(style: &Theme, level: Level) -> Sp {
288 let body = style.text.body_size.raw();
289 let (s_pct, ss_pct) = script_percents();
290 match level {
291 Level::Text => style.text.body_size,
292 Level::Script => Sp(body * s_pct / 100),
293 Level::ScriptScript => Sp(body * ss_pct / 100),
294 }
295}
296
297/// The maths font's two script scale-down percentages, or the plain-TeX 70 and 50 when the font carries
298/// no MATH table. Read from the cached table, so this costs only a lookup.
299fn script_percents() -> (i32, i32) {
300 match math_table() {
301 Ok(Some(t)) => {
302 let c = t.constants();
303 (c.script_percent_scale_down as i32, c.script_script_percent_scale_down as i32)
304 },
305 _ => (70, 50),
306 }
307}
308
309/// A design-unit length scaled to a type size in scaled points, from the parsed table.
310fn du(table: &Arc<MathTable>, value: i16, size_pt: f32) -> Sp {
311 Sp::from_pt(table.scaled(value, size_pt) as f64)
312}
313
314/// What a drawable is: a shaped glyph run, or a horizontal bar of the given thickness (a fraction
315/// rule). Both flatten to a leaf; the run to a [`LeafKind::Text`](crate::ir::LeafKind), the bar to a
316/// [`LeafKind::Rule`](crate::ir::LeafKind).
317enum Draw {
318 Glyph(ShapedText),
319 Bar(Sp), // thickness
320 Ink(Path), // a raw outline in the y-down frame, baseline at zero: a grown delimiter or radical
321}
322
323/// One drawable within a maths box: its left `x` from the box's own left, its `width`, and `rel` --
324/// the downward offset of its reference line from the maths baseline. For a glyph `rel` is the
325/// baseline offset (negative raises it); for a bar it is the offset of the bar's top edge.
326struct Piece {
327 x: Sp,
328 width: Sp,
329 rel: Sp,
330 draw: Draw,
331}
332
333/// A measured maths box: its drawables, its width, its extent above and below the maths baseline, and
334/// the class it presents to whatever it sits beside. The internal form before [`emit`] flattens it to
335/// leaves and glue.
336struct MBox {
337 pieces: Vec<Piece>,
338 width: Sp,
339 height: Sp, // above the maths baseline
340 depth: Sp, // below the maths baseline
341 class: Class,
342}
343
344/// Lays a maths expression into one [`Node::HBox`] of leaves and glue. `display` centres the box on
345/// its own baseline (the caller sets it on a line of its own); otherwise the box's baseline is seated
346/// at the surrounding text's ascent, so its variables sit on the paragraph's baseline. The returned
347/// HBox is meant to be unwrapped by the caller -- its `list` woven into a line, its `dims` read for the
348/// line's extent -- because a maths box left nested inside a paragraph line would draw as a bare
349/// rectangle.
350pub fn layout(
351 fonts: Arc<FontSet>,
352 style: &Theme,
353 expr: &Atom,
354 display: bool,
355)
356 -> Outcome<Node>
357{
358 let m = res!(build(style, expr, Level::Text, display));
359
360 // The baseline's distance from the box top. A display box stands on its own, so its top is its
361 // highest ink and the baseline sits `height` below it. An inline box shares the line, so its
362 // baseline meets the surrounding text's baseline -- a body ascent below the line top.
363 let base = if display {
364 m.height
365 } else {
366 res!(ascent(&fonts, style.text.body_size))
367 };
368
369 let (nodes, dims) = emit(m, base);
370 Ok(Node::HBox(BoxNode::new(nodes, dims)))
371}
372
373/// The body face's ascent at a size: how far the line top sits above the baseline, the reference an
374/// inline maths box seats its baseline against.
375fn ascent(fonts: &Arc<FontSet>, size: Sp) -> Outcome<Sp> {
376 let sample = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, "0"));
377 Ok(sample.dims().height)
378}
379
380/// Measures an atom into a maths box at a size level. Recursion mirrors the tree: a symbol shapes one
381/// run, a row concatenates its atoms with class spacing, a fraction stacks two smaller boxes over a
382/// bar or slashes them on the line, and a script hangs smaller boxes off a base. `display` distinguishes
383/// a display equation, which stacks its fractions, from inline maths, which slashes them so a running
384/// line never opens above a fraction.
385fn build(
386 style: &Theme,
387 expr: &Atom,
388 level: Level,
389 display: bool,
390)
391 -> Outcome<MBox>
392{
393 match expr {
394 Atom::Sym(text, class) => build_sym(style, text, *class, level, display),
395 Atom::Text(text) => build_text(style, text, level),
396 Atom::Space(mem) => Ok(build_space(style, *mem, level)),
397 Atom::Row(items) => build_row(style, items, level, display),
398 Atom::Frac { num, den } => build_frac(style, num, den, level, display, true),
399 Atom::Script { base, sup, sub } => build_script(style, base, sup.as_deref(), sub.as_deref(), level, display),
400 Atom::Sqrt(radicand) => build_sqrt(style, radicand, level, display),
401 Atom::Fence { left, body, right } => build_fence(style, *left, body, *right, level, display),
402 Atom::Accent { base, mark } => build_accent(style, base, mark, level, display),
403 Atom::Matrix { rows, left, right, kind }
404 => build_matrix(style, rows, *left, *right, *kind, level, display),
405 Atom::Binom { top, bottom } => build_binom(style, top, bottom, level, display),
406 // Display style forces the block context and the running size, so a fraction stacks and a big
407 // operator sets its limits below, as `math.display(x)` does in Typst.
408 Atom::Display(inner) => build(style, inner, Level::Text, true),
409 }
410}
411
412/// Sets one symbol from the maths font. A single-letter ordinary atom is drawn from the Mathematical
413/// Italic block -- a real maths italic, not the text italic -- while a function name, a digit, an
414/// operator or a delimiter is the font's upright glyph. The run's ascent and descent stand in for the
415/// atom's height and depth.
416fn build_sym(
417 style: &Theme,
418 text: &str,
419 class: Class,
420 level: Level,
421 display: bool,
422)
423 -> Outcome<MBox>
424{
425 let size = size_for(style, level);
426 // A big operator (a summation, an integral) is set from its larger display variant when it stands in
427 // a display equation at running size, the way TeX's `\displaystyle` grows it; inline and in a script
428 // it keeps its text-size glyph.
429 if display && level == Level::Text && is_bigop(text) {
430 if let Some(m) = res!(build_bigop(text, size)) {
431 return Ok(m);
432 }
433 }
434 let font = res!(math_font());
435 let shown = math_text(text, class);
436 let shaped = res!(ShapedText::new_with_font(font, Dir::Ltr, size, &shown));
437 let width = shaped.dims().width; // the advance, the horizontal extent
438 let (height, depth) = res!(shaped.ink_extent()); // the true ink extent, not the font's global metric
439 let piece = Piece { x: Sp::ZERO, width, rel: Sp::ZERO, draw: Draw::Glyph(shaped) };
440 Ok(MBox { pieces: vec![piece], width, height, depth, class })
441}
442
443/// Is the symbol a large operator that grows in a display equation -- a summation, a product, an
444/// integral, a big union or intersection?
445fn is_bigop(text: &str) -> bool {
446 matches!(text,
447 "\u{2211}" | "\u{220F}" | "\u{2210}" | // sum, product, coproduct
448 "\u{222B}" | "\u{222C}" | "\u{222D}" | "\u{222E}" | // integral and its multiples
449 "\u{22C0}" | "\u{22C1}" | "\u{22C2}" | "\u{22C3}" | // big and, or, intersection, union
450 "\u{2A00}" | "\u{2A01}" | "\u{2A02}" | "\u{2A04}" | "\u{2A06}") // big odot, oplus, otimes, uplus, sqcup
451}
452
453/// Does the symbol set its scripts as limits -- above and below in a display equation -- rather than
454/// beside it? The big operators do, and the named limit operators (`lim`, `max`, `min`, ...), but an
455/// integral does not: its bounds sit beside the sign even in display.
456fn is_limits(text: &str) -> bool {
457 let integral = matches!(text, "\u{222B}" | "\u{222C}" | "\u{222D}" | "\u{222E}");
458 if integral {
459 return false;
460 }
461 is_bigop(text) || matches!(text,
462 "lim" | "max" | "min" | "sup" | "inf" | "det" | "gcd" | "limsup" | "liminf" | "Pr")
463}
464
465/// Draws a big operator from its tallest-but-one display variant, seated centred on the maths axis like
466/// a delimiter, so a summation towers over its limits. `None` when the font offers no larger variant, in
467/// which case the caller sets the plain glyph.
468fn build_bigop(text: &str, size: Sp) -> Outcome<Option<MBox>> {
469 let table = res!(math_table());
470 let tb = match &table {
471 Some(t) => t,
472 None => return Ok(None),
473 };
474 let font = res!(math_font());
475 let size_pt = size.to_pt() as f32;
476 let base = res!(glyph_id(&font, size, text));
477 // A display big operator is about the next size up; ask for a variant around 1.4x the running size
478 // and take the tallest the font offers below that, which is the Computer Modern display form.
479 let target = Sp(size.raw() * 7 / 5);
480 let variant = match tb.variant_for(base as u16, target.to_pt() as f32, size_pt) {
481 Some(g) if g as u32 != base => g as u32,
482 _ => return Ok(None), // no larger form: leave the plain glyph
483 };
484 let (path, gh, gd, gw) = res!(glyph_ink(&font, variant, size));
485 let axis = du(tb, tb.constants().axis_height, size_pt);
486 // Centre the glyph's ink on the axis.
487 let rel = -axis - Sp((gd.raw() - gh.raw()) / 2);
488 let piece = Piece { x: Sp::ZERO, width: gw, rel, draw: Draw::Ink(path) };
489 let height = gh - rel; // ink top above the baseline
490 let depth = rel + gd; // ink bottom below the baseline
491 Ok(Some(MBox { pieces: vec![piece], width: gw, height, depth, class: Class::Op }))
492}
493
494/// Sets an upright roman run from the maths font: a quoted string, a `text()`/`upright()`, the `d` of a
495/// differential. Unlike a single-letter ordinary, it is never remapped to the maths italic. It presents
496/// as ordinary so it abuts what follows without an operator's spacing.
497fn build_text(
498 style: &Theme,
499 text: &str,
500 level: Level,
501)
502 -> Outcome<MBox>
503{
504 let size = size_for(style, level);
505 let font = res!(math_font());
506 let shaped = res!(ShapedText::new_with_font(font, Dir::Ltr, size, text));
507 let width = shaped.dims().width;
508 let (height, depth) = res!(shaped.ink_extent());
509 let piece = Piece { x: Sp::ZERO, width, rel: Sp::ZERO, draw: Draw::Glyph(shaped) };
510 Ok(MBox { pieces: vec![piece], width, height, depth, class: Class::Ord })
511}
512
513/// A fixed horizontal space: a zero-ink box the requested fraction of an em wide. It presents as
514/// ordinary, so it neither adds nor sheds the automatic inter-atom spacing around it -- its width is the
515/// whole of the gap.
516fn build_space(style: &Theme, milli_em: i32, level: Level) -> MBox {
517 let size = size_for(style, level);
518 let width = Sp(size.raw() * milli_em / 1000);
519 MBox { pieces: Vec::new(), width, height: Sp::ZERO, depth: Sp::ZERO, class: Class::Ord }
520}
521
522/// The characters actually shaped for an atom. A single-letter ordinary variable is remapped to its
523/// Mathematical Italic codepoint, so `x` draws as a maths italic from the maths font; everything else
524/// -- a digit, a multi-letter name, an operator -- is shaped as written, upright.
525fn math_text(text: &str, class: Class) -> String {
526 if class == Class::Ord {
527 let mut it = text.chars();
528 if let (Some(c), None) = (it.next(), it.next()) {
529 if let Some(m) = math_italic(c) {
530 return m.to_string();
531 }
532 }
533 }
534 text.to_string()
535}
536
537/// The Mathematical Italic codepoint of a Latin letter, or `None` for anything else. The italic small
538/// `h` is the one hole in the block -- U+1D455 is unassigned -- and lives at U+210E, the Planck
539/// constant, which every maths font draws as the italic h.
540fn math_italic(c: char) -> Option<char> {
541 let cp = match c {
542 'h' => 0x210E,
543 'a'..='z' => 0x1D44E + (c as u32 - 'a' as u32),
544 'A'..='Z' => 0x1D434 + (c as u32 - 'A' as u32),
545 _ => return None,
546 };
547 char::from_u32(cp)
548}
549
550/// Concatenates a row's atoms left to right, opening a class-driven space before each atom but the
551/// first. The row presents as ordinary to whatever encloses it.
552fn build_row(
553 style: &Theme,
554 items: &[Atom],
555 level: Level,
556 display: bool,
557)
558 -> Outcome<MBox>
559{
560 let size = size_for(style, level);
561 let mut pieces: Vec<Piece> = Vec::new();
562 let mut cursor = Sp::ZERO;
563 let mut height = Sp::ZERO;
564 let mut depth = Sp::ZERO;
565 let mut prev: Option<Class> = None;
566
567 for atom in items {
568 let m = res!(build(style, atom, level, display));
569 // TeX's binary-operator cancellation: a Bin with no atom to its left to bind -- at the row's
570 // start, or after another operator, a relation or an opening -- is not binary but a sign on the
571 // atom that follows, so it is retyped Ord and sheds its medium space. This is what turns the
572 // leading minus of `-b` from a subtraction with a gap into a tight unary sign.
573 let cls = match m.class {
574 Class::Bin => match prev {
575 None => Class::Ord,
576 Some(Class::Ord) | Some(Class::Close) => Class::Bin,
577 Some(_) => Class::Ord,
578 },
579 other => other,
580 };
581 if let Some(pc) = prev {
582 cursor += space_between(pc, cls, size);
583 }
584 place_into(&mut pieces, m.pieces, cursor, Sp::ZERO);
585 cursor += m.width;
586 if m.height > height { height = m.height; }
587 if m.depth > depth { depth = m.depth; }
588 prev = Some(cls);
589 }
590 Ok(MBox { pieces, width: cursor, height, depth, class: Class::Ord })
591}
592
593/// The inter-atom space set between two classes, in scaled points at the running size. TeX measures
594/// these in `mu` (eighteen to the em): a thin space is three, a medium four, a thick five. The table
595/// is simplified -- it does not reclassify a binary operator to ordinary at a row edge or beside
596/// another operator, which real maths spacing does -- but it sets the visible cases: a relation gets
597/// thick space, a binary operator medium, an operator or punctuation thin.
598fn space_between(left: Class, right: Class, size: Sp) -> Sp {
599 let mu = mu_between(left, right);
600 Sp(size.raw() * mu / 18)
601}
602
603/// The `mu` count between two atom classes, from TeX's spacing table, reduced to the pairs the text
604/// faces set.
605fn mu_between(left: Class, right: Class) -> i32 {
606 use Class::*;
607 match (left, right) {
608 (Rel, _) | (_, Rel) => 5, // thick around a relation
609 (Bin, _) | (_, Bin) => 4, // medium around a binary operator
610 (Ord, Op) | (Op, Ord) => 3, // thin between an operator and an ordinary
611 (Op, Op) => 3,
612 (Punct, _) => 3, // thin after punctuation
613 _ => 0, // delimiters and ordinaries abut
614 }
615}
616
617/// Stacks a numerator over a denominator, centred over a bar seated on the maths axis. Following TeX's
618/// style rule, a display fraction's immediate parts stay full size (Display's numerator style is Text)
619/// while a script-level fraction's parts step down; the shifts, the gaps, the rule thickness and the
620/// axis all come from the font's MATH constants -- the display-style variants of the fraction metrics --
621/// falling back to the plain-TeX guesses only when the font has no table.
622fn build_frac(
623 style: &Theme,
624 num: &Atom,
625 den: &Atom,
626 level: Level,
627 display: bool,
628 bar: bool,
629)
630 -> Outcome<MBox>
631{
632 // Inline, a real fraction is slashed on the line -- numerator, solidus, denominator abreast -- so it
633 // keeps within the line's height and never opens a gap above it. Only a display fraction stacks. A
634 // binomial (no bar) stacks even inline, as Typst does, so the slash path is taken only for a true bar.
635 if !display && bar {
636 let items = vec![
637 num.clone(),
638 Atom::Sym("/".to_string(), Class::Ord),
639 den.clone(),
640 ];
641 // A text/inline fraction's parts drop one size level (Text's numerator style is Script).
642 return build_row(style, &items, level.smaller(), display);
643 }
644
645 let size = size_for(style, level);
646 let size_pt = size.to_pt() as f32;
647 let table = res!(math_table());
648
649 // TeX's style progression: at display style (level Text here) the parts stay full size; a fraction
650 // nested inside a script steps its parts down. The parts keep the block (display) context so a big
651 // operator in a numerator still sets its limits below and a nested fraction still stacks, both a size
652 // smaller; only the running/inline distinction was ever about slashing, and that was handled above.
653 let sub = if level == Level::Text { Level::Text } else { level.smaller() };
654 let n = res!(build(style, num, sub, display));
655 let d = res!(build(style, den, sub, display));
656
657 // The display-style fraction metrics from the MATH table, or the plain-TeX guesses without one.
658 let (axis, t, num_shift, den_shift, num_gap, den_gap) = match &table {
659 Some(tb) => {
660 let c = tb.constants();
661 (
662 du(tb, c.axis_height, size_pt),
663 du(tb, c.fraction_rule_thickness, size_pt),
664 du(tb, c.fraction_num_display_shift_up, size_pt),
665 du(tb, c.fraction_den_display_shift_down, size_pt),
666 du(tb, c.fraction_num_display_gap_min, size_pt),
667 du(tb, c.fraction_denom_display_gap_min, size_pt),
668 )
669 },
670 None => (
671 Sp(size.raw() / 4), // axis
672 style.table.rule_thin, // bar thickness
673 Sp(size.raw() / 2), // numerator shift up
674 Sp(size.raw() / 2), // denominator shift down
675 Sp(size.raw() / 6), // numerator gap
676 Sp(size.raw() / 6), // denominator gap
677 ),
678 };
679
680 // The bar's top edge as a `rel` offset down from the maths baseline; above the baseline is negative.
681 // Its foot sits `t` below, at `-axis + t/2`, which the gap arithmetic below uses directly.
682 let bar_top = -axis - Sp(t.raw() / 2);
683
684 // The numerator baseline: the font's display shift, but pushed higher if that leaves less than the
685 // least gap between the numerator's foot and the bar's top.
686 let num_lift = num_shift.raw().max(axis.raw() + t.raw() / 2 + n.depth.raw() + num_gap.raw());
687 let num_base = Sp(-num_lift);
688 // The denominator baseline: the font's display shift, dropped further if the gap below the bar is
689 // tighter than the least.
690 let den_drop = den_shift.raw().max(d.height.raw() - axis.raw() + t.raw() / 2 + den_gap.raw());
691 let den_base = Sp(den_drop);
692
693 let pad = Sp(size.raw() / 8); // a small overhang of the bar past the wider part
694 let fw = n.width.raw().max(d.width.raw());
695 let width = Sp(fw) + pad;
696
697 let mut pieces: Vec<Piece> = Vec::new();
698 let nx = Sp((width.raw() - n.width.raw()) / 2);
699 let dx = Sp((width.raw() - d.width.raw()) / 2);
700 place_into(&mut pieces, n.pieces, nx, num_base);
701 place_into(&mut pieces, d.pieces, dx, den_base);
702 // A binomial coefficient stacks its terms with the same gaps but no rule between them.
703 if bar {
704 pieces.push(Piece { x: Sp::ZERO, width, rel: bar_top, draw: Draw::Bar(t) });
705 }
706
707 let height = n.height - num_base; // num_base is negative, so this reaches above the baseline
708 let depth = den_base + d.depth;
709 Ok(MBox { pieces, width, height, depth, class: Class::Ord })
710}
711
712/// Hangs a superscript and/or a subscript off a base. The scripts are set one size level down and
713/// lifted or dropped by the font's own MATH shifts: a superscript rides at least `superscriptShiftUp`
714/// but higher off a tall base or to keep its foot above `superscriptBottomMin`; a subscript drops at
715/// least `subscriptShiftDown`, deeper below a base that hangs below the baseline, and never letting its
716/// top climb past `subscriptTopMax`. With both present, the pair is spread so the gap between the
717/// superscript's foot and the subscript's top is no less than `subSuperscriptGapMin`. Without a MATH
718/// table the plain-TeX fractions of the em stand in.
719fn build_script(
720 style: &Theme,
721 base: &Atom,
722 sup: Option<&Atom>,
723 sub: Option<&Atom>,
724 level: Level,
725 display: bool,
726)
727 -> Outcome<MBox>
728{
729 // A big operator or a named limit operator in a display equation sets its scripts as limits, centred
730 // above and below the sign rather than beside it.
731 if display && level == Level::Text {
732 if let Atom::Sym(text, _) = base {
733 if is_limits(text) {
734 return build_limits(style, base, sup, sub, level);
735 }
736 }
737 }
738
739 let size = size_for(style, level);
740 let size_pt = size.to_pt() as f32;
741 let em = size.raw();
742 let table = res!(math_table());
743 // The base keeps the enclosing style; the scripts step down a size and are never display style.
744 let b = res!(build(style, base, level, display));
745
746 let mut pieces: Vec<Piece> = Vec::new();
747 place_into(&mut pieces, b.pieces, Sp::ZERO, Sp::ZERO);
748
749 let kern = Sp(em / 24); // a hair between the base and its scripts
750 let sx = b.width + kern;
751 let mut width = b.width;
752 let mut height = b.height;
753 let mut depth = b.depth;
754
755 // The MATH script shifts scaled to the base size, or the plain-TeX guesses without a table.
756 let (sup_shift, sup_bottom_min, sup_drop_max, sub_shift, sub_top_max, sub_drop_min, gap_min) =
757 match &table {
758 Some(t) => {
759 let c = t.constants();
760 (
761 du(t, c.superscript_shift_up, size_pt),
762 du(t, c.superscript_bottom_min, size_pt),
763 du(t, c.superscript_baseline_drop_max, size_pt),
764 du(t, c.subscript_shift_down, size_pt),
765 du(t, c.subscript_top_max, size_pt),
766 du(t, c.subscript_baseline_drop_min, size_pt),
767 du(t, c.sub_superscript_gap_min, size_pt),
768 )
769 },
770 None => (
771 Sp(em / 2), Sp(em / 12), Sp(em / 4), Sp(em / 4), Sp(em / 3), Sp(em / 20), Sp(em / 6),
772 ),
773 };
774
775 // The superscript's rise, and the subscript's drop, as positive distances from the maths baseline.
776 // Computed even when only one is present, so the combined-gap step can reconcile them.
777 let sup_m = match sup {
778 Some(s) => Some(res!(build(style, s, level.smaller(), false))),
779 None => None,
780 };
781 let sub_m = match sub {
782 Some(s) => Some(res!(build(style, s, level.smaller(), false))),
783 None => None,
784 };
785
786 let mut rise = Sp::ZERO;
787 if let Some(m) = &sup_m {
788 // At least the font's shift; higher off a tall base; and enough that the foot clears the
789 // baseline by `superscriptBottomMin`.
790 let r = sup_shift.raw()
791 .max(b.height.raw() - sup_drop_max.raw())
792 .max(sup_bottom_min.raw() + m.depth.raw());
793 rise = Sp(r);
794 }
795 let mut drop = Sp::ZERO;
796 if let Some(m) = &sub_m {
797 // At least the font's shift; deeper below a base that descends; and enough that the top does
798 // not rise past `subscriptTopMax` above the baseline.
799 let d = sub_shift.raw()
800 .max(b.depth.raw() + sub_drop_min.raw())
801 .max(m.height.raw() - sub_top_max.raw());
802 drop = Sp(d);
803 }
804
805 // With both scripts, widen the split until the vertical gap between the superscript's foot and the
806 // subscript's top reaches the font's minimum, deepening the subscript rather than raising the
807 // superscript, so the superscript keeps its natural height.
808 if let (Some(sm), Some(bm)) = (&sup_m, &sub_m) {
809 let gap = (drop.raw() - bm.height.raw()) + (rise.raw() - sm.depth.raw());
810 if gap < gap_min.raw() {
811 drop = Sp(drop.raw() + (gap_min.raw() - gap));
812 }
813 }
814
815 if let Some(m) = sup_m {
816 place_into(&mut pieces, m.pieces, sx, Sp(-rise.raw()));
817 let w = sx + m.width;
818 if w > width { width = w; }
819 let top = rise + m.height;
820 if top > height { height = top; }
821 }
822 if let Some(m) = sub_m {
823 place_into(&mut pieces, m.pieces, sx, drop);
824 let w = sx + m.width;
825 if w > width { width = w; }
826 let bottom = drop + m.depth;
827 if bottom > depth { depth = bottom; }
828 }
829
830 Ok(MBox { pieces, width, height, depth, class: b.class })
831}
832
833/// The maths axis at a size: the height a fraction bar, a relation and a grown delimiter centre on,
834/// from the font's MATH table or the plain-TeX quarter-em without one.
835fn axis_height(table: &Option<Arc<MathTable>>, size: Sp, size_pt: f32) -> Sp {
836 match table {
837 Some(t) => Sp::from_pt(t.scaled(t.constants().axis_height, size_pt) as f64),
838 None => Sp(size.raw() / 4),
839 }
840}
841
842/// Sets a big operator with its scripts as limits, centred above and below the sign. The operator keeps
843/// the running size (and grows to its display variant through [`build`]); each limit is set one size
844/// level down and centred on the operator, a small gap clear of it.
845fn build_limits(
846 style: &Theme,
847 base: &Atom,
848 sup: Option<&Atom>,
849 sub: Option<&Atom>,
850 level: Level,
851)
852 -> Outcome<MBox>
853{
854 let size = size_for(style, level);
855 let gap = Sp(size.raw() / 6); // clearance between the operator and a limit
856 // The operator itself, at display size (a summation grows to its larger variant here).
857 let op = res!(build(style, base, level, true));
858 let up = match sup {
859 Some(a) => Some(res!(build(style, a, level.smaller(), false))),
860 None => None,
861 };
862 let lo = match sub {
863 Some(a) => Some(res!(build(style, a, level.smaller(), false))),
864 None => None,
865 };
866
867 let mut width = op.width;
868 if let Some(m) = &up { if m.width > width { width = m.width; } }
869 if let Some(m) = &lo { if m.width > width { width = m.width; } }
870
871 let mut pieces: Vec<Piece> = Vec::new();
872 let centre = |w: Sp| Sp((width.raw() - w.raw()) / 2);
873 place_into(&mut pieces, op.pieces, centre(op.width), Sp::ZERO);
874 let mut height = op.height;
875 let mut depth = op.depth;
876
877 if let Some(m) = up {
878 // The upper limit's baseline, so its foot clears the operator's top by `gap`.
879 let drel = -(op.height + gap + m.depth);
880 let top = -drel + m.height;
881 place_into(&mut pieces, m.pieces, centre(m.width), drel);
882 if top > height { height = top; }
883 }
884 if let Some(m) = lo {
885 // The lower limit's baseline, so its top clears the operator's foot by `gap`.
886 let drel = op.depth + gap + m.height;
887 let bottom = drel + m.depth;
888 place_into(&mut pieces, m.pieces, centre(m.width), drel);
889 if bottom > depth { depth = bottom; }
890 }
891
892 Ok(MBox { pieces, width, height, depth, class: Class::Op })
893}
894
895/// Sets an accent over a base, or a rule over or under it. A glyph accent is shaped from the maths font
896/// and centred over the base, its foot a hair above the base's ink; an overline or underline is a bar
897/// the width of the base, a small gap clear of it. The base keeps its size and style.
898fn build_accent(
899 style: &Theme,
900 base: &Atom,
901 mark: &Accent,
902 level: Level,
903 display: bool,
904)
905 -> Outcome<MBox>
906{
907 let size = size_for(style, level);
908 let size_pt = size.to_pt() as f32;
909 let table = res!(math_table());
910 let b = res!(build(style, base, level, display));
911
912 let rule = match &table {
913 Some(t) => Sp::from_pt(t.scaled(t.constants().fraction_rule_thickness, size_pt) as f64),
914 None => style.table.rule_thin,
915 };
916 let gap = Sp(size.raw() / 12); // clearance between the base and its accent or rule
917
918 let mut pieces: Vec<Piece> = Vec::new();
919 place_into(&mut pieces, b.pieces, Sp::ZERO, Sp::ZERO);
920 let mut height = b.height;
921 let mut depth = b.depth;
922
923 match mark {
924 Accent::OverRule => {
925 let bar_top = -(b.height + gap + rule);
926 pieces.push(Piece { x: Sp::ZERO, width: b.width, rel: bar_top, draw: Draw::Bar(rule) });
927 height = b.height + gap + rule;
928 },
929 Accent::UnderRule => {
930 let bar_top = b.depth + gap;
931 pieces.push(Piece { x: Sp::ZERO, width: b.width, rel: bar_top, draw: Draw::Bar(rule) });
932 depth = b.depth + gap + rule;
933 },
934 Accent::Over(glyph) => {
935 // A spacing-accent glyph carries its own high position in the font, so it is placed by its
936 // actual outline rather than by shaping: the mark is dropped until its ink foot sits a small
937 // gap above the base's ink top, then centred over the base's width.
938 let font = res!(math_font());
939 let gid = res!(glyph_id(&font, size, glyph));
940 let (path, _, _, gw) = res!(glyph_ink(&font, gid, size));
941 let (ink_top, ink_bottom) = match path.bounds(&Transform::IDENTITY) {
942 Some(bd) => (Sp::from_pt(bd.y0 as f64), Sp::from_pt(bd.y1 as f64)),
943 None => (Sp::ZERO, Sp::ZERO),
944 };
945 // The ink foot wants to land a gap above the base top (negative is up).
946 let target = Sp(-(b.height.raw() + gap.raw()));
947 let dy = target - ink_bottom;
948 let moved = res!(path.transform(&Transform::translate(0.0, dy.to_pt() as f32)));
949 let x = Sp((b.width.raw() - gw.raw()) / 2);
950 pieces.push(Piece { x, width: gw, rel: Sp::ZERO, draw: Draw::Ink(moved) });
951 let top = Sp(-(ink_top.raw() + dy.raw()));
952 if top > height { height = top; }
953 },
954 }
955
956 Ok(MBox { pieces, width: b.width, height, depth, class: Class::Ord })
957}
958
959/// Sets a grid of atoms: a matrix, a `cases`, or a multi-line alignment block. Every cell is measured,
960/// the columns sized to their widest cell and the rows to their tallest; the grid is stacked with a row
961/// gap and centred on the maths axis, each column aligned by the grid's kind. A matrix and a `cases`
962/// then wrap the grid in delimiters grown to it. An alignment block carries none and butts its columns
963/// at the alignment point, so `a &= b` reads as one line.
964fn build_matrix(
965 style: &Theme,
966 rows: &[Vec<Atom>],
967 left: Option<char>,
968 right: Option<char>,
969 kind: MatKind,
970 level: Level,
971 _display: bool, // a grid sets its own cell style by kind, not the enclosing display flag
972)
973 -> Outcome<MBox>
974{
975 let size = size_for(style, level);
976 let size_pt = size.to_pt() as f32;
977 let table = res!(math_table());
978 let axis = axis_height(&table, size, size_pt);
979
980 let nrows = rows.len();
981 if nrows == 0 {
982 return Ok(MBox { pieces: Vec::new(), width: Sp::ZERO, height: Sp::ZERO, depth: Sp::ZERO, class: Class::Ord });
983 }
984 let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
985 // A cell keeps the grid's level; an alignment line is display style (its fractions stack), a matrix
986 // or a `cases` cell is text style.
987 let cell_display = kind == MatKind::Align;
988
989 // Measure every cell.
990 let mut cells: Vec<Vec<MBox>> = Vec::with_capacity(nrows);
991 for row in rows {
992 let mut cs = Vec::with_capacity(ncols);
993 for c in 0..ncols {
994 let m = match row.get(c) {
995 Some(a) => res!(build(style, a, level, cell_display)),
996 None => MBox { pieces: Vec::new(), width: Sp::ZERO, height: Sp::ZERO, depth: Sp::ZERO, class: Class::Ord },
997 };
998 cs.push(m);
999 }
1000 cells.push(cs);
1001 }
1002
1003 // Column widths and per-row height and depth.
1004 let mut colw = vec![Sp::ZERO; ncols];
1005 let mut rowh = vec![Sp::ZERO; nrows];
1006 let mut rowd = vec![Sp::ZERO; nrows];
1007 for (ri, row) in cells.iter().enumerate() {
1008 for (c, m) in row.iter().enumerate() {
1009 if m.width > colw[c] { colw[c] = m.width; }
1010 if m.height > rowh[ri] { rowh[ri] = m.height; }
1011 if m.depth > rowd[ri] { rowd[ri] = m.depth; }
1012 }
1013 }
1014
1015 let (col_gap, row_gap) = match kind {
1016 MatKind::Matrix => (Sp(size.raw() * 11 / 20), Sp(size.raw() * 7 / 20)), // 0.55em, 0.35em
1017 MatKind::Cases => (Sp(size.raw() * 4 / 5), Sp(size.raw() * 2 / 5)), // 0.80em, 0.40em
1018 // An alignment line breaks at `&` before a relation (`&=`), so the gap at the alignment point is
1019 // a relation's thick space, matching the space the relation carries on its other side.
1020 MatKind::Align => (Sp(size.raw() * 5 / 18), Sp(size.raw() * 2 / 5)), // 5 mu, 0.40em
1021 };
1022
1023 // The grid's total width.
1024 let mut content_w = Sp::ZERO;
1025 for c in 0..ncols {
1026 content_w += colw[c];
1027 if c + 1 < ncols { content_w += col_gap; }
1028 }
1029
1030 // Each row's baseline, relative to the first row's baseline (positive down), then the shift that puts
1031 // the grid's vertical centre on the axis.
1032 let mut base_rel = vec![Sp::ZERO; nrows];
1033 let mut y = Sp::ZERO;
1034 for i in 0..nrows {
1035 base_rel[i] = y;
1036 if i + 1 < nrows {
1037 y += rowd[i] + row_gap + rowh[i + 1];
1038 }
1039 }
1040 let span_below = base_rel[nrows - 1] + rowd[nrows - 1]; // first baseline to grid foot
1041 let span_above = rowh[0]; // first baseline to grid top
1042 let centre = Sp((span_below.raw() - span_above.raw()) / 2);
1043 let shift = Sp(-axis.raw()) - centre; // grid centre onto the axis
1044
1045 let content_h = span_above - shift; // grid top above the baseline
1046 let content_d = span_below + shift; // grid foot below the baseline
1047
1048 // Place the cells, with the content offset for a left delimiter added later.
1049 let mut pieces: Vec<Piece> = Vec::new();
1050 for (ri, row) in cells.into_iter().enumerate() {
1051 let by = base_rel[ri] + shift;
1052 let mut cx = Sp::ZERO;
1053 for (c, m) in row.into_iter().enumerate() {
1054 let dx = match kind {
1055 MatKind::Cases => Sp::ZERO, // left aligned
1056 MatKind::Align if c % 2 == 0 => Sp(colw[c].raw() - m.width.raw()), // right aligned
1057 MatKind::Align => Sp::ZERO, // left aligned
1058 MatKind::Matrix => Sp((colw[c].raw() - m.width.raw()) / 2), // centred
1059 };
1060 place_into(&mut pieces, m.pieces, cx + dx, by);
1061 cx += colw[c] + col_gap;
1062 }
1063 }
1064
1065 // Wrap the grid in its delimiters, each grown to span the grid symmetrically about the axis.
1066 if left.is_none() && right.is_none() {
1067 return Ok(MBox { pieces, width: content_w, height: content_h, depth: content_d, class: Class::Ord });
1068 }
1069 let font = res!(math_font());
1070 let target = delim_target(content_h + content_d);
1071 let gap = Sp(size.raw() / 8);
1072
1073 let mut out: Vec<Piece> = Vec::new();
1074 let mut cursor = Sp::ZERO;
1075 let mut height = content_h;
1076 let mut depth = content_d;
1077 if let Some(ch) = left {
1078 let (w, h, d) = res!(place_delim(&font, &table, ch, target, axis, size, size_pt, &mut out, cursor));
1079 cursor += w + gap;
1080 if h > height { height = h; }
1081 if d > depth { depth = d; }
1082 }
1083 for p in pieces {
1084 out.push(Piece { x: p.x + cursor, width: p.width, rel: p.rel, draw: p.draw });
1085 }
1086 cursor += content_w;
1087 if let Some(ch) = right {
1088 cursor += gap;
1089 let (w, h, d) = res!(place_delim(&font, &table, ch, target, axis, size, size_pt, &mut out, cursor));
1090 cursor += w;
1091 if h > height { height = h; }
1092 if d > depth { depth = d; }
1093 }
1094
1095 Ok(MBox { pieces: out, width: cursor, height, depth, class: Class::Ord })
1096}
1097
1098/// Sets a square root: a radical sign grown to the radicand, a vinculum ruled over it, and the radicand
1099/// seated beneath the vinculum. The gaps, the rule thickness and the space above come from the font's
1100/// MATH constants when it has them; the radical sign is the tallest-fitting vertical variant the MATH
1101/// table offers, or the plain sign when the font carries no table.
1102fn build_sqrt(
1103 style: &Theme,
1104 radicand: &Atom,
1105 level: Level,
1106 display: bool,
1107)
1108 -> Outcome<MBox>
1109{
1110 let size = size_for(style, level);
1111 let size_pt = size.to_pt() as f32;
1112 let r = res!(build(style, radicand, level, display));
1113 let table = res!(math_table());
1114
1115 let (gap, rule, extra) = match &table {
1116 Some(t) => {
1117 let c = t.constants();
1118 (
1119 Sp::from_pt(t.scaled(c.radical_vertical_gap, size_pt) as f64),
1120 Sp::from_pt(t.scaled(c.radical_rule_thickness, size_pt) as f64),
1121 Sp::from_pt(t.scaled(c.radical_extra_ascender, size_pt) as f64),
1122 )
1123 },
1124 None => (Sp(size.raw() / 18), style.table.rule_thin, Sp(size.raw() / 18)),
1125 };
1126
1127 let target = r.height + r.depth + gap + rule; // the radical sign must at least span this
1128 let font = res!(math_font());
1129 let base = res!(glyph_id(&font, size, "\u{221A}"));
1130 let variant = match &table {
1131 Some(t) => t.variant_for(base as u16, target.to_pt() as f32, size_pt).map(|g| g as u32),
1132 None => None,
1133 }.unwrap_or(base);
1134 let (path, gh, gd, gw) = res!(glyph_ink(&font, variant, size));
1135
1136 let mut pieces: Vec<Piece> = Vec::new();
1137 let kern = Sp(size.raw() / 24);
1138
1139 // The vinculum's top edge, a rel above the baseline (negative is up): clearing the radicand by `gap`.
1140 let bar_top = -(r.height + gap + rule);
1141 // The radical sign, seated so its ink top meets the bar top. A taller variant then reaches below the
1142 // radicand, the way a radical encloses it.
1143 let sign_rel = bar_top + gh;
1144 pieces.push(Piece { x: Sp::ZERO, width: gw, rel: sign_rel, draw: Draw::Ink(path) });
1145
1146 // The radicand, to the right of the sign, and the vinculum ruled across it.
1147 let rx = gw + kern;
1148 place_into(&mut pieces, r.pieces, rx, Sp::ZERO);
1149 pieces.push(Piece { x: gw, width: r.width + kern, rel: bar_top, draw: Draw::Bar(rule) });
1150
1151 let sign_bottom = sign_rel + gd;
1152 let depth = if sign_bottom > r.depth { sign_bottom } else { r.depth };
1153 Ok(MBox {
1154 pieces,
1155 width: gw + kern + r.width,
1156 height: r.height + gap + rule + extra,
1157 depth,
1158 class: Class::Ord,
1159 })
1160}
1161
1162/// The height a grown delimiter must reach for a content of the given symmetric height, after LaTeX's
1163/// allowance that a delimiter may fall a little short of its content rather than jump a whole variant
1164/// larger. The least acceptable clearance is the greater of `DelimiterFactor` (901/1000) of the content
1165/// and the content less `DelimiterShortfall` (5 pt); the caller then takes the tightest variant reaching
1166/// it. Covering the content outright instead runs a fence one variant larger than the New CM oracle
1167/// wherever the content sits just above a variant -- the oversize the fidelity sweep measured -- because
1168/// Latin Modern's variant ladder is coarse and the next size up overshoots by 15-20 %.
1169fn delim_target(content: Sp) -> Sp {
1170 // The DelimiterFactor product is computed in i64: a tall matrix or deep fence carries a content
1171 // extent past ~36 pt, where `content.raw() * 901` overflows the i32 scaled-point domain (a debug
1172 // build panics; a release build wraps to a garbage, often negative, factor and then falls through to
1173 // `floored`). Widening only the intermediate leaves the result identical for every in-range input --
1174 // the quotient can never exceed `content`, so narrowing back to i32 never truncates -- while a
1175 // legitimately tall content now yields the true 901/1000 factor instead of a wrapped one.
1176 let factor = Sp((content.raw() as i64 * 901 / 1000) as i32);
1177 let shortfall = Sp::from_pt(5.0);
1178 let floored = if content > shortfall { content - shortfall } else { Sp::ZERO };
1179 if factor > floored { factor } else { floored }
1180}
1181
1182/// Sets a body between a pair of delimiters grown to it. The delimiters span symmetrically about the
1183/// maths axis, tall enough to cover the body's reach above and below that axis, less LaTeX's shortfall
1184/// allowance ([`delim_target`]); each is the tightest-fitting vertical variant the MATH table offers, or
1185/// the plain delimiter when the font has no table (in which case it does not grow).
1186fn build_fence(
1187 style: &Theme,
1188 left: char,
1189 body: &Atom,
1190 right: char,
1191 level: Level,
1192 display: bool,
1193)
1194 -> Outcome<MBox>
1195{
1196 let b = res!(build(style, body, level, display));
1197 fence_around(style, left, right, b, level)
1198}
1199
1200/// A binomial coefficient: the two terms stacked with no rule between them, wrapped in parentheses grown
1201/// to the stack. Typst stacks a binom even inline, so the fraction is built bar-less and then fenced.
1202fn build_binom(
1203 style: &Theme,
1204 top: &Atom,
1205 bottom: &Atom,
1206 level: Level,
1207 display: bool,
1208)
1209 -> Outcome<MBox>
1210{
1211 let stack = res!(build_frac(style, top, bottom, level, display, false));
1212 fence_around(style, '(', ')', stack, level)
1213}
1214
1215/// Places an already-built body between a pair of delimiters grown to it, centred symmetrically about the
1216/// maths axis. Shared by [`build_fence`], whose body comes from an [`Atom`], and [`build_binom`], whose
1217/// body is the bar-less stacked fraction.
1218fn fence_around(
1219 style: &Theme,
1220 left: char,
1221 right: char,
1222 b: MBox,
1223 level: Level,
1224)
1225 -> Outcome<MBox>
1226{
1227 let size = size_for(style, level);
1228 let size_pt = size.to_pt() as f32;
1229 let table = res!(math_table());
1230 let font = res!(math_font());
1231
1232 let axis = match &table {
1233 Some(t) => Sp::from_pt(t.scaled(t.constants().axis_height, size_pt) as f64),
1234 None => Sp(size.raw() / 4),
1235 };
1236 // The content's symmetric reach about the axis: a delimiter is centred on the axis, so it must span
1237 // twice the body's greater reach from it.
1238 let above = b.height - axis;
1239 let below = b.depth + axis;
1240 let half = if above > below { above } else { below };
1241 let content = half + half;
1242
1243 let target = delim_target(content);
1244
1245 let mut pieces: Vec<Piece> = Vec::new();
1246 let gap = Sp(size.raw() / 12);
1247
1248 let (lw, lh, ld) = res!(place_delim(&font, &table, left, target, axis, size, size_pt, &mut pieces, Sp::ZERO));
1249 let mut cursor = lw + gap;
1250 let body_h = b.height;
1251 let body_d = b.depth;
1252 let body_w = b.width;
1253 place_into(&mut pieces, b.pieces, cursor, Sp::ZERO);
1254 cursor += body_w + gap;
1255 let (rw, rh, rd) = res!(place_delim(&font, &table, right, target, axis, size, size_pt, &mut pieces, cursor));
1256 cursor += rw;
1257
1258 Ok(MBox {
1259 pieces,
1260 width: cursor,
1261 height: body_h.max(lh).max(rh),
1262 depth: body_d.max(ld).max(rd),
1263 class: Class::Ord,
1264 })
1265}
1266
1267/// Places one delimiter for [`build_fence`], centred on the maths axis, and returns its width and its
1268/// reach above and below the baseline.
1269fn place_delim(
1270 font: &Arc<Font>,
1271 table: &Option<Arc<MathTable>>,
1272 ch: char,
1273 target: Sp,
1274 axis: Sp,
1275 size: Sp,
1276 size_pt: f32,
1277 pieces: &mut Vec<Piece>,
1278 x: Sp,
1279)
1280 -> Outcome<(Sp, Sp, Sp)>
1281{
1282 let s = ch.to_string();
1283 let base = res!(glyph_id(font, size, &s));
1284 let variant = match table {
1285 Some(t) => t.variant_for(base as u16, target.to_pt() as f32, size_pt).map(|g| g as u32),
1286 None => None,
1287 }.unwrap_or(base);
1288 let (path, gh, gd, gw) = res!(glyph_ink(font, variant, size));
1289
1290 // Centre the glyph's ink on the axis: a glyph at rel R has its ink centre at R + (gd - gh)/2, wanted
1291 // at -axis (the axis, above the baseline).
1292 let rel = -axis - Sp((gd.raw() - gh.raw()) / 2);
1293 pieces.push(Piece { x, width: gw, rel, draw: Draw::Ink(path) });
1294
1295 let height = gh - rel; // ink top above the baseline, as a positive reach
1296 let depth = rel + gd; // ink bottom below the baseline
1297 Ok((gw, height, depth))
1298}
1299
1300/// Copies a child box's drawables into a parent, offset right by `dx` and down by `drel`. This is the
1301/// one move composition needs: a row slides a child along, a fraction lifts and lowers its parts, a
1302/// script hangs them off the base.
1303fn place_into(dst: &mut Vec<Piece>, src: Vec<Piece>, dx: Sp, drel: Sp) {
1304 for p in src {
1305 dst.push(Piece { x: p.x + dx, width: p.width, rel: p.rel + drel, draw: p.draw });
1306 }
1307}
1308
1309/// Flattens a measured box to leaves and glue. Drawables are laid left to right; the glue before each
1310/// carries the jump from the running cursor to the drawable's `x`, which may be negative where a
1311/// numerator sits back over its denominator. Each leaf is shifted to `base + rel`, seating it on the
1312/// line at its computed height above or below the baseline. A trailing glue pads the cursor out to the
1313/// box width, so whatever follows the maths abuts it cleanly.
1314fn emit(mbox: MBox, base: Sp) -> (Vec<Node>, Dims) {
1315 let mut pieces = mbox.pieces;
1316 pieces.sort_by_key(|p| p.x.raw());
1317
1318 let mut nodes: Vec<Node> = Vec::new();
1319 let mut cursor = Sp::ZERO;
1320 for p in pieces {
1321 let gap = p.x - cursor;
1322 if gap != Sp::ZERO {
1323 nodes.push(Node::Glue(Glue::fixed(gap)));
1324 }
1325 let shift = base + p.rel;
1326 match p.draw {
1327 Draw::Glyph(shaped) => {
1328 // The run draws its baseline at the leaf's placement y, so a zero height plus the shift
1329 // puts the baseline exactly at `base + rel` below the line top.
1330 let dims = Dims::new(p.width, Sp::ZERO, Sp::ZERO);
1331 nodes.push(Node::Leaf(Leaf::text_dims(shaped, dims).with_shift(shift)));
1332 },
1333 Draw::Bar(t) => {
1334 let dims = Dims::new(p.width, t, Sp::ZERO);
1335 nodes.push(Node::Leaf(Leaf::rule(dims).with_shift(shift)));
1336 },
1337 Draw::Ink(path) => {
1338 // A grown delimiter or radical: its flipped outline as a one-op graphic, seated on the
1339 // line by the same shift as a glyph. The box is zero-height, so the shift alone seats it.
1340 let g = Graphic::new(
1341 vec![DrawOp::Fill { path, colour: Rgba::BLACK }],
1342 Dims::new(p.width, Sp::ZERO, Sp::ZERO));
1343 nodes.push(Node::Leaf(Leaf::graphic(g).with_shift(shift)));
1344 },
1345 }
1346 cursor = p.x + p.width;
1347 }
1348 if cursor < mbox.width {
1349 nodes.push(Node::Glue(Glue::fixed(mbox.width - cursor)));
1350 }
1351
1352 // The reported extent is the maths box's own: its height above the baseline and its depth below.
1353 // The caller reads these -- an inline caller compares the height against the surrounding ascent to
1354 // find how far the maths climbs above the line, a display caller takes the height as the box's own.
1355 let dims = Dims::new(mbox.width, mbox.height, mbox.depth);
1356 (nodes, dims)
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::*;
1362
1363 // A delimiter target for a tall matrix (content extent well past 36 pt) once overflowed the i32
1364 // scaled-point product `content.raw() * 901`: a debug build panicked, a release build wrapped to a
1365 // garbage factor. The widened arithmetic must yield the true DelimiterFactor (901/1000) instead.
1366 #[test]
1367 fn delim_target_tall_content_no_overflow() {
1368 // 50 pt of content: content.raw() * 901 = 2.95e9, past i32::MAX (2.147e9).
1369 let content = Sp::from_pt(50.0);
1370 let got = delim_target(content);
1371 // 0.901 * 50 pt = 45.05 pt, which exceeds content less the 5 pt shortfall (45 pt), so the factor
1372 // wins. A wrapped product would have given a negative or tiny factor, falling through to 45 pt.
1373 let expect = Sp((content.raw() as i64 * 901 / 1000) as i32);
1374 assert_eq!(got, expect);
1375 assert!(got.raw() > 0);
1376 assert!(got > content - Sp::from_pt(5.0));
1377 }
1378
1379 // In-range content is unchanged by the widening: below ~36 pt the i32 product never overflowed, so
1380 // the result must match exactly what it always was.
1381 #[test]
1382 fn delim_target_small_content_unchanged() {
1383 let content = Sp::from_pt(20.0);
1384 let got = delim_target(content);
1385 assert_eq!(got, Sp(content.raw() * 901 / 1000));
1386 }
1387}