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