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