Skip to main content

fmd_math/
layout.rs

1//! The layout engine: Appendix-G placement over the synthesized metrics.
2//!
3//! [`Engine::typeset`] runs the whole ratified pipeline — parse → classify
4//! → layout(Ctx) → [`Layout`] — with the constructions implemented from
5//! TeX's published rules (The TeXbook, Appendix G): generalized fractions
6//! (rule 15), scripts with the simultaneous-scripts clash rules (rule 18),
7//! radicals (rule 11), accents (rule 12), big-operator limits (rules
8//! 13/13a, reused for the `\overset` family exactly as amsmath defines
9//! them), and `\left…\right` sizing (rule 19). Every parameter comes from
10//! [`MathConstants`] scaled by the current style's size factor — the
11//! analogue of TeX reading fontdimens from the current size's fonts.
12//!
13//! **Determinism.** Layout arithmetic is pure f64 addition, subtraction,
14//! multiplication, division, and comparison — no transcendental function
15//! is ever called — so the same string and face set produce bit-identical
16//! layouts on every platform.
17//!
18//! **Delimiter mechanism (ADR-0005), complete.** Natural glyph when it
19//! covers the rule-19 target; uniform scaling up to the `1.25×` ceiling;
20//! the parametric drawn-path construction beyond ([`crate::drawn`], the
21//! mainline) — no requested size can fail, by construction. The same
22//! module serves the drawn surd and the stretchy over/under constructions
23//! (`\overbrace`, `\underbrace`, `\overrightarrow`, `\overleftarrow`,
24//! `\widehat`, `\widetilde`).
25//!
26//! **Environments** (the matrix family, `cases`, `array` with column
27//! specs, the `align*` class) lay out through the grid engine: shared
28//! column measurement, per-column alignment, `\baselineskip`/`\lineskip`
29//! row stacking (`\jot`-opened for `align`), `\vcenter` axis centering,
30//! and the rule-19 delimiter wrap for the delimited matrices.
31
32use std::cell::RefCell;
33
34use crate::atom::{AtomClass, classify_list, spacing_in_style};
35use crate::error::MathError;
36use crate::faces::{
37    FACE_BOLD, FACE_ITALIC, FACE_REGULAR, FACE_SYMBOLS, FaceSet, GlyphMetrics,
38    accent_spacing_fallback, alphabet_map, default_math_chain, glyph_metrics, kern_em,
39};
40use crate::mbox::{BoxKind, FaceId, GlueSpec, Layout, MBox, MNode, Positioned};
41use crate::metrics::MathConstants;
42use crate::node::{
43    AccentKind, Delim, DelimSize, FracSpec, FragmentKind, Limits, LineAlign, MathFont, Node,
44    NodeKind, PhantomKind, Span, StackKind, TextStyle,
45};
46use crate::style::{Style, StyleCtx};
47
48/// The layout engine: a face roster plus the calibrated math constants.
49pub struct Engine {
50    faces: FaceSet,
51    consts: MathConstants,
52    /// Memoized [`faces::glyph_metrics`](crate::faces::glyph_metrics) per
53    /// `(face, gid)`: one lazily-grown slot vector per face. The roster is
54    /// immutable after construction and `glyph_metrics` reads only font
55    /// tables, so the value at a slot is a constant — a warm memo returns
56    /// bit-identical metrics to a cold computation (pinned by the
57    /// warm-engine determinism test). Math documents resolve to few
58    /// distinct glyphs repeated across many spans, so the memo turns each
59    /// repeat from a `hmtx` + `glyf` re-decode into one Vec read.
60    glyph_memo: RefCell<Vec<Vec<Option<GlyphMetrics>>>>,
61}
62
63impl Engine {
64    /// Build an engine over a face roster with the CM constants.
65    #[must_use]
66    pub fn new(faces: FaceSet) -> Self {
67        Self {
68            faces,
69            consts: crate::metrics::CM,
70            glyph_memo: RefCell::new(Vec::new()),
71        }
72    }
73
74    /// The engine over the bundled sovereign face set.
75    ///
76    /// # Errors
77    ///
78    /// Propagates a bundled-face parse failure (build corruption).
79    #[cfg(feature = "bundled-faces")]
80    pub fn bundled() -> Result<Self, fmd_font::FontError> {
81        Ok(Self::new(FaceSet::bundled()?))
82    }
83
84    /// The face roster.
85    #[must_use]
86    pub fn faces(&self) -> &FaceSet {
87        &self.faces
88    }
89
90    /// The math constants in force.
91    #[must_use]
92    pub fn constants(&self) -> &MathConstants {
93        &self.consts
94    }
95
96    /// Typeset a math-mode string at the given outer style.
97    ///
98    /// # Errors
99    ///
100    /// Parse errors pass through; layout adds [`MathError::UnmappedChar`]
101    /// for characters no face covers and named layout-pending errors for
102    /// the extensions bead's constructs.
103    pub fn typeset(&self, source: &str, style: Style) -> Result<Layout, MathError> {
104        let root = crate::parse(source)?;
105        self.finish(&root, StyleCtx::new(style))
106    }
107
108    /// Typeset a TexText-contract string (text mainland + math islands).
109    ///
110    /// # Errors
111    ///
112    /// As [`Engine::typeset`].
113    pub fn typeset_text(&self, source: &str) -> Result<Layout, MathError> {
114        let root = crate::parse_text(source)?;
115        self.finish(&root, StyleCtx::new(Style::Text))
116    }
117
118    /// [`Engine::typeset`], against a macro set (a preamble pack and/or
119    /// caller definitions).
120    ///
121    /// # Errors
122    ///
123    /// As [`Engine::typeset`], plus the macro-expansion errors.
124    pub fn typeset_with_macros(
125        &self,
126        source: &str,
127        style: Style,
128        macros: &crate::macros::MacroSet,
129    ) -> Result<Layout, MathError> {
130        let root = crate::parse_with_macros(source, macros)?;
131        self.finish(&root, StyleCtx::new(style))
132    }
133
134    /// [`Engine::typeset_text`], against a macro set.
135    ///
136    /// # Errors
137    ///
138    /// As [`Engine::typeset_with_macros`].
139    pub fn typeset_text_with_macros(
140        &self,
141        source: &str,
142        macros: &crate::macros::MacroSet,
143    ) -> Result<Layout, MathError> {
144        let root = crate::parse_text_with_macros(source, macros)?;
145        self.finish(&root, StyleCtx::new(Style::Text))
146    }
147
148    fn finish(&self, root: &Node, ctx: StyleCtx) -> Result<Layout, MathError> {
149        let items = match &root.kind {
150            NodeKind::List(items) => items.as_slice(),
151            _ => std::slice::from_ref(root),
152        };
153        let boxx = self.hlist(
154            items,
155            LayCtx {
156                style: ctx,
157                alphabet: None,
158                text_mode: false,
159                decl_size: 1.0,
160                line_stretch: 1.0,
161            },
162        )?;
163        let mut layout = Layout {
164            width: boxx.width,
165            height: boxx.height,
166            depth: boxx.depth,
167            ..Layout::default()
168        };
169        boxx.flatten_into(0.0, 0.0, &mut layout);
170        Ok(layout)
171    }
172}
173
174/// The full layout context threaded through the recursion.
175#[derive(Clone, Copy)]
176struct LayCtx {
177    style: StyleCtx,
178    alphabet: Option<MathFont>,
179    /// Laying text (islands, `\text` bodies): letters stay upright.
180    text_mode: bool,
181    /// The size-declaration factor (`\small` …), group-scoped: multiplies
182    /// the style's own factor, exactly like LaTeX's current size setting
183    /// the design size every fontdimen is read from.
184    decl_size: f64,
185    /// \baselinestretch for subsequent stacked lines (\doublespacing).
186    line_stretch: f64,
187}
188
189impl LayCtx {
190    fn size(&self) -> f64 {
191        self.style.size_factor() * self.decl_size
192    }
193    fn map(self, f: impl FnOnce(StyleCtx) -> StyleCtx) -> Self {
194        Self {
195            style: f(self.style),
196            ..self
197        }
198    }
199}
200
201/// One laid atom with the metadata scripts and kerning need.
202struct Laid {
203    boxx: MBox,
204    /// Synthesized italic correction of the trailing glyph, ems.
205    italic: f64,
206    /// Set when the box is a single character glyph (rule 18's u=v=0 case,
207    /// and the kerning pass).
208    char_glyph: Option<(FaceId, u16)>,
209}
210
211enum LineItem {
212    Atom { laid: Laid, class: AtomClass },
213    Glue(GlueSpec),
214}
215
216impl Engine {
217    /// Lay out a horizontal list: split at `\\` into stacked lines; within
218    /// a line, walk atoms with their effective classes, inserting the
219    /// inter-atom glue of the spacing table and font kerns between
220    /// adjacent same-face character glyphs. Declarations (style switches,
221    /// size changes) persist across the line split: `\\` does not end the
222    /// enclosing group.
223    fn hlist(&self, items: &[Node], ctx: LayCtx) -> Result<MBox, MathError> {
224        let mut lines: Vec<(MBox, f64, f64)> = Vec::new();
225        let mut ctx = ctx;
226        for line in items.split(|n| matches!(n.kind, NodeKind::Linebreak)) {
227            let (boxx, after) = self.line(line, ctx)?;
228            ctx = after;
229            lines.push((boxx, ctx.size(), ctx.line_stretch));
230        }
231        if lines.len() == 1 {
232            return lines
233                .pop()
234                .map(|(boxx, _, _)| boxx)
235                .ok_or(MathError::Malformed {
236                    what: "internal: empty line vector".to_owned(),
237                    at: 0,
238                });
239        }
240        // Stack lines: baseline-to-baseline is \baselineskip (grown when
241        // boxes would come closer than \lineskip), read at each line's own
242        // size. The box baseline is the first line's.
243        let mut children = Vec::new();
244        let mut baseline = 0.0_f64;
245        let mut prev_depth = 0.0_f64;
246        let mut width = 0.0_f64;
247        let mut depth = 0.0_f64;
248        let mut height = 0.0_f64;
249        for (i, (line, size, stretch)) in lines.into_iter().enumerate() {
250            if i == 0 {
251                height = line.height;
252            } else {
253                // setspace's \baselinestretch scales the natural skip; the
254                // \lineskip floor still applies unstretched.
255                let natural = self.consts.baseline_skip * size * stretch;
256                let min_gap = prev_depth + line.height + self.consts.line_skip * size;
257                baseline -= natural.max(min_gap);
258            }
259            width = width.max(line.width);
260            depth = (-baseline) + line.depth;
261            prev_depth = line.depth;
262            children.push(Positioned {
263                dx: 0.0,
264                dy: baseline,
265                node: MNode::Box(line),
266            });
267        }
268        Ok(MBox {
269            kind: BoxKind::Vertical,
270            width,
271            height,
272            depth,
273            children,
274        })
275    }
276
277    /// One line of a horizontal list; also returns the context after the
278    /// line's declarations, so `\\`-separated lines inherit them.
279    fn line(&self, items: &[Node], outer: LayCtx) -> Result<(MBox, LayCtx), MathError> {
280        let classes = classify_list(items);
281        let mut ctx = outer;
282        let mut laid_items: Vec<LineItem> = Vec::new();
283        for (node, class) in items.iter().zip(classes) {
284            match &node.kind {
285                NodeKind::StyleChange(style) => {
286                    ctx = ctx.map(|s| StyleCtx {
287                        style: *style,
288                        cramped: s.cramped,
289                    });
290                }
291                NodeKind::SizeChange(factor) => {
292                    ctx.decl_size = *factor;
293                }
294                NodeKind::LineSpacing(stretch) => {
295                    ctx.line_stretch = *stretch;
296                }
297                // `\centering` and friends produce no glyphs; a line's
298                // alignment is applied where lines are stacked
299                // ([`NodeKind::AlignBlock`]), so mid-list the declaration
300                // is inert, exactly like a color declaration.
301                NodeKind::ColorChange(_)
302                | NodeKind::AlignChange(_)
303                | NodeKind::AlignTab
304                | NodeKind::Fragment(
305                    FragmentKind::UnmatchedClose | FragmentKind::RedundantMathShift,
306                ) => {}
307                NodeKind::Space(kind) => {
308                    let em = f64::from(kind.mu()) / 18.0 * ctx.size();
309                    laid_items.push(LineItem::Glue(GlueSpec {
310                        natural: em,
311                        stretch: 0.0,
312                        shrink: 0.0,
313                    }));
314                }
315                NodeKind::Tie => {
316                    laid_items.push(LineItem::Glue(GlueSpec {
317                        natural: self.space_width(ctx),
318                        stretch: 0.0,
319                        shrink: 0.0,
320                    }));
321                }
322                _ => {
323                    let laid = self.lay_node(node, ctx)?;
324                    laid_items.push(LineItem::Atom {
325                        laid,
326                        class: class.unwrap_or(AtomClass::Ord),
327                    });
328                }
329            }
330        }
331        // Assemble: inter-atom glue + kerning.
332        let mut children = Vec::new();
333        let mut x = 0.0_f64;
334        let mut height = 0.0_f64;
335        let mut depth = 0.0_f64;
336        let mut prev: Option<(AtomClass, Option<(FaceId, u16)>)> = None;
337        for item in laid_items {
338            match item {
339                LineItem::Glue(glue) => {
340                    x += glue.natural;
341                    children.push(Positioned {
342                        dx: x,
343                        dy: 0.0,
344                        node: MNode::Glue(glue),
345                    });
346                    prev = None;
347                }
348                LineItem::Atom { laid, class } => {
349                    if let Some((prev_class, prev_glyph)) = prev {
350                        let mu = spacing_in_style(prev_class, class, ctx.style.style).mu();
351                        x += f64::from(mu) / 18.0 * ctx.size();
352                        if mu == 0 && prev_class == AtomClass::Ord && class == AtomClass::Ord {
353                            if let (Some((pf, pg)), Some((cf, cg))) = (prev_glyph, laid.char_glyph)
354                            {
355                                if pf == cf {
356                                    if let Some(font) = self.faces.font(pf) {
357                                        x += kern_em(font, pg, cg) * ctx.size();
358                                    }
359                                    let _ = cg;
360                                }
361                            }
362                        }
363                    }
364                    height = height.max(laid.boxx.height);
365                    depth = depth.max(laid.boxx.depth);
366                    let advance = laid.boxx.width;
367                    let glyph = laid.char_glyph;
368                    children.push(Positioned {
369                        dx: x,
370                        dy: 0.0,
371                        node: MNode::Box(laid.boxx),
372                    });
373                    x += advance;
374                    prev = Some((class, glyph));
375                }
376            }
377        }
378        Ok((
379            MBox {
380                kind: BoxKind::Horizontal,
381                width: x,
382                height,
383                depth,
384                children,
385            },
386            ctx,
387        ))
388    }
389
390    /// Lay one node into an atom box.
391    #[allow(clippy::too_many_lines)]
392    fn lay_node(&self, node: &Node, ctx: LayCtx) -> Result<Laid, MathError> {
393        match &node.kind {
394            NodeKind::List(items) => Ok(Laid {
395                boxx: self.hlist(items, ctx)?,
396                italic: 0.0,
397                char_glyph: None,
398            }),
399            NodeKind::Symbol { ch, .. } => self.char_atom(*ch, node.span, ctx),
400            NodeKind::BigOp { ch, .. } => {
401                let scale = if ctx.style.style == Style::Display {
402                    self.consts.display_op_scale
403                } else {
404                    1.0
405                };
406                self.op_glyph(*ch, node.span, ctx, scale)
407            }
408            NodeKind::OpName { name, .. } => {
409                self.word_box(name, node.span, None, ctx, FACE_REGULAR)
410            }
411            NodeKind::TextRun { text, char_spans } => {
412                self.word_box(text, node.span, Some(char_spans), ctx, text_face(ctx))
413            }
414            NodeKind::Text { body } => Ok(Laid {
415                boxx: self.hlist(
416                    body,
417                    LayCtx {
418                        text_mode: true,
419                        ..ctx
420                    },
421                )?,
422                italic: 0.0,
423                char_glyph: None,
424            }),
425            NodeKind::TextStyled { style, body } => {
426                let styled = LayCtx {
427                    text_mode: true,
428                    alphabet: Some(match style {
429                        TextStyle::Bold => MathFont::Bold,
430                        TextStyle::Emph => MathFont::Italic,
431                        TextStyle::Underline => MathFont::Roman,
432                    }),
433                    ..ctx
434                };
435                let inner = self.hlist(body, styled)?;
436                if matches!(style, TextStyle::Underline) {
437                    Ok(Laid {
438                        boxx: self.underline_box(inner, ctx, node.span),
439                        italic: 0.0,
440                        char_glyph: None,
441                    })
442                } else {
443                    Ok(Laid {
444                        boxx: inner,
445                        italic: 0.0,
446                        char_glyph: None,
447                    })
448                }
449            }
450            NodeKind::MathIsland { body, display } => {
451                // amsmath's `\text` property: an inner math island resumes
452                // the SURROUNDING mathematics — script-size text (a
453                // `\substack` line, a script position) gets script-size
454                // islands. The nominal island style (`$…$` text,
455                // `$$…$$` display) applies only when the surroundings are
456                // not already smaller: an island never enlarges its text.
457                let nominal = if *display {
458                    Style::Display
459                } else {
460                    Style::Text
461                };
462                let style = if ctx.style.style.size_factor() < nominal.size_factor() {
463                    ctx.style.style
464                } else {
465                    nominal
466                };
467                Ok(Laid {
468                    boxx: self.hlist(
469                        body,
470                        LayCtx {
471                            style: StyleCtx {
472                                style,
473                                cramped: ctx.style.cramped,
474                            },
475                            alphabet: None,
476                            text_mode: false,
477                            ..ctx
478                        },
479                    )?,
480                    italic: 0.0,
481                    char_glyph: None,
482                })
483            }
484            NodeKind::MathFont { font, body } => self.lay_node(
485                body,
486                LayCtx {
487                    alphabet: Some(*font),
488                    ..ctx
489                },
490            ),
491            NodeKind::Scripts {
492                base,
493                sub,
494                sup,
495                primes,
496            } => self.scripts(base.as_deref(), sub.as_deref(), sup.as_deref(), primes, ctx),
497            NodeKind::Frac { num, den, spec } => self.fraction(num, den, *spec, ctx, node.span),
498            NodeKind::Radical { index, radicand } => {
499                self.radical(index.as_deref(), radicand, ctx, node.span)
500            }
501            NodeKind::Accent { accent, base } => self.accent(*accent, base, ctx, node.span),
502            NodeKind::LeftRight { left, right, body } => self.left_right(left, right, body, ctx),
503            NodeKind::SizedDelim { size, delim, .. } => {
504                let target = fixed_delim_target(*size) * ctx.size();
505                let boxx = self.delimiter_box(delim, target, ctx)?;
506                Ok(Laid {
507                    boxx,
508                    italic: 0.0,
509                    char_glyph: None,
510                })
511            }
512            NodeKind::Phantom { kind, body } => {
513                let inner = self.lay_node(body, ctx)?;
514                let (w, h, d) = match kind {
515                    PhantomKind::Full => (inner.boxx.width, inner.boxx.height, inner.boxx.depth),
516                    PhantomKind::Horizontal => (inner.boxx.width, 0.0, 0.0),
517                    PhantomKind::Vertical => (0.0, inner.boxx.height, inner.boxx.depth),
518                };
519                Ok(Laid {
520                    boxx: MBox {
521                        kind: BoxKind::Horizontal,
522                        width: w,
523                        height: h,
524                        depth: d,
525                        children: Vec::new(),
526                    },
527                    italic: 0.0,
528                    char_glyph: None,
529                })
530            }
531            NodeKind::Stack {
532                kind,
533                annotation,
534                base,
535            } => {
536                let base_laid = self.lay_node(base, ctx)?;
537                let ann_ctx = match kind {
538                    StackKind::Underset => ctx.map(StyleCtx::sub),
539                    StackKind::Stackrel | StackKind::Overset => ctx.map(StyleCtx::sup),
540                };
541                let ann = self.lay_node(annotation, ann_ctx)?;
542                let (upper, lower) = match kind {
543                    StackKind::Underset => (None, Some(ann.boxx)),
544                    StackKind::Stackrel | StackKind::Overset => (Some(ann.boxx), None),
545                };
546                Ok(Laid {
547                    boxx: self.with_limits(base_laid.boxx, upper, lower, ctx, base_laid.italic),
548                    italic: 0.0,
549                    char_glyph: None,
550                })
551            }
552            NodeKind::XArrow {
553                mapsto,
554                above,
555                below,
556            } => {
557                // amsmath's labeled extensible arrows: the drawn band
558                // stretches to its widest script-style label plus padding
559                // (a bare label still gets a healthy minimum arrow), sits
560                // on the math axis, and takes the labels as limits.
561                let size = ctx.size();
562                let above_laid = self.lay_node(above, ctx.map(StyleCtx::sup))?;
563                let below_laid = below
564                    .as_deref()
565                    .map(|b| self.lay_node(b, ctx.map(StyleCtx::sub)))
566                    .transpose()?;
567                let label_w = above_laid
568                    .boxx
569                    .width
570                    .max(below_laid.as_ref().map_or(0.0, |b| b.boxx.width));
571                let width = (label_w + 0.8 * size).max(1.2 * size);
572                let stretch_kind = if *mapsto {
573                    crate::drawn::Stretch::MapstoArrow
574                } else {
575                    crate::drawn::Stretch::RightArrow
576                };
577                let band = crate::drawn::stretch(stretch_kind, width, size);
578                let axis = self.consts.axis_height * size;
579                let band_dy = axis - band.height / 2.0;
580                let band_box = MBox {
581                    kind: BoxKind::Horizontal,
582                    width,
583                    height: axis + band.height / 2.0,
584                    depth: (band.height / 2.0 - axis).max(0.0),
585                    children: vec![Positioned {
586                        dx: 0.0,
587                        dy: band_dy,
588                        node: MNode::Path {
589                            contours: band.contours,
590                            span: node.span,
591                        },
592                    }],
593                };
594                Ok(Laid {
595                    boxx: self.with_limits(
596                        band_box,
597                        Some(above_laid.boxx),
598                        below_laid.map(|b| b.boxx),
599                        ctx,
600                        0.0,
601                    ),
602                    italic: 0.0,
603                    char_glyph: None,
604                })
605            }
606            NodeKind::Fragment(FragmentKind::StrayRight(delim)) => {
607                if let Some(ch) = delim.ch {
608                    self.char_atom(ch, delim.span, ctx)
609                } else {
610                    Ok(Laid {
611                        boxx: kern_box(self.consts.null_delimiter_space * ctx.size()),
612                        italic: 0.0,
613                        char_glyph: None,
614                    })
615                }
616            }
617            NodeKind::Environment { name, spec, rows } => {
618                self.environment(name, spec.as_deref(), rows, ctx, node.span)
619            }
620            NodeKind::AlignBlock { align, lines } => {
621                // `flushleft` / `center` / `flushright`: a single-column
622                // grid whose one column takes the block's line alignment.
623                // The grid engine supplies the shared \baselineskip /
624                // \lineskip stacking and the \vcenter axis centering the
625                // matrix family uses; only the column rule differs.
626                let cell_align = match align {
627                    LineAlign::Left => CellAlign::Left,
628                    LineAlign::Center => CellAlign::Center,
629                    LineAlign::Right => CellAlign::Right,
630                };
631                // Each line is one single-cell row (cells are `List` nodes,
632                // the same shape the environments feed the grid).
633                let rows: Vec<Vec<Node>> = lines.iter().map(|line| vec![line.clone()]).collect();
634                let boxx = self.grid(
635                    &rows,
636                    ctx,
637                    &Grid {
638                        align: AlignRule::Columns(vec![cell_align]),
639                        ..Grid::centered(1.0)
640                    },
641                    node.span,
642                )?;
643                Ok(Laid {
644                    boxx,
645                    italic: 0.0,
646                    char_glyph: None,
647                })
648            }
649            NodeKind::Fragment(_)
650            | NodeKind::StyleChange(_)
651            | NodeKind::SizeChange(_)
652            | NodeKind::ColorChange(_)
653            | NodeKind::AlignChange(_)
654            | NodeKind::LineSpacing(_)
655            | NodeKind::Space(_)
656            | NodeKind::Tie
657            | NodeKind::Linebreak
658            | NodeKind::AlignTab => Ok(Laid {
659                boxx: kern_box(0.0),
660                italic: 0.0,
661                char_glyph: None,
662            }),
663        }
664    }
665
666    // ── Glyph-level constructors ────────────────────────────────────────
667
668    fn resolve_math_char(
669        &self,
670        ch: char,
671        span: Span,
672        ctx: LayCtx,
673    ) -> Result<(FaceId, u16, char), MathError> {
674        let (mapped, chain): (char, &[FaceId]) = match ctx.alphabet {
675            Some(font) => alphabet_map(font, ch),
676            None if ctx.text_mode => (ch, &[FACE_REGULAR, FACE_ITALIC, FACE_SYMBOLS]),
677            None => (ch, default_math_chain(ch)),
678        };
679        match self.faces.resolve(mapped, chain) {
680            Some((face, gid)) => Ok((face, gid, mapped)),
681            None => Err(MathError::UnmappedChar { ch: mapped, span }),
682        }
683    }
684
685    /// A single-character atom.
686    fn char_atom(&self, ch: char, span: Span, ctx: LayCtx) -> Result<Laid, MathError> {
687        let (face, gid, mapped) = match self.resolve_math_char(ch, span, ctx) {
688            Ok(hit) => hit,
689            Err(err) => {
690                // The drawn-symbol mainline (fm-j5t.4, the
691                // Checkmark/Exmark drawn-mark precedent): characters no
692                // bundled face maps but the engine has a calibrated
693                // construction for — the wasysym astronomy/biology
694                // symbols ♀ ♂ ♁ — are DRAWN as paths in the same unit
695                // box, never silently substituted with a different
696                // glyph. Miss-only, exactly like the documented glyph
697                // alternates: the moment a face gains the exact
698                // codepoint, the authored glyph wins. Everything else
699                // keeps the precise UnmappedChar refusal.
700                if let Some(d) = crate::drawn::symbol(ch, ctx.size()) {
701                    return Ok(Laid {
702                        boxx: MBox {
703                            kind: BoxKind::Horizontal,
704                            width: d.width,
705                            height: d.height,
706                            depth: d.depth,
707                            children: vec![Positioned {
708                                dx: 0.0,
709                                dy: 0.0,
710                                node: MNode::Path {
711                                    contours: d.contours,
712                                    span,
713                                },
714                            }],
715                        },
716                        italic: 0.0,
717                        char_glyph: None,
718                    });
719                }
720                return Err(err);
721            }
722        };
723        let metrics = self.metrics_of(face, gid);
724        let size = ctx.size();
725        Ok(Laid {
726            boxx: glyph_box(face, gid, mapped, span, size, metrics),
727            italic: metrics.italic * size,
728            char_glyph: Some((face, gid)),
729        })
730    }
731
732    /// A big-operator glyph, axis-centered, optionally display-scaled.
733    fn op_glyph(&self, ch: char, span: Span, ctx: LayCtx, scale: f64) -> Result<Laid, MathError> {
734        let (face, gid, mapped) = self.resolve_math_char(ch, span, ctx)?;
735        let metrics = self.metrics_of(face, gid);
736        let size = ctx.size() * scale;
737        let gh = metrics.height * size;
738        let gd = metrics.depth * size;
739        let axis = self.consts.axis_height * ctx.size();
740        // Center the ink on the math axis.
741        let dy = axis - (gh - gd) / 2.0;
742        let boxx = MBox {
743            kind: BoxKind::Horizontal,
744            width: metrics.advance * size,
745            height: gh + dy,
746            depth: (gd - dy).max(0.0),
747            children: vec![Positioned {
748                dx: 0.0,
749                dy,
750                node: MNode::Glyph {
751                    face,
752                    gid,
753                    ch: mapped,
754                    size,
755                    span,
756                },
757            }],
758        };
759        Ok(Laid {
760            boxx,
761            italic: metrics.italic * size,
762            char_glyph: None,
763        })
764    }
765
766    /// A run of text glyphs from one preferred face (operator names, text
767    /// runs), with kerning and interword spaces. `char_spans` carries one
768    /// source span per character when the run has exact provenance (text
769    /// runs); operator names fall back to the command's span, which is the
770    /// documented synthetic-span policy.
771    fn word_box(
772        &self,
773        text: &str,
774        span: Span,
775        char_spans: Option<&[Span]>,
776        ctx: LayCtx,
777        prefer: FaceId,
778    ) -> Result<Laid, MathError> {
779        let size = ctx.size();
780        let mut children = Vec::new();
781        let mut x = 0.0_f64;
782        let mut height = 0.0_f64;
783        let mut depth = 0.0_f64;
784        let mut prev: Option<(FaceId, u16)> = None;
785        let mut last_italic = 0.0;
786        for (i, ch) in text.chars().enumerate() {
787            let ch_span = char_spans
788                .and_then(|spans| spans.get(i))
789                .copied()
790                .unwrap_or(span);
791            if ch == ' ' {
792                x += self.space_width(ctx);
793                prev = None;
794                continue;
795            }
796            let chain = [prefer, FACE_REGULAR, FACE_SYMBOLS];
797            let mapped = match ctx.alphabet {
798                Some(font) => alphabet_map(font, ch).0,
799                None => ch,
800            };
801            let Some((face, gid)) = self.faces.resolve(mapped, &chain) else {
802                return Err(MathError::UnmappedChar {
803                    ch: mapped,
804                    span: ch_span,
805                });
806            };
807            if let Some((pf, pg)) = prev {
808                if pf == face {
809                    if let Some(font) = self.faces.font(face) {
810                        x += kern_em(font, pg, gid) * size;
811                    }
812                }
813            }
814            let m = self.metrics_of(face, gid);
815            children.push(Positioned {
816                dx: x,
817                dy: 0.0,
818                node: MNode::Glyph {
819                    face,
820                    gid,
821                    ch: mapped,
822                    size,
823                    span: ch_span,
824                },
825            });
826            x += m.advance * size;
827            height = height.max(m.height * size);
828            depth = depth.max(m.depth * size);
829            last_italic = m.italic * size;
830            prev = Some((face, gid));
831        }
832        Ok(Laid {
833            boxx: MBox {
834                kind: BoxKind::Horizontal,
835                width: x,
836                height,
837                depth,
838                children,
839            },
840            italic: last_italic,
841            char_glyph: None,
842        })
843    }
844
845    #[inline(always)]
846    fn metrics_of(&self, face: FaceId, gid: u16) -> GlyphMetrics {
847        // Hot path: one RefCell read + two Vec reads. Misses and the
848        // table decodes live out of line in `metrics_of_miss` so the
849        // inlined body at every glyph site stays as small as the
850        // unmemoized call it replaced.
851        if let Some(hit) = self
852            .glyph_memo
853            .borrow()
854            .get(face.0)
855            .and_then(|per_face| per_face.get(usize::from(gid)))
856            .and_then(|slot| *slot)
857        {
858            return hit;
859        }
860        self.metrics_of_miss(face, gid)
861    }
862
863    fn metrics_of_miss(&self, face: FaceId, gid: u16) -> GlyphMetrics {
864        let Some(font) = self.faces.font(face) else {
865            // A face outside the roster can never be memoized under a
866            // bounded index; the default is its constant result.
867            return GlyphMetrics::default();
868        };
869        let m = glyph_metrics(font, gid);
870        let mut memo = self.glyph_memo.borrow_mut();
871        if memo.len() <= face.0 {
872            memo.resize(face.0 + 1, Vec::new());
873        }
874        let per_face = &mut memo[face.0];
875        if per_face.len() <= usize::from(gid) {
876            per_face.resize(usize::from(gid) + 1, None);
877        }
878        per_face[usize::from(gid)] = Some(m);
879        m
880    }
881
882    fn space_width(&self, ctx: LayCtx) -> f64 {
883        let width = self
884            .faces
885            .font(FACE_REGULAR)
886            .map(|font| {
887                let gid = font.glyph_index(' ');
888                if gid == 0 {
889                    self.consts.fallback_space
890                } else {
891                    self.metrics_of(FACE_REGULAR, gid).advance
892                }
893            })
894            .unwrap_or(self.consts.fallback_space);
895        width * ctx.size()
896    }
897
898    // ── The Appendix-G constructions ────────────────────────────────────
899
900    /// Rule 18: sub/superscripts (and primes, which are superscript
901    /// material), including the simultaneous-scripts clash rules and the
902    /// italic-correction shift of the superscript.
903    #[allow(clippy::too_many_lines)]
904    fn scripts(
905        &self,
906        base: Option<&Node>,
907        sub: Option<&Node>,
908        sup: Option<&Node>,
909        primes: &[Span],
910        ctx: LayCtx,
911    ) -> Result<Laid, MathError> {
912        // Big operators with active limits route to rule 13a instead.
913        if let Some(base_node) = base {
914            if self.limits_active(base_node, ctx) {
915                let base_laid = self.lay_node(base_node, ctx)?;
916                let upper = sup
917                    .map(|n| self.lay_node(n, ctx.map(StyleCtx::sup)))
918                    .transpose()?
919                    .map(|l| l.boxx);
920                let lower = sub
921                    .map(|n| self.lay_node(n, ctx.map(StyleCtx::sub)))
922                    .transpose()?
923                    .map(|l| l.boxx);
924                return Ok(Laid {
925                    boxx: self.with_limits(base_laid.boxx, upper, lower, ctx, base_laid.italic),
926                    italic: 0.0,
927                    char_glyph: None,
928                });
929            }
930        }
931        let size = ctx.size();
932        let c = &self.consts;
933        let base_laid = match base {
934            Some(node) => self.lay_node(node, ctx)?,
935            None => Laid {
936                boxx: kern_box(0.0),
937                italic: 0.0,
938                char_glyph: None,
939            },
940        };
941        let base_is_char = base_laid.char_glyph.is_some() || base.is_none();
942        let delta = base_laid.italic;
943        // Rule 18a: baseline drops from the base's extremes (zero for
944        // single characters).
945        let (u, v) = if base_is_char {
946            (0.0, 0.0)
947        } else {
948            (
949                base_laid.boxx.height - c.sup_drop * size,
950                base_laid.boxx.depth + c.sub_drop * size,
951            )
952        };
953        // The superscript material: an explicit box, or a prime run.
954        let sup_box = if primes.is_empty() {
955            match sup {
956                Some(node) => Some(self.lay_node(node, ctx.map(StyleCtx::sup))?.boxx),
957                None => None,
958            }
959        } else {
960            Some(self.prime_run(primes, ctx)?)
961        };
962        let sub_box = match sub {
963            Some(node) => Some(self.lay_node(node, ctx.map(StyleCtx::sub))?.boxx),
964            None => None,
965        };
966        let base_w = base_laid.boxx.width;
967        let mut children = vec![Positioned {
968            dx: 0.0,
969            dy: 0.0,
970            node: MNode::Box(base_laid.boxx),
971        }];
972        let mut width = base_w;
973        let mut height = children[0].node.box_height();
974        let mut depth = children[0].node.box_depth();
975        match (sup_box, sub_box) {
976            (None, None) => {}
977            (None, Some(sb)) => {
978                // Rule 18b: subscript alone.
979                let shift = v
980                    .max(c.sub1 * size)
981                    .max(sb.height - 0.8 * c.x_height * size);
982                width = width.max(base_w + sb.width);
983                height = height.max(sb.height - shift);
984                depth = depth.max(sb.depth + shift);
985                children.push(Positioned {
986                    dx: base_w,
987                    dy: -shift,
988                    node: MNode::Box(sb),
989                });
990            }
991            (Some(sp), None) => {
992                // Rule 18c: superscript alone.
993                let p = script_p(c, ctx.style) * size;
994                let shift = u.max(p).max(sp.depth + 0.25 * c.x_height * size);
995                width = width.max(base_w + delta + sp.width);
996                height = height.max(sp.height + shift);
997                depth = depth.max(sp.depth - shift);
998                children.push(Positioned {
999                    dx: base_w + delta,
1000                    dy: shift,
1001                    node: MNode::Box(sp),
1002                });
1003            }
1004            (Some(sp), Some(sb)) => {
1005                // Rules 18d–f: both scripts; resolve the clash, then the
1006                // ⅘ x-height redistribution.
1007                let p = script_p(c, ctx.style) * size;
1008                let mut shift_up = u.max(p).max(sp.depth + 0.25 * c.x_height * size);
1009                let mut shift_down = v.max(c.sub2 * size);
1010                let theta = c.rule_thickness * size;
1011                let gap = (shift_up - sp.depth) - (sb.height - shift_down);
1012                if gap < 4.0 * theta {
1013                    shift_down += 4.0 * theta - gap;
1014                    let psi = 0.8 * c.x_height * size - (shift_up - sp.depth);
1015                    if psi > 0.0 {
1016                        shift_up += psi;
1017                        shift_down -= psi;
1018                    }
1019                }
1020                width = width.max(base_w + delta + sp.width).max(base_w + sb.width);
1021                height = height.max(sp.height + shift_up);
1022                depth = depth.max(sb.depth + shift_down);
1023                children.push(Positioned {
1024                    dx: base_w + delta,
1025                    dy: shift_up,
1026                    node: MNode::Box(sp),
1027                });
1028                children.push(Positioned {
1029                    dx: base_w,
1030                    dy: -shift_down,
1031                    node: MNode::Box(sb),
1032                });
1033            }
1034        }
1035        Ok(Laid {
1036            boxx: MBox {
1037                kind: BoxKind::Horizontal,
1038                width,
1039                height,
1040                depth,
1041                children,
1042            },
1043            italic: 0.0,
1044            char_glyph: None,
1045        })
1046    }
1047
1048    fn limits_active(&self, base: &Node, ctx: LayCtx) -> bool {
1049        match &base.kind {
1050            NodeKind::BigOp {
1051                limits, integral, ..
1052            } => match limits {
1053                Limits::Limits => true,
1054                Limits::NoLimits => false,
1055                Limits::Default => ctx.style.style == Style::Display && !integral,
1056            },
1057            NodeKind::OpName { limits, .. } => *limits && ctx.style.style == Style::Display,
1058            // `\overbrace`/`\underbrace` are `\mathop…\limits` in both plain
1059            // TeX and amsmath: their scripts center above/below in every
1060            // style (`\overbrace{x+y}^{n}` puts the n over the brace).
1061            NodeKind::Accent { accent, .. } => {
1062                matches!(accent, AccentKind::OverBrace | AccentKind::UnderBrace)
1063            }
1064            _ => false,
1065        }
1066    }
1067
1068    /// A run of prime marks as superscript material, each carrying its own
1069    /// `'` token's span.
1070    fn prime_run(&self, primes: &[Span], ctx: LayCtx) -> Result<MBox, MathError> {
1071        let sup_ctx = ctx.map(StyleCtx::sup);
1072        let size = sup_ctx.size();
1073        let fallback = primes.first().copied().unwrap_or(Span::new(0, 0));
1074        let Some((face, gid)) = self.faces.resolve('′', &[FACE_REGULAR, FACE_SYMBOLS]) else {
1075            return Err(MathError::UnmappedChar {
1076                ch: '′',
1077                span: fallback,
1078            });
1079        };
1080        let m = self.metrics_of(face, gid);
1081        let mut children = Vec::new();
1082        let mut x = 0.0;
1083        for span in primes {
1084            children.push(Positioned {
1085                dx: x,
1086                dy: 0.0,
1087                node: MNode::Glyph {
1088                    face,
1089                    gid,
1090                    ch: '′',
1091                    size,
1092                    span: *span,
1093                },
1094            });
1095            x += m.advance * size;
1096        }
1097        Ok(MBox {
1098            kind: BoxKind::Horizontal,
1099            width: x,
1100            height: m.height * size,
1101            depth: m.depth * size,
1102            children,
1103        })
1104    }
1105
1106    /// Rules 13/13a: limits above and below a big operator (also the
1107    /// `\overset` family, which amsmath defines through the same rules).
1108    fn with_limits(
1109        &self,
1110        op: MBox,
1111        upper: Option<MBox>,
1112        lower: Option<MBox>,
1113        ctx: LayCtx,
1114        delta: f64,
1115    ) -> MBox {
1116        let size = ctx.size();
1117        let c = &self.consts;
1118        let width = op
1119            .width
1120            .max(upper.as_ref().map_or(0.0, |b| b.width))
1121            .max(lower.as_ref().map_or(0.0, |b| b.width));
1122        let op_dx = (width - op.width) / 2.0;
1123        let mut height = op.height;
1124        let mut depth = op.depth;
1125        let mut children = Vec::new();
1126        if let Some(up) = upper {
1127            let gap = (c.big_op_spacing1 * size).max(c.big_op_spacing3 * size - up.depth);
1128            let dy = op.height + gap + up.depth;
1129            height = dy + up.height + c.big_op_spacing5 * size;
1130            children.push(Positioned {
1131                dx: (width - up.width) / 2.0 + delta / 2.0,
1132                dy,
1133                node: MNode::Box(up),
1134            });
1135        }
1136        if let Some(low) = lower {
1137            let gap = (c.big_op_spacing2 * size).max(c.big_op_spacing4 * size - low.height);
1138            let dy = -(op.depth + gap + low.height);
1139            depth = -dy + low.depth + c.big_op_spacing5 * size;
1140            children.push(Positioned {
1141                dx: (width - low.width) / 2.0 - delta / 2.0,
1142                dy,
1143                node: MNode::Box(low),
1144            });
1145        }
1146        children.push(Positioned {
1147            dx: op_dx,
1148            dy: 0.0,
1149            node: MNode::Box(op),
1150        });
1151        MBox {
1152            kind: BoxKind::Horizontal,
1153            width,
1154            height,
1155            depth,
1156            children,
1157        }
1158    }
1159
1160    /// Rule 15: generalized fractions (with or without bar, with or
1161    /// without wrapped delimiters).
1162    fn fraction(
1163        &self,
1164        num: &Node,
1165        den: &Node,
1166        spec: FracSpec,
1167        ctx: LayCtx,
1168        span: Span,
1169    ) -> Result<Laid, MathError> {
1170        let c = &self.consts;
1171        let eff = match spec.forced_style {
1172            Some(forced) => ctx.map(|s| StyleCtx {
1173                style: forced,
1174                cramped: s.cramped,
1175            }),
1176            None => ctx,
1177        };
1178        let display = eff.style.style == Style::Display;
1179        let size = eff.size();
1180        let num_box = self.lay_node(num, eff.map(StyleCtx::num))?.boxx;
1181        let den_box = self.lay_node(den, eff.map(StyleCtx::den))?.boxx;
1182        let theta = c.rule_thickness * size;
1183        let axis = c.axis_height * size;
1184        // Rule 15b: initial shifts.
1185        let mut u = if display {
1186            c.num1
1187        } else if spec.bar {
1188            c.num2
1189        } else {
1190            c.num3
1191        } * size;
1192        let mut v = if display { c.denom1 } else { c.denom2 } * size;
1193        if spec.bar {
1194            // Rule 15d: clearances against the bar.
1195            let phi = if display { 3.0 * theta } else { theta };
1196            let num_gap = (u - num_box.depth) - (axis + theta / 2.0);
1197            if num_gap < phi {
1198                u += phi - num_gap;
1199            }
1200            let den_gap = (axis - theta / 2.0) - (den_box.height - v);
1201            if den_gap < phi {
1202                v += phi - den_gap;
1203            }
1204        } else {
1205            // Rule 15c: minimum clearance, split evenly.
1206            let phi = if display { 7.0 * theta } else { 3.0 * theta };
1207            let gap = (u - num_box.depth) - (den_box.height - v);
1208            if gap < phi {
1209                u += (phi - gap) / 2.0;
1210                v += (phi - gap) / 2.0;
1211            }
1212        }
1213        let inner_width = num_box.width.max(den_box.width);
1214        let num_dx = (inner_width - num_box.width) / 2.0;
1215        let den_dx = (inner_width - den_box.width) / 2.0;
1216        let height = u + num_box.height;
1217        let depth = v + den_box.depth;
1218        let mut children = vec![
1219            Positioned {
1220                dx: num_dx,
1221                dy: u,
1222                node: MNode::Box(num_box),
1223            },
1224            Positioned {
1225                dx: den_dx,
1226                dy: -v,
1227                node: MNode::Box(den_box),
1228            },
1229        ];
1230        if spec.bar {
1231            children.push(Positioned {
1232                dx: 0.0,
1233                dy: axis - theta / 2.0,
1234                node: MNode::Rule {
1235                    width: inner_width,
1236                    height: theta,
1237                    span,
1238                },
1239            });
1240        }
1241        let core = MBox {
1242            kind: BoxKind::Horizontal,
1243            width: inner_width,
1244            height,
1245            depth,
1246            children,
1247        };
1248        let boxx = if let Some((l, r)) = spec.delims {
1249            // Rule 15e: wrap in delimiters of the style-fixed size.
1250            let target = if display { c.delim1 } else { c.delim2 } * size;
1251            let left = self.delimiter_box(&Delim { ch: Some(l), span }, target, eff)?;
1252            let right = self.delimiter_box(&Delim { ch: Some(r), span }, target, eff)?;
1253            hcat(vec![left, core, right])
1254        } else {
1255            core
1256        };
1257        Ok(Laid {
1258            boxx,
1259            italic: 0.0,
1260            char_glyph: None,
1261        })
1262    }
1263
1264    /// Rule 11: radicals, with the plain-TeX degree placement.
1265    fn radical(
1266        &self,
1267        index: Option<&Node>,
1268        radicand: &Node,
1269        ctx: LayCtx,
1270        span: Span,
1271    ) -> Result<Laid, MathError> {
1272        let c = &self.consts;
1273        let size = ctx.size();
1274        let x = self.lay_node(radicand, ctx.map(StyleCtx::cramp))?.boxx;
1275        let theta = c.rule_thickness * size;
1276        let mut psi = if ctx.style.style == Style::Display {
1277            theta + 0.25 * c.x_height * size
1278        } else {
1279            theta + 0.25 * theta
1280        };
1281        let target = x.height + x.depth + psi + theta;
1282        let sign = self.delimiter_box(
1283            &Delim {
1284                ch: Some('√'),
1285                span,
1286            },
1287            target,
1288            ctx,
1289        )?;
1290        let sign_total = sign.height + sign.depth;
1291        let excess = sign_total - target;
1292        if excess > 0.0 {
1293            psi += excess / 2.0;
1294        }
1295        // Vertical frame: radicand baseline at 0; rule sits ψ above the
1296        // radicand's top; the sign's top aligns with the rule's top.
1297        let rule_y = x.height + psi;
1298        let sign_dy = rule_y + theta - sign.height;
1299        let mut pen = 0.0_f64;
1300        let mut children = Vec::new();
1301        let mut total_height = rule_y + theta;
1302        // Degree: raised 60% of the sign's extent, kerned 5mu in and
1303        // −10.5mu back (plain TeX's \root placement).
1304        if let Some(ix) = index {
1305            let ix_box = self
1306                .lay_node(
1307                    ix,
1308                    LayCtx {
1309                        style: StyleCtx {
1310                            style: Style::ScriptScript,
1311                            cramped: ctx.style.cramped,
1312                        },
1313                        ..ctx
1314                    },
1315                )?
1316                .boxx;
1317            // Plain TeX raises the degree by 60% of the radical construct's
1318            // total extent, measured from the baseline.
1319            let raise = 0.6 * (rule_y + theta + x.depth);
1320            pen += 5.0 / 18.0 * size;
1321            total_height = total_height.max(raise + ix_box.height);
1322            let ix_width = ix_box.width;
1323            children.push(Positioned {
1324                dx: pen,
1325                dy: raise,
1326                node: MNode::Box(ix_box),
1327            });
1328            pen += ix_width - 10.5 / 18.0 * size;
1329            pen = pen.max(0.0);
1330        }
1331        let sign_width = sign.width;
1332        children.push(Positioned {
1333            dx: pen,
1334            dy: sign_dy,
1335            node: MNode::Box(sign),
1336        });
1337        pen += sign_width;
1338        children.push(Positioned {
1339            dx: pen,
1340            dy: rule_y,
1341            node: MNode::Rule {
1342                width: x.width,
1343                height: theta,
1344                span,
1345            },
1346        });
1347        let x_width = x.width;
1348        let x_depth = x.depth;
1349        children.push(Positioned {
1350            dx: pen,
1351            dy: 0.0,
1352            node: MNode::Box(x),
1353        });
1354        Ok(Laid {
1355            boxx: MBox {
1356                kind: BoxKind::Horizontal,
1357                width: pen + x_width,
1358                height: total_height,
1359                depth: x_depth,
1360                children,
1361            },
1362            italic: 0.0,
1363            char_glyph: None,
1364        })
1365    }
1366
1367    /// Rule 12: accents, with the synthesized-skew centering; rules 9/10
1368    /// for `\overline`/`\underline`.
1369    fn accent(
1370        &self,
1371        kind: AccentKind,
1372        base: &Node,
1373        ctx: LayCtx,
1374        span: Span,
1375    ) -> Result<Laid, MathError> {
1376        let c = &self.consts;
1377        let size = ctx.size();
1378        match kind {
1379            AccentKind::OverLine => {
1380                let inner = self.lay_node(base, ctx.map(StyleCtx::cramp))?.boxx;
1381                let theta = c.rule_thickness * size;
1382                let rule_y = inner.height + 3.0 * theta;
1383                let width = inner.width;
1384                Ok(Laid {
1385                    boxx: MBox {
1386                        kind: BoxKind::Horizontal,
1387                        width,
1388                        height: rule_y + 2.0 * theta,
1389                        depth: inner.depth,
1390                        children: vec![
1391                            Positioned {
1392                                dx: 0.0,
1393                                dy: rule_y,
1394                                node: MNode::Rule {
1395                                    width,
1396                                    height: theta,
1397                                    span,
1398                                },
1399                            },
1400                            Positioned {
1401                                dx: 0.0,
1402                                dy: 0.0,
1403                                node: MNode::Box(inner),
1404                            },
1405                        ],
1406                    },
1407                    italic: 0.0,
1408                    char_glyph: None,
1409                })
1410            }
1411            AccentKind::UnderLine => {
1412                let inner = self.lay_node(base, ctx)?.boxx;
1413                Ok(Laid {
1414                    boxx: self.underline_box(inner, ctx, span),
1415                    italic: 0.0,
1416                    char_glyph: None,
1417                })
1418            }
1419            AccentKind::OverBrace
1420            | AccentKind::UnderBrace
1421            | AccentKind::OverRightArrow
1422            | AccentKind::OverLeftArrow
1423            | AccentKind::WideHat
1424            | AccentKind::WideTilde => self.stretchy_accent(kind, base, ctx, span),
1425            AccentKind::Dddot | AccentKind::Ddddot => {
1426                // amsmath builds `\dddot`/`\ddddot` from a row of dots (no
1427                // face carries U+20DB/U+20DC): resolve the single dot mark
1428                // once and place n copies contiguously by ink width,
1429                // centered over the base with rule 12's raise and skew.
1430                let n: u8 = if matches!(kind, AccentKind::Dddot) {
1431                    3
1432                } else {
1433                    4
1434                };
1435                let base_laid = self.lay_node(base, ctx.map(StyleCtx::cramp))?;
1436                let combining = accent_char(AccentKind::Dot);
1437                let Some((face, gid)) = self
1438                    .faces
1439                    .resolve(combining, &[FACE_REGULAR, FACE_ITALIC, FACE_SYMBOLS])
1440                    .or_else(|| {
1441                        accent_spacing_fallback(combining)
1442                            .and_then(|alt| self.faces.resolve(alt, &[FACE_REGULAR, FACE_SYMBOLS]))
1443                    })
1444                else {
1445                    return Err(MathError::UnmappedChar {
1446                        ch: combining,
1447                        span,
1448                    });
1449                };
1450                let Some(font) = self.faces.font(face) else {
1451                    return Err(MathError::UnmappedChar {
1452                        ch: combining,
1453                        span,
1454                    });
1455                };
1456                let upm = f64::from(font.units_per_em.max(1));
1457                let bbox = font.glyph_bbox(gid).unwrap_or([0, 0, 0, 0]);
1458                let ink_left = f64::from(bbox[0]) / upm * size;
1459                let ink_right = f64::from(bbox[2]) / upm * size;
1460                let ink_top = f64::from(bbox[3]) / upm * size;
1461                let ink_bottom = f64::from(bbox[1]) / upm * size;
1462                let ink_w = ink_right - ink_left;
1463                let row_w = ink_w * f64::from(n);
1464                let dy = base_laid.boxx.height - c.x_height * size;
1465                let skew = base_laid.italic / 2.0;
1466                let row_left = base_laid.boxx.width / 2.0 + skew - row_w / 2.0;
1467                let width = base_laid.boxx.width;
1468                let height = base_laid.boxx.height.max(dy + ink_top);
1469                let depth = base_laid.boxx.depth.max(-(dy + ink_bottom));
1470                let mut children = Vec::with_capacity(usize::from(n) + 1);
1471                for i in 0..n {
1472                    children.push(Positioned {
1473                        dx: row_left + ink_w * f64::from(i) - ink_left,
1474                        dy,
1475                        node: MNode::Glyph {
1476                            face,
1477                            gid,
1478                            ch: combining,
1479                            size,
1480                            span,
1481                        },
1482                    });
1483                }
1484                children.push(Positioned {
1485                    dx: 0.0,
1486                    dy: 0.0,
1487                    node: MNode::Box(base_laid.boxx),
1488                });
1489                Ok(Laid {
1490                    boxx: MBox {
1491                        kind: BoxKind::Horizontal,
1492                        width,
1493                        height,
1494                        depth,
1495                        children,
1496                    },
1497                    italic: 0.0,
1498                    char_glyph: None,
1499                })
1500            }
1501            _ => {
1502                let base_laid = self.lay_node(base, ctx.map(StyleCtx::cramp))?;
1503                let combining = accent_char(kind);
1504                let resolved = self
1505                    .faces
1506                    .resolve(combining, &[FACE_REGULAR, FACE_ITALIC, FACE_SYMBOLS])
1507                    .or_else(|| {
1508                        accent_spacing_fallback(combining)
1509                            .and_then(|alt| self.faces.resolve(alt, &[FACE_REGULAR, FACE_SYMBOLS]))
1510                    });
1511                let Some((face, gid)) = resolved else {
1512                    return Err(MathError::UnmappedChar {
1513                        ch: combining,
1514                        span,
1515                    });
1516                };
1517                let Some(font) = self.faces.font(face) else {
1518                    return Err(MathError::UnmappedChar {
1519                        ch: combining,
1520                        span,
1521                    });
1522                };
1523                let upm = f64::from(font.units_per_em.max(1));
1524                let bbox = font.glyph_bbox(gid).unwrap_or([0, 0, 0, 0]);
1525                let ink_left = f64::from(bbox[0]) / upm * size;
1526                let ink_right = f64::from(bbox[2]) / upm * size;
1527                let ink_top = f64::from(bbox[3]) / upm * size;
1528                let ink_bottom = f64::from(bbox[1]) / upm * size;
1529                // Rule 12: raise by the base's height over x-height; center
1530                // ink over the base, skewed by half the italic correction.
1531                let dy = base_laid.boxx.height - c.x_height * size;
1532                let skew = base_laid.italic / 2.0;
1533                let dx = base_laid.boxx.width / 2.0 + skew - (ink_left + ink_right) / 2.0;
1534                let width = base_laid.boxx.width;
1535                let height = base_laid.boxx.height.max(dy + ink_top);
1536                let depth = base_laid.boxx.depth.max(-(dy + ink_bottom));
1537                let base_box = base_laid.boxx;
1538                Ok(Laid {
1539                    boxx: MBox {
1540                        kind: BoxKind::Horizontal,
1541                        width,
1542                        height,
1543                        depth,
1544                        children: vec![
1545                            Positioned {
1546                                dx,
1547                                dy,
1548                                node: MNode::Glyph {
1549                                    face,
1550                                    gid,
1551                                    ch: combining,
1552                                    size,
1553                                    span,
1554                                },
1555                            },
1556                            Positioned {
1557                                dx: 0.0,
1558                                dy: 0.0,
1559                                node: MNode::Box(base_box),
1560                            },
1561                        ],
1562                    },
1563                    italic: 0.0,
1564                    char_glyph: None,
1565                })
1566            }
1567        }
1568    }
1569
1570    /// Environment layout: the matrix family, `cases`, `array` with column
1571    /// specs, the `align*` class, and `substack` (amsmath's single-column
1572    /// script-style stack) — column measurement, per-column
1573    /// alignment, and inter-row spacing per the published rules
1574    /// (`\arraycolsep`-derived column separation, `\baselineskip` with
1575    /// `\lineskip` growth between rows, `\jot` opening the `align` rows,
1576    /// `\vcenter` axis centering), with the matrix family wrapped by the
1577    /// rule-19 delimiter mechanism.
1578    fn environment(
1579        &self,
1580        name: &str,
1581        spec: Option<&str>,
1582        rows: &[Vec<Node>],
1583        ctx: LayCtx,
1584        span: Span,
1585    ) -> Result<Laid, MathError> {
1586        // amsmath sets matrix/cases cells in text style (script styles keep
1587        // their size); align-class cells are display style.
1588        let text_cells = ctx.map(|s| StyleCtx {
1589            style: if s.style == Style::Display {
1590                Style::Text
1591            } else {
1592                s.style
1593            },
1594            cramped: s.cramped,
1595        });
1596        let boxx = match name {
1597            "matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" => {
1598                let grid = self.grid(rows, text_cells, &Grid::centered(1.0), span)?;
1599                match name {
1600                    // A bare matrix has no delimiters at all (not even the
1601                    // null-delimiter kerns a `\left.` would add).
1602                    "matrix" => grid,
1603                    "pmatrix" => self.wrap_delims(grid, Some('('), Some(')'), ctx, span)?,
1604                    "bmatrix" => self.wrap_delims(grid, Some('['), Some(']'), ctx, span)?,
1605                    "Bmatrix" => self.wrap_delims(grid, Some('{'), Some('}'), ctx, span)?,
1606                    "vmatrix" => self.wrap_delims(grid, Some('|'), Some('|'), ctx, span)?,
1607                    _ => self.wrap_delims(grid, Some('‖'), Some('‖'), ctx, span)?,
1608                }
1609            }
1610            "smallmatrix" => {
1611                // The inline matrix: script-size cells, tightened columns
1612                // and rows (amsmath's 0.7-factor feel).
1613                let cells = ctx.map(StyleCtx::sup);
1614                self.grid(
1615                    rows,
1616                    cells,
1617                    &Grid {
1618                        col_sep: 0.35,
1619                        row_factor: 0.7,
1620                        ..Grid::centered(1.0)
1621                    },
1622                    span,
1623                )?
1624            }
1625            "cases" => {
1626                // Text-style cells, left-aligned value and condition
1627                // columns a quad apart, behind a stretched `{`.
1628                let grid = self.grid(
1629                    rows,
1630                    text_cells,
1631                    &Grid {
1632                        align: AlignRule::AllLeft,
1633                        ..Grid::centered(1.0)
1634                    },
1635                    span,
1636                )?;
1637                self.wrap_delims(grid, Some('{'), None, ctx, span)?
1638            }
1639            "array" => {
1640                let plan = parse_array_spec(spec.unwrap_or(""), span)?;
1641                self.grid(
1642                    rows,
1643                    text_cells,
1644                    &Grid {
1645                        align: AlignRule::Columns(plan.aligns),
1646                        vrules: plan.vrules,
1647                        outer_pad: 0.5,
1648                        ..Grid::centered(1.0)
1649                    },
1650                    span,
1651                )?
1652            }
1653            "align" | "align*" | "aligned" => {
1654                // Display-style cells; r,l alternation with the alignment
1655                // point closed up; \minalignsep between pairs; \jot opens
1656                // the rows.
1657                let cells = ctx.map(|s| StyleCtx {
1658                    style: Style::Display,
1659                    cramped: s.cramped,
1660                });
1661                self.grid(
1662                    rows,
1663                    cells,
1664                    &Grid {
1665                        align: AlignRule::AlignPairs,
1666                        col_sep: 1.0,
1667                        jot: 0.3,
1668                        ..Grid::centered(1.0)
1669                    },
1670                    span,
1671                )?
1672            }
1673            "substack" => {
1674                // amsmath's \subarray{c}: every line set in \scriptstyle
1675                // (forced, whatever the ambient style — that is what makes
1676                // \substack usable in base position too), centered in one
1677                // column, \vcenter'd on the axis: the grid engine's
1678                // single-column case, rows on the shared
1679                // \baselineskip/\lineskip rule at script size.
1680                let cells = ctx.map(|_| StyleCtx::new(Style::Script));
1681                self.grid(rows, cells, &Grid::centered(1.0), span)?
1682            }
1683            other => {
1684                // Parse admits only known environments; a new name landing
1685                // here is a construct the layout tier does not cover yet.
1686                return Err(MathError::UnsupportedCommand {
1687                    name: format!("env:{other}"),
1688                    span,
1689                });
1690            }
1691        };
1692        Ok(Laid {
1693            boxx,
1694            italic: 0.0,
1695            char_glyph: None,
1696        })
1697    }
1698
1699    /// Measure and assemble a cell grid: shared column widths, per-row
1700    /// height/depth, baseline stacking, and `\vcenter` axis centering.
1701    fn grid(
1702        &self,
1703        rows: &[Vec<Node>],
1704        cell_ctx: LayCtx,
1705        grid: &Grid,
1706        span: Span,
1707    ) -> Result<MBox, MathError> {
1708        let size = cell_ctx.size();
1709        let c = &self.consts;
1710        // 1. Lay every cell.
1711        let mut cells: Vec<Vec<MBox>> = Vec::new();
1712        for row in rows {
1713            let mut out = Vec::new();
1714            for cell in row {
1715                out.push(self.lay_node(cell, cell_ctx)?.boxx);
1716            }
1717            cells.push(out);
1718        }
1719        let ncols = cells.iter().map(Vec::len).max().unwrap_or(0);
1720        // 2. Column widths and row metrics.
1721        let mut col_w = vec![0.0_f64; ncols];
1722        for row in &cells {
1723            for (j, cell) in row.iter().enumerate() {
1724                col_w[j] = col_w[j].max(cell.width);
1725            }
1726        }
1727        let row_h: Vec<f64> = cells
1728            .iter()
1729            .map(|r| r.iter().map(|b| b.height).fold(0.0, f64::max))
1730            .collect();
1731        let row_d: Vec<f64> = cells
1732            .iter()
1733            .map(|r| r.iter().map(|b| b.depth).fold(0.0, f64::max))
1734            .collect();
1735        // 3. Column x positions.
1736        let mut col_x = vec![0.0_f64; ncols];
1737        let mut x = grid.outer_pad * size;
1738        for (j, w) in col_w.iter().enumerate() {
1739            if j > 0 {
1740                x += grid.sep_before(j) * size;
1741            }
1742            col_x[j] = x;
1743            x += w;
1744        }
1745        let total_width = x + grid.outer_pad * size;
1746        // 4. Row baselines: \baselineskip (+\jot) grown by \lineskip when
1747        // boxes would touch — the same stacking rule multi-line hlists use.
1748        let mut baselines = vec![0.0_f64; cells.len()];
1749        for i in 1..cells.len() {
1750            let natural = (c.baseline_skip + grid.jot) * grid.row_factor * size;
1751            let min_gap = row_d[i - 1] + row_h[i] + c.line_skip * size;
1752            baselines[i] = baselines[i - 1] - natural.max(min_gap);
1753        }
1754        // 5. Assemble, then axis-center the whole grid (\vcenter).
1755        let top = row_h.first().copied().unwrap_or(0.0);
1756        let bottom =
1757            baselines.last().copied().unwrap_or(0.0) - row_d.last().copied().unwrap_or(0.0);
1758        let total = top - bottom;
1759        let axis = c.axis_height * cell_ctx.size();
1760        let height = total / 2.0 + axis;
1761        let shift = height - top; // added to every child dy
1762        let depth = (total / 2.0 - axis).max(0.0);
1763        let mut children = Vec::new();
1764        for (i, row) in cells.into_iter().enumerate() {
1765            for (j, cell) in row.into_iter().enumerate() {
1766                let slack = col_w[j] - cell.width;
1767                let dx = col_x[j] + grid.align.factor(j) * slack;
1768                children.push(Positioned {
1769                    dx,
1770                    dy: baselines[i] + shift,
1771                    node: MNode::Box(cell),
1772                });
1773            }
1774        }
1775        // 6. Vertical rules from an `array` spec span the full grid.
1776        for &before_col in &grid.vrules {
1777            let theta = c.rule_thickness * size;
1778            let rule_x = if before_col == 0 {
1779                (grid.outer_pad * size - theta).max(0.0) / 2.0
1780            } else if before_col < ncols {
1781                col_x[before_col] - grid.sep_before(before_col) * size / 2.0 - theta / 2.0
1782            } else {
1783                total_width - (grid.outer_pad * size - theta).max(0.0) / 2.0 - theta
1784            };
1785            children.push(Positioned {
1786                dx: rule_x,
1787                dy: -depth,
1788                node: MNode::Rule {
1789                    width: theta,
1790                    height: height + depth,
1791                    span,
1792                },
1793            });
1794        }
1795        Ok(MBox {
1796            kind: BoxKind::Vertical,
1797            width: total_width,
1798            height,
1799            depth,
1800            children,
1801        })
1802    }
1803
1804    /// Wrap a box in rule-19-sized delimiters (the matrix family, `cases`).
1805    fn wrap_delims(
1806        &self,
1807        inner: MBox,
1808        left: Option<char>,
1809        right: Option<char>,
1810        ctx: LayCtx,
1811        span: Span,
1812    ) -> Result<MBox, MathError> {
1813        let c = &self.consts;
1814        let size = ctx.size();
1815        let axis = c.axis_height * size;
1816        let delta = (inner.height - axis).max(inner.depth + axis);
1817        let target =
1818            (2.0 * delta * c.delimiter_factor).max(2.0 * delta - c.delimiter_shortfall * size);
1819        let left_box = self.delimiter_box(&Delim { ch: left, span }, target, ctx)?;
1820        let right_box = self.delimiter_box(&Delim { ch: right, span }, target, ctx)?;
1821        Ok(hcat(vec![left_box, inner, right_box]))
1822    }
1823
1824    /// The stretchy over/under constructions (`\widehat`, `\widetilde`,
1825    /// `\overbrace`, `\underbrace`, `\overrightarrow`, `\overleftarrow`):
1826    /// a drawn band spanning the base's width, placed with a small
1827    /// rule-thickness clearance (hats and tildes ride close, the way the
1828    /// authored accents do; braces and arrows take a little more air).
1829    /// Any width draws — the constructions are total.
1830    fn stretchy_accent(
1831        &self,
1832        kind: AccentKind,
1833        base: &Node,
1834        ctx: LayCtx,
1835        span: Span,
1836    ) -> Result<Laid, MathError> {
1837        let size = ctx.size();
1838        let theta = self.consts.rule_thickness * size;
1839        let under = matches!(kind, AccentKind::UnderBrace);
1840        // Over-accents cramp their base (rule 12 / overline's rule 9);
1841        // under-constructions do not (underline's rule 10).
1842        let inner = if under {
1843            self.lay_node(base, ctx)?.boxx
1844        } else {
1845            self.lay_node(base, ctx.map(StyleCtx::cramp))?.boxx
1846        };
1847        let stretch_kind = match kind {
1848            AccentKind::WideHat => crate::drawn::Stretch::Hat,
1849            AccentKind::WideTilde => crate::drawn::Stretch::Tilde,
1850            AccentKind::OverBrace => crate::drawn::Stretch::OverBrace,
1851            AccentKind::UnderBrace => crate::drawn::Stretch::UnderBrace,
1852            AccentKind::OverRightArrow => crate::drawn::Stretch::RightArrow,
1853            _ => crate::drawn::Stretch::LeftArrow,
1854        };
1855        let width = inner.width.max(0.25 * size);
1856        let band = crate::drawn::stretch(stretch_kind, width, size);
1857        let gap = match kind {
1858            AccentKind::WideHat | AccentKind::WideTilde => theta,
1859            _ => 2.0 * theta,
1860        };
1861        let (band_dy, height, depth) = if under {
1862            let dy = -(inner.depth + gap + band.height);
1863            (dy, inner.height, inner.depth + gap + band.height)
1864        } else {
1865            let dy = inner.height + gap;
1866            (dy, inner.height + gap + band.height, inner.depth)
1867        };
1868        let inner_width = inner.width;
1869        Ok(Laid {
1870            boxx: MBox {
1871                kind: BoxKind::Horizontal,
1872                width: inner_width.max(width),
1873                height,
1874                depth,
1875                children: vec![
1876                    Positioned {
1877                        dx: 0.0,
1878                        dy: band_dy,
1879                        node: MNode::Path {
1880                            contours: band.contours,
1881                            span,
1882                        },
1883                    },
1884                    Positioned {
1885                        dx: 0.0,
1886                        dy: 0.0,
1887                        node: MNode::Box(inner),
1888                    },
1889                ],
1890            },
1891            italic: 0.0,
1892            char_glyph: None,
1893        })
1894    }
1895
1896    fn underline_box(&self, inner: MBox, ctx: LayCtx, span: Span) -> MBox {
1897        let theta = self.consts.rule_thickness * ctx.size();
1898        let rule_y = -(inner.depth + 3.0 * theta) - theta;
1899        let width = inner.width;
1900        MBox {
1901            kind: BoxKind::Horizontal,
1902            width,
1903            height: inner.height,
1904            depth: -rule_y + 2.0 * theta,
1905            children: vec![
1906                Positioned {
1907                    dx: 0.0,
1908                    dy: rule_y,
1909                    node: MNode::Rule {
1910                        width,
1911                        height: theta,
1912                        span,
1913                    },
1914                },
1915                Positioned {
1916                    dx: 0.0,
1917                    dy: 0.0,
1918                    node: MNode::Box(inner),
1919                },
1920            ],
1921        }
1922    }
1923
1924    /// Rule 19: `\left…\right`.
1925    fn left_right(
1926        &self,
1927        left: &Delim,
1928        right: &Delim,
1929        body: &[Node],
1930        ctx: LayCtx,
1931    ) -> Result<Laid, MathError> {
1932        let c = &self.consts;
1933        let size = ctx.size();
1934        let inner = self.hlist(body, ctx)?;
1935        let axis = c.axis_height * size;
1936        let delta = (inner.height - axis).max(inner.depth + axis);
1937        let target =
1938            (2.0 * delta * c.delimiter_factor).max(2.0 * delta - c.delimiter_shortfall * size);
1939        let left_box = self.delimiter_box(left, target, ctx)?;
1940        let right_box = self.delimiter_box(right, target, ctx)?;
1941        Ok(Laid {
1942            boxx: hcat(vec![left_box, inner, right_box]),
1943            italic: 0.0,
1944            char_glyph: None,
1945        })
1946    }
1947
1948    /// The ADR-0005 delimiter mechanism, complete: natural glyph if it
1949    /// covers the target; uniform scaling up to the `1.25×` ceiling; the
1950    /// parametric drawn-path construction beyond (the mainline — stroke
1951    /// weights calibrated against the authored glyphs so the threshold
1952    /// seam is invisible at a glance, and no requested size can fail).
1953    /// Axis-centered every way; the null delimiter is a
1954    /// `nulldelimiterspace` kern. Characters without a drawn construction
1955    /// keep uniform scaling — nothing regresses past the ceiling.
1956    fn delimiter_box(
1957        &self,
1958        delim: &Delim,
1959        target_total: f64,
1960        ctx: LayCtx,
1961    ) -> Result<MBox, MathError> {
1962        let Some(ch) = delim.ch else {
1963            return Ok(kern_box(self.consts.null_delimiter_space * ctx.size()));
1964        };
1965        let Some((face, gid)) = self
1966            .faces
1967            .resolve(ch, &[FACE_REGULAR, FACE_SYMBOLS, FACE_ITALIC])
1968        else {
1969            // No authored glyph at all (e.g. `‖` on a face without it): the
1970            // drawn construction serves every size, floored at a text-ish
1971            // height so an empty body still gets a visible delimiter.
1972            if let Some(d) =
1973                crate::drawn::delimiter(ch, target_total.max(0.7 * ctx.size()), ctx.size())
1974            {
1975                let total = target_total.max(0.7 * ctx.size());
1976                let axis = self.consts.axis_height * ctx.size();
1977                let dy = axis - total / 2.0;
1978                return Ok(MBox {
1979                    kind: BoxKind::Horizontal,
1980                    width: d.width,
1981                    height: total + dy,
1982                    depth: (-dy).max(0.0),
1983                    children: vec![Positioned {
1984                        dx: 0.0,
1985                        dy,
1986                        node: MNode::Path {
1987                            contours: d.contours,
1988                            span: delim.span,
1989                        },
1990                    }],
1991                });
1992            }
1993            return Err(MathError::UnmappedChar {
1994                ch,
1995                span: delim.span,
1996            });
1997        };
1998        let m = self.metrics_of(face, gid);
1999        let natural_total = (m.height + m.depth) * ctx.size();
2000        let scale = if natural_total >= target_total || natural_total <= 0.0 {
2001            1.0
2002        } else {
2003            target_total / natural_total
2004        };
2005        if scale > self.consts.delimiter_scale_ceiling
2006            && let Some(d) = crate::drawn::delimiter(ch, target_total, ctx.size())
2007        {
2008            // The drawn mainline: contours span y ∈ [0, target]; center
2009            // the construction on the axis exactly as a glyph would be.
2010            let axis = self.consts.axis_height * ctx.size();
2011            let dy = axis - target_total / 2.0;
2012            return Ok(MBox {
2013                kind: BoxKind::Horizontal,
2014                width: d.width,
2015                height: target_total + dy,
2016                depth: (-dy).max(0.0),
2017                children: vec![Positioned {
2018                    dx: 0.0,
2019                    dy,
2020                    node: MNode::Path {
2021                        contours: d.contours,
2022                        span: delim.span,
2023                    },
2024                }],
2025            });
2026        }
2027        let size = ctx.size() * scale;
2028        let gh = m.height * size;
2029        let gd = m.depth * size;
2030        let axis = self.consts.axis_height * ctx.size();
2031        let dy = axis - (gh - gd) / 2.0;
2032        Ok(MBox {
2033            kind: BoxKind::Horizontal,
2034            width: m.advance * size,
2035            height: gh + dy,
2036            depth: (gd - dy).max(0.0),
2037            children: vec![Positioned {
2038                dx: 0.0,
2039                dy,
2040                node: MNode::Glyph {
2041                    face,
2042                    gid,
2043                    ch,
2044                    size,
2045                    span: delim.span,
2046                },
2047            }],
2048        })
2049    }
2050}
2051
2052/// σ13/σ14/σ15 selection for rule 18.
2053fn script_p(c: &MathConstants, style: StyleCtx) -> f64 {
2054    if style.cramped {
2055        c.sup3
2056    } else if style.style == Style::Display {
2057        c.sup1
2058    } else {
2059        c.sup2
2060    }
2061}
2062
2063/// The `\big` family's total-size targets in ems (plain TeX's 8.5 pt /
2064/// 11.5 pt / 14.5 pt / 17.5 pt at 10 pt).
2065fn fixed_delim_target(size: DelimSize) -> f64 {
2066    match size {
2067        DelimSize::Big => 0.85,
2068        DelimSize::BBig => 1.15,
2069        DelimSize::Bigg => 1.45,
2070        DelimSize::BBigg => 1.75,
2071    }
2072}
2073
2074fn text_face(ctx: LayCtx) -> FaceId {
2075    match ctx.alphabet {
2076        Some(MathFont::Bold) => FACE_BOLD,
2077        Some(MathFont::Italic) => FACE_ITALIC,
2078        _ => FACE_REGULAR,
2079    }
2080}
2081
2082const fn accent_char(kind: AccentKind) -> char {
2083    match kind {
2084        AccentKind::Hat => '\u{0302}',
2085        AccentKind::Check => '\u{030C}',
2086        AccentKind::Tilde => '\u{0303}',
2087        AccentKind::Acute => '\u{0301}',
2088        AccentKind::Grave => '\u{0300}',
2089        AccentKind::Dot => '\u{0307}',
2090        AccentKind::Ddot => '\u{0308}',
2091        AccentKind::Breve => '\u{0306}',
2092        AccentKind::Bar => '\u{0304}',
2093        AccentKind::Vec => '\u{20D7}',
2094        AccentKind::Ring => '\u{030A}',
2095        _ => '\u{0302}',
2096    }
2097}
2098
2099/// Grid parameters for one environment family (ems, of the cell size).
2100struct Grid {
2101    /// Per-column horizontal alignment.
2102    align: AlignRule,
2103    /// Space between adjacent columns (`2\arraycolsep`-derived; the
2104    /// `align` rule reads it as the between-pairs `\minalignsep`).
2105    col_sep: f64,
2106    /// Padding at the grid's outer edges (`array`'s `\arraycolsep`).
2107    outer_pad: f64,
2108    /// Extra opening of the natural row skip (`align`'s `\jot`).
2109    jot: f64,
2110    /// Multiplier on the natural row skip (`smallmatrix` tightening).
2111    row_factor: f64,
2112    /// Vertical rules before these column indices (an `array` spec's `|`;
2113    /// `ncols` means after the last column).
2114    vrules: Vec<usize>,
2115}
2116
2117impl Grid {
2118    /// All-centered columns with the given separation — the matrix baseline.
2119    fn centered(col_sep: f64) -> Self {
2120        Self {
2121            align: AlignRule::AllCenter,
2122            col_sep,
2123            outer_pad: 0.0,
2124            jot: 0.0,
2125            row_factor: 1.0,
2126            vrules: Vec::new(),
2127        }
2128    }
2129
2130    /// The separation inserted before column `j` (j ≥ 1). The `align` rule
2131    /// closes up the r,l pair around its alignment point and separates
2132    /// *pairs* by `col_sep`.
2133    fn sep_before(&self, j: usize) -> f64 {
2134        if matches!(self.align, AlignRule::AlignPairs) && j % 2 == 1 {
2135            0.0
2136        } else {
2137            self.col_sep
2138        }
2139    }
2140}
2141
2142/// How a grid aligns cells within their columns.
2143enum AlignRule {
2144    /// Every column centered (the matrix family).
2145    AllCenter,
2146    /// Every column left (`cases`).
2147    AllLeft,
2148    /// Per-column from an `array` spec; columns past the spec center.
2149    Columns(Vec<CellAlign>),
2150    /// `align`-class r,l alternation.
2151    AlignPairs,
2152}
2153
2154impl AlignRule {
2155    /// The slack fraction placed left of a cell in column `j`.
2156    fn factor(&self, j: usize) -> f64 {
2157        match self {
2158            Self::AllCenter => 0.5,
2159            Self::AllLeft => 0.0,
2160            Self::Columns(cols) => match cols.get(j) {
2161                Some(CellAlign::Left) => 0.0,
2162                Some(CellAlign::Right) => 1.0,
2163                Some(CellAlign::Center) | None => 0.5,
2164            },
2165            Self::AlignPairs => {
2166                if j % 2 == 0 {
2167                    1.0 // the left column of a pair sets right, toward the point
2168                } else {
2169                    0.0 // the right column sets left, away from it
2170                }
2171            }
2172        }
2173    }
2174}
2175
2176/// One `array` column's alignment.
2177enum CellAlign {
2178    /// `l`.
2179    Left,
2180    /// `c`.
2181    Center,
2182    /// `r`.
2183    Right,
2184}
2185
2186/// A parsed `array` column spec: alignments plus vertical-rule positions.
2187struct ArrayPlan {
2188    aligns: Vec<CellAlign>,
2189    vrules: Vec<usize>,
2190}
2191
2192/// Parse an `array` column spec. The tier-1 surface covers `l`, `c`, `r`,
2193/// and `|`; anything else is a precise refusal naming the character.
2194fn parse_array_spec(spec: &str, span: Span) -> Result<ArrayPlan, MathError> {
2195    let mut aligns = Vec::new();
2196    let mut vrules = Vec::new();
2197    for ch in spec.chars() {
2198        match ch {
2199            'l' => aligns.push(CellAlign::Left),
2200            'c' => aligns.push(CellAlign::Center),
2201            'r' => aligns.push(CellAlign::Right),
2202            '|' => vrules.push(aligns.len()),
2203            c if c.is_whitespace() => {}
2204            other => {
2205                return Err(MathError::Malformed {
2206                    what: format!(
2207                        "unsupported array column-spec character {other:?} \
2208                         (the tier-1 surface covers l, c, r, |)"
2209                    ),
2210                    at: span.start,
2211                });
2212            }
2213        }
2214    }
2215    Ok(ArrayPlan { aligns, vrules })
2216}
2217
2218/// A zero-height horizontal kern box.
2219fn kern_box(width: f64) -> MBox {
2220    MBox {
2221        kind: BoxKind::Horizontal,
2222        width,
2223        height: 0.0,
2224        depth: 0.0,
2225        children: Vec::new(),
2226    }
2227}
2228
2229/// Concatenate boxes horizontally on a shared baseline.
2230fn hcat(boxes: Vec<MBox>) -> MBox {
2231    let mut x = 0.0_f64;
2232    let mut height = 0.0_f64;
2233    let mut depth = 0.0_f64;
2234    let mut children = Vec::new();
2235    for b in boxes {
2236        height = height.max(b.height);
2237        depth = depth.max(b.depth);
2238        let w = b.width;
2239        children.push(Positioned {
2240            dx: x,
2241            dy: 0.0,
2242            node: MNode::Box(b),
2243        });
2244        x += w;
2245    }
2246    MBox {
2247        kind: BoxKind::Horizontal,
2248        width: x,
2249        height,
2250        depth,
2251        children,
2252    }
2253}
2254
2255impl MNode {
2256    fn box_height(&self) -> f64 {
2257        match self {
2258            Self::Box(b) => b.height,
2259            _ => 0.0,
2260        }
2261    }
2262    fn box_depth(&self) -> f64 {
2263        match self {
2264            Self::Box(b) => b.depth,
2265            _ => 0.0,
2266        }
2267    }
2268}
2269
2270/// A single positioned glyph box.
2271fn glyph_box(face: FaceId, gid: u16, ch: char, span: Span, size: f64, m: GlyphMetrics) -> MBox {
2272    MBox {
2273        kind: BoxKind::Horizontal,
2274        width: m.advance * size,
2275        height: m.height * size,
2276        depth: m.depth * size,
2277        children: vec![Positioned {
2278            dx: 0.0,
2279            dy: 0.0,
2280            node: MNode::Glyph {
2281                face,
2282                gid,
2283                ch,
2284                size,
2285                span,
2286            },
2287        }],
2288    }
2289}