Skip to main content

rustyfi_lang/
primitives.rs

1//! The primitive registry. Shaped so the ~300 vminst instructions can be
2//! ported one `prims!` line at a time; primitives are registered under their
3//! real v0.0.6 names so later stdlib loading finds them.
4//!
5//! `document`, `+p` and `\emph` are not natives: they live in the
6//! `stdja-mini` stdlib package
7//! (`lib-rustyfi/dist/packages/stdja-mini.satyh`), loaded through
8//! `rustyfi-loader` and typechecked/evaluated like any other `.satyh`
9//! library. See that file's header comment for the primitives it is built
10//! from.
11
12use crate::eval::{available_fields, eval_error, DecoEntry, EvalError, Interp};
13use crate::quoted::{BText, IText, MathElem};
14use crate::value::{BaseEnv, DocumentValue, Env, TextInfo, Value};
15use rustyfi_backend::char_script;
16use rustyfi_backend::{
17    break_into_lines, break_opportunities, chop_page, default_math_variant_char, fit_cell,
18    graphics_bbox, linear_transform_graphics, linear_transform_path, measure_block,
19    natural_metrics, path_bbox, place_block_at, placed_line_extent, shift_graphics, shift_path,
20    Annot, AnnotAction, BreakKind, Cell, Closing, Color, Context, Dash, DecoId, DocExtras, DocInfo,
21    FontKey, GraphicsElem, GraphicsFnId, HookId, HorzBox, HorzStringInfo, HyphenLang, ImageId,
22    ImageResource, InlineMarkKind, Language, Length, ListMarkKind, MathCharClass, MathConstants,
23    MathCorner, MathGlyph, MathKind, MathScriptLevel, NamedDest, OutlineEntry, Paddings, Page,
24    PageGeometry, PaperSize, Path, PathSeg, Point, PrePath, PureHorzBox, Script, ScriptFont,
25    Subpath, TabularBox, VertBox, VertVariantPolicy, FORCED_BREAK_PENALTY, MIN_FIRST_ASCENDER,
26    NO_BREAK_PENALTY,
27};
28// Only the `load-pdf-image` importer builds these, and it is compiled out
29// without the `pdf-image` feature.
30#[cfg(feature = "pdf-image")]
31use rustyfi_backend::{ImportedObjects, ObjRepr, PdfPageResource};
32use rustyfi_syntax::RustyfiVersion;
33use std::collections::BTreeMap;
34#[cfg(feature = "pdf-image")]
35use std::collections::BTreeSet;
36use std::rc::Rc;
37use std::sync::Arc;
38// UAX #15 normalization / UAX #29 grapheme segmentation, for
39// `normalize-string-to-nf{c,d}`/`split-grapheme-cluster`.
40use unicode_normalization::UnicodeNormalization;
41use unicode_segmentation::UnicodeSegmentation;
42
43/// Font keys agreed with this port's base-14 metrics provider.
44const FONT_REGULAR: FontKey = FontKey(0);
45const FONT_BOLD: FontKey = FontKey(1);
46const FONT_OBLIQUE: FontKey = FontKey(2);
47
48/// Which target version(s) a `PrimDef` row is registered under. Mirrors
49/// `RustyfiVersion`'s two-variant shape today; `#[non_exhaustive]` for the
50/// same reason `RustyfiVersion` is (a future third generation gets a new
51/// arm here, not a redesign) — every `match` on this type needs a wildcard.
52#[non_exhaustive]
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum VersionSpan {
55    /// Registered under every version this port implements. The default
56    /// for every `prims!` line that omits a tag.
57    Both,
58    V0_0Only,
59    V0_1Only,
60}
61
62impl VersionSpan {
63    /// Whether a `PrimDef`/type-table row tagged `self` should be visible
64    /// under `version`. `Both` always allows; `V0_0Only`/`V0_1Only` allow
65    /// exactly their own version — no partial/future-version fallback (a
66    /// third generation gets its own new `VersionSpan` arm, not silent
67    /// inclusion under an existing one).
68    pub fn allows(self, version: RustyfiVersion) -> bool {
69        match (self, version) {
70            (VersionSpan::Both, _) => true,
71            (VersionSpan::V0_0Only, RustyfiVersion::V0_0) => true,
72            (VersionSpan::V0_1Only, RustyfiVersion::V0_1) => true,
73            _ => false,
74        }
75    }
76}
77
78pub struct PrimDef {
79    pub name: &'static str,
80    pub arity: usize,
81    pub run: fn(&mut Interp, Vec<Value>) -> Result<Value, EvalError>,
82    pub version: VersionSpan,
83}
84
85impl std::fmt::Debug for PrimDef {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(
88            f,
89            "PrimDef({}/{}, {:?})",
90            self.name, self.arity, self.version
91        )
92    }
93}
94
95macro_rules! prims {
96    ($($($tag:ident)? $name:literal ($arity:literal) => $f:path;)*) => {
97        static PRIM_DEFS: &[PrimDef] = &[
98            $(PrimDef {
99                name: $name,
100                arity: $arity,
101                run: $f,
102                version: prims!(@span $($tag)?),
103            },)*
104        ];
105    };
106    (@span) => { VersionSpan::Both };
107    (@span v006) => { VersionSpan::V0_0Only };
108    (@span v01) => { VersionSpan::V0_1Only };
109}
110
111/// Generate the `_v006`/`_v01` `PrimDef`-shaped pair for a primitive body
112/// that needs to know the generation of the code that CALLED it.
113///
114/// The graphics-callback family (the primitives below, and the deco family
115/// behind them) needs this: those bodies decide whether a callback returns `graphics
116/// list` (0.0.6) or one `graphics` collection (0.1). Do NOT read
117/// `interp.version` for that — it is a single whole-program field naming the
118/// ENTRY document's generation, while a spliced 0.0.6 package calls these
119/// primitives with its OWN convention.
120///
121/// Registering the body twice fixes it at compile time: `compile.rs`'s
122/// `Ast::VersionScope` arm folds a primitive reference against the innermost
123/// enclosing scope's version, so a call inside a 0.0.6 dependency picks the
124/// `_v006` row and one in the 0.1 entry picks `_v01`, with nothing threaded
125/// through the interpreter. The two rows share one type-table entry per
126/// version (`prim_types::primitive_type_with_version`).
127macro_rules! version_forked_prims {
128    ($($v006:ident, $v01:ident => $body:path;)*) => {$(
129        fn $v006(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
130            $body(interp, RustyfiVersion::V0_0, args)
131        }
132        fn $v01(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
133            $body(interp, RustyfiVersion::V0_1, args)
134        }
135    )*};
136}
137
138version_forked_prims! {
139    prim_inline_graphics_v006, prim_inline_graphics_v01 => prim_inline_graphics;
140    prim_inline_graphics_outer_v006, prim_inline_graphics_outer_v01
141        => prim_inline_graphics_outer;
142    prim_tabular_v006, prim_tabular_v01 => prim_tabular;
143    prim_inline_frame_outer_v006, prim_inline_frame_outer_v01 => prim_inline_frame_outer;
144    prim_inline_frame_inner_v006, prim_inline_frame_inner_v01 => prim_inline_frame_inner;
145    prim_inline_frame_breakable_v006, prim_inline_frame_breakable_v01
146        => prim_inline_frame_breakable;
147    prim_block_frame_breakable_v006, prim_block_frame_breakable_v01
148        => prim_block_frame_breakable;
149}
150
151prims! {
152    "read-inline" (2) => prim_read_inline;
153    "read-block" (2) => prim_read_block;
154    // v0.0.6 (vminst.ml `BackendLineBreaking`): `bool -> bool -> context ->
155    // inline-boxes -> block-boxes` — the two leading bools select whether
156    // the paragraph's top/bottom edge is breakable across a page boundary.
157    "line-break" (4) => prim_line_break;
158    // `page -> (pbinfo -> page-content-scheme) -> (pbinfo -> page-parts) ->
159    // block-boxes -> document` (vminst.ml:1024, `BackendPageBreaking`) —
160    // v0.0.6's `page` ADT argument. 0.1's `page` ADT is gone; the v01 arm's
161    // first argument becomes a plain `length * length` instead, same arity
162    // and same `page_break_core` backing loop.
163    v006 "page-break" (4) => prim_page_break_v006;
164    v01  "page-break" (4) => prim_page_break_v01;
165
166    // `page-break-multicolumn` (vminst.ml:1065
167    // `BackendPageBreakingMultiColumn`) / `page-break-two-column`
168    // (vminst.ml:1041 `BackendPageBreakingTwoColumn`): same v006/v01 fork
169    // shape as `page-break` above.
170    v006 "page-break-multicolumn" (7) => prim_page_break_multicolumn_v006;
171    v01  "page-break-multicolumn" (7) => prim_page_break_multicolumn_v01;
172    v006 "page-break-two-column" (6) => prim_page_break_two_column_v006;
173    v01  "page-break-two-column" (6) => prim_page_break_two_column_v01;
174
175    // ---- int arithmetic (vminst.ml: Plus/Minus/Times/Divides/Mod) --------
176    "+" (2) => prim_int_add;
177    "-" (2) => prim_int_sub;
178    "*" (2) => prim_int_mul;
179    "/" (2) => prim_int_div;
180    "mod" (2) => prim_int_mod;
181
182    // ---- int comparisons (vminst.ml: EqualTo/GreaterThan/LessThan; the "<>"/">="/"<=" trio comes from primitives.cppo.ml's `general_table`,
183    // defined there as `LogicalNot (EqualTo ..)` / `LogicalNot (LessThan ..)`
184    // / `LogicalNot (GreaterThan ..)`, typed `int -> int -> bool`) ----------
185    "==" (2) => prim_int_eq;
186    "<>" (2) => prim_int_ne;
187    "<" (2) => prim_int_lt;
188    ">" (2) => prim_int_gt;
189    "<=" (2) => prim_int_le;
190    ">=" (2) => prim_int_ge;
191
192    // ---- 0.1 bitwise ops (dev-0-1-0 vminst.ml: PrimitiveBitShiftLeft :2495, PrimitiveBitShiftRight :2477, PrimitiveBand :2527,
193    // PrimitiveBor :2541, PrimitiveBxor :2513, PrimitiveBnot :2556).
194    // 0.0.6 upstream has none of these; `<<`/`>>` lex as ordinary
195    // BinopLt/BinopGt opsymbol runs under BOTH versions (lexer.rs:634-653)
196    // and simply stay unbound names under 0.0.6. --
197    v01 "<<" (2) => prim_bit_shift_left;
198    v01 ">>" (2) => prim_bit_shift_right;
199    v01 "band" (2) => prim_band;
200    v01 "bor" (2) => prim_bor;
201    v01 "bxor" (2) => prim_bxor;
202    v01 "bnot" (1) => prim_bnot;
203
204    // ---- bool (vminst.ml: LogicalAnd/LogicalOr/LogicalNot) ----------------
205    // NOTE: registered here as strict 2-arg primitives (both arguments are
206    // evaluated before the call, since primitive application is call-by-
207    // value). Real SATySFi short-circuits "&&"/"||" via elaboration
208    // (build-in `if`); that desugaring lives in the (out-of-scope) elaborator.
209    "&&" (2) => prim_bool_and;
210    "||" (2) => prim_bool_or;
211    "not" (1) => prim_bool_not;
212
213    // ---- float (vminst.ml: FloatPlus/FloatMinus/FloatTimes/FloatDivides, PrimitiveFloat, PrimitiveRound) ---------------------------------------
214    "+." (2) => prim_float_add;
215    "-." (2) => prim_float_sub;
216    "*." (2) => prim_float_mul;
217    "/." (2) => prim_float_div;
218    "float" (1) => prim_float_of_int;
219    "round" (1) => prim_round;
220
221    // ---- 0.1 float comparisons (saphe-split@b836d512 vminst.ml:2679-2740:
222    // PrimitiveFloatGreaterThan/-LessThan/-GreaterThanOrEqualTo/
223    // -LessThanOrEqualTo, named ">."/"<."/">=."/"<=."). Confirmed absent
224    // from 0.0.6 upstream (0 hits in either its v0.0.6 tag or dev-0-1-0's
225    // vminst.ml/primitives.cppo.ml) — genuinely v01-only, unlike "+."/"-."/
226    // "*."/"/." above; float.satyg's `abs`/`max`/`min` need `>=.`/`<=.`.
227    // All four lex as ordinary BinopGt/BinopLt opsymbol runs under both
228    // versions (lexer.rs:634-653), same as the bitwise "<<"/">>" above.
229    v01 ">." (2) => prim_float_gt;
230    v01 "<." (2) => prim_float_lt;
231    v01 ">=." (2) => prim_float_ge;
232    v01 "<=." (2) => prim_float_le;
233
234    // ---- length arithmetic (vminst.ml: LengthPlus/LengthMinus/LengthTimes/ LengthDivides/LengthLessThan/LengthGreaterThan) -----------------------
235    "+'" (2) => prim_length_add;
236    "-'" (2) => prim_length_sub;
237    "*'" (2) => prim_length_scale;
238    "/'" (2) => prim_length_div;
239    "<'" (2) => prim_length_lt;
240    ">'" (2) => prim_length_gt;
241
242    // ---- string (vminst.ml: Concat, PrimitiveArabic, PrimitiveSame) -------
243    "^" (2) => prim_string_concat;
244    "arabic" (1) => prim_arabic;
245    "string-same" (2) => prim_string_same;
246
247    // ---- list cons ----------------------------------------------------------
248    // Upstream makes `::` syntax (`UTListCons`/`ListCons`), not a primitive.
249    // This port's elaborator flattens every binary operator into
250    // `Apply(Apply(Var(op_text), lhs), rhs)` (see `elaborate.rs`'s
251    // operator-precedence fold), so `::` needs an env-bound primitive like
252    // `+`/`^`.
253    "::" (2) => prim_list_cons;
254
255    // ---- mutable-cell dereference (evaluator.cppo.ml `Dereference`) --------
256    // Upstream's "!" *constructs* a `Dereference` AST node that a later pass
257    // reduces (primitives.cppo.ml: `lambda1 (fun v1 -> Dereference(v1))`);
258    // this port has no such two-step split, so "!" is an ordinary strict
259    // primitive that dereferences directly — structural deviation only.
260    "!" (1) => prim_deref;
261
262    // ---- string, continued (vminst.ml: PrimitiveStringLength/StringSub/ StringExplode; low-priority additions verified against vminst.ml) ----
263    "string-length" (1) => prim_string_length;
264    "string-sub" (3) => prim_string_sub;
265    "string-explode" (1) => prim_string_explode;
266    "regexp-of-string" (1) => prim_regexp_of_string;
267    "string-match" (2) => prim_string_match;
268    "split-on-regexp" (2) => prim_split_on_regexp;
269
270    // ---- text embedding (vminst.ml:1707 PrimitiveEmbed: string -> inline- text; the interp body wraps the string as a one-element quoted text) --
271    "embed-string" (1) => prim_embed_string;
272
273    // ---- context ops -----------------------------------------------------
274    //
275    // vminst.ml:1434 `PrimitiveSetFontSize`: `~% (tLN @-> tCTX @-> tCTX)`.
276    "set-font-size" (2) => prim_set_font_size;
277    // vminst.ml:1449 `PrimitiveGetFontSize`: `~% (tCTX @-> tLN)`.
278    "get-font-size" (1) => prim_get_font_size;
279    // vminst.ml:1633 `PrimitiveSetLeading`: `~% (tLN @-> tCTX @-> tCTX)`,
280    // sets `ctx.leading` — the baseline-to-baseline distance, which is
281    // exactly our existing `Context::leading` field. (There is *also* a
282    // `set-min-gap-of-lines`, vminst.ml:1291-1292, which sets a *different*
283    // field, `min_gap_of_lines` — the minimum extra gap between two lines'
284    // bounding boxes, on top of `leading`. We don't model that separate
285    // field, so `set-leading` is the one that matches "baseline distance"
286    // and an existing Context field.)
287    "set-leading" (2) => prim_set_leading;
288    // vminst.ml:1396 `PrimitiveSetParagraphMargin`:
289    // `~% (tLN @-> tLN @-> tCTX @-> tCTX)`. Sets the new `paragraph_top`/
290    // `paragraph_bottom` fields (see context.rs); not wired into any
291    // box-producing primitive yet (a future `+p` would consult them).
292    "set-paragraph-margin" (3) => prim_set_paragraph_margin;
293    // vminst.ml:1648 `PrimitiveGetTextWidth`: `~% (tCTX @-> tLN)`.
294    "get-text-width" (1) => prim_get_text_width;
295    // vminst.ml:1247 `PrimitiveGetInitialContext`:
296    // `~% (tLN @-> tICMD tMATH @-> tCTX)` — a paragraph width and the
297    // *default math command* (the handler used for bare `${...}` math
298    // embedded directly in inline text). FAITHFUL: the second argument is
299    // interned via `Interp::register_math_command` and installed as
300    // `Context::math_command`, consulted by `read_inline`'s `EmbedMath` arm.
301    "get-initial-context" (2) => prim_get_initial_context;
302    // LOCAL, non-upstream primitive: `set-font-key : int -> context ->
303    // context`, sets `Context::font` directly to `FontKey(n)`. v0.0.6 has no
304    // primitive shaped like this at all — real font switching there goes
305    // through `set-font : script -> (string * float * float) -> context ->
306    // context` (choosing a font *by name* per script, vminst.ml's
307    // `PrimitiveSetFont`), which is far richer than this port's
308    // base-14-metrics-by-`FontKey` model can support. `set-font-key` is the
309    // minimal faithful-enough stand-in the `stdja-mini` stdlib package
310    // (lib-rustyfi/dist/packages/stdja-mini.satyh) needs to implement
311    // `\emph`/`\bold` by switching to the oblique/bold base-14 face
312    // (`FONT_OBLIQUE`/`FONT_BOLD` above) without inventing a whole font-name
313    // resolution layer. Out-of-range keys are accepted as-is (there is no
314    // registry to validate against yet); an unknown `FontKey` simply fails
315    // later, when a font metrics lookup for it comes up empty.
316    "set-font-key" (2) => prim_set_font_key;
317
318    // ---- box combinators (vminst.ml `HorzConcat`/`VertConcat`/ `BackendVertSkip`/`BackendFixedEmpty`/`BackendOuterEmpty`) ----------
319    //
320    // vminst.ml:803 `HorzConcat`: `~% (tIB @-> tIB @-> tIB)`.
321    "++" (2) => prim_inline_concat;
322    // vminst.ml:818 `VertConcat`: `~% (tBB @-> tBB @-> tBB)`.
323    "+++" (2) => prim_block_concat;
324    // vminst.ml:1757 `BackendFixedEmpty`: `~% (tLN @-> tIB)` — a fixed-width
325    // box with no stretch/shrink (`PureHorzBox::FixedEmpty`, hbox.rs).
326    "inline-skip" (1) => prim_inline_skip;
327    // vminst.ml:1771 `BackendOuterEmpty`: `~% (tLN @-> tLN @-> tLN @-> tIB)`,
328    // params `(widnat, widshrink, widstretch)` in that order — exactly the
329    // (natural, shrinkable, stretchable) field order `PureHorzBox::OuterEmpty`
330    // already uses, so this is a direct wrap, no new box variant needed.
331    "inline-glue" (3) => prim_inline_glue;
332    // vminst.ml:1171 `BackendVertSkip`: `~% (tLN @-> tBB)`, builds
333    // `VertFixedBreakable(len)` — our existing `VertBox::Skip(len)`.
334    "block-skip" (1) => prim_block_skip;
335
336    // ---- the reflow marker-box
337    // constructors. No vminst.ml entry — these are NEW primitives (not an
338    // upstream port), the minimal hook that is unavoidable since
339    // list/emphasis structure is 100% interpreted `.satyh` with no existing
340    // Rust interception point. Both take a plain `int` tag (there is no
341    // surface syntax to pass a Rust enum literal from `.satyh` source) —
342    // see `prim_list_mark`/`prim_inline_mark`'s doc comments for the exact
343    // tag encoding. Registered for `Both` versions (harmless/unused under
344    // 0.0.6 today; the 0.0.6 `itemize.satyh` may be wired to them later). ----
345    "list-mark" (1) => prim_list_mark;
346    "inline-mark" (1) => prim_inline_mark;
347
348    // `|>` (reverse application) is NOT a primitive: it is elaborated
349    // directly to `Apply(f, x)` (see `elaborate.rs`'s `climb`).
350
351    // ---- float trig / log / exp / rounding (vminst.ml 2729-2880) ----------
352    "sin" (1) => prim_sin;
353    "asin" (1) => prim_asin;
354    "cos" (1) => prim_cos;
355    "acos" (1) => prim_acos;
356    "tan" (1) => prim_tan;
357    "atan" (1) => prim_atan;
358    "atan2" (2) => prim_atan2;
359    "log" (1) => prim_log;
360    "exp" (1) => prim_exp;
361    // vminst.ml:2865/2880 `PrimitiveCeil`/`PrimitiveFloor`: both `float ->
362    // float` (NOT `int` — easy to mistype; contrast `round`, above, which
363    // does return `int`).
364    "ceil" (1) => prim_ceil;
365    "floor" (1) => prim_floor;
366    // vminst.ml:2319 `PrimitiveShowFloat`: `float -> string`, OCaml's
367    // `string_of_float`.
368    "show-float" (1) => prim_show_float;
369
370    // ---- byte-indexed string ops (vminst.ml 2056-2196) ---------------------
371    // vminst.ml:2159 `PrimitiveStringByteLength`: counts UTF-8 BYTES, unlike
372    // `string-length`'s Unicode-scalar-value count above.
373    "string-byte-length" (1) => prim_string_byte_length;
374    // vminst.ml:2123 `PrimitiveStringSubBytes`: byte-indexed `string-sub`.
375    "string-sub-bytes" (3) => prim_string_sub_bytes;
376    // vminst.ml:2196 `PrimitiveStringUnexplode`: inverse of `string-explode`.
377    "string-unexplode" (1) => prim_string_unexplode;
378
379    // ---- 0.1 Unicode string prims (dev-0-1-0 vminst.ml :2050/:2066/:2082),
380    // via the `unicode-normalization`/`unicode-segmentation` crates. --------
381    v01 "normalize-string-to-nfc" (1) => prim_normalize_string_to_nfc;
382    v01 "normalize-string-to-nfd" (1) => prim_normalize_string_to_nfd;
383    v01 "split-grapheme-cluster" (1) => prim_split_grapheme_cluster;
384
385    // ---- diagnostics (vminst.ml 2056, 3133) --------------------------------
386    // vminst.ml:2056 `PrimitiveDisplayMessage`: `string -> unit`. Upstream
387    // prints to stdout (`print_endline`); see `prim_display_message`'s doc
388    // comment for why this port deliberately prints to stderr instead.
389    "display-message" (1) => prim_display_message;
390    // vminst.ml:3133 `AbortWithMessage`: `string -> 'a` — raises a dynamic
391    // error carrying the message verbatim.
392    "abort-with-message" (1) => prim_abort_with_message;
393    // ---- images (raster images). Mirrors v0.0.6 vminstdef.yaml:540/:554. -
394    "load-image"          (1) => prim_load_image;         // string -> image
395    "use-image-by-width"  (2) => prim_use_image_by_width; // image -> length -> inline-boxes
396    // `load-pdf-image : string -> int -> image` (v0.0.6 vminstdef.yaml:525;
397    // dev-0-1-0 `PrimitiveLoadPdfImage` — same name/type/body across both
398    // versions).
399    "load-pdf-image" (2) => prim_load_pdf_image;
400    // `read-file : string -> list string` (dev-0-1-0 vminst.ml :3073) —
401    // REAL, `load-image`'s cwd-relative-path precedent
402    // (`prim_load_image`'s doc comment above): job-directory resolution
403    // isn't plumbed into `Interp` at all yet, so this resolves against the
404    // process cwd instead of upstream's job directory — documented
405    // deviation, see `prim_read_file`'s own doc comment.
406    v01 "read-file" (1) => prim_read_file;
407    // `register-document-information : document-information-dictionary ->
408    // unit` (dev-0-1-0 vminst.ml :2978) — REAL:
409    // stores into `Interp::doc_info` (last-write-wins), drained into
410    // `DocExtras::doc_info`, emitted as the PDF `/Info` dictionary by both
411    // writers.
412    v01 "register-document-information" (1) => prim_register_document_information;
413    // ==== graphics primitives ====
414    // Paths, fill/stroke, and the `inline-graphics` on-page sink. Argument
415    // order transcribed from `tools/gencode/vminst.ml`: `start-path` :713,
416    // `line-to` :727, `terminate-path` :759, `close-with-line` :773,
417    // `fill` :2398, `stroke` :2381, `inline-graphics` :1872.
418    "start-path" (1) => prim_start_path;
419    "line-to" (2) => prim_line_to;
420    "terminate-path" (1) => prim_terminate_path;
421    "close-with-line" (1) => prim_close_with_line;
422    "fill" (2) => prim_fill;
423    "stroke" (3) => prim_stroke;
424    // These three take a graphics-producing CALLBACK whose result shape
425    // forks (`graphics list` vs one `graphics` collection) — see
426    // `version_forked_prims!`'s doc comment.
427    v006 "inline-graphics" (4) => prim_inline_graphics_v006;
428    v01  "inline-graphics" (4) => prim_inline_graphics_v01;
429    // `tabular : (cell list) list -> (length list -> length list ->
430    // graphics list) -> inline-boxes` (vminst.ml:539);
431    v006 "tabular" (2) => prim_tabular_v006;
432    v01  "tabular" (2) => prim_tabular_v01;
433    // `inline-graphics-outer : length -> length -> (length -> point ->
434    // graphics list) -> inline-boxes` (vminst.ml:1891
435    // `BackendInlineGraphicsOuter`).
436    v006 "inline-graphics-outer" (3) => prim_inline_graphics_outer_v006;
437    v01  "inline-graphics-outer" (3) => prim_inline_graphics_outer_v01;
438    // ---- gr.satyh prims — see tools/gencode/vminst.ml for exact
439    // signatures: `bezier-to` :742, `close-with-bezier` :787, `shift-path`
440    // :663, `linear-transform-path` :678, `shift-graphics` :2451,
441    // `linear-transform-graphics` :2432, `get-graphics-bbox` :2466,
442    // `get-path-bbox` :696, `dashed-stroke` :2414, `draw-text` :2363.
443    "bezier-to" (4) => prim_bezier_to;
444    "close-with-bezier" (3) => prim_close_with_bezier;
445    "shift-path" (2) => prim_shift_path;
446    "linear-transform-path" (5) => prim_linear_transform_path;
447    "shift-graphics" (2) => prim_shift_graphics;
448    "linear-transform-graphics" (5) => prim_linear_transform_graphics;
449    // `get-graphics-bbox`: v0.0.6 = un-optioned pair (vminst.ml:2466); v0.1
450    // wraps `option` (dev-0-1-0 vminst.ml:2301).
451    v006 "get-graphics-bbox" (1) => prim_get_graphics_bbox_v006;
452    v01  "get-graphics-bbox" (1) => prim_get_graphics_bbox_v01;
453    "get-path-bbox" (1) => prim_get_path_bbox;
454    "dashed-stroke" (4) => prim_dashed_stroke;
455    "draw-text" (2) => prim_draw_text;
456    // ---- 0.1 graphics-collection prims (dev-0-1-0 vminst.ml :3105/:3119).
457    // `graphics` is a collection under 0.1 — these two build/wrap it; the 6
458    // hidden callback-result retypes that make a `graphics`-producing
459    // callback return ONE collection instead of `list graphics` live at
460    // their existing (untagged `Both`) rows below, coerced per-version by
461    // `coerce_graphics_result`.
462    v01 "unite-graphics" (1) => prim_unite_graphics;
463    v01 "clip-graphics-by-path" (2) => prim_clip_graphics_by_path;
464
465    // ==== `pervasives.satyh` prims. Argument order transcribed from
466    // `tools/gencode/vminst.ml`: `get-natural-metrics` :2020,
467    // `inline-frame-outer` :1787, `set-manual-rising` :1661,
468    // `script-guard` :1908, `discretionary` :1969. ====
469    "get-natural-metrics" (1) => prim_get_natural_metrics;
470    // A `deco`'s result shape forks the same way, and its closure fires
471    // LONG after (a post-page-break pass), so the generation must be
472    // captured here — see `version_forked_prims!`/`DecoEntry`.
473    v006 "inline-frame-outer" (3) => prim_inline_frame_outer_v006;
474    v01  "inline-frame-outer" (3) => prim_inline_frame_outer_v01;
475    // vminst.ml:1807 `BackendInnerFrame`: same `tPADS @-> tDECO @-> tIB @->
476    // tIB` as `inline-frame-outer`.
477    v006 "inline-frame-inner" (3) => prim_inline_frame_inner_v006;
478    v01  "inline-frame-inner" (3) => prim_inline_frame_inner_v01;
479    "set-manual-rising" (2) => prim_set_manual_rising;
480    "script-guard" (2) => prim_script_guard;
481    "discretionary" (4) => prim_discretionary;
482
483    // `get-axis-height` (vminst.ml:1739 `PrimitiveGetAxisHeight`) —
484    // STAND-IN, see body; REMOVED in 0.1 (superseded by
485    // `get-math-axis-height-ratio`).
486    v006 "get-axis-height" (1) => prim_get_axis_height;
487
488    // ==== page-break-hook callback seam + cross-reference fixpoint ====
489    "hook-page-break" (1) => prim_hook_page_break;
490    "hook-page-break-block" (1) => prim_hook_page_break_block;
491    "register-cross-reference" (2) => prim_register_cross_reference;
492    "get-cross-reference" (1) => prim_get_cross_reference;
493    "probe-cross-reference" (1) => prim_probe_cross_reference;
494
495    // ==== `annot.satyh`'s prim surface (link annotations + the frame/
496    // script stand-ins it needs to type-check) ====
497    "get-leftmost-script" (1) => prim_get_leftmost_script;
498    "get-rightmost-script" (1) => prim_get_rightmost_script;
499    v006 "inline-frame-breakable" (3) => prim_inline_frame_breakable_v006;
500    v01  "inline-frame-breakable" (3) => prim_inline_frame_breakable_v01;
501    "register-destination" (2) => prim_register_destination;
502    "register-link-to-uri" (6) => prim_register_link_to_uri;
503    "register-link-to-location" (6) => prim_register_link_to_location;
504
505    // ==== the faithful `Value::Math` primitive layer `math.satyh` is built
506    // out of. 19 fork into v006/v01 pairs (v006 = zero behavior change; v01
507    // consumes/produces `Value::MathBoxes`); 5 more are REMOVED in 0.1
508    // outright (v006-tagged, untouched bodies). ====
509    v006 "math-char" (2) => prim_math_char_v006;
510    v01  "math-char" (3) => prim_math_char_v01;
511    v006 "math-big-char" (2) => prim_math_big_char_v006;
512    v01  "math-big-char" (3) => prim_math_big_char_v01;
513    v006 "math-char-with-kern" (4) => prim_math_char_with_kern_v006;
514    v01  "math-char-with-kern" (5) => prim_math_char_with_kern_v01;
515    v006 "math-big-char-with-kern" (4) => prim_math_big_char_with_kern_v006;
516    v01  "math-big-char-with-kern" (5) => prim_math_big_char_with_kern_v01;
517    v006 "math-concat" (2) => prim_math_concat_v006;
518    v01  "math-concat" (2) => prim_math_concat_v01;
519    v006 "math-group" (3) => prim_math_group_v006;
520    v01  "math-group" (3) => prim_math_group_v01;
521    v006 "math-sup" (2) => prim_math_sup_v006;
522    v01  "math-sup" (3) => prim_math_sup_v01;
523    v006 "math-sub" (2) => prim_math_sub_v006;
524    v01  "math-sub" (3) => prim_math_sub_v01;
525    v006 "math-frac" (2) => prim_math_frac_v006;
526    v01  "math-frac" (3) => prim_math_frac_v01;
527    v006 "math-radical" (2) => prim_math_radical_v006;
528    v01  "math-radical" (3) => prim_math_radical_v01;
529    v006 "math-lower" (2) => prim_math_lower_v006;
530    v01  "math-lower" (3) => prim_math_lower_v01;
531    v006 "math-upper" (2) => prim_math_upper_v006;
532    v01  "math-upper" (3) => prim_math_upper_v01;
533    // REMOVED in 0.1 outright — v006-tagged, untouched bodies.
534    v006 "math-pull-in-scripts" (3) => prim_math_pull_in_scripts;
535    v006 "math-color" (2) => prim_math_color;
536    v006 "math-char-class" (2) => prim_math_char_class;
537    v006 "math-variant-char" (2) => prim_math_variant_char;
538    // ==== the `set-math-variant-char`/`get-left-math-class`/
539    // `get-right-math-class` trio: no bundled `.satyh` consumer needed yet,
540    // built on `Context::math_variant_char_map` + `VariantCharPending`.
541    // Forked v006/v01. ====
542    v006 "set-math-variant-char" (4) => prim_set_math_variant_char_v006;
543    v01  "set-math-variant-char" (3) => prim_set_math_variant_char_v01;
544    v006 "get-left-math-class" (2) => prim_get_left_math_class_v006;
545    v01  "get-left-math-class" (1) => prim_get_left_math_class_v01;
546    v006 "get-right-math-class" (2) => prim_get_right_math_class_v006;
547    v01  "get-right-math-class" (1) => prim_get_right_math_class_v01;
548    v006 "math-paren" (3) => prim_math_paren_v006;
549    v01  "math-paren" (4) => prim_math_paren_v01;
550    v006 "math-paren-with-middle" (4) => prim_math_paren_with_middle_v006;
551    v01  "math-paren-with-middle" (5) => prim_math_paren_with_middle_v01;
552    // REMOVED in 0.1 outright.
553    v006 "text-in-math" (2) => prim_text_in_math;
554    "convert-string-for-math" (3) => prim_convert_string_for_math;
555    v006 "embed-math" (2) => prim_embed_math_v006;
556    v01  "embed-math" (2) => prim_embed_math_v01;
557    "set-math-command" (2) => prim_set_math_command;
558    // `set-math-font` forks in its argument, not its effect: 0.0.6 takes the
559    // math face's ABBREV (`string`), saphe-split takes the opaque `font`
560    // handle (`tFONTKEY`). Both end at the same `Context::math_font`.
561    v006 "set-math-font" (2) => prim_set_math_font_v006;
562    v01  "set-math-font" (2) => prim_set_math_font_v01;
563    // LOCAL, non-upstream, V0_1-only — the port's spelling for upstream's
564    // internal `LoadSingleFont{path}` node; see `prim_load_single_font`.
565    v01  "load-single-font" (1) => prim_load_single_font;
566    v006 "space-between-maths" (3) => prim_space_between_maths_v006;
567    v01  "space-between-maths" (3) => prim_space_between_maths_v01;
568    // ==== NEW in 0.1 — `math-text`/`math-boxes` split + `read-math` + the
569    // hidden `val math`-without-scripts wrapper prim. ====
570    v01 "read-math" (2) => prim_read_math;
571    v01 "stringify-math" (2) => prim_stringify_math;
572    v01 "set-math-char" (4) => prim_set_math_char;
573    v01 "set-math-char-class" (2) => prim_set_math_char_class;
574    v01 "get-math-char-class" (1) => prim_get_math_char_class;
575    v01 "embed-inline-to-math" (2) => prim_embed_inline_to_math;
576    v01 "get-math-axis-height-ratio" (1) => prim_get_math_axis_height_ratio;
577    v01 "%math-attach-scripts" (4) => prim_math_attach_scripts;
578
579    // ==== hyphenation/unidata loader + setter stand-ins, V0_1-only
580    // (genuinely absent from 0.0.6 upstream). FAITHFUL types
581    // (`prim_types.rs`); ACCEPT-AND-RETURN bodies, not hard-error
582    // stand-ins like `stringify-math` above — std-ja evaluates `val
583    // unidata = load-unicode-char-database …` at module LOAD time, so an
584    // erroring stand-in would break every consumer at load, not just at
585    // use. ====
586    v01 "load-hyphenation-dictionary" (1) => prim_load_hyphenation_dictionary;
587    v01 "load-unicode-char-database"  (3) => prim_load_unicode_char_database;
588    v01 "set-hyphenation-dictionary"  (2) => prim_set_hyphenation_dictionary;
589    v01 "set-unicode-char-database"   (2) => prim_set_unicode_char_database;
590
591    "raise-inline" (2) => prim_raise_inline;
592    "embed-block-breakable" (2) => prim_embed_block_breakable;
593    "unite-path" (2) => prim_unite_path;
594    "set-min-gap-of-lines" (2) => prim_set_min_gap_of_lines;
595
596    // ==== context-setter + box-combinator prims `code.satyh`/
597    // `itemize.satyh` need. Argument order from `tools/gencode/vminst.ml`:
598    // `set-text-color` :1603, `get-text-color` :1618, `set-hyphen-penalty`
599    // :1692, `set-space-ratio` :1309, `split-into-lines` :2269,
600    // `block-frame-breakable` :1090, `embed-block-top` :1145, `set-font`
601    // :1463; `set-code-text-command`/`get-natural-length` have no
602    // vminst.ml entry. ====
603    "set-text-color" (2) => prim_set_text_color;
604    "get-text-color" (1) => prim_get_text_color;
605    "set-hyphen-penalty" (2) => prim_set_hyphen_penalty;
606    // `set-hyphen-min : int -> int -> context -> context` (left_hyphen_min,
607    // right_hyphen_min).
608    "set-hyphen-min" (3) => prim_set_hyphen_min;
609    "set-space-ratio" (4) => prim_set_space_ratio;
610    "set-space-ratio-between-scripts" (6) => prim_set_space_ratio_between_scripts;
611    "split-into-lines" (1) => prim_split_into_lines;
612    v006 "block-frame-breakable" (4) => prim_block_frame_breakable_v006;
613    v01  "block-frame-breakable" (4) => prim_block_frame_breakable_v01;
614    "embed-block-top" (3) => prim_embed_block_top;
615    // `set-font` forks in its SECOND argument's head only: 0.0.6's
616    // `string * float * float` vs saphe-split's `font * float * float`.
617    v006 "set-font" (3) => prim_set_font_v006;
618    v01  "set-font" (3) => prim_set_font_v01;
619    // `get-font` (vminstdef.yaml:1350) forks in its RESULT's head, for the
620    // same reason and along the same seam.
621    v006 "get-font" (2) => prim_get_font_v006;
622    v01  "get-font" (2) => prim_get_font_v01;
623    "set-code-text-command" (2) => prim_set_code_text_command;
624    "get-natural-length" (1) => prim_get_natural_length;
625
626    // ==== `set-dominant-wide-script`/`set-dominant-narrow-script`/
627    // `set-language` are FAITHFUL stores with real getter round-trips
628    // below; `register-outline` is likewise FAITHFUL (drives real PDF
629    // `/Outlines` bookmarks). Only `set-every-word-break` remains a
630    // STAND-IN (accepted and dropped). ====
631    "set-dominant-wide-script" (2) => prim_set_dominant_wide_script;
632    "set-dominant-narrow-script" (2) => prim_set_dominant_narrow_script;
633    "set-language" (3) => prim_set_language;
634    "get-dominant-wide-script" (1) => prim_get_dominant_wide_script;
635    "get-dominant-narrow-script" (1) => prim_get_dominant_narrow_script;
636    "get-language" (2) => prim_get_language;
637    "set-every-word-break" (3) => prim_set_every_word_break;
638    "register-outline" (1) => prim_register_outline;
639    "extract-string" (1) => prim_extract_string;
640
641    // ==== proof.satyh/footnote-scheme.satyh prims: `embed-block-bottom`
642    // :1185, `line-stack-bottom` :1229 (both `tools/gencode/vminst.ml`),
643    // `add-footnote` :1130. ====
644    "embed-block-bottom" (3) => prim_embed_block_bottom;
645    "line-stack-bottom" (1) => prim_line_stack_bottom;
646    "line-stack-top" (1) => prim_line_stack_top;
647    "add-footnote" (1) => prim_add_footnote;
648
649    // ==== three PURE text-info prims — `get-initial-text-info` :953,
650    // `deepen-indent` :921, `break` :935 (tools/gencode/vminst.ml,
651    // text-mode). The text/html backends are OUT of scope for this PDF
652    // port, so all three live in the single shared env (upstream keys
653    // prims per mode).
654    //
655    // `get-initial-text-info` forks: v0.0.6 (vminst.ml:953) is `unit ->
656    // text-info`; v0.1 (dev-0-1-0 vminst.ml:904-925) threads a text-mode
657    // default math command + math-scripts stringifier into `tctxsub`. The
658    // v01 body ACCEPTS AND DROPS both (STAND-IN, same degenerate policy as
659    // `stringify-math`) — both bodies return `TextInfo{indent: 0}`. ====
660    v006 "get-initial-text-info" (1) => prim_get_initial_text_info_v006;
661    v01  "get-initial-text-info" (2) => prim_get_initial_text_info_v01;
662    "deepen-indent" (2) => prim_deepen_indent;
663    "break" (1) => prim_break;
664}
665
666/// The base environment v0.0.6 `document` programs start in. Back-compat
667/// wrapper over `base_env_with_version(V0_0)`.
668pub fn base_env() -> BaseEnv {
669    base_env_with_version(RustyfiVersion::V0_0)
670}
671
672/// The base environment for a given target version — filters `PRIM_DEFS` by
673/// `VersionSpan::allows`, so e.g. a `V0_1` env binds `prim_page_break_v01`
674/// under the name `"page-break"`, never `prim_page_break_v006`. The five
675/// bare-constant `env.define`s below (`inline-fil`/`inline-nil`/`block-nil`/
676/// `omit-skip-after`/`clear-page`) live outside `PrimDef`/`VersionSpan` and
677/// stay unconditional — all five exist in 0.1 upstream too (audited against
678/// `dev-0-1-0:src/frontend/primitives.cppo.ml`); `tests/v01_prims_scalar.rs`'s
679/// `bare_constants_bound_under_v01` proves it.
680pub fn base_env_with_version(version: RustyfiVersion) -> BaseEnv {
681    let mut env = BaseEnv::new();
682    for def in PRIM_DEFS {
683        if !def.version.allows(version) {
684            continue;
685        }
686        env.define(
687            def.name,
688            Value::Prim {
689                def,
690                applied: Vec::new(),
691            },
692        );
693    }
694    env.define(
695        "inline-fil",
696        Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::OuterFil)]),
697    );
698    // `inline-nil`/`block-nil`: no vminst.ml entry — v0.0.6 gets the empty
699    // list for free from literal `{}`/`<>` syntax, which this port's syntax
700    // layer doesn't produce standalone; these constants are the equivalent
701    // value bound to a name.
702    env.define("inline-nil", Value::InlineBoxes(Vec::new()));
703    env.define("block-nil", Value::BlockBoxes(Vec::new()));
704    // `omit-skip-after : inline-boxes` (`primitives.cppo.ml:567`) — a bare
705    // CONSTANT marking `HorzOmitSkipAfter`, a line-breaking hint to drop the
706    // interword glue that would otherwise follow (used at the tail of
707    // `math.satyh`'s `\eqn`/`\math-list`/`\align`). STAND-IN: this port's
708    // line-breaker has no such marker box, so it's the empty `inline-boxes`
709    // list — never consulted, since none of those wrappers is called by
710    // the file itself.
711    env.define("omit-skip-after", Value::InlineBoxes(Vec::new()));
712    // `clear-page : block-boxes` (`primitives.cppo.ml:569`) — a single-
713    // element list carrying `VertBox::ClearPage`, which `chop_page`
714    // (rustyfi-backend) treats as "end this page here". FAITHFUL.
715    env.define("clear-page", Value::BlockBoxes(vec![VertBox::ClearPage]));
716    // `here : string` — upstream `here` is a LEXER keyword expanding at lex
717    // time to the source file's directory (`Filename.dirname`). This port
718    // has no such lexer entry (`here` lexes as a plain `Token::Var`), so
719    // it's a V0_1-only nullary CONSTANT bound to the empty string. Never
720    // dereferenced as a real path: its consumers (`unidata.satyh`/
721    // `hyph-english.satyh`) feed `here ^ …` into the `load-*` stand-ins
722    // above, which drop the path unread.
723    if version == RustyfiVersion::V0_1 {
724        env.define("here", Value::Str(String::new()));
725    }
726    env
727}
728
729// ---- argument extractors ------------------------------------------------------
730
731fn as_context(v: Value) -> Result<Context, EvalError> {
732    match v {
733        Value::Context(c) => Ok(*c),
734        other => eval_error(format!("expected a context, got {}", other.type_name())),
735    }
736}
737
738fn as_text_info(v: Value) -> Result<TextInfo, EvalError> {
739    match v {
740        Value::TextInfo(t) => Ok(t),
741        other => eval_error(format!("expected a text-info, got {}", other.type_name())),
742    }
743}
744
745fn as_hyphenation(v: Value) -> Result<HyphenLang, EvalError> {
746    match v {
747        Value::Hyphenation(tag) => Ok(tag),
748        other => eval_error(format!("expected a hyphenation, got {}", other.type_name())),
749    }
750}
751
752fn as_inline_text(v: Value) -> Result<(Rc<Vec<IText>>, Env), EvalError> {
753    match v {
754        Value::InlineText { elems, env } => Ok((elems, env)),
755        other => eval_error(format!("expected inline-text, got {}", other.type_name())),
756    }
757}
758
759fn as_block_text(v: Value) -> Result<(Rc<Vec<BText>>, Env), EvalError> {
760    match v {
761        Value::BlockText { elems, env } => Ok((elems, env)),
762        other => eval_error(format!("expected block-text, got {}", other.type_name())),
763    }
764}
765
766fn as_inline_boxes(v: Value) -> Result<Vec<HorzBox>, EvalError> {
767    match v {
768        Value::InlineBoxes(b) => Ok(b),
769        other => eval_error(format!("expected inline-boxes, got {}", other.type_name())),
770    }
771}
772
773fn as_block_boxes(v: Value) -> Result<Vec<VertBox>, EvalError> {
774    match v {
775        Value::BlockBoxes(b) => Ok(b),
776        other => eval_error(format!("expected block-boxes, got {}", other.type_name())),
777    }
778}
779
780fn as_int(v: Value) -> Result<i64, EvalError> {
781    match v {
782        Value::Int(n) => Ok(n),
783        other => eval_error(format!("expected int, got {}", other.type_name())),
784    }
785}
786
787fn as_float(v: Value) -> Result<f64, EvalError> {
788    match v {
789        Value::Float(x) => Ok(x),
790        other => eval_error(format!("expected float, got {}", other.type_name())),
791    }
792}
793
794fn as_bool(v: Value) -> Result<bool, EvalError> {
795    match v {
796        Value::Bool(b) => Ok(b),
797        other => eval_error(format!("expected bool, got {}", other.type_name())),
798    }
799}
800
801fn as_str(v: Value) -> Result<String, EvalError> {
802    match v {
803        Value::Str(s) => Ok(s),
804        other => eval_error(format!("expected string, got {}", other.type_name())),
805    }
806}
807
808// `regexp-of-string : string -> regexp` — the port models a `regexp` as its
809// underlying pattern string, so this is the identity on the string.
810fn prim_regexp_of_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
811    let s = as_str(args.pop().unwrap())?;
812    Ok(Value::Str(s))
813}
814
815// `string-match : regexp -> string -> bool` — whether `input` matches the
816// pattern in full (anchored). Only the character-class subset `satysfi-base`'s
817// `char.satyg` uses (`[…]`, with `a-z` ranges and an optional leading `^`
818// negation) is modeled; any other pattern is compared literally.
819fn prim_string_match(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
820    let input = as_str(args.pop().unwrap())?;
821    let pattern = as_str(args.pop().unwrap())?;
822    Ok(Value::Bool(regexp_full_match(&pattern, &input)))
823}
824
825fn regexp_full_match(pattern: &str, input: &str) -> bool {
826    let p: Vec<char> = pattern.chars().collect();
827    if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
828        // A character class matches exactly one character.
829        let mut chars = input.chars();
830        match (chars.next(), chars.next()) {
831            (Some(c), None) => char_in_class(&p[1..p.len() - 1], c),
832            _ => false,
833        }
834    } else {
835        input == pattern
836    }
837}
838
839// `split-on-regexp : regexp -> string -> (int * string) list` — split `input`
840// at every character matching the (single-character) pattern, pairing each
841// resulting segment with its starting code-point offset. Handles the pattern
842// forms base uses: a `[…]` class, an escaped literal (`\.`), or a bare
843// literal character; anything else never matches (one segment = the whole
844// string).
845fn prim_split_on_regexp(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
846    let input = as_str(args.pop().unwrap())?;
847    let pattern = as_str(args.pop().unwrap())?;
848    let is_delim = single_char_matcher(&pattern);
849    let mut segments: Vec<Value> = Vec::new();
850    let mut seg_start = 0usize;
851    let mut cur = String::new();
852    for (idx, c) in input.chars().enumerate() {
853        if is_delim(c) {
854            segments.push(Value::Tuple(vec![
855                Value::Int(seg_start as i64),
856                Value::Str(std::mem::take(&mut cur)),
857            ]));
858            seg_start = idx + 1;
859        } else {
860            cur.push(c);
861        }
862    }
863    segments.push(Value::Tuple(vec![
864        Value::Int(seg_start as i64),
865        Value::Str(cur),
866    ]));
867    Ok(Value::List(segments))
868}
869
870/// A predicate matching one character against a `regexp` pattern's single-char
871/// forms (a `[…]` class, an escaped literal `\X`, or a bare literal char).
872fn single_char_matcher(pattern: &str) -> Box<dyn Fn(char) -> bool> {
873    let p: Vec<char> = pattern.chars().collect();
874    if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
875        let cls: Vec<char> = p[1..p.len() - 1].to_vec();
876        Box::new(move |c| char_in_class(&cls, c))
877    } else if p.len() == 2 && p[0] == '\\' {
878        let lit = p[1];
879        Box::new(move |c| c == lit)
880    } else if p.len() == 1 {
881        let lit = p[0];
882        Box::new(move |c| c == lit)
883    } else {
884        Box::new(|_| false)
885    }
886}
887
888fn char_in_class(cls: &[char], c: char) -> bool {
889    let (neg, cls) = match cls.first() {
890        Some('^') => (true, &cls[1..]),
891        _ => (false, cls),
892    };
893    let mut i = 0;
894    let mut found = false;
895    while i < cls.len() {
896        if i + 2 < cls.len() && cls[i + 1] == '-' {
897            if cls[i] <= c && c <= cls[i + 2] {
898                found = true;
899            }
900            i += 3;
901        } else {
902            if cls[i] == c {
903                found = true;
904            }
905            i += 1;
906        }
907    }
908    found ^ neg
909}
910
911fn as_length(v: Value) -> Result<Length, EvalError> {
912    match v {
913        Value::Length(l) => Ok(l),
914        other => eval_error(format!("expected length, got {}", other.type_name())),
915    }
916}
917
918fn as_list(v: Value) -> Result<Vec<Value>, EvalError> {
919    match v {
920        Value::List(items) => Ok(items),
921        other => eval_error(format!("expected list, got {}", other.type_name())),
922    }
923}
924
925fn as_image(v: Value) -> Result<ImageId, EvalError> {
926    match v {
927        Value::Image(id) => Ok(id),
928        other => eval_error(format!("expected image, got {}", other.type_name())),
929    }
930}
931
932// ---- graphics argument extractors ------------------------------------------
933
934/// `point` = `Value::Tuple([Length, Length])` (mirrors `evalUtil.ml:228`'s
935/// point extraction).
936fn as_point(v: Value) -> Result<Point, EvalError> {
937    match v {
938        Value::Tuple(vs) if vs.len() == 2 => {
939            let mut it = vs.into_iter();
940            let x = as_length(it.next().unwrap())?;
941            let y = as_length(it.next().unwrap())?;
942            Ok((x, y))
943        }
944        other => eval_error(format!(
945            "expected a point (length * length), got {}",
946            other.type_name()
947        )),
948    }
949}
950
951/// `color` = `Value::Ctor("Gray"|"RGB"|"CMYK", ..)` (mirrors
952/// `evalUtil.ml:124`'s `get_color` exactly — a wrong shape here would
953/// surface only at draw time).
954fn as_color(v: Value) -> Result<Color, EvalError> {
955    match v {
956        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
957            ("Gray", Some(p)) => Ok(Color::Gray(as_float(p)?)),
958            ("RGB", Some(Value::Tuple(vs))) if vs.len() == 3 => {
959                let mut it = vs.into_iter();
960                let r = as_float(it.next().unwrap())?;
961                let g = as_float(it.next().unwrap())?;
962                let b = as_float(it.next().unwrap())?;
963                Ok(Color::Rgb(r, g, b))
964            }
965            ("CMYK", Some(Value::Tuple(vs))) if vs.len() == 4 => {
966                let mut it = vs.into_iter();
967                let c = as_float(it.next().unwrap())?;
968                let m = as_float(it.next().unwrap())?;
969                let y = as_float(it.next().unwrap())?;
970                let k = as_float(it.next().unwrap())?;
971                Ok(Color::Cmyk(c, m, y, k))
972            }
973            (other, _) => eval_error(format!(
974                "expected a color (Gray/RGB/CMYK), got variant '{other}'"
975            )),
976        },
977        other => eval_error(format!("expected a color, got {}", other.type_name())),
978    }
979}
980
981/// `script` = nullary `Value::Ctor` (prim_types.rs `script_decl`); mirrors
982/// upstream `get_script` (evalUtil.ml:235-241).
983fn as_script(v: Value) -> Result<Script, EvalError> {
984    match v {
985        Value::Ctor(name, None) => match name.as_str() {
986            "HanIdeographic" => Ok(Script::HanIdeographic),
987            "Kana" => Ok(Script::Kana),
988            "Latin" => Ok(Script::Latin),
989            "OtherScript" => Ok(Script::OtherScript),
990            other => eval_error(format!("expected a script, got variant '{other}'")),
991        },
992        other => eval_error(format!("expected a script, got {}", other.type_name())),
993    }
994}
995
996/// Inverse of [`as_script`] (upstream `make_script_value`, evalUtil.ml:244).
997fn make_script_value(s: Script) -> Value {
998    let name = match s {
999        Script::HanIdeographic => "HanIdeographic",
1000        Script::Kana => "Kana",
1001        Script::Latin => "Latin",
1002        Script::OtherScript => "OtherScript",
1003    };
1004    Value::Ctor(name.to_string(), None)
1005}
1006
1007/// `language` = nullary `Value::Ctor` (prim_types.rs `language_decl`);
1008/// mirrors upstream `get_language_system` (evalUtil.ml:262).
1009fn as_language(v: Value) -> Result<Language, EvalError> {
1010    match v {
1011        Value::Ctor(name, None) => match name.as_str() {
1012            "Japanese" => Ok(Language::Japanese),
1013            "English" => Ok(Language::English),
1014            "NoLanguageSystem" => Ok(Language::NoLanguageSystem),
1015            other => eval_error(format!("expected a language, got variant '{other}'")),
1016        },
1017        other => eval_error(format!("expected a language, got {}", other.type_name())),
1018    }
1019}
1020
1021/// Inverse of [`as_language`] (upstream `make_language_system_value`).
1022fn make_language_value(l: Language) -> Value {
1023    let name = match l {
1024        Language::Japanese => "Japanese",
1025        Language::English => "English",
1026        Language::NoLanguageSystem => "NoLanguageSystem",
1027    };
1028    Value::Ctor(name.to_string(), None)
1029}
1030
1031/// `page` = `Value::Ctor("A4Paper"|.., None | Some(Tuple[Length;2]))`
1032/// — `page-break`'s first argument, mapped to the backend's
1033/// `PaperSize`.
1034fn as_page(v: Value) -> Result<PaperSize, EvalError> {
1035    match v {
1036        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1037            ("A0Paper", None) => Ok(PaperSize::A0),
1038            ("A1Paper", None) => Ok(PaperSize::A1),
1039            ("A2Paper", None) => Ok(PaperSize::A2),
1040            ("A3Paper", None) => Ok(PaperSize::A3),
1041            ("A4Paper", None) => Ok(PaperSize::A4),
1042            ("A5Paper", None) => Ok(PaperSize::A5),
1043            ("USLetter", None) => Ok(PaperSize::USLetter),
1044            ("USLegal", None) => Ok(PaperSize::USLegal),
1045            ("UserDefinedPaper", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1046                let mut it = vs.into_iter();
1047                let w = as_length(it.next().unwrap())?;
1048                let h = as_length(it.next().unwrap())?;
1049                Ok(PaperSize::UserDefined(w, h))
1050            }
1051            (other, _) => eval_error(format!(
1052                "expected a page (A4Paper/.../UserDefinedPaper), got variant '{other}'"
1053            )),
1054        },
1055        other => eval_error(format!("expected a page, got {}", other.type_name())),
1056    }
1057}
1058
1059/// v0.1's `page-break`'s first argument: a plain `(length * length)` tuple
1060/// — the `page` ADT (`as_page` above) no longer exists upstream in 0.1.
1061/// Maps straight into `PaperSize::UserDefined`, the exact same backend
1062/// value `as_page`'s own `UserDefinedPaper` arm produces: the retype drops
1063/// the ADT wrapper without changing what geometry `page-break` can
1064/// express, so only the source `Value` shape differs.
1065fn as_page_v01(v: Value) -> Result<PaperSize, EvalError> {
1066    match v {
1067        Value::Tuple(vs) if vs.len() == 2 => {
1068            let mut it = vs.into_iter();
1069            let w = as_length(it.next().unwrap())?;
1070            let h = as_length(it.next().unwrap())?;
1071            Ok(PaperSize::UserDefined(w, h))
1072        }
1073        other => eval_error(format!(
1074            "expected a page as (length * length), got {}",
1075            other.type_name()
1076        )),
1077    }
1078}
1079
1080/// `paddings` = `Value::Tuple([Length; 4])` in `(paddingL, paddingR,
1081/// paddingT, paddingB)` order (mirrors `evalUtil.ml`'s `get_paddings`).
1082/// `inline-frame-outer`'s first argument.
1083fn as_paddings(v: Value) -> Result<(Length, Length, Length, Length), EvalError> {
1084    match v {
1085        Value::Tuple(vs) if vs.len() == 4 => {
1086            let mut it = vs.into_iter();
1087            let l = as_length(it.next().unwrap())?;
1088            let r = as_length(it.next().unwrap())?;
1089            let t = as_length(it.next().unwrap())?;
1090            let b = as_length(it.next().unwrap())?;
1091            Ok((l, r, t, b))
1092        }
1093        other => eval_error(format!(
1094            "expected paddings (length * length * length * length), got {}",
1095            other.type_name()
1096        )),
1097    }
1098}
1099
1100/// `cell` = `Value::Ctor("NormalCell"|"EmptyCell"|"MultiCell", ..)` (mirrors
1101/// `evalUtil.ml:102`'s `get_cell`) — `tabular`'s grid entries;
1102fn as_cell(v: Value) -> Result<Cell, EvalError> {
1103    match v {
1104        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1105            ("NormalCell", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1106                let mut it = vs.into_iter();
1107                let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1108                let ib = as_inline_boxes(it.next().unwrap())?;
1109                Ok(Cell::Normal(Paddings { l, r, t, b }, ib))
1110            }
1111            ("EmptyCell", None) => Ok(Cell::Empty),
1112            ("MultiCell", Some(Value::Tuple(vs))) if vs.len() == 4 => {
1113                let mut it = vs.into_iter();
1114                let numrow = as_int(it.next().unwrap())?;
1115                let numcol = as_int(it.next().unwrap())?;
1116                let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1117                let ib = as_inline_boxes(it.next().unwrap())?;
1118                Ok(Cell::Multi(
1119                    numrow.max(0) as usize,
1120                    numcol.max(0) as usize,
1121                    Paddings { l, r, t, b },
1122                    ib,
1123                ))
1124            }
1125            (other, _) => eval_error(format!(
1126                "expected a cell (NormalCell/EmptyCell/MultiCell), got variant '{other}'"
1127            )),
1128        },
1129        other => eval_error(format!("expected a cell, got {}", other.type_name())),
1130    }
1131}
1132
1133/// `(cell list) list` — `tabular`'s first argument.
1134fn as_cell_grid(v: Value) -> Result<Vec<Vec<Cell>>, EvalError> {
1135    as_list(v)?
1136        .into_iter()
1137        .map(|row| -> Result<Vec<Cell>, EvalError> {
1138            as_list(row)?.into_iter().map(as_cell).collect()
1139        })
1140        .collect()
1141}
1142
1143fn as_prepath(v: Value) -> Result<PrePath, EvalError> {
1144    match v {
1145        Value::PrePath(p) => Ok(p),
1146        other => eval_error(format!("expected pre-path, got {}", other.type_name())),
1147    }
1148}
1149
1150fn as_path(v: Value) -> Result<Path, EvalError> {
1151    match v {
1152        Value::Path(p) => Ok(p),
1153        other => eval_error(format!("expected path, got {}", other.type_name())),
1154    }
1155}
1156
1157fn as_graphics(v: Value) -> Result<GraphicsElem, EvalError> {
1158    match v {
1159        Value::Graphics(g) => Ok(g),
1160        other => eval_error(format!("expected graphics, got {}", other.type_name())),
1161    }
1162}
1163
1164/// `dash` = `length * length * length` (mirrors `evalUtil.ml`'s `get_tuple3
1165/// get_length`) — `dashed-stroke`'s 2nd argument, `(d1, d2, d0)` = on-length,
1166/// off-length, phase.
1167fn as_dash(v: Value) -> Result<Dash, EvalError> {
1168    match v {
1169        Value::Tuple(vs) if vs.len() == 3 => {
1170            let mut it = vs.into_iter();
1171            let d1 = as_length(it.next().unwrap())?;
1172            let d2 = as_length(it.next().unwrap())?;
1173            let d0 = as_length(it.next().unwrap())?;
1174            Ok((d1, d2, d0))
1175        }
1176        other => eval_error(format!(
1177            "expected a dash pattern (length * length * length), got {}",
1178            other.type_name()
1179        )),
1180    }
1181}
1182
1183/// The inverse of `as_point` (mirrors `evalUtil.ml:228`'s point
1184/// construction) — used by `inline-graphics` to build the `(0pt, 0pt)`
1185/// origin its callback is (eagerly) invoked with; see that primitive's doc
1186/// comment for the shift-covariance caveat this stands in for.
1187fn make_point_value(pt: Point) -> Value {
1188    Value::Tuple(vec![Value::Length(pt.0), Value::Length(pt.1)])
1189}
1190
1191/// `length list` construction (mirrors `evalUtil.ml:709`) — builds the
1192/// box-local grid-line coordinates `tabular`'s rule callback is (eagerly)
1193/// invoked with; see `prim_tabular`'s doc comment.
1194fn make_length_list(lens: &[Length]) -> Value {
1195    Value::List(lens.iter().map(|l| Value::Length(*l)).collect())
1196}
1197
1198// ---- primitive-body macros ----------------------------------------------------
1199//
1200// The arithmetic, comparison, boolean, and unary-conversion primitives all
1201// share one strict-call shape: the (already-evaluated) operands are popped
1202// right-to-left through a type extractor, then the result is re-wrapped as a
1203// `Value`. These macros capture that shape so each primitive is a single line.
1204// The vminst.ml citations for each stay on the `prims!` registration table
1205// above; per-primitive notes ride along on the invocations below.
1206
1207/// A strict binary primitive. Pops `b` then `a` (i.e. rightmost argument
1208/// first, matching application order) through the given extractor(s) and wraps
1209/// `body` as `Value::$ctor`. Accepts either one extractor for both operands or
1210/// a `(as_a, as_b)` pair when the operands have different types.
1211macro_rules! binop_prim {
1212    ($name:ident, ($as_a:path, $as_b:path), $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1213        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1214            let $b = $as_b(args.pop().unwrap())?;
1215            let $a = $as_a(args.pop().unwrap())?;
1216            Ok(Value::$ctor($body))
1217        }
1218    };
1219    ($name:ident, $as:path, $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1220        binop_prim!($name, ($as, $as), $ctor, |$a, $b| $body);
1221    };
1222}
1223
1224/// A strict binary comparison: like `binop_prim!` but always wraps as
1225/// `Value::Bool`.
1226macro_rules! cmp_prim {
1227    ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1228        binop_prim!($name, ($as, $as), Bool, |$a, $b| $body);
1229    };
1230}
1231
1232/// A strict unary primitive: pops one operand through `as` and wraps `body`
1233/// as `Value::$ctor`.
1234macro_rules! unop_prim {
1235    ($name:ident, $as:path, $ctor:ident, |$a:ident| $body:expr) => {
1236        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1237            let $a = $as(args.pop().unwrap())?;
1238            Ok(Value::$ctor($body))
1239        }
1240    };
1241}
1242
1243/// A strict binary primitive with a fallible body: `body` is the function's
1244/// tail expression and must itself yield `Result<Value, EvalError>`, so it can
1245/// guard cases like division by zero.
1246macro_rules! binop_prim_try {
1247    ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1248        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1249            let $b = $as(args.pop().unwrap())?;
1250            let $a = $as(args.pop().unwrap())?;
1251            $body
1252        }
1253    };
1254}
1255
1256// ---- text conversion ----------------------------------------------------------
1257
1258/// Convert quoted inline text to boxes under `ctx` (the core of
1259/// `read-inline`): words become measured `InnerString`s, whitespace becomes
1260/// glue, embedded commands are applied to `ctx` and their arguments.
1261pub fn read_inline(
1262    interp: &mut Interp,
1263    ctx: &Context,
1264    elems: &[IText],
1265    env: &Env,
1266) -> Result<Vec<HorzBox>, EvalError> {
1267    let mut out = Vec::new();
1268    for elem in elems {
1269        match elem {
1270            IText::Text(text) => text_to_boxes(interp, ctx, text, &mut out)?,
1271            // `ImInputHorzEmbeddedCodeText` (`evaluator.cppo.ml:768-779`): hand
1272            // the literal to the context's code-text command if one is
1273            // installed, else set it as ordinary text
1274            // (`DefaultCodeTextCommand`).
1275            IText::CodeText(text) => match ctx.code_text_command {
1276                Some(id) => {
1277                    let cmd = interp.math_commands[id.0].clone();
1278                    let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1279                    let v = interp.apply(v, Value::Str(text.clone()))?;
1280                    out.extend(as_inline_boxes(v)?);
1281                }
1282                None => text_to_boxes(interp, ctx, text, &mut out)?,
1283            },
1284            IText::Cmd { cmd, args } => {
1285                // Resolved at compile time (`crate::quoted`); running it can
1286                // still raise the same "unbound inline command" error for the
1287                // defensive case the compiler could not resolve.
1288                let cmd = cmd.run(env, interp)?;
1289                let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1290                for arg in args {
1291                    let mut opt_vals = Vec::with_capacity(arg.opts.len());
1292                    for (label, e) in &arg.opts {
1293                        opt_vals.push((label.clone(), e.run(env, interp)?));
1294                    }
1295                    let arg_v = arg.arg.run(env, interp)?;
1296                    v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1297                }
1298                out.extend(as_inline_boxes(v)?);
1299            }
1300            IText::Embed { expr, span } => {
1301                let v = expr.run(env, interp)?;
1302                match v {
1303                    Value::InlineText {
1304                        elems: sub_elems,
1305                        env: cap_env,
1306                    } => {
1307                        out.extend(read_inline(interp, ctx, &sub_elems, &cap_env)?);
1308                    }
1309                    other => {
1310                        return Err(EvalError {
1311                            span: Some(*span),
1312                            msg: format!(
1313                                "expected inline-text in '#…;' embed, got {}",
1314                                other.type_name()
1315                            ),
1316                        });
1317                    }
1318                }
1319            }
1320            IText::EmbedMath { elems, .. } => {
1321                // Upstream: a bare `${…}` in inline text evaluates by
1322                // applying the context's installed `[math] inline-cmd` to
1323                // (ctx, the math value) — `apply(cmd, ctx)` then
1324                // `apply(_, math)`, exactly like `IText::Cmd` above.
1325                let installed = ctx
1326                    .math_command
1327                    .and_then(|id| interp.math_commands.get(id.0).cloned());
1328                match installed {
1329                    Some(cmd) => {
1330                        let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1331                        let v = interp.apply(
1332                            v,
1333                            Value::MathText {
1334                                elems: Rc::clone(elems),
1335                                env: env.clone(),
1336                            },
1337                        )?;
1338                        out.extend(as_inline_boxes(v)?);
1339                    }
1340                    None => {
1341                        // No installed command (contexts built by
1342                        // `Context::initial` directly, i.e. unit tests):
1343                        // reflect + lay out through the faithful engine so
1344                        // `\cmd`/`#var` still evaluate — the same machinery
1345                        // `+math(${…})` uses via `as_math`. This fallback
1346                        // dispatches on `interp.version`
1347                        // — the installed-command path above is version-
1348                        // blind already (an ordinary `[math-text] inline-
1349                        // cmd` applied to `(ctx, math-text)`).
1350                        let mut atoms = Vec::new();
1351                        if interp.version.math_is_split() {
1352                            for e in elems.iter() {
1353                                reflect_math_elem_v01(interp, ctx, e, env, &mut atoms)?;
1354                            }
1355                        } else {
1356                            for e in elems.iter() {
1357                                reflect_math_elem(interp, e, env, &mut atoms)?;
1358                            }
1359                        }
1360                        out.push(HorzBox::Pure(layout_math_value(interp, ctx, &atoms)?));
1361                    }
1362                }
1363            }
1364        }
1365    }
1366    // Space inline `\code(…)`/`${…}` boxes against adjacent CJK prose the way
1367    // SATySFi does (the text-run glue in `text_to_boxes` can't see these
1368    // cross-element boundaries). Idempotent — a boundary already carrying glue
1369    // is skipped.
1370    Ok(insert_box_interscript_glue(out, ctx))
1371}
1372
1373/// Convert quoted block text to vertical boxes (the core of `read-block`).
1374fn read_block(
1375    interp: &mut Interp,
1376    ctx: &Context,
1377    elems: &[BText],
1378    env: &Env,
1379) -> Result<Vec<VertBox>, EvalError> {
1380    let mut out = Vec::new();
1381    for elem in elems {
1382        match elem {
1383            BText::Cmd { cmd, args } => {
1384                let cmd = cmd.run(env, interp)?;
1385                let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1386                for arg in args {
1387                    let mut opt_vals = Vec::with_capacity(arg.opts.len());
1388                    for (label, e) in &arg.opts {
1389                        opt_vals.push((label.clone(), e.run(env, interp)?));
1390                    }
1391                    let arg_v = arg.arg.run(env, interp)?;
1392                    v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1393                }
1394                out.extend(as_block_boxes(v)?);
1395            }
1396            BText::Embed { expr, span } => {
1397                let v = expr.run(env, interp)?;
1398                match v {
1399                    Value::BlockText {
1400                        elems: sub_elems,
1401                        env: cap_env,
1402                    } => {
1403                        out.extend(read_block(interp, ctx, &sub_elems, &cap_env)?);
1404                    }
1405                    other => {
1406                        return Err(EvalError {
1407                            span: Some(*span),
1408                            msg: format!(
1409                                "expected block-text in '#…;' embed, got {}",
1410                                other.type_name()
1411                            ),
1412                        });
1413                    }
1414                }
1415            }
1416        }
1417    }
1418    Ok(out)
1419}
1420
1421/// UAX#14 byte offsets in `text` that are a real, content-driven break
1422/// candidate: every `break_opportunities` boundary except the one always
1423/// reported at `text.len()` (the segmenter's "always break at the end of
1424/// text" convention — an artifact of segmenting this one run in isolation,
1425/// not a signal about what follows it in the paragraph, since
1426/// `text_to_boxes` is called once per `IText::Text` leaf and more content
1427/// may follow via a sibling `Cmd`, `Embed`, or `EmbedMath`).
1428fn uax14_boundaries(text: &str) -> Vec<Option<BreakKind>> {
1429    let mut boundary = vec![None; text.len() + 1];
1430    for (offset, kind) in break_opportunities(text) {
1431        if offset < text.len() {
1432            boundary[offset] = Some(kind);
1433        }
1434    }
1435    boundary
1436}
1437
1438/// This run's `(font, size, rising)` for `script` (see `Context::font_scheme`'s
1439/// doc comment): `Latin` reads `ctx.font` itself (NOT `font_scheme[Latin].font`)
1440/// so `set-font-key`/`\bold`/`\emph` keep working unchanged, while still
1441/// picking up `font_scheme[Latin]`'s ratio/rising (written in lockstep by
1442/// `set-font Latin ..`).
1443///
1444/// `OtherScript` first goes through `normalize_script` (`horzBox.ml:472`):
1445/// upstream's `CommonNarrow`/`Inherited` resolve to `ctx.dominant_narrow_script`
1446/// rather than to a scheme slot of their own; this port's `char_script` has no
1447/// separate Common bucket, so everything outside Latin-1..Latin-Ext-B and the
1448/// CJK ranges lands in `OtherScript` and gets the same treatment — the only
1449/// WIDE Common chars (`U+3000` fullwidth forms) already fall in
1450/// `char_script`'s `HanIdeographic` range, so this costs nothing there.
1451///
1452/// Real effect, not a niceness: without `set-dominant-narrow-script Kana`, a
1453/// document's `□`/`✓` both resolve to a Latin face with NEITHER glyph, degrade
1454/// to the same `.notdef` glyph id and `ToUnicode` entry, and one of the two
1455/// simply vanishes from the extracted text — enumitem's three missing `✓`,
1456/// each overprinted onto a `□` by the document's own `ooalign`.
1457///
1458/// Defaults to `OtherScript` (`Context::initial`, matching upstream), so a
1459/// document that never calls the primitive is unaffected and the recursion is
1460/// one step deep at most.
1461fn script_font(ctx: &Context, script: Script) -> ScriptFont {
1462    if script == Script::OtherScript && ctx.dominant_narrow_script != Script::OtherScript {
1463        return script_font(ctx, ctx.dominant_narrow_script);
1464    }
1465    if script == Script::Latin {
1466        ScriptFont {
1467            font: ctx.font,
1468            ..ctx.font_scheme[Script::Latin as usize]
1469        }
1470    } else {
1471        ctx.font_scheme[script as usize]
1472    }
1473}
1474
1475/// Measure `text` (already known to be one script run) at `size` under
1476/// `font`, falling back per-glyph to `fallback_font` (`ctx.font`) when
1477/// `font` has no glyph for a character — the "CJK per-glyph metrics path
1478/// stubbed" case: a character within a script-run's bucket
1479/// that its assigned font happens to lack (e.g. a fullwidth-form character
1480/// absent from a narrow CJK face) still measures via the Latin default
1481/// rather than failing the whole run. Errors (both fonts lack the glyph)
1482/// name the offending character and font key.
1483///
1484/// **Known limitation** (documented, not fixed): the
1485/// measurement here can fall back per-glyph, but `PureHorzBox::InnerString`
1486/// carries one `HorzStringInfo::font` for its WHOLE text run — so if a
1487/// fallback glyph is actually used, the PDF writer's `emit_box` still tries
1488/// to look it up in `font`'s face at render time and fails there instead.
1489/// Splitting a run into sub-boxes at the source-font-only/fallback boundary
1490/// (a faithful fix) is future work; every stdja default face configuration
1491/// covers its script's whole repertoire, so this path is not expected to
1492/// trigger in practice.
1493fn measure_run(
1494    interp: &Interp,
1495    font: FontKey,
1496    fallback_font: FontKey,
1497    text: &str,
1498    size: Length,
1499) -> Result<Length, EvalError> {
1500    let mut width = Length::ZERO;
1501    for c in text.chars() {
1502        // A character absent from BOTH the run font and the fallback degrades
1503        // to a `.notdef`-style box (half-em advance) rather than aborting the
1504        // whole document — the way real typesetters render an uncovered glyph.
1505        // (satysfi-base's `enumitem`/the SATySFi Book use a few glyphs — `□`,
1506        // `〚` — that the bundled Latin face lacks; a faithful per-glyph
1507        // font-fallback via run-splitting is the documented follow-up.) This
1508        // only ever changes behavior for a glyph that would otherwise be a
1509        // hard error, so covered-glyph documents are byte-identical.
1510        let advance = interp
1511            .metrics
1512            .advance(font, c, size)
1513            .or_else(|| interp.metrics.advance(fallback_font, c, size))
1514            .unwrap_or(size * 0.5);
1515        width += advance;
1516    }
1517    Ok(width)
1518}
1519
1520/// Build one `InnerString` box for `text`, measured through [`measure_run`]
1521/// with `sf`'s font/size/rising — the single construction site shared by
1522/// `text_to_boxes`'s `flush_word` for both the plain (no-hyphenation) path
1523/// and each hyphenated fragment / hyphen glyph. Factored out so both paths
1524/// measure/build identically — this is part of what makes the width-identity
1525/// argument hold: `measure_run` is purely additive per char (no
1526/// kerning/ligatures), so concatenating the fragments this produces
1527/// reconstructs exactly the box a single un-split call would have produced.
1528fn make_inner_string_pure_box(
1529    interp: &Interp,
1530    ctx: &Context,
1531    sf: ScriptFont,
1532    size: Length,
1533    rising: Length,
1534    text: String,
1535) -> Result<PureHorzBox, EvalError> {
1536    let width = measure_run(interp, sf.font, ctx.font, &text, size)?;
1537    // SATySFi measures a run's height/depth from the ACTUAL per-glyph bounding
1538    // boxes (fontInfo.ml `get_metrics_of_word`), not the font-level
1539    // ascender/descender — so a no-descender run (CJK, digits, TOC dots) is
1540    // shorter and packs tighter at block boundaries.
1541    let (height, depth) = interp.metrics.run_vextent(sf.font, &text, size);
1542    Ok(PureHorzBox::InnerString {
1543        info: HorzStringInfo {
1544            font: sf.font,
1545            size,
1546            rising,
1547            color: ctx.text_color,
1548        },
1549        height,
1550        depth,
1551        text,
1552        width,
1553    })
1554}
1555
1556/// Whether two adjacent runs' scripts form a Latin↔CJK boundary that gets
1557/// SATySFi's default inter-script glue (`primitives.ml:517-524`: entries for
1558/// `(Latin, Kana)`, `(Kana, Latin)`, `(Latin, Han)`, `(Han, Latin)` only —
1559/// NOT Kana↔Han, and not same-script).
1560fn is_latin_cjk_boundary(a: Script, b: Script) -> bool {
1561    let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
1562    (a == Script::Latin && is_cjk(b)) || (is_cjk(a) && b == Script::Latin)
1563}
1564
1565/// Upstream `is_open_punctuation` (`charBasis.ml:133`: `OP | QU | JLOP`) —
1566/// opening brackets and quotes. Consulted for the LEFT edge of a script
1567/// boundary only.
1568fn is_open_punct(c: char) -> bool {
1569    matches!(
1570        c,
1571        '(' | '['
1572            | '{'
1573            | '"'
1574            | '\''
1575            | '('
1576            | '「'
1577            | '『'
1578            | '【'
1579            | '〔'
1580            | '〈'
1581            | '《'
1582            | '['
1583            | '{'
1584            | '〖'
1585            | '〘'
1586            | '〚'
1587            | '“'
1588            | '‘'
1589    )
1590}
1591
1592/// Upstream `is_close_punctuation` (`charBasis.ml:139`: `CL | CP | QU | NS |
1593/// JLCP | JLNS | JLCM | JLFS`) — closing brackets, quotes, and the kuten/touten
1594/// family. Consulted for the RIGHT edge of a script boundary only.
1595///
1596/// Two families the port used to list are NOT in that set, and their absence is
1597/// upstream's own, not an oversight:
1598/// - `!` `?` `!` `?` are line-break class `EX` (`LineBreak.txt:2566,2582` for
1599///   the fullwidth pair), which appears in no arm of `is_close_punctuation`;
1600/// - `,` `.` `;` `:` are `IS`, likewise absent.
1601///
1602/// Their FULLWIDTH cousins are a different matter and stay: `,`/`.` are
1603/// overridden to `JLCM`/`JLFS` (`lineBreakDataMap.ml:95-96`) and `:`/`;` are
1604/// `NS` (`LineBreak.txt:2580`).
1605///
1606/// Listing the six suppressed the 0.24em inter-script glue — and, since that
1607/// glue is the boundary's only break candidate, the break opportunity with it —
1608/// before every sentence-final mark that is not a kuten.
1609fn is_close_punct(c: char) -> bool {
1610    matches!(
1611        c,
1612        ')' | ']'
1613            | '}'
1614            | '"'
1615            | '\''
1616            | ')'
1617            | '」'
1618            | '』'
1619            | '】'
1620            | '〕'
1621            | '〉'
1622            | '》'
1623            | ']'
1624            | '}'
1625            | '〗'
1626            | '〙'
1627            | '〛'
1628            | '”'
1629            | '’'
1630            | '、'
1631            | '。'
1632            | ','
1633            | '.'
1634            | '・'
1635            | ':'
1636            | ';'
1637    )
1638}
1639
1640/// Whether SATySFi's default inter-script glue is suppressed between a
1641/// left-hand character `l` and a right-hand `r`.
1642///
1643/// `pure_space_between_scripts` (`convertText.ml:31`) drops the glue when
1644/// `is_open_punctuation lbc1 || is_close_punctuation lbc2` — the LEFT edge being
1645/// OPENING punctuation, or the RIGHT edge being CLOSING punctuation. The aki
1646/// that would otherwise sit there is supplied by the separate JLreq
1647/// class-spacing layer.
1648///
1649/// The port used to test one symmetric "is punctuation" predicate against BOTH
1650/// edges, which suppressed far more than upstream: `、` before a Latin/math run
1651/// is a *closing* mark on the LEFT, which upstream does not suppress. Since this
1652/// glue is also the only break opportunity at such a boundary, suppressing it
1653/// left the breaker with nowhere to break — latexcmds ran
1654/// `…、${dropcolor}` 32pt past the margin because there was no legal break
1655/// between the touten and the math box.
1656fn interscript_glue_suppressed(l: char, r: char) -> bool {
1657    is_open_punct(l) || is_close_punct(r)
1658}
1659
1660/// JLreq character classes SATySFi's inter-CJK spacing distinguishes
1661/// (`charBasis.ml:116-122`). Only the classes that actually change spacing are
1662/// modelled; every other CJK character is `None` ("ordinary").
1663#[derive(Clone, Copy, PartialEq, Eq)]
1664enum JlClass {
1665    /// cl-01, fullwidth OPEN punctuation — carries a leading half-width kern.
1666    Open,
1667    /// cl-02, fullwidth CLOSE punctuation — trailing half-width kern.
1668    Close,
1669    /// cl-06, kuten (fullwidth full stop) — trailing half-width kern.
1670    FullStop,
1671    /// cl-07, touten (fullwidth comma) — trailing half-width kern.
1672    Comma,
1673    /// cl-05, nakaten (fullwidth middle dot) — quarter-width kern BOTH sides.
1674    MiddleDot,
1675}
1676
1677fn jl_class(c: char) -> Option<JlClass> {
1678    match c {
1679        '(' | '「' | '『' | '【' | '〔' | '〈' | '《' | '[' | '{' | '〖' | '〘' | '〚' => {
1680            Some(JlClass::Open)
1681        }
1682        ')' | '」' | '』' | '】' | '〕' | '〉' | '》' | ']' | '}' | '〗' | '〙' | '〛' => {
1683            Some(JlClass::Close)
1684        }
1685        '。' | '.' => Some(JlClass::FullStop),
1686        '、' | ',' => Some(JlClass::Comma),
1687        '・' | ':' | ';' => Some(JlClass::MiddleDot),
1688        _ => None,
1689    }
1690}
1691
1692/// `ideographic_single`'s TRAILING kern for `c` (`convertText.ml:266-283`), as a
1693/// negative ratio of `font_size`: JLCP/JLFS/JLCM are `[glyph; hwkern]`, JLMD is
1694/// `[qwkern; glyph; qwkern]`.
1695///
1696/// A kern belongs to the CHARACTER, not to the boundary — `ideographic_single`
1697/// runs per chunk and never consults its neighbours. Hence one char per
1698/// function, even though the only caller today is the pair-shaped
1699/// [`cjk_pair_space`]: at a CJK↔Latin boundary the CJK side still carries its own
1700/// kern upstream, and this port does not yet emit it there (see the
1701/// `PreventBreak` arm of `text_to_boxes` for what that costs and what unblocking
1702/// it needs).
1703fn cjk_trailing_kern(c: char) -> f64 {
1704    match jl_class(c) {
1705        Some(JlClass::Close) | Some(JlClass::FullStop) | Some(JlClass::Comma) => -0.5,
1706        Some(JlClass::MiddleDot) => -0.25,
1707        _ => 0.0,
1708    }
1709}
1710
1711/// `ideographic_single`'s LEADING kern for `c` — JLOP is `[hwkern; glyph]`,
1712/// JLMD `[qwkern; glyph; qwkern]`. See [`cjk_trailing_kern`].
1713fn cjk_leading_kern(c: char) -> f64 {
1714    match jl_class(c) {
1715        Some(JlClass::Open) => -0.5,
1716        Some(JlClass::MiddleDot) => -0.25,
1717        _ => 0.0,
1718    }
1719}
1720
1721/// The glue SATySFi puts between two directly adjacent CJK characters, as an
1722/// absolute `(natural, shrink, stretch)` — `space_between_chunks`
1723/// (`convertText.ml:220`) with `ideographic_single`'s compensating kerns
1724/// (`convertText.ml:266`) folded in.
1725///
1726/// Upstream renders CJK punctuation at its full em and kerns it back:
1727/// `。`/`、`/`)` carry a trailing −0.5em kern, `(` a leading one, `・` −0.25em
1728/// on both sides. `pure_space_between_classes` (`convertText.ml:194`) then adds
1729/// a half-width space back — natural 0.5em, stretch 0.25em, shrink 0.25em
1730/// unless the pair is "hard" (after a full stop). Net natural width is
1731/// unchanged, but each punctuation mark contributes **0.25em of stretch** — ten
1732/// times the 0.025em `adjacent_stretch` between ordinary characters, and the
1733/// bulk of a Japanese line's elasticity. Two punctuation marks in a row
1734/// (`」、`, `」。`) get NO space back, so the pair sets 0.5em tighter.
1735///
1736/// **Which font size each part scales against is not uniform, and that is the
1737/// point of taking three sizes rather than one.** Upstream applies the kerns
1738/// (`halfwidth_kern`/`quarterwidth_kern`, `convertText.ml:110-118`) and all
1739/// four `pure_halfwidth_space_*` sizes to `get_corrected_font_size ctx script`
1740/// (`convertText.ml:76-79`) — the font size TIMES the script's own ratio, 0.88
1741/// for stdja's CJK face, so a half-width kern at 12pt is −5.28pt and not −6pt.
1742/// Only `adjacent_space` (`:101-106`) takes the RAW `ctx.font_size`. Scaling
1743/// everything by the raw size made every JLreq class space 13.6% too elastic
1744/// (0.25 × 12 = 3pt of stretch where upstream has 0.25 × 10.56 = 2.64pt);
1745/// since punctuation carries ten times the stretch of an ordinary
1746/// inter-character gap, that error set the stretch budget of a whole Japanese
1747/// line and so its justified glyph positions.
1748///
1749/// `size_a`/`size_b` are the corrected sizes of the LEFT and RIGHT characters,
1750/// upstream's `size1`/`size2` (`convertText.ml:196-198`); the `hwsoftM`/
1751/// `hwhardM` arms take `Length::max` of the two, exactly as `sizeM` does. They
1752/// differ only when the two characters resolve to different `font_scheme` slots
1753/// (`Kana` vs `HanIdeographic`) carrying different ratios.
1754fn cjk_pair_space(
1755    a: char,
1756    size_a: Length,
1757    b: char,
1758    size_b: Length,
1759    raw_size: Length,
1760    adjacent_stretch: f64,
1761) -> (Length, Length, Length) {
1762    use JlClass::*;
1763    let (ca, cb) = (jl_class(a), jl_class(b));
1764    // Kerns from `ideographic_single`, each a NEGATIVE ratio of its OWN
1765    // character's corrected size. Between two CJK characters the pair's kern is
1766    // exactly `a`'s trailing plus `b`'s leading one, which is what makes the
1767    // pair form equivalent to upstream's per-character one here.
1768    let kern = size_a * cjk_trailing_kern(a) + size_b * cjk_leading_kern(b);
1769    let size_m = Length::max(size_a, size_b);
1770    // `pure_space_between_classes`, in its own match order. The third component
1771    // of each arm is the size that arm's space scales against.
1772    let hwsoft = |s: Length| (s * 0.5, s * 0.25, s * 0.25);
1773    let hwhard = |s: Length| (s * 0.5, Length::ZERO, s * 0.25);
1774    let cls = match (ca, cb) {
1775        (Some(Close), Some(Open)) | (Some(Comma), Some(Open)) => Some(hwsoft(size_m)),
1776        (Some(FullStop), Some(Open)) => Some(hwhard(size_m)),
1777        (_, Some(Open)) => Some(hwsoft(size_b)),
1778        (Some(Close), Some(Comma)) | (Some(Close), Some(FullStop)) => None,
1779        (Some(Close), _) | (Some(Comma), _) => Some(hwsoft(size_a)),
1780        (Some(FullStop), _) => Some(hwhard(size_a)),
1781        _ => None,
1782    };
1783    match cls {
1784        Some((n, sh, st)) => (kern + n, sh, st),
1785        // No class space: `adjacent_space` (natural 0, shrink 0, stretch
1786        // `adjacent_stretch` × the RAW size), plus whatever kern the pair
1787        // carries.
1788        None => (kern, Length::ZERO, raw_size * adjacent_stretch),
1789    }
1790}
1791
1792/// A box's LEADING glyph for inter-script spacing, or `None` for
1793/// glue/discretionary/skip/image (a "transparent" separator — an inter-script
1794/// space is never inserted adjacent to one) and for math (reported as a Latin
1795/// `'x'`, matching SATySFi where a `${…}` chunk spaces against CJK like Western
1796/// text). The char lets the caller apply the `is_interscript_punct` guard.
1797fn box_leading_char(b: &HorzBox) -> Option<char> {
1798    match b {
1799        HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().next(),
1800        HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1801        _ => None,
1802    }
1803}
1804
1805/// A box's TRAILING glyph (see `box_leading_char`).
1806fn box_trailing_char(b: &HorzBox) -> Option<char> {
1807    match b {
1808        HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().last(),
1809        HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1810        _ => None,
1811    }
1812}
1813
1814/// Insert SATySFi's inter-script glue (`default_script_space_map`) between two
1815/// DIRECTLY-adjacent boxes whose touching edges are Latin↔CJK — the boundary
1816/// that `text_to_boxes` can't see because it spans separate inline elements: a
1817/// `\code(…)`/`${…}` box against surrounding CJK prose ("cellfmt 型", "𝑛 番目").
1818/// A boundary already carrying a glue/discretionary reads as `None` on one edge
1819/// and is skipped, so this is idempotent and never doubles the text-run glue.
1820/// The Latin↔CJK inter-script space, `pure_space_between_scripts`
1821/// (`convertText.ml:29-50`).
1822///
1823/// **RIGID — natural `0.24 * font_size`, no shrink, no stretch** — and that is
1824/// upstream's behaviour, not a simplification. `default_script_space_map`
1825/// (`primitives.cppo.ml:488`) really does carry the triple
1826/// `(0.24, 0.08, 0.16)`, but `pure_space_between_scripts` spends it like this:
1827///
1828/// ```ocaml
1829/// Some(LBAtom((natural (size *% r0), size *% r1, size *% r2), EvHorzEmpty))
1830/// ```
1831///
1832/// `LBAtom`'s first field is `metrics = length_info * length * length`, i.e.
1833/// *(width info, HEIGHT, DEPTH)* (`lineBreakBox.ml:7`), and `natural wid`
1834/// builds `{natural = wid; shrinkable = zero; stretchable = zero}`
1835/// (`lineBreakBox.ml:54-59`). So `r1` and `r2` land in the height and depth
1836/// slots; only `r0` reaches the width. Contrast the sibling
1837/// `pure_halfwidth_space_soft` (`convertText.ml:83-85`), which builds its
1838/// elasticity with `make_width_info` and passes `Length.zero, Length.zero` for
1839/// height and depth — the correct shape, right next door. The commented-out
1840/// predecessor at `convertText.ml:58` has the same misplacement, so v0.0.6 has
1841/// never had an elastic inter-script space.
1842///
1843/// The stray height (`0.08 * size`) and depth (`0.16 * size`) are swallowed by
1844/// `get_total_metrics`'s `max hacc h` / `min dacc d` (`lineBreak.ml:55-59`) —
1845/// a 0.96pt height never exceeds a real glyph's and a POSITIVE depth never
1846/// wins a `min` against a descender — so rigidity is the only observable
1847/// consequence, and it is the one that matters: this glue sits at every
1848/// Japanese/Latin junction, and giving it 0.16em of stretch let the port soak
1849/// up justification slack that upstream is forced to push into the
1850/// inter-character `adjacent_space` inside the CJK runs themselves.
1851///
1852/// Still a break point: upstream wraps this box in `discretionary_if_breakable`
1853/// (`convertText.ml:228`) exactly as it does the elastic glues, and an
1854/// `OuterEmpty` with zero shrink and stretch is still `is_glue()` — which is
1855/// also why a ratio of 0.0 emits a zero-width box rather than nothing.
1856///
1857/// The ratio comes from `ctx.script_space_map`, so
1858/// `set-space-ratio-between-scripts` reaches it (slydifi's arctic theme zeroes
1859/// all four Latin↔CJK directions).
1860fn interscript_glue(ctx: &Context, left: Script, right: Script) -> PureHorzBox {
1861    PureHorzBox::OuterEmpty {
1862        natural: ctx.font_size * ctx.script_space_map[left as usize][right as usize],
1863        shrinkable: Length::ZERO,
1864        stretchable: Length::ZERO,
1865    }
1866}
1867
1868fn insert_box_interscript_glue(boxes: Vec<HorzBox>, ctx: &Context) -> Vec<HorzBox> {
1869    if boxes.len() < 2 {
1870        return boxes;
1871    }
1872    let mut out: Vec<HorzBox> = Vec::with_capacity(boxes.len());
1873    for b in boxes {
1874        if let (Some(pc), Some(cc)) = (out.last().and_then(box_trailing_char), box_leading_char(&b))
1875        {
1876            let (ls, rs) = (char_script(pc), char_script(cc));
1877            if is_latin_cjk_boundary(ls, rs) && !interscript_glue_suppressed(pc, cc) {
1878                out.push(HorzBox::Pure(interscript_glue(ctx, ls, rs)));
1879            }
1880        }
1881        out.push(b);
1882    }
1883    out
1884}
1885
1886fn text_to_boxes(
1887    interp: &mut Interp,
1888    ctx: &Context,
1889    text: &str,
1890    out: &mut Vec<HorzBox>,
1891) -> Result<(), EvalError> {
1892    // Interword glue is upstream's
1893    // `context_main.space_natural`/`space_shrink`/`space_stretch`
1894    // (`set-space-ratio`), each a ratio of `font_size` — NOT a measured
1895    // glyph-advance of the space character and NOT a fraction of the natural
1896    // width. `ctx.space_*` always carries a value (defaults 0.33/0.08/0.16,
1897    // matching `Context::initial`'s own upstream-faithful defaults), so this
1898    // is a plain formula, no fallback needed.
1899    let space_width = ctx.font_size * ctx.space_natural;
1900    let boundary = uax14_boundaries(text);
1901    let mut word = String::new();
1902    let flush_word =
1903        |word: &mut String, script: Script, out: &mut Vec<HorzBox>| -> Result<(), EvalError> {
1904            if word.is_empty() {
1905                return Ok(());
1906            }
1907            let sf = script_font(ctx, script);
1908            let size = ctx.font_size * sf.ratio;
1909            // The script-font's own baseline raise (a ratio of font_size) PLUS the
1910            // manual raise from `set-manual-rising` (`ctx.manual_rising`, an
1911            // absolute Length). Both feed `HorzStringInfo.rising`, which every
1912            // render path adds to the baseline before `Tj`. `manual_rising`
1913            // defaults to `Length::ZERO` (`Context::initial`), so a document that
1914            // never calls `set-manual-rising` is byte-identical. Real effect: the
1915            // `\SATySFi`/`\LaTeX`/`\TeX` logo kerning.
1916            let rising = ctx.font_size * sf.rising + ctx.manual_rising;
1917
1918            // Knuth-Liang hyphenation opt-in injection: fires ONLY when a
1919            // dictionary has been installed (`ctx.hyphen_dictionary ==
1920            // Some(tag)`) and the run's script is Latin. With `hyphen_dictionary
1921            // == None` (the `Context::initial` default), `breaks` is always
1922            // empty and the code below falls straight through to the
1923            // single-`InnerString` path.
1924            let breaks = match ctx.hyphen_dictionary {
1925                Some(tag) if script == Script::Latin => {
1926                    // An explicit soft hyphen (U+00AD) authored in the word
1927                    // takes priority over dictionary-derived breaks (matches the
1928                    // `hyphenation` crate's own `Standard::hyphenate` priority
1929                    // rule). Only reachable here with a soft hyphen still
1930                    // embedded in `word` because the tokenizer above
1931                    // (`text_to_boxes`'s per-char loop) defers to this branch
1932                    // instead of splitting on it as an ordinary UAX#14 boundary
1933                    // — gated on this same `Some(tag) && Latin` condition, so
1934                    // `hyphen_dictionary == None` never reaches
1935                    // `strip_soft_hyphens` and reproduces exactly today's
1936                    // split-at-soft-hyphen behavior.
1937                    let (clean, shy_breaks) = crate::hyphenation::strip_soft_hyphens(word);
1938                    if !shy_breaks.is_empty() {
1939                        *word = clean;
1940                        shy_breaks
1941                    } else {
1942                        crate::hyphenation::hyphenate_word(
1943                            tag,
1944                            word,
1945                            ctx.left_hyphen_min.max(0) as usize,
1946                            ctx.right_hyphen_min.max(0) as usize,
1947                        )
1948                    }
1949                }
1950                _ => Vec::new(),
1951            };
1952
1953            if breaks.is_empty() {
1954                out.push(HorzBox::Pure(make_inner_string_pure_box(
1955                    interp,
1956                    ctx,
1957                    sf,
1958                    size,
1959                    rising,
1960                    std::mem::take(word),
1961                )?));
1962                return Ok(());
1963            }
1964
1965            // Width-identity invariant (also see
1966            // `make_inner_string_pure_box`'s doc comment): `measure_run` is
1967            // purely additive per char (no
1968            // kerning/ligatures), so splitting `word` into fragments here and
1969            // rejoining them via empty-slot `Discretionary`s (taken only at a
1970            // chosen line break) reproduces the exact width/height/depth of the
1971            // un-split box when no break is actually taken — only words the DP
1972            // *does* break render differently, which is the intended new
1973            // behavior, confined to documents that opt in.
1974            let chars: Vec<char> = word.chars().collect();
1975            let penalty = ctx.hyphen_badness.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
1976            let mut prev = 0usize;
1977            for &b in &breaks {
1978                let fragment: String = chars[prev..b].iter().collect();
1979                out.push(HorzBox::Pure(make_inner_string_pure_box(
1980                    interp, ctx, sf, size, rising, fragment,
1981                )?));
1982                let hyphen_box =
1983                    make_inner_string_pure_box(interp, ctx, sf, size, rising, "-".to_string())?;
1984                out.push(HorzBox::Pure(PureHorzBox::Discretionary {
1985                    penalty,
1986                    pre_break: vec![hyphen_box],
1987                    post_break: Vec::new(),
1988                    no_break: Vec::new(),
1989                }));
1990                prev = b;
1991            }
1992            let tail: String = chars[prev..].iter().collect();
1993            out.push(HorzBox::Pure(make_inner_string_pure_box(
1994                interp, ctx, sf, size, rising, tail,
1995            )?));
1996            word.clear();
1997            Ok(())
1998        };
1999    // `Some(s)` exactly when `word` is non-empty — the script of the run
2000    // currently being accumulated (a run also breaks on a script
2001    // change, not just on whitespace/UAX#14, see `char_script`).
2002    let mut word_script: Option<Script> = None;
2003    // The script of the immediately-preceding *typeset* character (persists
2004    // across the UAX#14 discretionary flushing that resets `word_script`), so
2005    // an inter-script boundary can be detected even between two single-char
2006    // CJK/Latin runs. Reset by an explicit space (no auto inter-script glue is
2007    // added adjacent to a real space). See the `is_latin_cjk_boundary` insert.
2008    let mut prev_script: Option<Script> = None;
2009    // The preceding typeset char itself, for the `is_interscript_punct` guard.
2010    let mut prev_char: Option<char> = None;
2011    for (i, c) in text.char_indices() {
2012        // Whitespace normalization around CJK — upstream's rewrite table
2013        // (`lineBreakDataMap.ml:143-157`, applied before any box is built):
2014        //
2015        //   CJK + (SP|BR) + Latin -> deleted      Latin + (SP|BR) + CJK -> deleted
2016        //   CJK + BR      + CJK   -> deleted      CJK   + SP      + CJK -> KEPT
2017        //   any remaining (SP|BR) touching CJK -> deleted; a leftover BR -> space
2018        //
2019        // Every space/line break adjacent to CJK is dropped EXCEPT a single
2020        // literal space between two CJK characters — the Latin/CJK boundary's
2021        // spacing is supplied by the inter-script glue below (0.24em), not by
2022        // the author's whitespace, so keeping it both double-counted that
2023        // boundary and turned every source line break into a space (the port
2024        // set `あります。 1 つは`/`これは 指定した` where SATySFi sets both
2025        // tight — figbox `manual.saty:116-120`). Deleting is a plain
2026        // `continue`: the characters either side still space against each
2027        // other through the inter-script rule, as if the whitespace had never
2028        // been written.
2029        if c == ' ' || c == '\n' {
2030            let is_cjk_script = |s| matches!(s, Script::HanIdeographic | Script::Kana);
2031            let prev_cjk = prev_script.is_some_and(is_cjk_script);
2032            let rest = &text[i + c.len_utf8()..];
2033            // Whether more whitespace follows: upstream's rules only ever match
2034            // ONE space between the two CJK characters (a longer run falls
2035            // through to the delete-everything rules), so a run collapses away.
2036            let run_continues = rest
2037                .chars()
2038                .next()
2039                .is_some_and(|ch| matches!(ch, ' ' | '\n'));
2040            let next_cjk = rest
2041                .chars()
2042                .find(|ch| !matches!(ch, ' ' | '\n'))
2043                .is_some_and(|ch| is_cjk_script(char_script(ch)));
2044            if prev_cjk || next_cjk {
2045                let keep = c == ' ' && !run_continues && prev_cjk && next_cjk;
2046                if !keep {
2047                    continue;
2048                }
2049            }
2050        }
2051        if c == ' ' || c == '\n' {
2052            if let Some(s) = word_script.take() {
2053                flush_word(&mut word, s, out)?;
2054            }
2055            prev_script = None;
2056            prev_char = None;
2057            // Avoid piling up doubled glue at text-run boundaries — but ONLY
2058            // for elastic (prose) spaces. A RIGID space (shrink == stretch == 0,
2059            // i.e. `code.satyh`'s `set-space-ratio (charwid/fs) 0. 0.`) is a
2060            // fixed-width verbatim column: SATySFi never collapses consecutive
2061            // ones, so the aligned source in a `+code` block keeps its spacing
2062            // (`| How       | I`, not the collapsed `| How | I`). Collapsing them
2063            // shortened code lines and let the port pack code blocks too tight.
2064            let rigid_space = ctx.space_shrink == 0.0 && ctx.space_stretch == 0.0;
2065            if rigid_space
2066                || !matches!(
2067                    out.last(),
2068                    Some(HorzBox::Pure(PureHorzBox::OuterEmpty { .. }))
2069                )
2070            {
2071                out.push(HorzBox::Pure(PureHorzBox::OuterEmpty {
2072                    natural: space_width,
2073                    // Upstream derives shrink/stretch directly as a ratio
2074                    // of `font_size` (`ctx.space_shrink`/`space_stretch`),
2075                    // NOT as a fraction of `space_width` — the previous
2076                    // `space_width * 0.25`/`* 0.5` was a port-invented
2077                    // approximation.
2078                    shrinkable: ctx.font_size * ctx.space_shrink,
2079                    stretchable: ctx.font_size * ctx.space_stretch,
2080                }));
2081            }
2082            continue;
2083        }
2084        let script = char_script(c);
2085        // Inter-script glue (`primitives.ml:517-524` `default_script_space_map`,
2086        // applied in `convertText.ml` `pure_space_between_scripts`): SATySFi's
2087        // default context inserts a `0.24 * size` space between a Latin run and
2088        // an adjacent CJK (Kana/Han) run — the space visible as "2 つ" /
2089        // "+easytable は" that the port otherwise packs tight ("2つ"). Emitted
2090        // at the boundary using `prev_script` (a CJK char resets `word_script`
2091        // via its UAX#14 discretionary, so this can't rely on `word_script`
2092        // alone). The glue is also an `is_break_point`, matching upstream (the
2093        // boundary is a legal break). See [`interscript_glue`] for why it is
2094        // RIGID even though `default_script_space_map` carries a triple.
2095        if let (Some(prev), Some(pc)) = (prev_script, prev_char) {
2096            if is_latin_cjk_boundary(prev, script) && !interscript_glue_suppressed(pc, c) {
2097                if let Some(s) = word_script.take() {
2098                    if !word.is_empty() {
2099                        flush_word(&mut word, s, out)?;
2100                    }
2101                }
2102                out.push(HorzBox::Pure(interscript_glue(ctx, prev, script)));
2103            }
2104        }
2105        if let Some(cur) = word_script {
2106            if cur != script {
2107                flush_word(&mut word, cur, out)?;
2108            }
2109        }
2110        word_script = Some(script);
2111        prev_script = Some(script);
2112        prev_char = Some(c);
2113        word.push(c);
2114        // Only non-ASCII text gets UAX#14 discretionaries: plain ASCII stays
2115        // on exactly today's space/newline-only splitter, so existing Latin
2116        // fixtures wrap identically (a real, tested divergence otherwise —
2117        // UAX#14 allows a break after a hyphen, which would fragment e.g.
2118        // "SATySFi-in-Rust" into three `InnerString`s instead of one,
2119        // changing the PDF content stream even though the zero-width
2120        // discretionaries between them render no differently when unchosen).
2121        // CJK and other non-ASCII scripts have no such existing behavior to
2122        // preserve, and are exactly where UAX#14 breaking is the whole point
2123        // (no interword glue at all otherwise, see `is_break_point`'s doc).
2124        // A soft hyphen (U+00AD) inside a run that the Knuth-Liang injection
2125        // above will consume (dictionary installed, Latin script) must NOT
2126        // be split here as an ordinary UAX#14 break-after point — doing so
2127        // would flush/fragment the word right at the soft hyphen before
2128        // `flush_word`'s hyphenation branch ever sees the whole word,
2129        // pre-empting `strip_soft_hyphens`'s explicit-break handling.
2130        // Instead let it accumulate into `word` like any other Latin letter.
2131        // Gated on the exact same `Some(_) && Latin` condition as that
2132        // branch, so `hyphen_dictionary == None` (or a non-Latin run)
2133        // reproduces exactly today's split-at-soft-hyphen behavior.
2134        let is_gated_soft_hyphen =
2135            c == '\u{ad}' && script == Script::Latin && ctx.hyphen_dictionary.is_some();
2136        // UAX#14 break opportunities apply to ALL text, ASCII included — that
2137        // is simply what upstream's line-break engine does (it runs over the
2138        // whole run with no script gate). Do NOT narrow this to non-ASCII, or
2139        // to the explicit hyphen — both approximations leave a load-bearing
2140        // gap:
2141        //
2142        //   - `+fig-center` (54.7pt, unbreakable) made the candidate widths jump
2143        //     clean over the feasible window — 400.32pt (ratio 2.72, dropped) to
2144        //     455.00pt (overfull) with nothing between — so the breaker fell back
2145        //     to a degenerate one-character line.
2146        //   - a `+code` line `…?:(drop) ?:(dropcolor)` ran 80pt past the column
2147        //     and clean off the paper, because the only break the port allowed
2148        //     was at a space, and breaking there left a rigid line 7.7pt short
2149        //     (dropped). UAX#14 grants a break between `:` and `(` — offset 2 of
2150        //     `?:(drop)…` — which is exactly where SATySFi breaks it.
2151        //
2152        // Cost: an ASCII run is now split into one `InnerString` per break
2153        // opportunity. Widths are unaffected (`measure_run` is purely additive,
2154        // see `make_inner_string_pure_box`), so this only changes how the text
2155        // is CHUNKED, not where any glyph lands.
2156        if !is_gated_soft_hyphen {
2157            let after = i + c.len_utf8();
2158            // The inter-chunk spacing between two DIRECTLY ADJACENT CJK
2159            // characters: `cjk_pair_space` folds `pure_space_between_classes` /
2160            // `adjacent_space` (`convertText.ml:101/194`) together with
2161            // `ideographic_single`'s compensating kerns (`convertText.ml:266`).
2162            //
2163            // The elastic part is the give a Japanese line justifies with.
2164            // Without it a CJK line's only give was whatever incidental Latin
2165            // spaces it happened to contain — a handful of points across a whole
2166            // line — so the breaker could neither fill to the column nor accept a
2167            // break that needed a hair of stretch.
2168            //
2169            // Only between two CJK characters: a CJK/Latin boundary is
2170            // `pure_space_between_scripts`'s job (the inter-script glue
2171            // above), and upstream falls through to `adjacent_space` only
2172            // once that has returned `None` (`space_between_chunks`,
2173            // `convertText.ml:220`).
2174            let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
2175            let next_char = text[after..].chars().next();
2176            let next_is_cjk = next_char.is_some_and(|nc| is_cjk(char_script(nc)));
2177            let pair = if is_cjk(script) && next_is_cjk {
2178                let nc = next_char.expect("checked");
2179                // `get_corrected_font_size` per SIDE (`convertText.ml:76-79`):
2180                // font size times the script's own `font_scheme` ratio. The
2181                // kerns and the JLreq class spaces scale against these; only
2182                // `adjacent_space` takes the raw size. See `cjk_pair_space`.
2183                let size_a = ctx.font_size * script_font(ctx, script).ratio;
2184                let size_b = ctx.font_size * script_font(ctx, char_script(nc)).ratio;
2185                Some(cjk_pair_space(
2186                    c,
2187                    size_a,
2188                    nc,
2189                    size_b,
2190                    ctx.font_size,
2191                    ctx.adjacent_stretch,
2192                ))
2193            } else {
2194                None
2195            };
2196            // `discretionary_if_breakable alw badns lphb`
2197            // (`convertText.ml:183-190`) — the ONE decision upstream makes at a
2198            // chunk boundary. The spacing is computed the same way either way;
2199            // only its container depends on whether UAX#14 grants a break:
2200            //
2201            //   AllowBreak    -> LBDiscretionary(badns, id, [glue], [], [])
2202            //   PreventBreak  -> LBPure(glue)
2203            //
2204            // The port used to emit the `AllowBreak` arm and *nothing* for
2205            // `PreventBreak`, so at every prohibited boundary — and in Japanese
2206            // prose that is one boundary in several, since LB13 forbids a break
2207            // before `、`/`。`/`」`/`)` and LB14 after `(`/`「` — a CJK line
2208            // carried give only at the subset of its boundaries that happened to
2209            // be breakable. A line with no give has to FILL its measure with
2210            // characters, which is part of why the port packs more per line than
2211            // SATySFi.
2212            match boundary[after] {
2213                Some(kind) => {
2214                    flush_word(&mut word, script, out)?;
2215                    word_script = None;
2216                    let mut no_break = Vec::new();
2217                    if let Some((n, sh, st)) = pair {
2218                        // The kern part is RIGID and must never be a break point,
2219                        // so it rides as a `FixedEmpty` rather than as glue.
2220                        if n != Length::ZERO {
2221                            no_break.push(PureHorzBox::FixedEmpty { width: n });
2222                        }
2223                        no_break.push(PureHorzBox::OuterEmpty {
2224                            natural: Length::ZERO,
2225                            shrinkable: sh,
2226                            stretchable: st,
2227                        });
2228                    }
2229                    out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2230                        penalty: match kind {
2231                            BreakKind::Allowed => 0,
2232                            BreakKind::Mandatory => FORCED_BREAK_PENALTY,
2233                        },
2234                        pre_break: Vec::new(),
2235                        post_break: Vec::new(),
2236                        no_break,
2237                    }));
2238                }
2239                // The `PreventBreak` arm: `LBPure(glue)`, spelled as a
2240                // `Discretionary` whose every break slot is empty and whose
2241                // penalty is `NO_BREAK_PENALTY` (a bare `OuterEmpty` IS a
2242                // breakpoint in this box model, so a pure elastic box has no
2243                // other spelling).
2244                //
2245                // Only the ELASTIC half, deliberately — the one place this
2246                // port knowingly diverges from `discretionary_if_breakable`.
2247                // Landing the RIGID half too was written and MEASURED: it makes
2248                // the kern model ASYMMETRIC, since `cjk_pair_space`'s kern is a
2249                // property of the PAIR while upstream's is a property of the
2250                // CHARACTER, and the two agree only when BOTH neighbours are
2251                // CJK — `生成・変換` would get the nakaten's kern on both sides
2252                // while `(例:textbox` gets only its leading one (the trailing
2253                // side faces Latin, unreached by `cjk_pair_space`) — figbox's
2254                // largest intra-line divergence from upstream (mean |dx| on
2255                // that line 2.5pt -> 6.5pt).
2256                //
2257                // Completing it needs the per-character kerns at CJK<->Latin
2258                // boundaries and run edges too, which needs the source-
2259                // whitespace rewrite applied BEFORE `uax14_boundaries` rather
2260                // than during this loop (upstream's own order). Without that a
2261                // `。` before a deleted source newline gets its trailing kern
2262                // while the class space that pays it back is skipped (the
2263                // lookahead sees the newline, not the character after it), and
2264                // the layout-fidelity gate fails 12 ways. So the natural-width
2265                // bug the rigid half would fix — `」。`/`」、` a half-em too
2266                // wide, `末・雲` a quarter — stays exactly as open as before.
2267                None => {
2268                    if let Some((_, sh, st)) = pair {
2269                        if sh != Length::ZERO || st != Length::ZERO {
2270                            flush_word(&mut word, script, out)?;
2271                            word_script = None;
2272                            out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2273                                penalty: NO_BREAK_PENALTY,
2274                                pre_break: Vec::new(),
2275                                post_break: Vec::new(),
2276                                no_break: vec![PureHorzBox::OuterEmpty {
2277                                    natural: Length::ZERO,
2278                                    shrinkable: sh,
2279                                    stretchable: st,
2280                                }],
2281                            }));
2282                        }
2283                    }
2284                }
2285            }
2286        }
2287    }
2288    match word_script {
2289        Some(s) => flush_word(&mut word, s, out),
2290        None => Ok(()),
2291    }
2292}
2293
2294// ---- math conversion ----------------------
2295//
2296// Walks the already-elaborated `MathElem` tree straight into one
2297// `PureHorzBox::Math`, fixed-constant shift/scale (no MATH table).
2298
2299/// Superscript/subscript size ratio, used ONLY as `MathC`'s fallback when
2300/// the current math font has no OpenType MATH table (`script_percent_scale_down
2301/// / 100`). Not read anywhere outside `MathC` — every layout site goes
2302/// through `MathC::script_scale`/`sup_shift_clamped`/etc. so a MATH-table
2303/// font gets the real per-font ratio instead.
2304const SCRIPT_SCALE: f64 = 0.7;
2305/// Superscript raise, as a fraction of `ctx.font_size` — `MathC`'s
2306/// no-MATH-table fallback (`superscript_shift_up` clamped per
2307/// `math.ml:527`). Not read outside `MathC`.
2308const SUP_SHIFT: f64 = 0.5;
2309/// Cramped-style superscript raise fallback — the no-MATH-table fallback
2310/// `sup_shift_clamped` uses in place of `SuperscriptShiftUpCramped` when there
2311/// is no real MATH table to read. Deliberately set EQUAL to `SUP_SHIFT`: every
2312/// checked-in fixture font has no MATH table, so cramped and uncramped
2313/// superscripts get the identical fallback shift there. Only a real MATH
2314/// font (host-installed, test-guarded) makes cramped/uncramped diverge.
2315const SUP_SHIFT_CRAMPED: f64 = SUP_SHIFT;
2316/// Subscript drop, as a fraction of `ctx.font_size` — `MathC`'s
2317/// no-MATH-table fallback (`subscript_shift_down` per
2318/// `math.ml:545`). Not read outside `MathC`.
2319const SUB_SHIFT: f64 = 0.25;
2320/// `MathC::frac_numer_shift`'s no-MATH-table fallback: a flat,
2321/// content-independent numerator raise, as a fraction of the fraction's own
2322/// LOCAL size (mirrors `sup_shift_clamped`'s None-branch style, which
2323/// also ignores ink extent with no MATH table). Not read outside `MathC`.
2324const FRAC_NUMER_SHIFT_FALLBACK: f64 = 0.33;
2325/// `MathC::frac_denom_shift`'s no-MATH-table fallback (mirrors
2326/// `FRAC_NUMER_SHIFT_FALLBACK`; applied as a downward, i.e. negative, shift
2327/// by the caller). Not read outside `MathC`.
2328const FRAC_DENOM_SHIFT_FALLBACK: f64 = 0.33;
2329
2330/// MATH-table resolver: one query of `interp.metrics.math_constants(font)`
2331/// per laid-out math run, memoized here so every shift/scale/kern site in
2332/// that run reads the SAME `Option` instead of re-querying — and so a font
2333/// with no MATH table (every `Base14Metrics` call, and any TTF that lacks
2334/// one) transparently falls back to the flat pre-MATH-table constants
2335/// above. Fields are ratios of
2336/// the font size; callers multiply by whichever size is in scope
2337/// (`ctx.font_size` for the shift magnitudes — matching the pre-existing
2338/// "shift doesn't shrink with nesting" behavior these constants always had
2339/// — or the atom's own local `size` for glyph-relative queries like
2340/// `script_scale`/kerning).
2341struct MathC {
2342    c: Option<MathConstants>,
2343    /// `ctx.math_cramped` at the point this `MathC` was built — whether the
2344    /// current math sub-formula is laid out cramped. Consulted only by
2345    /// `sup_shift`/`sup_shift_clamped`, the sole positioning formula cramped
2346    /// changes in this port's feature set.
2347    cramped: bool,
2348}
2349
2350impl MathC {
2351    fn of(interp: &Interp, ctx: &Context) -> Self {
2352        Self {
2353            c: interp.metrics.math_constants(ctx.math_font),
2354            cramped: ctx.math_cramped,
2355        }
2356    }
2357
2358    /// Flat, unclamped superscript raise (`math.ml:527`'s `h_supstd` alone,
2359    /// no `math.ml:524-533` clamp) — the shape `layout_math_atom`'s callers
2360    /// need when no base/script ink extent is at hand yet.
2361    fn sup_shift(&self, s: Length) -> Length {
2362        match self.c {
2363            None => {
2364                s * if self.cramped {
2365                    SUP_SHIFT_CRAMPED
2366                } else {
2367                    SUP_SHIFT
2368                }
2369            }
2370            Some(c) => {
2371                s * if self.cramped {
2372                    c.superscript_shift_up_cramped
2373                } else {
2374                    c.superscript_shift_up
2375                }
2376            }
2377        }
2378    }
2379
2380    /// Flat, unclamped subscript drop (mirrors `sup_shift`).
2381    fn sub_shift(&self, s: Length) -> Length {
2382        self.c
2383            .map(|c| s * c.subscript_shift_down)
2384            .unwrap_or(s * SUB_SHIFT)
2385    }
2386
2387    /// `script_percent_scale_down / 100`, or the fixed `SCRIPT_SCALE`
2388    /// fallback. Nesting-level scale gap: upstream
2389    /// switches to `script_script_percent_scale_down` one level deeper;
2390    /// this port applies `script_scale_down` uniformly at every depth.
2391    fn script_scale(&self) -> f64 {
2392        self.c.map(|c| c.script_scale_down).unwrap_or(SCRIPT_SCALE)
2393    }
2394
2395    /// `math.ml`'s `h_bar` (axis height): the vertical center math content
2396    /// (fraction bars, `get-axis-height`) aligns to. Falls back to a fixed
2397    /// `0.25` ratio with no MATH table.
2398    fn axis(&self, s: Length) -> Length {
2399        self.c.map(|c| s * c.axis_height).unwrap_or(s * 0.25)
2400    }
2401
2402    /// `math.ml:524-533` `superscript_baseline_height`, clamped: the
2403    /// MAGNITUDE of the upward shift a superscript needs given the base's
2404    /// own ink height (`h_base`, a positive extent above ITS baseline) and
2405    /// the superscript's own ink depth (`d_sup`, a positive extent below
2406    /// ITS baseline — i.e. `MathGlyph.height`/`.depth`, not upstream's
2407    /// signed `Length.negate`d fields). Falls back to the flat `sup_shift`
2408    /// (ignoring `h_base`/`d_sup`) when there's no MATH table, so base-14
2409    /// output is untouched by this clamp.
2410    fn sup_shift_clamped(&self, s: Length, h_base: Length, d_sup: Length) -> Length {
2411        match self.c {
2412            None => self.sup_shift(s),
2413            Some(c) => {
2414                let shift_up = if self.cramped {
2415                    c.superscript_shift_up_cramped
2416                } else {
2417                    c.superscript_shift_up
2418                };
2419                let cand1 = s * shift_up;
2420                let cand2 = h_base - s * c.superscript_baseline_drop_max;
2421                let cand3 = s * c.superscript_bottom_min + d_sup;
2422                cand1.max(cand2).max(cand3)
2423            }
2424        }
2425    }
2426
2427    /// `math.ml:545-553` `subscript_baseline_depth`, clamped: the MAGNITUDE
2428    /// of the downward shift, given the base's own ink depth (`d_base`) and
2429    /// the subscript's own ink height (`h_sub`). Mirrors
2430    /// `sup_shift_clamped`'s fallback behavior.
2431    fn sub_shift_clamped(&self, s: Length, d_base: Length, h_sub: Length) -> Length {
2432        match self.c {
2433            None => self.sub_shift(s),
2434            Some(c) => {
2435                let cand1 = s * c.subscript_shift_down;
2436                let cand2 = d_base + s * c.subscript_baseline_drop_min;
2437                let cand3 = h_sub - s * c.subscript_top_max;
2438                cand1.max(cand2).max(cand3)
2439            }
2440        }
2441    }
2442
2443    /// `math.ml:562-573` `correct_script_baseline_heights`: when a base
2444    /// carries BOTH a subscript and a superscript, nudge the two
2445    /// already-clamped shift magnitudes apart so their ink keeps at least
2446    /// `sub_superscript_gap_min` clearance. `d_sup`/`h_sub` are the same ink
2447    /// extents `sup_shift_clamped`/`sub_shift_clamped` took; `sup`/`sub` are
2448    /// their (already clamped) outputs. A no-op when there's no MATH table
2449    /// — the flat fallback shifts are never additionally corrected, so
2450    /// base-14 output stays exactly `(sup, sub)`.
2451    fn correct_script_gap(
2452        &self,
2453        s: Length,
2454        d_sup: Length,
2455        h_sub: Length,
2456        sup: Length,
2457        sub: Length,
2458    ) -> (Length, Length) {
2459        let Some(c) = self.c else {
2460            return (sup, sub);
2461        };
2462        let gap_min = s * c.sub_superscript_gap_min;
2463        let gap = (sup - d_sup) - (h_sub - sub);
2464        if gap < gap_min {
2465            let corr = (gap_min - gap) * 0.5;
2466            (sup + corr, sub + corr)
2467        } else {
2468            (sup, sub)
2469        }
2470    }
2471
2472    /// `math.ml:596-602` `upper_limit_baseline_height`, clamped: the
2473    /// MAGNITUDE of the upward shift for an `\overset`-like upper limit,
2474    /// given the base's own ink height (`h_base`) and the limit content's
2475    /// own ink depth (`d_up`). Falls back to the flat `sup_shift` (same
2476    /// shape upstream's superscript raise uses) with no MATH table.
2477    fn upper_limit_shift(&self, s: Length, h_base: Length, d_up: Length) -> Length {
2478        match self.c {
2479            None => self.sup_shift(s),
2480            Some(c) => {
2481                let cand1 = h_base + s * c.upper_limit_baseline_rise_min;
2482                let cand2 = h_base + s * c.upper_limit_gap_min + d_up;
2483                cand1.max(cand2)
2484            }
2485        }
2486    }
2487
2488    /// `math.ml:605-611` `lower_limit_baseline_depth`, clamped: mirrors
2489    /// `upper_limit_shift` for a lower limit, given the base's own ink
2490    /// depth (`d_base`) and the limit content's own ink height (`h_low`).
2491    fn lower_limit_shift(&self, s: Length, d_base: Length, h_low: Length) -> Length {
2492        match self.c {
2493            None => self.sub_shift(s),
2494            Some(c) => {
2495                let cand1 = d_base + s * c.lower_limit_baseline_drop_min;
2496                let cand2 = d_base + s * c.lower_limit_gap_min + h_low;
2497                cand1.max(cand2)
2498            }
2499        }
2500    }
2501
2502    /// `math.ml:982-991` `horz_fraction_bar`'s rule thickness (also
2503    /// `radical_bar_metrics`'s `t_bar` — both are "the same generic rule
2504    /// ratio" in the pre-MATH-table fixed-constant world):
2505    /// `fraction_rule_thickness`, or the fixed `0.04` fallback. Multiplied
2506    /// by the ambient LOCAL nesting `size` (not `ctx.font_size` — a
2507    /// fraction/radical's own metrics DO shrink with nesting, matching
2508    /// upstream's `FontInfo.actual_math_font_size`, unlike the sup/sub shift
2509    /// constants' documented `ctx.font_size` simplification above).
2510    fn frac_rule(&self, s: Length) -> Length {
2511        self.c
2512            .map(|c| s * c.fraction_rule_thickness)
2513            .unwrap_or(s * 0.04)
2514    }
2515
2516    /// `math.ml:574-583` `numerator_baseline_height`, clamped: the
2517    /// MAGNITUDE of the upward shift a numerator needs given its own ink
2518    /// depth (`d_numer`, a positive extent below ITS baseline — this port's
2519    /// convention, see `sup_shift_clamped`'s doc comment; upstream's
2520    /// `Length.negate d_numer` becomes a plain ADD of `d_numer` here, not a
2521    /// subtract — getting this sign wrong would shrink the raise for a
2522    /// deeper numerator instead of growing it, overlapping the bar). Falls
2523    /// back to a flat, content-independent ratio with no MATH table
2524    /// (mirrors `sup_shift_clamped`'s None-branch style).
2525    fn frac_numer_shift(&self, s: Length, d_numer: Length) -> Length {
2526        match self.c {
2527            None => s * FRAC_NUMER_SHIFT_FALLBACK,
2528            Some(c) => {
2529                let std = s * c.fraction_numer_shift_up;
2530                let gap =
2531                    self.axis(s) + self.frac_rule(s) * 0.5 + s * c.fraction_numer_gap_min + d_numer;
2532                std.max(gap)
2533            }
2534        }
2535    }
2536
2537    /// `math.ml:585-594` `denominator_baseline_depth`, clamped: mirrors
2538    /// `frac_numer_shift`. Returns the SIGNED (already-negative) drop the
2539    /// caller applies straight to `dy` — unlike the sup/sub methods'
2540    /// positive-magnitude-then-caller-negates convention — because
2541    /// upstream's own `d_denombl` is signed too, so there's no sign flip to
2542    /// make here (and `h_denom`, a HEIGHT not a depth, is subtracted
2543    /// directly, matching upstream's un-negated use of it).
2544    fn frac_denom_shift(&self, s: Length, h_denom: Length) -> Length {
2545        match self.c {
2546            None => -(s * FRAC_DENOM_SHIFT_FALLBACK),
2547            Some(c) => {
2548                let std = -(s * c.fraction_denom_shift_down);
2549                let gap =
2550                    self.axis(s) - self.frac_rule(s) * 0.5 - s * c.fraction_denom_gap_min - h_denom;
2551                std.min(gap)
2552            }
2553        }
2554    }
2555
2556    /// `math.ml:620-626` `radical_bar_metrics`: `(h_bar, t_bar, l_extra)` —
2557    /// the bar's height above baseline (radicand height + gap, so the bar
2558    /// always clears the radicand with no separate raise needed), its rule
2559    /// thickness, and the extra ascender the WHOLE radical run reports
2560    /// above the bar. Fallback ratios (no MATH table):
2561    /// vertical_gap=0.06, rule=0.04 (same fixed ratio `frac_rule` falls back
2562    /// to), extra_ascender=0.06.
2563    fn radical_bar_metrics(&self, s: Length, h_cont: Length) -> (Length, Length, Length) {
2564        match self.c {
2565            Some(c) => (
2566                h_cont + s * c.radical_vertical_gap,
2567                s * c.radical_rule_thickness,
2568                s * c.radical_extra_ascender,
2569            ),
2570            None => (h_cont + s * 0.06, s * 0.04, s * 0.06),
2571        }
2572    }
2573}
2574
2575/// The ink height/depth of an already-laid-out run, as positive magnitudes
2576/// (`MathGlyph.dy` is signed, up-positive; `.height`/`.depth` are always
2577/// non-negative extents from EACH glyph's own local baseline) — the same
2578/// aggregate `read_math`/`layout_math_value` compute for a whole
2579/// `PureHorzBox::Math`, reused here per sub-run so `MathC`'s clamp formulas
2580/// have an `h_base`/`d_sup`/etc to clamp against. Empty input -> `(ZERO,
2581/// ZERO)` (an empty base/script contributes no clamp pressure).
2582fn glyphs_extent(glyphs: &[MathGlyph]) -> (Length, Length) {
2583    let mut height = Length::ZERO;
2584    let mut depth = Length::ZERO;
2585    for g in glyphs {
2586        height = height.max(g.dy + g.height);
2587        depth = depth.max(g.depth - g.dy);
2588    }
2589    (height, depth)
2590}
2591
2592/// `glyphs_extent` plus `rules`' own bounding boxes folded in — exactly the
2593/// aggregate `layout_math_value` computes for a whole `PureHorzBox::Math`
2594/// (see that function's doc comment on why a bare `Fill`, e.g. a fraction
2595/// bar/radical sign, needs its own bbox folded in rather than being silently
2596/// undercounted). Also reused to size a stretchy delimiter to its
2597/// enclosed run's REAL ink (glyphs + any drawn rules), not just its glyphs.
2598///
2599/// This — NOT bare `glyphs_extent` — is what every `layout_math_value` arm
2600/// must use for a sub-run's `h_base`/`d_base`/`d_sup`/`h_sub`/`d_numer`/…,
2601/// because it is upstream's `convert_to_low` return value: each arm's
2602/// `(_, h, d, _, _)` is the whole sub-run's `h_whole`/`d_whole`, and a
2603/// `MathParen`'s is `max(hC, hL, hR)` / `min(dC, dL, dR)` over the
2604/// DELIMITER boxes too (`math.ml:908-909`). A `math.satyh` delimiter is
2605/// `inline-graphics` ink, so it lands in `rules` and in nothing else: with
2606/// `glyphs_extent` a `\paren{…}` base reported only its CONTENT's height,
2607/// which is smaller than the delimiter it just sized, and
2608/// `sup_shift_clamped`'s `h_base - SuperscriptBaselineDropMax` candidate
2609/// therefore lost when upstream's wins. `${\paren{\frac{1}{1-v}}^{2}}` at
2610/// 12pt: `h_base` 16.116pt (content) vs upstream's 17.316pt (`hgtaxis +
2611/// halflen`, the paren's own declared box), i.e. a 1.2pt-too-low superscript
2612/// — `layout-tests/probes/math_box_extent.saty` row 4. The bbox of
2613/// `math.satyh`'s `paren-left`/`angle-left`/… path is exactly that declared
2614/// box (its extreme points ARE `ycenter ± halflen`), so folding the rule in
2615/// reproduces upstream's number rather than approximating it.
2616fn inner_ink_extent(glyphs: &[MathGlyph], rules: &[GraphicsElem]) -> (Length, Length) {
2617    let (mut height, mut depth) = glyphs_extent(glyphs);
2618    for r in rules {
2619        // `graphics_bbox` is now `Option` (`None` for an empty `Group`
2620        // — unreachable here under 0.0.6 math rules, but the fold is
2621        // version-blind and correct either way: a `None` rule contributes
2622        // nothing to the ink extent).
2623        if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
2624            height = height.max(max_y);
2625            depth = depth.max(-min_y);
2626        }
2627    }
2628    (height, depth)
2629}
2630
2631/// `math.ml:1040-1075`'s superscript kern tuck: the italic correction of
2632/// the base's TRAILING glyph plus the two corner kerns — the base's
2633/// top-right sampled at the height the raised superscript's ink starts
2634/// (`l_base = sup_shift - d_sup`, `superscript_correction_heights`'s first
2635/// component), and the superscript's own bottom-left sampled (at the
2636/// superscript's OWN size) at the height the base's ink ends (`l_sup =
2637/// h_base - sup_shift`, that function's second component) — the extra
2638/// horizontal gap upstream inserts between a base and a raised superscript
2639/// so slanted glyphs (an italic integral, say) don't collide with what's
2640/// stacked above them. `size`/`script_size` are the local sizes the base/
2641/// script glyphs were actually measured at (NOT `ctx.font_size`, unlike the
2642/// shift magnitude — these feed a design-units conversion that must match
2643/// each glyph's own em square). Every lookup misses to `Length::ZERO` (no
2644/// MATH table, no glyph, no kern data, ...), so base-14 output is
2645/// untouched: this returns exactly `Length::ZERO` whenever `ctx.math_font`
2646/// has no MATH table.
2647#[allow(clippy::too_many_arguments)]
2648fn superscript_kern(
2649    interp: &Interp,
2650    ctx: &Context,
2651    size: Length,
2652    script_size: Length,
2653    base_glyphs: &[MathGlyph],
2654    script_glyphs: &[MathGlyph],
2655    sup_shift: Length,
2656    h_base: Length,
2657    d_sup: Length,
2658) -> Length {
2659    let font = ctx.math_font;
2660    let last_base = base_glyphs.last().and_then(|g| g.text.chars().last());
2661    let first_script = script_glyphs.first().and_then(|g| g.text.chars().next());
2662    let l_italic = last_base
2663        .and_then(|c| interp.metrics.italic_correction(font, c, size))
2664        .unwrap_or(Length::ZERO);
2665    let l_base = sup_shift - d_sup;
2666    let l_sup = h_base - sup_shift;
2667    let l_kernbase = last_base
2668        .and_then(|c| {
2669            interp
2670                .metrics
2671                .math_kern(font, c, size, MathCorner::TopRight, l_base)
2672        })
2673        .unwrap_or(Length::ZERO);
2674    let l_kernsup = first_script
2675        .and_then(|c| {
2676            interp
2677                .metrics
2678                .math_kern(font, c, script_size, MathCorner::BottomLeft, l_sup)
2679        })
2680        .unwrap_or(Length::ZERO);
2681    l_italic + l_kernbase + l_kernsup
2682}
2683
2684/// A minimal stand-in for v0.0.6's per-codepoint math-class table
2685/// (`primitives.cppo.ml`) + `normalize_math_kind` (`math.ml:240`) — just
2686/// enough for `${a+b}` to get binary-operator spacing. Letters/digits/
2687/// everything else default to `Ord`.
2688fn ascii_math_kind(c: char) -> MathKind {
2689    match c {
2690        '+' | '-' | '*' | '/' => MathKind::Bin,
2691        '=' | '<' | '>' => MathKind::Rel,
2692        ',' | ';' | ':' | '.' => MathKind::Punct,
2693        _ => MathKind::Ord,
2694    }
2695}
2696
2697/// `normalize_math_kind` (`math.ml:238-277`): a BINARY atom whose neighbours
2698/// make it unary is really an ORDINARY one, and gets none of `Bin`'s spacing.
2699/// Upstream demotes on `mkprev in {Op, Bin, Rel, Open, Punct}` or `mknext in
2700/// {Rel, Close, Punct}`; `MathEnd` — the sentinel `math.ml:1270` passes for
2701/// both ends of a formula — is included here on the LEFT, which is what makes
2702/// `${-------}` set seven tight glyphs rather than a leading binary minus
2703/// followed by six ordinaries, and what keeps `${-N, -N + 1}`'s minus signs
2704/// tight against their operands the way the reference sets them. Every other
2705/// class passes through unchanged.
2706///
2707/// Reachable at all only since the math lexer stopped gluing a run of symbols
2708/// into one token: before that a `--` was a single `Ord` atom and there was no
2709/// adjacent pair to normalize.
2710fn normalize_math_kind(prev: MathKind, next: MathKind, raw: MathKind) -> MathKind {
2711    if raw != MathKind::Bin {
2712        return raw;
2713    }
2714    let unary_left = matches!(
2715        prev,
2716        MathKind::Op
2717            | MathKind::Bin
2718            | MathKind::Rel
2719            | MathKind::Open
2720            | MathKind::Punct
2721            | MathKind::End
2722    );
2723    let unary_right = matches!(next, MathKind::Rel | MathKind::Close | MathKind::Punct);
2724    if unary_left || unary_right {
2725        MathKind::Ord
2726    } else {
2727        MathKind::Bin
2728    }
2729}
2730
2731/// The six inter-atom space ratios `space_between_math_kinds` multiplies the
2732/// math font size by — `primitives.cppo.ml:528-533`'s `space_math_bin`, `_rel`,
2733/// `_op`, `_punct`, `_inner`, `_prefix`. Upstream keeps them as a
2734/// natural/shrink/stretch triple on `HorzBox.context_main` that no v0.0.6
2735/// primitive writes, and this port's math spacer emits a fixed kern rather than
2736/// glue, so only the natural component is needed.
2737const SPACE_MATH_BIN: f64 = 0.25;
2738const SPACE_MATH_REL: f64 = 0.375;
2739const SPACE_MATH_OP: f64 = 0.125;
2740const SPACE_MATH_PUNCT: f64 = 0.125;
2741const SPACE_MATH_INNER: f64 = 0.125;
2742const SPACE_MATH_PREFIX: f64 = 0.125;
2743
2744/// `space_between_math_kinds` (`math.ml:319-410`), arm for arm and in the same
2745/// ORDER: OCaml's `match` is first-match, so `(Punct, Close)` must reach the
2746/// `(Punct, _)` arm and not `(_, Close)`. Every ratio is
2747/// `primitives.cppo.ml:528-533`'s.
2748///
2749/// `in_script` is `not (MathContext.is_in_base_level mathctx)` — inside a
2750/// sub/superscript upstream suppresses the whole table except the five operator
2751/// pairs. `font_size` is `FontInfo.actual_math_font_size mathctx`, the size of
2752/// the LEVEL being laid out and not the ambient base size, which is why callers
2753/// pass their local `size`.
2754///
2755/// Upstream's `space_correction` channel is pinned at `NoSpace` here, so the
2756/// three arms that read it — `(_, Close)`, `(Ord|Prefix, Open)` and the
2757/// fallthrough — all yield nothing: exact for `NoSpace`, and the conservative
2758/// reading of a trailing italics correction and the MATH table's
2759/// `space_after_script`.
2760fn space_before(prev: MathKind, cur: MathKind, in_script: bool, font_size: Length) -> Length {
2761    use MathKind::*;
2762    let ratio = if in_script {
2763        match (prev, cur) {
2764            (Op, Ord) | (Ord, Op) | (Op, Op) | (Close, Op) | (Inner, Op) => SPACE_MATH_OP,
2765            _ => return Length::ZERO,
2766        }
2767    } else {
2768        match (prev, cur) {
2769            (Punct, _) => SPACE_MATH_PUNCT,
2770
2771            (Inner, Ord) | (Inner, Open) | (Inner, Punct) | (Inner, Inner) | (Ord, Inner)
2772            | (Prefix, Inner) | (Close, Inner) => SPACE_MATH_INNER,
2773
2774            // `corr = NoSpace`: no italics correction to append.
2775            (_, Close) => return Length::ZERO,
2776
2777            // `corr = NoSpace`: no `space_after_script` either.
2778            (Ord, Open) | (Prefix, Open) => return Length::ZERO,
2779
2780            (Bin, Ord) | (Bin, Prefix) | (Bin, Op) | (Bin, Open) | (Bin, Inner) | (Ord, Bin)
2781            | (Close, Bin) | (Inner, Bin) => SPACE_MATH_BIN,
2782
2783            (Rel, Ord) | (Rel, Op) | (Rel, Inner) | (Rel, Open) | (Rel, Prefix) | (Ord, Rel)
2784            | (Op, Rel) | (Inner, Rel) | (Close, Rel) => SPACE_MATH_REL,
2785
2786            (Op, Ord) | (Op, Op) | (Op, Inner) | (Op, Prefix) | (Ord, Op) | (Close, Op)
2787            | (Inner, Op) => SPACE_MATH_OP,
2788
2789            (Ord, Prefix) | (Inner, Prefix) => SPACE_MATH_PREFIX,
2790
2791            (_, End) | (End, _) => return Length::ZERO,
2792
2793            _ => return Length::ZERO,
2794        }
2795    };
2796    font_size * ratio
2797}
2798
2799/// `not (MathContext.is_in_base_level mathctx)` for the `(ctx, size)` pair this
2800/// port threads instead of upstream's `math_context`. BOTH witnesses are
2801/// needed: `layout_math_atom`'s script arms shrink only the local `size` and
2802/// pass the ambient `ctx` down, while `enter_script` (`attach_scripts`,
2803/// `read-math`'s `Math::WithContext`) advances `Context::math_script_level` and
2804/// scales `font_size` together — so a `WithContext` captured under a script
2805/// arrives with `size == ctx.font_size` and is recognisable only by its level.
2806fn math_in_script(ctx: &Context, size: Length) -> bool {
2807    size != ctx.font_size || ctx.math_script_level != MathScriptLevel::Base
2808}
2809
2810/// FontKey a math glyph c@size should measure/emit in: dedicated ctx.math_font
2811/// when it can render c, else text ctx.font. The one place math diverges from
2812/// text font; the MATH-table slice keys lookups on the same returned FontKey.
2813fn math_glyph_font(interp: &Interp, ctx: &Context, c: char, size: Length) -> FontKey {
2814    if interp.metrics.advance(ctx.math_font, c, size).is_some() {
2815        ctx.math_font
2816    } else {
2817        ctx.font
2818    }
2819}
2820
2821/// gap-5 metrics-probe predicate, now math-font-aware.
2822fn math_char_available(interp: &Interp, ctx: &Context, c: char, size: Length) -> bool {
2823    interp.metrics.advance(ctx.math_font, c, size).is_some()
2824        || interp.metrics.advance(ctx.font, c, size).is_some()
2825}
2826
2827/// Measure one math character at `size` under `math_glyph_font(ctx, c)` and
2828/// push it as a `MathGlyph` at the running `*x` (`dy = 0`; callers shift
2829/// scripts afterward), advancing `*x` past it.
2830fn push_char_glyph(
2831    interp: &mut Interp,
2832    ctx: &Context,
2833    c: char,
2834    size: Length,
2835    out: &mut Vec<MathGlyph>,
2836    x: &mut Length,
2837) -> Result<(), EvalError> {
2838    let font = math_glyph_font(interp, ctx, c, size);
2839    // `fontInfo.ml:379-383`: a math glyph below base level is set in the font's
2840    // `ssty` variant — a purpose-drawn form with its own advance, not the base
2841    // glyph shrunk (see `FontMetrics::math_script_variant`). Any miss falls
2842    // through to the base glyph unchanged.
2843    if math_in_script(ctx, size) {
2844        if let Some(v) = interp.metrics.math_script_variant(font, c, size) {
2845            out.push(MathGlyph {
2846                info: HorzStringInfo {
2847                    font,
2848                    size,
2849                    rising: Length::ZERO,
2850                    color: ctx.text_color,
2851                },
2852                text: c.to_string(),
2853                gid: Some(v.gid),
2854                dx: *x,
2855                dy: Length::ZERO,
2856                width: v.advance,
2857                height: v.height,
2858                depth: v.depth,
2859            });
2860            *x += v.advance;
2861            return Ok(());
2862        }
2863    }
2864    // Graceful degradation for a math character neither the math font nor the
2865    // text font can render (e.g. `⋯` U+22EF under the bundled faces): fall back
2866    // to a half-em advance and let the glyph degrade to `.notdef` at render
2867    // time (`gid: None`, resolved by `cid::encode_glyph_run`), exactly as the
2868    // text path does in `measure_run` — a missing glyph must not abort the whole
2869    // document. This only ever changes behavior for a glyph that would otherwise
2870    // be a hard error, so covered-glyph documents stay byte-identical.
2871    let advance = interp.metrics.advance(font, c, size).unwrap_or(size * 0.5);
2872    let (height, depth) = math_glyph_vextent(interp, font, c, size);
2873    out.push(MathGlyph {
2874        info: HorzStringInfo {
2875            font,
2876            size,
2877            rising: Length::ZERO,
2878            color: ctx.text_color,
2879        },
2880        text: c.to_string(),
2881        gid: None,
2882        dx: *x,
2883        dy: Length::ZERO,
2884        width: advance,
2885        height,
2886        depth,
2887    });
2888    *x += advance;
2889    Ok(())
2890}
2891
2892/// One math glyph's vertical ink extent, the way upstream measures it:
2893/// `FontFormat.get_math_glyph_metrics` (`fontFormat.ml:2257-2264`) takes the
2894/// glyph's OWN bounding box and truncates each side towards the baseline —
2895/// `hgt = truncate_negative ymax` (so a wholly-subscripted glyph reports
2896/// height 0, never a negative height) and `dpt = truncate_positive ymin` (so a
2897/// glyph entirely above the baseline, `*` at ymin=+320, reports depth 0).
2898///
2899/// This is NOT the font-level ascender/descender:
2900/// `MathC::sub_shift_clamped`/`sup_shift_clamped` clamp against these extents
2901/// (`math.ml:527-552`), so feeding them latinmodern-math's hhea ascender
2902/// (806/1000 em) and descender (194/1000 em) instead of `m`'s real ink box
2903/// (ymax 442, ymin 0) made the `superscript_baseline_drop_max` /
2904/// `subscript_baseline_drop_min` candidate win every time. At 12pt that is
2905/// `9.672 - 3.0 = 6.672pt` of superscript rise where upstream's own clamp
2906/// picks `SuperscriptShiftUp = 4.356pt` (plus a gap correction, 4.525pt), and
2907/// `2.328 + 2.4 = 4.728pt` of subscript drop where upstream picks
2908/// `SubscriptShiftDown = 2.964pt` — measured by `layout-tests/probes/
2909/// math_script_drop.saty`.
2910///
2911/// Falls back to `ascender`/`descender` when the provider exposes no per-glyph
2912/// bbox (base-14 metrics, test stubs).
2913fn math_glyph_vextent(interp: &Interp, font: FontKey, c: char, size: Length) -> (Length, Length) {
2914    match interp.metrics.glyph_vextent(font, c, size) {
2915        Some((h, d)) => (h.max(Length::ZERO), d.max(Length::ZERO)),
2916        None => (
2917            interp.metrics.ascender(font, size),
2918            interp.metrics.descender(font, size),
2919        ),
2920    }
2921}
2922
2923/// `push_char_glyph`'s big-operator sibling: try the v0.0.6 `BigOp`
2924/// vertical variant (`fontInfo.ml:386-401` — the 2nd `MathVariants` record if
2925/// present, else the 1st) unconditionally. Upstream's own guard is
2926/// `is_in_display && is_big`, but `math.ml`'s `convert_math_char` hardcodes
2927/// `is_in_display = true`, so it reduces to just `is_big` — a big operator
2928/// grows even inline, even at script size, exactly like upstream; the port
2929/// tracks no display/inline distinction and needs none here. On any miss (no
2930/// MATH table, no vertical construction for `c`, or a variant/hmtx/bbox
2931/// lookup failure — every base-14 call, always) falls back to
2932/// `push_char_glyph`, byte-identical to the base output.
2933fn push_big_char_glyph(
2934    interp: &mut Interp,
2935    ctx: &Context,
2936    c: char,
2937    size: Length,
2938    out: &mut Vec<MathGlyph>,
2939    x: &mut Length,
2940) -> Result<(), EvalError> {
2941    let font = math_glyph_font(interp, ctx, c, size);
2942    match interp
2943        .metrics
2944        .math_vertical_variant(font, c, size, VertVariantPolicy::BigOp)
2945    {
2946        Some(v) => {
2947            out.push(MathGlyph {
2948                info: HorzStringInfo {
2949                    font,
2950                    size,
2951                    rising: Length::ZERO,
2952                    color: ctx.text_color,
2953                },
2954                text: c.to_string(),
2955                gid: Some(v.gid),
2956                dx: *x,
2957                dy: Length::ZERO,
2958                width: v.advance,
2959                height: v.height,
2960                depth: v.depth,
2961            });
2962            *x += v.advance;
2963            Ok(())
2964        }
2965        None => push_char_glyph(interp, ctx, c, size, out, x),
2966    }
2967}
2968
2969/// One stretchy-delimiter glyph: the smallest `MathVariants`
2970/// record whose `advance_measurement` covers `target` (else the largest
2971/// record — `VertVariantPolicy::AtLeast`), centered on the math axis
2972/// (`dy = axis - (h - d) / 2`; y-**up**, same sign convention as
2973/// `shift_and_append`'s `dy_shift` — see that function's doc comment on the
2974/// mirroring trap a flipped sign causes). Falls back to the baseline
2975/// base glyph (`push_char_glyph`) when there's no vertical construction.
2976fn push_delimiter_glyph(
2977    interp: &mut Interp,
2978    ctx: &Context,
2979    c: char,
2980    size: Length,
2981    target: Length,
2982    axis: Length,
2983    out: &mut Vec<MathGlyph>,
2984    x: &mut Length,
2985) -> Result<(), EvalError> {
2986    let font = math_glyph_font(interp, ctx, c, size);
2987    let variant =
2988        interp
2989            .metrics
2990            .math_vertical_variant(font, c, size, VertVariantPolicy::AtLeast(target));
2991    // `GlyphAssembly`: if even the largest discrete variant's own ink
2992    // extent (`height + depth`) still doesn't span `target` — a delimiter
2993    // taller than anything the font enumerates as a prepared variant — grow
2994    // it from the assembly parts instead (stack top + repeated extenders +
2995    // bottom). `None` (no MATH table / no assembly / base-14) leaves the
2996    // discrete/base path below byte-identical.
2997    let discrete_covers = variant
2998        .map(|v| (v.height + v.depth).0 >= target.0)
2999        .unwrap_or(false);
3000    if !discrete_covers {
3001        if let Some(parts) = interp.metrics.math_vertical_assembly(font, c, size, target) {
3002            if !parts.is_empty() {
3003                // Horizontal advance of the delimiter column: the largest
3004                // discrete variant's own hmtx advance when we have one (the
3005                // parts share the same nominal delimiter width), else the base
3006                // glyph's advance.
3007                let hadv = match variant {
3008                    Some(v) => v.advance,
3009                    None => interp
3010                        .metrics
3011                        .advance(font, c, size)
3012                        .unwrap_or(Length::ZERO),
3013                };
3014                // Total vertical extent of the stacked assembly (local, from
3015                // the bottom part's baseline at 0), then center it on the math
3016                // axis exactly like the discrete path centers a variant's ink.
3017                let total = parts
3018                    .last()
3019                    .map(|(_, dy, adv)| *dy + *adv)
3020                    .unwrap_or(Length::ZERO);
3021                let base_off = axis - total * 0.5;
3022                for (i, (gid, dy_local, adv)) in parts.iter().enumerate() {
3023                    out.push(MathGlyph {
3024                        info: HorzStringInfo {
3025                            font,
3026                            size,
3027                            rising: Length::ZERO,
3028                            color: ctx.text_color,
3029                        },
3030                        text: c.to_string(),
3031                        gid: Some(*gid),
3032                        dx: *x,
3033                        dy: base_off + *dy_local,
3034                        // Only the first part carries the column's horizontal
3035                        // width (all parts are stacked in the SAME x column);
3036                        // its baseline-relative extent is the part's vertical
3037                        // advance (up), so `glyphs_extent` folds the whole
3038                        // stacked column into the box's height/depth.
3039                        width: if i == 0 { hadv } else { Length::ZERO },
3040                        height: *adv,
3041                        depth: Length::ZERO,
3042                    });
3043                }
3044                *x += hadv;
3045                return Ok(());
3046            }
3047        }
3048    }
3049    match variant {
3050        Some(v) => {
3051            let dy = axis - (v.height - v.depth) * 0.5;
3052            out.push(MathGlyph {
3053                info: HorzStringInfo {
3054                    font,
3055                    size,
3056                    rising: Length::ZERO,
3057                    color: ctx.text_color,
3058                },
3059                text: c.to_string(),
3060                gid: Some(v.gid),
3061                dx: *x,
3062                dy,
3063                width: v.advance,
3064                height: v.height,
3065                depth: v.depth,
3066            });
3067            *x += v.advance;
3068            Ok(())
3069        }
3070        None => push_char_glyph(interp, ctx, c, size, out, x),
3071    }
3072}
3073
3074/// Lay out `elems` in isolation (its own local `x` starting at 0, its own
3075/// spacing state) at `size` — the shape a `Sup`/`Sub`/`Primes` script needs
3076/// before its glyphs get re-anchored onto the base's running `x` and
3077/// shifted by the caller. `size` is the caller's `MathC::script_scale`-
3078/// derived script size (real MATH-table ratio when available, `SCRIPT_SCALE`
3079/// otherwise). Returns the glyphs (still at local coordinates) and
3080/// the script's total width.
3081fn layout_script(
3082    interp: &mut Interp,
3083    ctx: &Context,
3084    elems: &[MathElem],
3085    size: Length,
3086) -> Result<(Vec<MathGlyph>, Length), EvalError> {
3087    let mut glyphs = Vec::new();
3088    let mut x = Length::ZERO;
3089    let mut last_kind: Option<MathKind> = None;
3090    for e in elems {
3091        layout_math_elem(interp, ctx, e, size, &mut glyphs, &mut x, &mut last_kind)?;
3092    }
3093    Ok((glyphs, x))
3094}
3095
3096/// Re-anchor an isolated script's glyphs (`layout_script`'s output) onto the
3097/// base's running `*x`, adding `dy_shift` to every glyph's vertical offset —
3098/// `dy_shift > 0` raises (superscript), `< 0` lowers (subscript). Advances
3099/// `*x` past the whole script.
3100fn place_script(
3101    out: &mut Vec<MathGlyph>,
3102    x: &mut Length,
3103    script_glyphs: Vec<MathGlyph>,
3104    script_width: Length,
3105    dy_shift: Length,
3106) {
3107    let base_x = *x;
3108    for mut g in script_glyphs {
3109        g.dx = base_x + g.dx;
3110        g.dy = g.dy + dy_shift;
3111        out.push(g);
3112    }
3113    *x = base_x + script_width;
3114}
3115
3116/// The recursive core of `read_math`: lays out one `MathElem` into `out`,
3117/// advancing `*x` and threading `*last_kind` (the trailing `MathKind` of
3118/// whatever was laid out immediately before, for `space_before`) through
3119/// siblings — the analog of `convert_to_low` + `horz_of_low_math`
3120/// (`math.ml:753`/`:1016`), fused and with fixed constants.
3121fn layout_math_elem(
3122    interp: &mut Interp,
3123    ctx: &Context,
3124    elem: &MathElem,
3125    size: Length,
3126    out: &mut Vec<MathGlyph>,
3127    x: &mut Length,
3128    last_kind: &mut Option<MathKind>,
3129) -> Result<(), EvalError> {
3130    match elem {
3131        MathElem::Chars(s) => {
3132            for c in s.chars() {
3133                let kind = ascii_math_kind(c);
3134                if let Some(prev) = *last_kind {
3135                    *x += space_before(prev, kind, math_in_script(ctx, size), size);
3136                }
3137                push_char_glyph(interp, ctx, c, size, out, x)?;
3138                *last_kind = Some(kind);
3139            }
3140            Ok(())
3141        }
3142        MathElem::Group(elems) => {
3143            for e in elems {
3144                layout_math_elem(interp, ctx, e, size, out, x, last_kind)?;
3145            }
3146            Ok(())
3147        }
3148        MathElem::Sup(base, script) => {
3149            let base_start = out.len();
3150            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3151            let mc = MathC::of(interp, ctx);
3152            let script_size = ctx.font_size * mc.script_scale();
3153            let (h_base, _) = glyphs_extent(&out[base_start..]);
3154            let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3155            let (_, d_sup) = glyphs_extent(&script_glyphs);
3156            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3157            let kern = superscript_kern(
3158                interp,
3159                ctx,
3160                size,
3161                script_size,
3162                &out[base_start..],
3163                &script_glyphs,
3164                sup_shift,
3165                h_base,
3166                d_sup,
3167            );
3168            *x += kern;
3169            place_script(out, x, script_glyphs, script_width, sup_shift);
3170            Ok(())
3171        }
3172        MathElem::Sub(base, script) => {
3173            let base_start = out.len();
3174            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3175            let mc = MathC::of(interp, ctx);
3176            let script_size = ctx.font_size * mc.script_scale();
3177            let (_, d_base) = glyphs_extent(&out[base_start..]);
3178            let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3179            let (h_sub, _) = glyphs_extent(&script_glyphs);
3180            let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
3181            place_script(out, x, script_glyphs, script_width, -sub_shift);
3182            Ok(())
3183        }
3184        MathElem::Primes(base, n) => {
3185            let base_start = out.len();
3186            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3187            let mc = MathC::of(interp, ctx);
3188            let script_size = ctx.font_size * mc.script_scale();
3189            let (h_base, _) = glyphs_extent(&out[base_start..]);
3190            // Upstream desugars primes to exactly this: a superscript of `n`
3191            // U+2032 `′` chars (`parser.mly:1082`).
3192            let primes = vec![MathElem::Chars("\u{2032}".repeat(*n))];
3193            let (script_glyphs, script_width) = layout_script(interp, ctx, &primes, script_size)?;
3194            let (_, d_sup) = glyphs_extent(&script_glyphs);
3195            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3196            let kern = superscript_kern(
3197                interp,
3198                ctx,
3199                size,
3200                script_size,
3201                &out[base_start..],
3202                &script_glyphs,
3203                sup_shift,
3204                h_base,
3205                d_sup,
3206            );
3207            *x += kern;
3208            place_script(out, x, script_glyphs, script_width, sup_shift);
3209            Ok(())
3210        }
3211        MathElem::Cmd { name, span, .. } => Err(EvalError {
3212            span: Some(*span),
3213            msg: format!("math command `{name}` needs the math package (phase 7 roadmap A)"),
3214        }),
3215        MathElem::Embed { span, .. } => Err(EvalError {
3216            span: Some(*span),
3217            msg: "embedding a program value in math needs the math package \
3218                  (phase 7 roadmap A)"
3219                .into(),
3220        }),
3221    }
3222}
3223
3224/// Walk an elaborated `${…}` tree (`read_inline`'s `EmbedMath` arm) into one
3225/// `PureHorzBox::Math`, measuring every glyph through `interp.metrics` at
3226/// `ctx.font`/`ctx.font_size` — the same `FontMetrics` seam `text_to_boxes`
3227/// uses. Box-model rationale: a math run carries its own pre-shifted
3228/// sub-glyphs, since the line model has no per-box vertical slot.
3229pub fn read_math(
3230    interp: &mut Interp,
3231    ctx: &Context,
3232    elems: &[MathElem],
3233) -> Result<PureHorzBox, EvalError> {
3234    let mut glyphs: Vec<MathGlyph> = Vec::new();
3235    let mut x = Length::ZERO;
3236    let mut last_kind: Option<MathKind> = None;
3237    for e in elems {
3238        layout_math_elem(
3239            interp,
3240            ctx,
3241            e,
3242            ctx.font_size,
3243            &mut glyphs,
3244            &mut x,
3245            &mut last_kind,
3246        )?;
3247    }
3248    let width = x;
3249    let mut height = Length::ZERO;
3250    let mut depth = Length::ZERO;
3251    for g in &glyphs {
3252        height = height.max(g.dy + g.height);
3253        depth = depth.max(g.depth - g.dy);
3254    }
3255    Ok(PureHorzBox::Math {
3256        width,
3257        height,
3258        depth,
3259        glyphs,
3260        rules: Vec::new(),
3261    })
3262}
3263
3264// ---- primitive bodies ----------------------------------------------------------
3265
3266fn prim_read_inline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3267    let it = args.pop().unwrap();
3268    let ctx = as_context(args.pop().unwrap())?;
3269    let (elems, env) = as_inline_text(it)?;
3270    Ok(Value::InlineBoxes(read_inline(interp, &ctx, &elems, &env)?))
3271}
3272
3273fn prim_read_block(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3274    let bt = args.pop().unwrap();
3275    let ctx = as_context(args.pop().unwrap())?;
3276    let (elems, env) = as_block_text(bt)?;
3277    Ok(Value::BlockBoxes(read_block(interp, &ctx, &elems, &env)?))
3278}
3279
3280/// `line-break : bool -> bool -> context -> inline-boxes -> block-boxes`
3281/// (vminst.ml `BackendLineBreaking`). The two leading bools tell the real
3282/// line breaker whether the paragraph's top/bottom edge may break across a
3283/// page; this port's `break_into_lines` does not yet model breakability
3284/// at all, so both are accepted (to keep the arity/signature faithful to
3285/// v0.0.6) and ignored for now.
3286///
3287/// This is upstream's `form_paragraph` seam — every stdlib caller
3288/// (`form-paragraph = line-break true true`, and every direct `line-break _
3289/// _ (ctx |> set-paragraph-margin …)` call for headings/itemize/footnotes)
3290/// relies on `line-break` itself to apply
3291/// `ctx.paragraph_top`/`paragraph_bottom` around the formed lines,
3292/// unconditionally of the two breakability bools (those only ever gate
3293/// page-break eligibility upstream, never whether the margin applies).
3294/// Prepending/appending `VertBox::Skip` here is a no-op in extent for a
3295/// caller that already zeroed the margin (e.g. `footnote-scheme.satyh`'s
3296/// `set-paragraph-margin 0pt 0pt`), and the leading skip specifically is
3297/// further discarded by `chop_page` when it lands at the very top of a
3298/// page/column (see that function's `pending_skip` handling) — mirroring
3299/// upstream's page-top glue suppression so a page's first paragraph does not
3300/// get a spurious gap above it.
3301fn prim_line_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3302    let ib = as_inline_boxes(args.pop().unwrap())?;
3303    let ctx = as_context(args.pop().unwrap())?;
3304    let _is_breakable_bottom = as_bool(args.pop().unwrap())?;
3305    let _is_breakable_top = as_bool(args.pop().unwrap())?;
3306    let lines = break_into_lines(&ctx, ib);
3307    // No lines were actually formed (empty inline content, `break_into_lines`'s
3308    // own `n == 0` early return) — don't manufacture a margin around nothing.
3309    let mut out = Vec::with_capacity(lines.len() + 2);
3310    if !lines.is_empty() {
3311        // `min_first_line_ascender` (9pt, `primitives.cppo.ml:516`) is folded
3312        // into the paragraph's OWN top margin, exactly as `lineBreak.ml:855-857`
3313        // does — `margin_top = paragraph_margin_top + max(0, 9pt - hgt)` over
3314        // the FIRST formed line's height. That padded value is what
3315        // `pageBreak.ml`'s `squash_margins` (`:596-601`) then max-collapses
3316        // against the previous block's bottom margin, so a larger predecessor
3317        // ABSORBS the pad instead of stacking it on top. Applying the floor
3318        // downstream to the line HEIGHT, after the collapse, is a different
3319        // function: it over-spaced every block whose predecessor had the larger
3320        // bottom margin — 5pt at every stdjabook section heading, whose 4pt
3321        // rule lines are shorter than the floor.
3322        //
3323        // The BOTTOM margin takes no pad: `min_last_descender` is assigned at
3324        // `lineBreak.ml:1144` and never read.
3325        let first_height = lines
3326            .iter()
3327            .find_map(|vb| match vb {
3328                VertBox::Line { height, .. } => Some(*height),
3329                _ => None,
3330            })
3331            .unwrap_or(Length::ZERO);
3332        let pad = (MIN_FIRST_ASCENDER - first_height).max(Length::ZERO);
3333        out.push(VertBox::ParagTop(ctx.paragraph_top + pad));
3334        out.extend(lines);
3335        out.push(VertBox::Skip(ctx.paragraph_bottom));
3336    }
3337    for vb in &mut out {
3338        if let VertBox::Line { contents, .. } = vb {
3339            resolve_outer_graphics_in_contents(interp, contents)?;
3340        }
3341    }
3342    Ok(Value::BlockBoxes(out))
3343}
3344
3345/// Look up `field` in a scheme record's fields, erroring with the
3346/// available-fields hint (mirrors `evalUtil.ml`'s `report_bug_value` arms
3347/// for a missing/mistyped scheme field) if it's absent.
3348fn record_field(
3349    fields: &BTreeMap<String, Value>,
3350    record_name: &str,
3351    field: &str,
3352) -> Result<Value, EvalError> {
3353    match fields.get(field) {
3354        Some(v) => Ok(v.clone()),
3355        None => eval_error(format!(
3356            "{record_name} record is missing field '{field}' (available fields: {})",
3357            available_fields(fields)
3358        )),
3359    }
3360}
3361
3362/// Extract `(text-origin, text-height)` from a `page-content-scheme`
3363/// record (`{| text-origin : point; text-height : length |}`) — the direct
3364/// port of `make_page_content_scheme_func`'s field pull (`evalUtil.ml:558-
3365/// 565`).
3366fn read_content_scheme(v: Value) -> Result<(Point, Length), EvalError> {
3367    let fields = match v {
3368        Value::Record(m) => m,
3369        other => {
3370            return eval_error(format!(
3371                "a page-content-scheme closure must return a record, got {}",
3372                other.type_name()
3373            ))
3374        }
3375    };
3376    let origin = as_point(record_field(&fields, "page-content-scheme", "text-origin")?)?;
3377    let height = as_length(record_field(&fields, "page-content-scheme", "text-height")?)?;
3378    Ok((origin, height))
3379}
3380
3381/// Extract `(header-origin, header-content, footer-origin, footer-content)`
3382/// from a `page-parts` record — the direct port of
3383/// `make_page_parts_scheme_func`'s field pull (`evalUtil.ml:576-595`).
3384fn read_parts_scheme(v: Value) -> Result<(Point, Vec<VertBox>, Point, Vec<VertBox>), EvalError> {
3385    let fields = match v {
3386        Value::Record(m) => m,
3387        other => {
3388            return eval_error(format!(
3389                "a page-parts closure must return a record, got {}",
3390                other.type_name()
3391            ))
3392        }
3393    };
3394    let header_origin = as_point(record_field(&fields, "page-parts", "header-origin")?)?;
3395    let header_content = as_block_boxes(record_field(&fields, "page-parts", "header-content")?)?;
3396    let footer_origin = as_point(record_field(&fields, "page-parts", "footer-origin")?)?;
3397    let footer_content = as_block_boxes(record_field(&fields, "page-parts", "footer-content")?)?;
3398    Ok((header_origin, header_content, footer_origin, footer_content))
3399}
3400
3401/// Upstream's `--page-number-limit` default (main.ml:1029). v0.0.6 guards
3402/// only the multicolumn loop (pageBreak.ml:765, `PageNumberLimitExceeded`);
3403/// the port guards the shared loop unconditionally — a hook-less run is
3404/// already bounded by the vbox count (`chop_page`'s progress guarantee), so
3405/// the guard can only fire when column hooks inject content, exactly the
3406/// case upstream added it for.
3407const PAGE_NUMBER_LIMIT: i64 = 10_000;
3408
3409/// The real 4-arg `page-break`, v0.0.6 arm — upstream `BCDocument(pagesize,
3410/// SingleColumn, (fun () -> []), (fun () -> []), …)` (vminst.ml:1039): one
3411/// zero-shift column, no hooks. Forked from the v0.1 arm below ONLY in its
3412/// first-argument extraction (`as_page` vs `as_page_v01`) — deliberately two
3413/// separate functions per tag rather than one branching on a `version`
3414/// parameter, so that a "shared" function is genuinely shared code.
3415/// `page_break_core` below IS that shared code.
3416fn prim_page_break_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3417    let bb = as_block_boxes(args.pop().unwrap())?;
3418    let pagepartsf = args.pop().unwrap();
3419    let pagecontf = args.pop().unwrap();
3420    let paper = as_page(args.pop().unwrap())?;
3421    page_break_core(
3422        interp,
3423        paper,
3424        vec![Length::ZERO],
3425        None,
3426        None,
3427        pagecontf,
3428        pagepartsf,
3429        bb,
3430    )
3431}
3432
3433/// v0.1 arm of `page-break`. Identical to `prim_page_break_v006` above
3434/// except `as_page_v01` in place of `as_page`; everything downstream
3435/// (`page_break_core`, `chop_page`, `place_block_at`, `DocumentValue`
3436/// assembly) is the SAME shared code both arms call, unedited by this fork.
3437fn prim_page_break_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3438    let bb = as_block_boxes(args.pop().unwrap())?;
3439    let pagepartsf = args.pop().unwrap();
3440    let pagecontf = args.pop().unwrap();
3441    let paper = as_page_v01(args.pop().unwrap())?;
3442    page_break_core(
3443        interp,
3444        paper,
3445        vec![Length::ZERO],
3446        None,
3447        None,
3448        pagecontf,
3449        pagepartsf,
3450        bb,
3451    )
3452}
3453
3454/// `page-break-two-column : page -> length -> (unit -> block-boxes) ->
3455/// (pbinfo -> page-content-scheme) -> (pbinfo -> page-parts) ->
3456/// block-boxes -> document` (vminst.ml:1041 `BackendPageBreakingTwoColumn`),
3457/// v0.0.6 arm — upstream builds `MultiColumn([origin_shift])` with the
3458/// user's column hook and a trivial column-end hook (vminst.ml:1062); the
3459/// `length` is the x-shift of the SECOND column's origin. See
3460/// `prim_page_break_v006`'s doc comment for the fork rationale.
3461fn prim_page_break_two_column_v006(
3462    interp: &mut Interp,
3463    mut args: Vec<Value>,
3464) -> Result<Value, EvalError> {
3465    let bb = as_block_boxes(args.pop().unwrap())?;
3466    let pagepartsf = args.pop().unwrap();
3467    let pagecontf = args.pop().unwrap();
3468    let columnhookf = args.pop().unwrap();
3469    let origin_shift = as_length(args.pop().unwrap())?;
3470    let paper = as_page(args.pop().unwrap())?;
3471    page_break_core(
3472        interp,
3473        paper,
3474        vec![Length::ZERO, origin_shift],
3475        Some(columnhookf),
3476        None,
3477        pagecontf,
3478        pagepartsf,
3479        bb,
3480    )
3481}
3482
3483/// v0.1 arm of `page-break-two-column`, using `as_page_v01` in place of
3484/// `as_page`.
3485fn prim_page_break_two_column_v01(
3486    interp: &mut Interp,
3487    mut args: Vec<Value>,
3488) -> Result<Value, EvalError> {
3489    let bb = as_block_boxes(args.pop().unwrap())?;
3490    let pagepartsf = args.pop().unwrap();
3491    let pagecontf = args.pop().unwrap();
3492    let columnhookf = args.pop().unwrap();
3493    let origin_shift = as_length(args.pop().unwrap())?;
3494    let paper = as_page_v01(args.pop().unwrap())?;
3495    page_break_core(
3496        interp,
3497        paper,
3498        vec![Length::ZERO, origin_shift],
3499        Some(columnhookf),
3500        None,
3501        pagecontf,
3502        pagepartsf,
3503        bb,
3504    )
3505}
3506
3507/// `page-break-multicolumn : page -> length list -> (unit -> block-boxes)
3508/// -> (unit -> block-boxes) -> (pbinfo -> page-content-scheme) -> (pbinfo
3509/// -> page-parts) -> block-boxes -> document` (vminst.ml:1065
3510/// `BackendPageBreakingMultiColumn`), v0.0.6 arm — FAITHFUL: the shift list
3511/// gives columns 2..N's x-origin shifts; upstream prepends `Length.zero` for
3512/// column 1 (pageBreak.ml:762), so `stdjareport.satyh:403`'s `[]` is a
3513/// one-column layout whose hooks still fire per column/page.
3514fn prim_page_break_multicolumn_v006(
3515    interp: &mut Interp,
3516    mut args: Vec<Value>,
3517) -> Result<Value, EvalError> {
3518    let bb = as_block_boxes(args.pop().unwrap())?;
3519    let pagepartsf = args.pop().unwrap();
3520    let pagecontf = args.pop().unwrap();
3521    let columnendhookf = args.pop().unwrap();
3522    let columnhookf = args.pop().unwrap();
3523    let mut origin_shifts = vec![Length::ZERO];
3524    for v in as_list(args.pop().unwrap())? {
3525        origin_shifts.push(as_length(v)?);
3526    }
3527    let paper = as_page(args.pop().unwrap())?;
3528    page_break_core(
3529        interp,
3530        paper,
3531        origin_shifts,
3532        Some(columnhookf),
3533        Some(columnendhookf),
3534        pagecontf,
3535        pagepartsf,
3536        bb,
3537    )
3538}
3539
3540/// v0.1 arm of `page-break-multicolumn`, using `as_page_v01` in place of
3541/// `as_page`.
3542fn prim_page_break_multicolumn_v01(
3543    interp: &mut Interp,
3544    mut args: Vec<Value>,
3545) -> Result<Value, EvalError> {
3546    let bb = as_block_boxes(args.pop().unwrap())?;
3547    let pagepartsf = args.pop().unwrap();
3548    let pagecontf = args.pop().unwrap();
3549    let columnendhookf = args.pop().unwrap();
3550    let columnhookf = args.pop().unwrap();
3551    let mut origin_shifts = vec![Length::ZERO];
3552    for v in as_list(args.pop().unwrap())? {
3553        origin_shifts.push(as_length(v)?);
3554    }
3555    let paper = as_page_v01(args.pop().unwrap())?;
3556    page_break_core(
3557        interp,
3558        paper,
3559        origin_shifts,
3560        Some(columnhookf),
3561        Some(columnendhookf),
3562        pagecontf,
3563        pagepartsf,
3564        bb,
3565    )
3566}
3567
3568/// Apply a `unit -> block-boxes` column hook and PREPEND its result to the
3569/// remaining content — the port of `chop_single_column_with_insertion`
3570/// (pageBreak.ml:699-702; the upstream `normalize` is a no-op here because
3571/// block-boxes are already solid `Vec<VertBox>`).
3572fn apply_column_hook(
3573    interp: &mut Interp,
3574    hook: &Value,
3575    remaining: &mut Vec<VertBox>,
3576) -> Result<(), EvalError> {
3577    let inserted = as_block_boxes(interp.apply(hook.clone(), Value::Unit)?)?;
3578    remaining.splice(0..0, inserted);
3579    Ok(())
3580}
3581
3582/// The shared per-page loop backing `page-break`, `page-break-two-column`,
3583/// and `page-break-multicolumn` — the port of `PageBreak.main` /
3584/// `main_multicolumn` (pageBreak.ml:705-781). Lang-side because it is the
3585/// one place that legally holds `&mut Interp` to apply the scheme/hook
3586/// closures (the `fire_hooks` seam). `origin_shifts` is the FULL column
3587/// list (leading zero included by the callers); `None` hooks are upstream's
3588/// `(fun () -> [])`.
3589///
3590/// Per page: apply `pagecontf` once; per column: fire `columnhookf`
3591/// (start of EVERY column, pageBreak.ml:700), chop one column at
3592/// `(x0 + shift, y0)` (footnotes bottom-place per column inside
3593/// `chop_page`), stop early when content runs out; then fire
3594/// `columnendhookf` exactly once (both upstream arms — exhausted
3595/// mid-columns `:751` and shifts-exhausted `:736` — reduce to "prepend its
3596/// output to the remainder"); then apply `pagepartsf` and place the parts.
3597#[allow(clippy::too_many_arguments)]
3598fn page_break_core(
3599    interp: &mut Interp,
3600    paper: PaperSize,
3601    origin_shifts: Vec<Length>,
3602    columnhookf: Option<Value>,
3603    columnendhookf: Option<Value>,
3604    pagecontf: Value,
3605    pagepartsf: Value,
3606    bb: Vec<VertBox>,
3607) -> Result<Value, EvalError> {
3608    let (paper_w, paper_h) = paper.dims();
3609
3610    // Capture the flat pre-page-break `Vec<VertBox>` BEFORE
3611    // `chop_page`/`apply_column_hook` below start draining/mutating
3612    // `remaining` — this clone is the document's natural linear flow exactly
3613    // as `bb` arrived here (no pages, no injected headers/footers, no
3614    // column-hook-inserted content). Unconditional (not gated on which
3615    // output format was requested — see `DocumentValue::reflow_source`'s doc
3616    // comment): PDF and the faithful HTML backend never read the field, so
3617    // this costs them only the clone itself, never a byte of their rendered
3618    // output.
3619    let reflow_source = bb.clone();
3620
3621    let mut remaining = bb;
3622    let mut pages: Vec<Page> = Vec::new();
3623    let mut pageno: i64 = 1;
3624    loop {
3625        if pageno > PAGE_NUMBER_LIMIT {
3626            return eval_error(format!(
3627                "page number limit exceeded ({PAGE_NUMBER_LIMIT}); a column hook keeps injecting content"
3628            ));
3629        }
3630        let mut pb_fields = BTreeMap::new();
3631        pb_fields.insert("page-number".to_string(), Value::Int(pageno));
3632        let pbinfo = Value::Record(pb_fields);
3633
3634        // ---- content scheme: this page's text area (applied ONCE per page, shared by all its columns — pageBreak.ml:769) ----
3635        let sch = interp.apply(pagecontf.clone(), pbinfo.clone())?;
3636        let (origin, height) = read_content_scheme(sch)?;
3637        let (x0, y0) = origin;
3638
3639        // ---- columns ----
3640        let mut lines = Vec::new();
3641        for shift in &origin_shifts {
3642            if let Some(hook) = &columnhookf {
3643                apply_column_hook(interp, hook, &mut remaining)?;
3644            }
3645            lines.extend(chop_page((x0 + *shift, y0), height, &mut remaining));
3646            if remaining.is_empty() {
3647                break; // content exhausted: remaining columns are skipped
3648            }
3649        }
3650        if let Some(hook) = &columnendhookf {
3651            apply_column_hook(interp, hook, &mut remaining)?;
3652        }
3653
3654        // A trailing pure-skip/glue (e.g. the last block's `paragraph_bottom`)
3655        // can roll past the previous page's bottom into a final `chop_page`
3656        // that places NO real line — `chop_page` discards it as a page-top
3657        // skip, leaving an empty body. SATySFi never emits such a trailing
3658        // blank page (glue at the end of the vertical list is dropped), so when
3659        // the body is empty AND content is now exhausted, stop before turning
3660        // that leftover into a spurious blank page (header/footer included).
3661        if remaining.is_empty() && !lines.iter().any(|l| placed_line_extent(l).is_some()) {
3662            break;
3663        }
3664
3665        // ---- parts scheme: this page's header + footer ----
3666        // Everything placed so far is body/column content; the header and
3667        // footer append AFTER it (see `Page::body_lines`).
3668        let body_lines = lines.len();
3669        let parts = interp.apply(pagepartsf.clone(), pbinfo)?;
3670        let (header_origin, header_content, footer_origin, footer_content) =
3671            read_parts_scheme(parts)?;
3672        lines.extend(place_block_at(header_origin, header_content));
3673        lines.extend(place_block_at(footer_origin, footer_content));
3674
3675        pages.push(Page { lines, body_lines });
3676        if remaining.is_empty() {
3677            break;
3678        }
3679        pageno += 1;
3680    }
3681
3682    // Every image `load-image` decoded while evaluating this document (see
3683    // `Interp::images`'s doc comment) rides along in the packaged
3684    // `DocumentValue` so the PDF writer can emit XObjects for the ones
3685    // actually placed on a page.
3686    let images = interp.images.clone();
3687    Ok(Value::Document(Rc::new(DocumentValue {
3688        geometry: PageGeometry::for_paper(paper_w, paper_h),
3689        pages,
3690        images,
3691        // Filled in by `compile_document_cst_with_trials` once `fire_hooks`
3692        // has walked the final trial's placed geometry (see
3693        // `DocumentValue::extras`'s doc comment) — hooks/decos haven't fired
3694        // yet at this point in `page-break`'s own evaluation.
3695        extras: DocExtras::default(),
3696        reflow_source: Some(reflow_source),
3697        // Filled in alongside `extras` once `fire_hooks` has run — see
3698        // `DocumentValue::reflow_links`'s doc comment.
3699        reflow_links: Vec::new(),
3700        reflow_dests: Vec::new(),
3701        reflow_frame_decos: Vec::new(),
3702    })))
3703}
3704
3705// ---- int arithmetic -------------------------------------------------------
3706
3707// Wrapping arithmetic to match OCaml's native `int` (SATySFi's `int` is an
3708// OCaml int, which wraps on overflow) — and, decisively, so a debug build does
3709// not panic on the large intermediate products base's float bit-twiddling
3710// (`exp2i`, `ldexp`, `frexp`) computes.
3711binop_prim!(prim_int_add, as_int, Int, |a, b| a.wrapping_add(b));
3712binop_prim!(prim_int_sub, as_int, Int, |a, b| a.wrapping_sub(b));
3713binop_prim!(prim_int_mul, as_int, Int, |a, b| a.wrapping_mul(b));
3714
3715// OCaml catches `Division_by_zero` and reports `"division by zero"`; `mod`
3716// (see `Mod` in vminst.ml) shares that behavior.
3717binop_prim_try!(prim_int_div, as_int, |a, b| if b == 0 {
3718    eval_error("division by zero")
3719} else {
3720    Ok(Value::Int(a / b))
3721});
3722binop_prim_try!(prim_int_mod, as_int, |a, b| if b == 0 {
3723    eval_error("division by zero")
3724} else {
3725    Ok(Value::Int(a % b))
3726});
3727
3728// ---- int comparisons -------------------------------------------------------
3729
3730cmp_prim!(prim_int_eq, as_int, |a, b| a == b);
3731cmp_prim!(prim_int_ne, as_int, |a, b| a != b);
3732cmp_prim!(prim_int_lt, as_int, |a, b| a < b);
3733cmp_prim!(prim_int_gt, as_int, |a, b| a > b);
3734cmp_prim!(prim_int_le, as_int, |a, b| a <= b);
3735cmp_prim!(prim_int_ge, as_int, |a, b| a >= b);
3736
3737// ---- 0.1 bitwise ops -------------------------------------------------------
3738//
3739// `band`/`bor`/`bxor` mirror OCaml's `land`/`lor`/`lxor`; `bnot` mirrors
3740// `lnot` (bitwise complement). DOCUMENTED DEVIATION: this port's `int` is a
3741// 64-bit two's-complement `i64`, vs upstream's 63-bit boxed OCaml `int` — a
3742// value that actually uses bit 62 (the port's sign-adjacent bit upstream
3743// doesn't have) will complement/shift differently than upstream on that
3744// platform; upstream's own results are themselves platform-width-dependent
3745// there, and no bundled package relies on it.
3746binop_prim!(prim_band, as_int, Int, |a, b| a & b);
3747binop_prim!(prim_bor, as_int, Int, |a, b| a | b);
3748binop_prim!(prim_bxor, as_int, Int, |a, b| a ^ b);
3749unop_prim!(prim_bnot, as_int, Int, |a| !a);
3750
3751// `<<`/`>>` (dev-0-1-0 vminst.ml :2495/:2477): logical shifts (OCaml's
3752// `lsl`/`lsr`, NOT arithmetic — `>>` on a negative int does NOT sign-extend,
3753// see the `-16 >> 2` witness in the test suite), with upstream's exact
3754// dynamic-error message when the shift amount is out of `0..=63`.
3755binop_prim_try!(
3756    prim_bit_shift_left,
3757    as_int,
3758    |a, b| if !(0..=63).contains(&b) {
3759        eval_error("Bit offset out of bounds for '<<'")
3760    } else {
3761        Ok(Value::Int(((a as u64) << b) as i64))
3762    }
3763);
3764binop_prim_try!(
3765    prim_bit_shift_right,
3766    as_int,
3767    |a, b| if !(0..=63).contains(&b) {
3768        eval_error("Bit offset out of bounds for '>>'")
3769    } else {
3770        Ok(Value::Int(((a as u64) >> b) as i64))
3771    }
3772);
3773
3774// ---- bool -------------------------------------------------------------------
3775
3776// Strict (both arguments already evaluated by the caller before these natives
3777// run): real SATySFi source-level `&&`/`||` short-circuit via elaboration into
3778// `if`, which is out of scope here.
3779binop_prim!(prim_bool_and, as_bool, Bool, |a, b| a && b);
3780binop_prim!(prim_bool_or, as_bool, Bool, |a, b| a || b);
3781unop_prim!(prim_bool_not, as_bool, Bool, |a| !a);
3782
3783// ---- float --------------------------------------------------------------------
3784
3785binop_prim!(prim_float_add, as_float, Float, |a, b| a + b);
3786binop_prim!(prim_float_sub, as_float, Float, |a, b| a - b);
3787binop_prim!(prim_float_mul, as_float, Float, |a, b| a * b);
3788binop_prim!(prim_float_div, as_float, Float, |a, b| a / b);
3789unop_prim!(prim_float_of_int, as_int, Float, |n| n as f64);
3790
3791// `PrimitiveRound` in vminst.ml is, despite the name, `int_of_float`
3792// (truncation toward zero), not rounding to nearest.
3793unop_prim!(prim_round, as_float, Int, |x| x as i64);
3794
3795// ---- 0.1 float comparisons (saphe-split vminst.ml:2679-2740) ----
3796cmp_prim!(prim_float_gt, as_float, |a, b| a > b);
3797cmp_prim!(prim_float_lt, as_float, |a, b| a < b);
3798cmp_prim!(prim_float_ge, as_float, |a, b| a >= b);
3799cmp_prim!(prim_float_le, as_float, |a, b| a <= b);
3800
3801// ---- length ---------------------------------------------------------------------
3802
3803binop_prim!(prim_length_add, as_length, Length, |a, b| a + b);
3804binop_prim!(prim_length_sub, as_length, Length, |a, b| a - b);
3805binop_prim!(prim_length_scale, (as_length, as_float), Length, |a, b| a
3806    * b);
3807binop_prim!(prim_length_div, as_length, Float, |a, b| a / b);
3808cmp_prim!(prim_length_lt, as_length, |a, b| a < b);
3809
3810// `LengthGreaterThan` in vminst.ml is implemented as `len2 <% len1`, i.e.
3811// `a >' b` iff `b <' a` — the same ordering, just flipped operands.
3812cmp_prim!(prim_length_gt, as_length, |a, b| b < a);
3813
3814// ---- string -----------------------------------------------------------------------
3815
3816binop_prim!(prim_string_concat, as_str, Str, |a, b| a + &b);
3817unop_prim!(prim_arabic, as_int, Str, |n| n.to_string());
3818cmp_prim!(prim_string_same, as_str, |a, b| a == b);
3819
3820// ---- list -----------------------------------------------------------------
3821
3822/// `x :: xs` — prepend `x` onto the list `xs`.
3823fn prim_list_cons(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3824    let tail = args.pop().unwrap();
3825    let head = args.pop().unwrap();
3826    let mut list = match tail {
3827        Value::List(v) => v,
3828        other => return eval_error(format!("expected list, got {}", other.type_name())),
3829    };
3830    list.insert(0, head);
3831    Ok(Value::List(list))
3832}
3833
3834// ---- mutable-cell dereference ----------------------------------------------
3835
3836/// `!` — read the current contents of a mutable cell (see the `prims!`
3837/// registration above for how this differs structurally, not semantically,
3838/// from v0.0.6's `Dereference`/`Location` handling).
3839fn prim_deref(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3840    let v = args.pop().unwrap();
3841    match v {
3842        Value::Ref(cell) => Ok(cell.borrow().clone()),
3843        other => eval_error(format!(
3844            "expected a mutable cell for '!', got {}",
3845            other.type_name()
3846        )),
3847    }
3848}
3849
3850// ---- string, continued -----------------------------------------------------
3851
3852// `string-length : string -> int` (vminst.ml `PrimitiveStringLength`) —
3853// counts Unicode scalar values (`BatUTF8.length`), not UTF-8 bytes.
3854unop_prim!(prim_string_length, as_str, Int, |s| s.chars().count()
3855    as i64);
3856
3857/// `string-sub : string -> int -> int -> string` (vminst.ml
3858/// `PrimitiveStringSub`) — a substring addressed by Unicode-scalar-value
3859/// offset/width (`BatUTF8.sub`), not byte offset. Upstream raises a dynamic
3860/// error ("illegal index for string-sub") on an out-of-range index; we do
3861/// the same.
3862fn prim_string_sub(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3863    let wid = as_int(args.pop().unwrap())?;
3864    let pos = as_int(args.pop().unwrap())?;
3865    let s = as_str(args.pop().unwrap())?;
3866    if wid < 0 || pos < 0 {
3867        return eval_error("illegal index for string-sub");
3868    }
3869    let chars: Vec<char> = s.chars().collect();
3870    let pos = pos as usize;
3871    let wid = wid as usize;
3872    match pos.checked_add(wid) {
3873        Some(end) if end <= chars.len() => Ok(Value::Str(chars[pos..end].iter().collect())),
3874        _ => eval_error("illegal index for string-sub"),
3875    }
3876}
3877
3878// `string-explode : string -> int list` (vminst.ml `PrimitiveStringExplode`)
3879// — the string's Unicode scalar values (code points) in order, not its bytes.
3880unop_prim!(prim_string_explode, as_str, List, |s| s
3881    .chars()
3882    .map(|c| Value::Int(c as i64))
3883    .collect());
3884
3885fn prim_embed_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3886    let s = as_str(args.pop().unwrap())?;
3887    Ok(Value::InlineText {
3888        elems: Rc::new(vec![IText::Text(s)]),
3889        env: Env::root(),
3890    })
3891}
3892
3893// ---- context ops ------------------------------------------------------------
3894
3895/// `set-font-size : length -> context -> context` (vminst.ml
3896/// `PrimitiveSetFontSize`).
3897fn prim_set_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3898    let ctx = as_context(args.pop().unwrap())?;
3899    let size = as_length(args.pop().unwrap())?;
3900    Ok(Value::Context(Box::new(Context {
3901        font_size: size,
3902        ..ctx
3903    })))
3904}
3905
3906/// `get-font-size : context -> length` (vminst.ml `PrimitiveGetFontSize`).
3907fn prim_get_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3908    let ctx = as_context(args.pop().unwrap())?;
3909    Ok(Value::Length(ctx.font_size))
3910}
3911
3912/// `set-leading : length -> context -> context` (vminst.ml
3913/// `PrimitiveSetLeading`; see the `prims!` table comment for why this is
3914/// the baseline-distance setter and not `set-min-gap-of-lines`).
3915fn prim_set_leading(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3916    let ctx = as_context(args.pop().unwrap())?;
3917    let leading = as_length(args.pop().unwrap())?;
3918    Ok(Value::Context(Box::new(Context { leading, ..ctx })))
3919}
3920
3921/// `set-paragraph-margin : length -> length -> context -> context`
3922/// (vminst.ml `PrimitiveSetParagraphMargin`).
3923fn prim_set_paragraph_margin(
3924    _interp: &mut Interp,
3925    mut args: Vec<Value>,
3926) -> Result<Value, EvalError> {
3927    let ctx = as_context(args.pop().unwrap())?;
3928    let bottom = as_length(args.pop().unwrap())?;
3929    let top = as_length(args.pop().unwrap())?;
3930    Ok(Value::Context(Box::new(Context {
3931        paragraph_top: top,
3932        paragraph_bottom: bottom,
3933        ..ctx
3934    })))
3935}
3936
3937/// `get-text-width : context -> length` (vminst.ml `PrimitiveGetTextWidth`).
3938fn prim_get_text_width(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3939    let ctx = as_context(args.pop().unwrap())?;
3940    Ok(Value::Length(ctx.paragraph_width))
3941}
3942
3943/// `get-initial-context : length -> [math] inline-cmd -> context`
3944/// (vminst.ml `PrimitiveGetInitialContext`) — the second argument is the
3945/// default math command a bare `${…}` in inline text dispatches to (v0.0.6
3946/// `context_main.math_command`); interned via
3947/// `Interp::register_math_command`, carried as `Context::math_command`.
3948fn prim_get_initial_context(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3949    let cmd = args.pop().unwrap();
3950    let width = as_length(args.pop().unwrap())?;
3951    let mut ctx = Context::initial(width);
3952    ctx.math_command = Some(interp.register_math_command(cmd));
3953    // Overlay the configured `default-font.satysfi-hash` `scripts`
3954    // block, if any, so a bare document with a configured font root renders
3955    // CJK/etc. with zero `set-font` calls (`interp.metrics.
3956    // default_script_font` is `None` for every script on a provider with no
3957    // such config — `Base14Metrics` and a bare `TtfFontStore::load` both —
3958    // so this loop is a no-op there).
3959    for (idx, script) in [
3960        Script::HanIdeographic,
3961        Script::Kana,
3962        Script::Latin,
3963        Script::OtherScript,
3964    ]
3965    .into_iter()
3966    .enumerate()
3967    {
3968        if let Some((font, ratio, rising)) = interp.metrics.default_script_font(script) {
3969            ctx.font_scheme[idx] = ScriptFont {
3970                font,
3971                ratio,
3972                rising,
3973            };
3974            if script == Script::Latin {
3975                ctx.font = font;
3976            }
3977        }
3978    }
3979    // Overlay the configured `default-font.satysfi-hash` `"math"`
3980    // abbrev, if any, so a document with a bundled MATH-table font renders
3981    // real cramped/uncramped math metrics with zero `set-math-font` calls.
3982    // `interp.metrics.default_math_font` is `None` on a provider with no such
3983    // config, the same no-op-by-default shape as the `scripts` overlay above.
3984    if let Some(font) = interp.metrics.default_math_font() {
3985        ctx.math_font = font;
3986    }
3987    Ok(Value::Context(Box::new(ctx)))
3988}
3989
3990/// `set-font-key : int -> context -> context` — LOCAL, non-upstream
3991/// primitive; see the `prims!` table comment on `"set-font-key"` for why it
3992/// exists. Sets `Context::font` directly to `FontKey(n)`.
3993fn prim_set_font_key(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3994    let ctx = as_context(args.pop().unwrap())?;
3995    let key = as_int(args.pop().unwrap())?;
3996    if key < 0 || key > i64::from(u16::MAX) {
3997        return eval_error(format!("set-font-key: font key {key} is out of range"));
3998    }
3999    Ok(Value::Context(Box::new(Context {
4000        font: FontKey(key as u16),
4001        ..ctx
4002    })))
4003}
4004
4005// ---- box combinators ---------------------------------------------------------
4006
4007/// `++ : inline-boxes -> inline-boxes -> inline-boxes` (vminst.ml
4008/// `HorzConcat`).
4009fn prim_inline_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4010    let mut b = as_inline_boxes(args.pop().unwrap())?;
4011    let mut a = as_inline_boxes(args.pop().unwrap())?;
4012    a.append(&mut b);
4013    Ok(Value::InlineBoxes(a))
4014}
4015
4016/// `+++ : block-boxes -> block-boxes -> block-boxes` (vminst.ml
4017/// `VertConcat`).
4018fn prim_block_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4019    let mut b = as_block_boxes(args.pop().unwrap())?;
4020    let mut a = as_block_boxes(args.pop().unwrap())?;
4021    a.append(&mut b);
4022    Ok(Value::BlockBoxes(a))
4023}
4024
4025/// `inline-skip : length -> inline-boxes` (vminst.ml `BackendFixedEmpty`).
4026fn prim_inline_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4027    let width = as_length(args.pop().unwrap())?;
4028    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4029        PureHorzBox::FixedEmpty { width },
4030    )]))
4031}
4032
4033/// `inline-glue : length -> length -> length -> inline-boxes` (vminst.ml
4034/// `BackendOuterEmpty`; params `(widnat, widshrink, widstretch)`, i.e.
4035/// natural, then shrink, then stretch — the same order `OuterEmpty`'s
4036/// fields are already declared in).
4037fn prim_inline_glue(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4038    let stretchable = as_length(args.pop().unwrap())?;
4039    let shrinkable = as_length(args.pop().unwrap())?;
4040    let natural = as_length(args.pop().unwrap())?;
4041    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4042        PureHorzBox::OuterEmpty {
4043            natural,
4044            shrinkable,
4045            stretchable,
4046        },
4047    )]))
4048}
4049
4050/// `block-skip : length -> block-boxes` (vminst.ml `BackendVertSkip`).
4051fn prim_block_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4052    let len = as_length(args.pop().unwrap())?;
4053    Ok(Value::BlockBoxes(vec![VertBox::Skip(len)]))
4054}
4055
4056/// `list-mark : int -> block-boxes` — the block-level reflow marker
4057/// constructor `itemize.satyh`'s `listing`/`listing-item`/`listing-item-
4058/// breakable`/`enumerate`/`enumerate-item` call to fence list/item
4059/// boundaries. Returns a single-element `block-boxes` carrying an INERT
4060/// `VertBox::ListMark` — zero height/depth, stripped with zero contribution
4061/// by `chop_page`/`place_block_at`/`measure_block` before it can ever reach a
4062/// `PlacedLine`, so PDF and faithful HTML render identically whether or not
4063/// a document's stdlib calls this. Only `page_break_core`'s `reflow_source`
4064/// clone (taken BEFORE `chop_page` drains its input) retains it, for the
4065/// `html-support` branch's reflow HTML walker to read back.
4066///
4067/// `tag` encoding (the only "int tag" scheme any caller needs to know,
4068/// since this primitive is never reflected through the type system beyond
4069/// `int -> block-boxes`):
4070/// - `0` = `ListStart { ordered: false }` (opens a `<ul>`)
4071/// - `1` = `ListStart { ordered: true }` (opens an `<ol>`)
4072/// - `2` = `ListEnd`
4073/// - `3` = `ItemStart`
4074/// - `4` = `ItemEnd`
4075fn prim_list_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4076    let tag = as_int(args.pop().unwrap())?;
4077    let kind = match tag {
4078        0 => ListMarkKind::ListStart { ordered: false },
4079        1 => ListMarkKind::ListStart { ordered: true },
4080        2 => ListMarkKind::ListEnd,
4081        3 => ListMarkKind::ItemStart,
4082        4 => ListMarkKind::ItemEnd,
4083        other => return eval_error(format!("list-mark: unknown tag {other}")),
4084    };
4085    Ok(Value::BlockBoxes(vec![VertBox::ListMark(kind)]))
4086}
4087
4088/// `inline-mark : int -> inline-boxes` — the inline-level reflow marker
4089/// constructor: `itemize.satyh`'s `make-bullet`/`enumerate-item` fence the
4090/// drawn bullet/number glyph run with `BulletStart`/`BulletEnd`, and the
4091/// repo-controlled `\emph`/`\bold` definitions (an opt-in, per-command wrap)
4092/// fence their body with `EmphStart`/`EmphEnd`. Same INERT-marker contract as
4093/// `list-mark` above — a zero-size `PureHorzBox::InlineMark`, ignored by
4094/// `measure`/`natural_metrics`/`justify_line`
4095/// (rustyfi-backend's `linebreak.rs`),
4096/// `math_glyphs_of_inline_boxes`/`math_boxes_of_inline_boxes` below, and both
4097/// the PDF and faithful HTML writers; read only by the `html-support`
4098/// branch's reflow HTML walker.
4099///
4100/// `tag` encoding:
4101/// - `0` = `EmphStart { strong: false }` (opens `<em>`)
4102/// - `1` = `EmphStart { strong: true }` (opens `<strong>`)
4103/// - `2` = `EmphEnd`
4104/// - `3` = `BulletStart`
4105/// - `4` = `BulletEnd`
4106fn prim_inline_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4107    let tag = as_int(args.pop().unwrap())?;
4108    let kind = match tag {
4109        0 => InlineMarkKind::EmphStart { strong: false },
4110        1 => InlineMarkKind::EmphStart { strong: true },
4111        2 => InlineMarkKind::EmphEnd,
4112        3 => InlineMarkKind::BulletStart,
4113        4 => InlineMarkKind::BulletEnd,
4114        other => return eval_error(format!("inline-mark: unknown tag {other}")),
4115    };
4116    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4117        PureHorzBox::InlineMark(kind),
4118    )]))
4119}
4120
4121// ---- pure float primitives --------------------------------------------------
4122//
4123// All bodies below are `float -> float` (or `float -> float -> float`)
4124// straight wraps of the matching `f64` method — vminst.ml's OCaml bodies
4125// (`make_float (sin flt1)`, etc.) are themselves direct wraps of the same
4126// IEEE-754 libm functions, so there is no behavioral daylight here.
4127
4128binop_prim!(prim_atan2, as_float, Float, |a, b| a.atan2(b));
4129unop_prim!(prim_sin, as_float, Float, |x| x.sin());
4130unop_prim!(prim_asin, as_float, Float, |x| x.asin());
4131unop_prim!(prim_cos, as_float, Float, |x| x.cos());
4132unop_prim!(prim_acos, as_float, Float, |x| x.acos());
4133unop_prim!(prim_tan, as_float, Float, |x| x.tan());
4134unop_prim!(prim_atan, as_float, Float, |x| x.atan());
4135// vminst.ml:2834 `FloatLogarithm`: OCaml's `log` is the NATURAL logarithm
4136// (`ln`), not `log10`.
4137unop_prim!(prim_log, as_float, Float, |x| x.ln());
4138unop_prim!(prim_exp, as_float, Float, |x| x.exp());
4139// `ceil`/`floor` return `float`, unlike `round` (above), which returns
4140// `int` — see this file's `prims!` table comment on `"ceil"`/`"floor"`.
4141unop_prim!(prim_ceil, as_float, Float, |x| x.ceil());
4142unop_prim!(prim_floor, as_float, Float, |x| x.floor());
4143
4144// `show-float : float -> string` (vminst.ml:2319 `PrimitiveShowFloat`) —
4145// OCaml's `string_of_float`. See `ocaml_show_float`'s doc comment (below)
4146// for the emulation and its known fidelity limits.
4147unop_prim!(prim_show_float, as_float, Str, |x| ocaml_show_float(x));
4148
4149/// A from-scratch emulation of OCaml's `Stdlib.string_of_float`: format via
4150/// a C `%.12g` equivalent (12 significant digits; fixed-point when the
4151/// decimal exponent falls in `-4..12`, scientific otherwise; trailing
4152/// fractional zeros trimmed), then apply `valid_float_lexem`'s post-pass —
4153/// append a trailing `.` when the result would otherwise print as a bare
4154/// integer (`"1."`, never `"1"`, so a `float`'s printed form always reads
4155/// as a float, not an `int`). Known limitation: this is a Rust
4156/// reimplementation of the same specification (OCaml itself defers to the
4157/// platform C library's `%.12g`), so it may disagree with OCaml in obscure
4158/// corner cases, though it agrees on ordinary values (verified by hand
4159/// against real OCaml output for `0.`, `-0.`, `1.`, `100.`, `0.0025`,
4160/// `1e+20`, `1e-05`).
4161fn ocaml_show_float(x: f64) -> String {
4162    if x.is_nan() {
4163        return "nan".to_string();
4164    }
4165    if x.is_infinite() {
4166        return if x < 0.0 { "-infinity" } else { "infinity" }.to_string();
4167    }
4168    const PREC: i32 = 12;
4169    // Style-E rendering at precision PREC-1 recovers the correctly-rounded
4170    // decimal exponent (a naive `log10().floor()` can be off by one right
4171    // at a power of ten, because of binary/decimal rounding).
4172    let sci = format!("{:.*e}", (PREC - 1) as usize, x);
4173    let epos = sci
4174        .find('e')
4175        .expect("scientific formatting always emits 'e'");
4176    let exp: i32 = sci[epos + 1..].parse().expect("well-formed exponent");
4177    let body = if exp < -4 || exp >= PREC {
4178        let mantissa = trim_trailing_fractional_zeros(&sci[..epos]);
4179        format!(
4180            "{mantissa}e{}{:02}",
4181            if exp < 0 { "-" } else { "+" },
4182            exp.abs()
4183        )
4184    } else {
4185        let decimals = (PREC - 1 - exp).max(0) as usize;
4186        trim_trailing_fractional_zeros(&format!("{:.*}", decimals, x)).to_string()
4187    };
4188    if body.bytes().all(|b| b.is_ascii_digit() || b == b'-') {
4189        format!("{body}.")
4190    } else {
4191        body
4192    }
4193}
4194
4195/// Strip trailing zeros after a decimal point, then the point itself if
4196/// nothing remains after it (`"3.140" -> "3.14"`, `"5.000" -> "5"`);
4197/// already-integer-shaped strings (no `.`) pass through unchanged.
4198fn trim_trailing_fractional_zeros(s: &str) -> &str {
4199    if !s.contains('.') {
4200        return s;
4201    }
4202    s.trim_end_matches('0').trim_end_matches('.')
4203}
4204
4205// `string-byte-length : string -> int` (vminst.ml:2159
4206// `PrimitiveStringByteLength`) — UTF-8 BYTE count (`String.length` in
4207// OCaml, whose native strings are raw byte sequences), unlike
4208// `string-length`'s Unicode-scalar-value count.
4209unop_prim!(prim_string_byte_length, as_str, Int, |s| s.len() as i64);
4210
4211/// `string-sub-bytes : string -> int -> int -> string` (vminst.ml:2123
4212/// `PrimitiveStringSubBytes`) — byte-indexed substring (OCaml's
4213/// `String.sub`), unlike `string-sub`'s Unicode-scalar-value indexing.
4214/// Guards an out-of-range span exactly like `prim_string_sub`'s "illegal
4215/// index" dynamic error, AND a split landing inside a multi-byte UTF-8
4216/// sequence — impossible for OCaml's byte-oriented strings, but a
4217/// `Value::Str` here is a Rust `String`, which must stay valid UTF-8.
4218fn prim_string_sub_bytes(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4219    let wid = as_int(args.pop().unwrap())?;
4220    let pos = as_int(args.pop().unwrap())?;
4221    let s = as_str(args.pop().unwrap())?;
4222    if wid < 0 || pos < 0 {
4223        return eval_error("illegal index for string-sub-bytes");
4224    }
4225    let (pos, wid) = (pos as usize, wid as usize);
4226    match pos.checked_add(wid) {
4227        Some(end) if end <= s.len() && s.is_char_boundary(pos) && s.is_char_boundary(end) => {
4228            Ok(Value::Str(s[pos..end].to_string()))
4229        }
4230        _ => eval_error("illegal index for string-sub-bytes"),
4231    }
4232}
4233
4234/// `string-unexplode : int list -> string` (vminst.ml:2196
4235/// `PrimitiveStringUnexplode`) — the inverse of `string-explode` (above):
4236/// each int is a Unicode scalar value (code point), concatenated into one
4237/// UTF-8 string. Upstream's `Uchar.of_int` raises on an int that isn't a
4238/// valid Unicode scalar value (a surrogate, or out of range); reported here
4239/// as the same kind of dynamic error rather than panicking.
4240fn prim_string_unexplode(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4241    let items = as_list(args.pop().unwrap())?;
4242    let mut s = String::new();
4243    for v in items {
4244        let n = as_int(v)?;
4245        match u32::try_from(n).ok().and_then(char::from_u32) {
4246            Some(c) => s.push(c),
4247            None => {
4248                return eval_error(format!(
4249                    "string-unexplode: {n} is not a valid Unicode scalar value"
4250                ))
4251            }
4252        }
4253    }
4254    Ok(Value::Str(s))
4255}
4256
4257/// `normalize-string-to-nfc : string -> string` (dev-0-1-0 vminst.ml:2050
4258/// `NormalizeStringToNFC`) — REAL: UAX #15 Normalization Form C, via the
4259/// `unicode-normalization` crate (`UnicodeNormalization::nfc`), a pure-Rust
4260/// stand-in for upstream's uunf-backed `NormalizeString.of_utf8_nfc`.
4261/// DOCUMENTED NON-RISK: this crate's embedded Unicode table version may lag
4262/// or lead upstream's uunf pin — both track recent Unicode, and no bundled
4263/// package/test relies on a normalization pair that changed between
4264/// versions.
4265fn prim_normalize_string_to_nfc(
4266    _interp: &mut Interp,
4267    mut args: Vec<Value>,
4268) -> Result<Value, EvalError> {
4269    let s = as_str(args.pop().unwrap())?;
4270    Ok(Value::Str(s.nfc().collect()))
4271}
4272
4273/// `normalize-string-to-nfd : string -> string` (dev-0-1-0 vminst.ml:2066
4274/// `NormalizeStringToNFD`) — REAL: UAX #15 Normalization Form D, same
4275/// crate/caveats as [`prim_normalize_string_to_nfc`] above.
4276fn prim_normalize_string_to_nfd(
4277    _interp: &mut Interp,
4278    mut args: Vec<Value>,
4279) -> Result<Value, EvalError> {
4280    let s = as_str(args.pop().unwrap())?;
4281    Ok(Value::Str(s.nfd().collect()))
4282}
4283
4284/// `split-grapheme-cluster : string -> list string` (dev-0-1-0 vminst.ml:
4285/// 2082 `SplitOnGraphemeCluster` / `GraphemeCluster.split_utf8`) — REAL: UAX
4286/// #29 EXTENDED grapheme clusters, via the `unicode-segmentation` crate's
4287/// `graphemes(s, true)` (`true` selects the extended, not legacy, cluster
4288/// rules — what upstream's uuseg default segmenter produces).
4289fn prim_split_grapheme_cluster(
4290    _interp: &mut Interp,
4291    mut args: Vec<Value>,
4292) -> Result<Value, EvalError> {
4293    let s = as_str(args.pop().unwrap())?;
4294    let clusters: Vec<Value> = s
4295        .graphemes(true)
4296        .map(|g| Value::Str(g.to_string()))
4297        .collect();
4298    Ok(Value::List(clusters))
4299}
4300
4301/// `display-message : string -> unit` (vminst.ml:2056
4302/// `PrimitiveDisplayMessage`) — upstream prints via `print_endline`
4303/// (STDOUT); this port deliberately prints to STDERR instead (`eprintln!`),
4304/// keeping stdout reserved for actual document output. This matches the
4305/// existing house convention: the CLI's own "output written" status line
4306/// (`rustyfi`'s `main.rs`) is likewise stderr-only, never stdout — a
4307/// documented deviation, not an oversight.
4308fn prim_display_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4309    let msg = as_str(args.pop().unwrap())?;
4310    eprintln!("{msg}");
4311    Ok(Value::Unit)
4312}
4313
4314/// `abort-with-message : string -> 'a` (vminst.ml:3133 `AbortWithMessage`)
4315/// — raises a dynamic error carrying `msg` verbatim. The polymorphic result
4316/// type (`prim_types.rs`'s `poly1`) is vacuously satisfiable: this always
4317/// evaluates to `Err`, never actually producing a value of whatever type
4318/// the call site expected.
4319fn prim_abort_with_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4320    let msg = as_str(args.pop().unwrap())?;
4321    eval_error(msg)
4322}
4323
4324// ---- images (raster images) -----------
4325
4326/// `load-image : string -> image` (v0.0.6 vminstdef.yaml:540). Resolves
4327/// `path` against the process's current working directory — this port
4328/// has no "job directory" threaded through `Interp` yet, so this is a
4329/// deliberately simple stand-in for v0.0.6's real job-directory-relative
4330/// resolution, good enough for a CLI invoked from the document's own
4331/// directory and for this crate's fixture-driven tests (which pass an
4332/// absolute path).
4333///
4334/// Decoding is eager (via the `image` crate, to 8-bit `DeviceRGB` — see
4335/// `ImageResource`'s doc comment for the alpha-dropping/format caveats),
4336/// matching v0.0.6's `ImageInfo.add_image` (imageInfo.ml): a missing or
4337/// undecodable file is a clean `EvalError` here, not deferred to the PDF
4338/// writer.
4339///
4340/// JPEG DCTDecode passthrough: in addition to the eager RGB8 decode above
4341/// (still needed for `use-image-by-width`'s aspect ratio and the HTML
4342/// backend's `<img>` data URI), this re-reads the same path's raw bytes and
4343/// sniffs them for a baseline JPEG (`ImageResource::sniff_baseline_jpeg_dct`)
4344/// so the PDF writer can embed the ORIGINAL DCT-encoded bytes instead of
4345/// re-encoding the flattened samples. The second read is best-effort: a
4346/// failure just leaves `jpeg_dct` as `None` and falls back to flat-RGB8
4347/// embedding, since the file already decoded fine above.
4348fn prim_load_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4349    let path = as_str(args.pop().unwrap())?;
4350    let decoded = image::open(&path).map_err(|e| EvalError {
4351        span: None,
4352        msg: format!("load-image: cannot decode '{path}': {e}"),
4353    })?;
4354    let rgb = decoded.to_rgb8();
4355    let (px_w, px_h) = rgb.dimensions();
4356    let jpeg_dct = std::fs::read(&path)
4357        .ok()
4358        .and_then(ImageResource::sniff_baseline_jpeg_dct);
4359    let id = ImageId(interp.images.len());
4360    interp.images.push(ImageResource {
4361        samples: rgb.into_raw(),
4362        px_w,
4363        px_h,
4364        jpeg_dct,
4365        pdf: None,
4366    });
4367    Ok(Value::Image(id))
4368}
4369
4370/// `use-image-by-width : image -> length -> inline-boxes` (v0.0.6
4371/// vminstdef.yaml:554). Computes the on-page height from the source
4372/// image's own pixel aspect ratio (v0.0.6
4373/// `ImageInfo.get_height_from_width`, imageInfo.ml:44): `height = width *
4374/// px_h / px_w`.
4375fn prim_use_image_by_width(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4376    let width = as_length(args.pop().unwrap())?;
4377    let image = as_image(args.pop().unwrap())?;
4378    let resource = interp.images.get(image.0).ok_or_else(|| EvalError {
4379        span: None,
4380        msg: format!("internal error: image id {} out of range", image.0),
4381    })?;
4382    let (iw, ih) = resource.intrinsic_dims_pt();
4383    if iw == 0.0 {
4384        return eval_error("use-image-by-width: image has zero width, cannot scale");
4385    }
4386    let height = width * (ih / iw);
4387    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4388        PureHorzBox::Image {
4389            width,
4390            height,
4391            image,
4392        },
4393    )]))
4394}
4395
4396/// `load-pdf-image : string -> int -> image` (v0.0.6 vminstdef.yaml:525-538
4397/// `BackendRegisterPdfImage`; dev-0-1-0 renames it `PrimitiveLoadPdfImage`
4398/// with the identical type/body). Loads page `pageno` (1-based) of the PDF
4399/// at `path`, parsed eagerly with `lopdf`, and stores a `PdfPageResource` —
4400/// the page's `/MediaBox` (for `use-image-by-width`'s aspect ratio), its
4401/// content stream(s) (already inflated/concatenated by
4402/// `lopdf::Document::get_page_content`), and its imported `/Resources`
4403/// object subtree (for the PDF writer's Form XObject).
4404///
4405/// Path resolution is cwd-relative, the same documented deviation as
4406/// `prim_load_image`/`prim_read_file` (no job-directory threaded through
4407/// `Interp` yet).
4408///
4409/// Errors (all clean `EvalError`, no panics):
4410/// - file missing/unreadable → "cannot open '<path>': <e>";
4411/// - malformed/unparseable PDF → "cannot parse PDF '<path>': <e>";
4412/// - `pageno < 1` → "page number must be >= 1 (got <n>)";
4413/// - `pageno` beyond the page count → "'<path>' has no page <n>";
4414/// - `/Encrypt` present in the trailer → "'<path>' is encrypted; not
4415///   supported" (decryption is never attempted);
4416/// - no usable `/MediaBox` (missing at every level of the inherited page
4417///   tree, wrong array length, or non-numeric entries) → "page <n> of
4418///   '<path>' has no usable MediaBox".
4419#[cfg(feature = "pdf-image")]
4420fn prim_load_pdf_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4421    let pageno = as_int(args.pop().unwrap())?;
4422    let path = as_str(args.pop().unwrap())?;
4423    if pageno < 1 {
4424        return eval_error(format!(
4425            "load-pdf-image: page number must be >= 1 (got {pageno})"
4426        ));
4427    }
4428    let doc = lopdf::Document::load(&path).map_err(|e| {
4429        let msg = match &e {
4430            lopdf::Error::IO(io_e) => format!("load-pdf-image: cannot open '{path}': {io_e}"),
4431            other => format!("load-pdf-image: cannot parse PDF '{path}': {other}"),
4432        };
4433        EvalError { span: None, msg }
4434    })?;
4435    if doc.is_encrypted() {
4436        return eval_error(format!(
4437            "load-pdf-image: '{path}' is encrypted; not supported"
4438        ));
4439    }
4440    let pages = doc.get_pages();
4441    let page_id = *pages.get(&(pageno as u32)).ok_or_else(|| EvalError {
4442        span: None,
4443        msg: format!("load-pdf-image: '{path}' has no page {pageno}"),
4444    })?;
4445    let page_dict = doc.get_dictionary(page_id).map_err(|e| EvalError {
4446        span: None,
4447        msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4448    })?;
4449    let media_box = resolve_pdf_media_box(&doc, page_dict).ok_or_else(|| EvalError {
4450        span: None,
4451        msg: format!("load-pdf-image: page {pageno} of '{path}' has no usable MediaBox"),
4452    })?;
4453    let content = doc.get_page_content(page_id).map_err(|e| EvalError {
4454        span: None,
4455        msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4456    })?;
4457    let resources = import_pdf_resources(&doc, page_dict);
4458    let id = ImageId(interp.images.len());
4459    interp.images.push(ImageResource {
4460        samples: Vec::new(),
4461        px_w: 0,
4462        px_h: 0,
4463        jpeg_dct: None,
4464        pdf: Some(PdfPageResource {
4465            media_box,
4466            content,
4467            resources,
4468        }),
4469    });
4470    Ok(Value::Image(id))
4471}
4472
4473/// Without the `pdf-image` feature no PDF reader is linked in, so the
4474/// primitive can only fail — which it does at the call site, naming why.
4475///
4476/// The name stays REGISTERED rather than being gated out of the primitive
4477/// tables: `typecheck::PRIMITIVE_NAMES`, `prim_types::primitive_type` and this
4478/// table are cross-checked against each other (`tests/typecheck.rs`), and a
4479/// document that reaches for `load-pdf-image` is better told that this build
4480/// cannot read PDFs than that the name does not exist.
4481#[cfg(not(feature = "pdf-image"))]
4482fn prim_load_pdf_image(_interp: &mut Interp, _args: Vec<Value>) -> Result<Value, EvalError> {
4483    eval_error(
4484        "load-pdf-image: this build has no PDF reader (the `pdf-image` feature \
4485         is off). It is off for WebAssembly, where the primitive could not work \
4486         regardless: it takes a filesystem path."
4487            .to_string(),
4488    )
4489}
4490
4491/// `/MediaBox` lookup with page-tree inheritance (`lopdf` does not resolve
4492/// this automatically, unlike upstream camlpdf's `Pdfpage` helpers): walk
4493/// `page_dict`, then its `/Parent` chain, returning the first `/MediaBox`
4494/// found as `(x0, y0, x1, y1)` in raw PDF points. `None`
4495/// if no ancestor carries a well-formed 4-element numeric array, or if a
4496/// `/Parent` cycle is detected.
4497#[cfg(feature = "pdf-image")]
4498fn resolve_pdf_media_box(
4499    doc: &lopdf::Document,
4500    page_dict: &lopdf::Dictionary,
4501) -> Option<(f64, f64, f64, f64)> {
4502    let mut cur = page_dict;
4503    let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4504    loop {
4505        if let Ok(obj) = cur.get(b"MediaBox") {
4506            if let Ok(arr) = obj.as_array() {
4507                if arr.len() == 4 {
4508                    let mut v = [0f64; 4];
4509                    let mut ok = true;
4510                    for (slot, item) in v.iter_mut().zip(arr.iter()) {
4511                        match item.as_float() {
4512                            Ok(f) => *slot = f as f64,
4513                            Err(_) => {
4514                                ok = false;
4515                                break;
4516                            }
4517                        }
4518                    }
4519                    if ok {
4520                        return Some((v[0], v[1], v[2], v[3]));
4521                    }
4522                }
4523            }
4524        }
4525        match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4526            Ok(parent_id) => {
4527                if !seen.insert(parent_id) {
4528                    return None; // cycle
4529                }
4530                cur = doc.get_dictionary(parent_id).ok()?;
4531            }
4532            Err(_) => return None,
4533        }
4534    }
4535}
4536
4537/// Import the page's `/Resources` subtree (walking page-tree inheritance
4538/// like `resolve_pdf_media_box`) into a neutral `ImportedObjects` table for
4539/// the PDF writer. Local id `0` always holds the
4540/// (possibly inline) `/Resources` dictionary itself; every other entry is a
4541/// real source PDF object number, keyed by `convert_pdf_object`'s
4542/// transitive walk of every `Reference` reachable from it.
4543#[cfg(feature = "pdf-image")]
4544fn import_pdf_resources(doc: &lopdf::Document, page_dict: &lopdf::Dictionary) -> ImportedObjects {
4545    let mut out: Vec<(u32, ObjRepr)> = Vec::new();
4546    let mut seen: BTreeSet<u32> = BTreeSet::new();
4547    let root_repr = match resolve_pdf_resources_object(doc, page_dict) {
4548        Some(obj) => convert_pdf_object(doc, obj, &mut out, &mut seen),
4549        None => ObjRepr::Dict(Vec::new()),
4550    };
4551    out.insert(0, (0, root_repr));
4552    ImportedObjects(out)
4553}
4554
4555/// `/Resources` lookup with page-tree inheritance, mirroring
4556/// `resolve_pdf_media_box` but returning the raw (possibly-inline)
4557/// `&lopdf::Object` rather than a decoded value, since `/Resources` may
4558/// legally be either a direct dictionary or an indirect reference.
4559#[cfg(feature = "pdf-image")]
4560fn resolve_pdf_resources_object<'a>(
4561    doc: &'a lopdf::Document,
4562    page_dict: &'a lopdf::Dictionary,
4563) -> Option<&'a lopdf::Object> {
4564    let mut cur = page_dict;
4565    let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4566    loop {
4567        if let Ok(obj) = cur.get(b"Resources") {
4568            return Some(obj);
4569        }
4570        match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4571            Ok(parent_id) => {
4572                if !seen.insert(parent_id) {
4573                    return None;
4574                }
4575                cur = doc.get_dictionary(parent_id).ok()?;
4576            }
4577            Err(_) => return None,
4578        }
4579    }
4580}
4581
4582/// Recursively convert one `lopdf::Object` into the neutral `ObjRepr`
4583/// grammar, following every `Reference` transitively and
4584/// appending newly-visited indirect objects to `out` keyed by their source
4585/// object number (`seen` guards against re-visiting/cycles — a shared
4586/// object referenced from multiple places is emitted once and pointed at by
4587/// `ObjRepr::Ref` from every occurrence). Stream objects are copied
4588/// **verbatim** (still-filtered bytes, `/Filter`/`/DecodeParms` kept as-is;
4589/// only `/Length` is dropped since the writer derives it) — unlike the
4590/// page's own content stream (`Document::get_page_content`, inflated
4591/// separately in `prim_load_pdf_image`), a resource stream (font program,
4592/// embedded image XObject, ICC profile, ...) is re-emitted byte-for-byte,
4593/// so no decode/re-encode risk is taken on data this importer doesn't need
4594/// to understand.
4595#[cfg(feature = "pdf-image")]
4596fn convert_pdf_object(
4597    doc: &lopdf::Document,
4598    obj: &lopdf::Object,
4599    out: &mut Vec<(u32, ObjRepr)>,
4600    seen: &mut BTreeSet<u32>,
4601) -> ObjRepr {
4602    use lopdf::Object as LObj;
4603    match obj {
4604        LObj::Null => ObjRepr::Null,
4605        LObj::Boolean(b) => ObjRepr::Bool(*b),
4606        LObj::Integer(n) => ObjRepr::Int(*n),
4607        LObj::Real(r) => ObjRepr::Real(*r as f64),
4608        LObj::Name(n) => ObjRepr::Name(n.clone()),
4609        LObj::String(s, _) => ObjRepr::String(s.clone()),
4610        LObj::Array(items) => ObjRepr::Array(
4611            items
4612                .iter()
4613                .map(|it| convert_pdf_object(doc, it, out, seen))
4614                .collect(),
4615        ),
4616        LObj::Dictionary(d) => ObjRepr::Dict(convert_pdf_dict(doc, d, out, seen)),
4617        LObj::Stream(s) => {
4618            let dict_entries = convert_pdf_dict(doc, &s.dict, out, seen)
4619                .into_iter()
4620                .filter(|(k, _)| k.as_slice() != b"Length")
4621                .collect();
4622            ObjRepr::Stream(dict_entries, s.content.clone())
4623        }
4624        LObj::Reference((obj_num, gen)) => {
4625            let (obj_num, gen) = (*obj_num, *gen);
4626            if obj_num != 0 && seen.insert(obj_num) {
4627                if let Ok(target) = doc.get_object((obj_num, gen)) {
4628                    let repr = convert_pdf_object(doc, target, out, seen);
4629                    out.push((obj_num, repr));
4630                }
4631            }
4632            ObjRepr::Ref(obj_num)
4633        }
4634    }
4635}
4636
4637#[cfg(feature = "pdf-image")]
4638fn convert_pdf_dict(
4639    doc: &lopdf::Document,
4640    dict: &lopdf::Dictionary,
4641    out: &mut Vec<(u32, ObjRepr)>,
4642    seen: &mut BTreeSet<u32>,
4643) -> Vec<(Vec<u8>, ObjRepr)> {
4644    dict.iter()
4645        .map(|(k, v)| (k.clone(), convert_pdf_object(doc, v, out, seen)))
4646        .collect()
4647}
4648
4649/// `read-file : string -> list string` (dev-0-1-0 vminst.ml:3073
4650/// `PrimitiveReadFile`) — REAL, with two documented
4651/// deviations:
4652///
4653/// 1. **Path resolution**: resolves `path` against the process's current
4654///    working directory, the same `load-image` precedent
4655///    (`prim_load_image`'s doc comment) — this port has no job-directory
4656///    notion threaded through `Interp` yet. Upstream resolves against
4657///    `OptionState.job_directory ()` (the input document's own directory).
4658/// 2. **Containment tightening**: upstream rejects any `..` path component
4659///    (`"cannot access files by using '..'"`, vminst.ml:3084-3090) but
4660///    otherwise resolves `Filename.concat jobdir path` literally — an
4661///    absolute `path` silently escapes the job directory upstream. This
4662///    port ALSO rejects absolute paths (same error class), making the
4663///    containment upstream's own message implies actually real.
4664///
4665/// Line splitting is faithful to OCaml's `input_line` loop: split on `'\n'`,
4666/// drop a trailing empty piece (file ends with `\n`), keep `'\r'` (do NOT
4667/// use `BufRead::lines`, which strips `\r\n`) — an empty file yields `[]`.
4668/// Non-UTF-8 content is a clean `EvalError` (upstream's OCaml strings are
4669/// byte-transparent; this port's `Value::Str` must stay valid UTF-8 —
4670/// documented deviation).
4671fn prim_read_file(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4672    let path_str = as_str(args.pop().unwrap())?;
4673    let path = std::path::Path::new(&path_str);
4674    if path.is_absolute() {
4675        return eval_error(
4676            "read-file: cannot access files by using an absolute path (job-directory containment)",
4677        );
4678    }
4679    if path
4680        .components()
4681        .any(|c| matches!(c, std::path::Component::ParentDir))
4682    {
4683        return eval_error("cannot access files by using '..'");
4684    }
4685    let bytes = std::fs::read(path).map_err(|e| EvalError {
4686        span: None,
4687        msg: format!("read-file: cannot open '{path_str}': {e}"),
4688    })?;
4689    let text = String::from_utf8(bytes).map_err(|_| EvalError {
4690        span: None,
4691        msg: format!("read-file '{path_str}': not valid UTF-8"),
4692    })?;
4693    let mut lines: Vec<Value> = text
4694        .split('\n')
4695        .map(|s| Value::Str(s.to_string()))
4696        .collect();
4697    if matches!(lines.last(), Some(Value::Str(s)) if s.is_empty()) {
4698        lines.pop();
4699    }
4700    Ok(Value::List(lines))
4701}
4702
4703/// `(string) option` — `register-document-information`'s `title`/`subject`/
4704/// `author` fields, parsed the same way [`as_border_option`] reads a
4705/// `Value::Ctor`.
4706fn as_option_string(v: Value) -> Result<Option<String>, EvalError> {
4707    match v {
4708        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
4709            ("None", None) => Ok(None),
4710            ("Some", Some(Value::Str(s))) => Ok(Some(s)),
4711            (other, _) => eval_error(format!(
4712                "expected a string option (None / Some(string)), got variant '{other}'"
4713            )),
4714        },
4715        other => eval_error(format!("expected an option, got {}", other.type_name())),
4716    }
4717}
4718
4719/// `register-document-information : document-information-dictionary ->
4720/// unit` (dev-0-1-0 vminst.ml:2978 `PrimitiveRegisterDocumentInformation`)
4721/// — REAL: extracts `title`/`subject`/`author`
4722/// (`option string`) and `keywords` (`list string`) from the record
4723/// argument (`t_doc_info_dictionary()`'s shape, `prim_types.rs`) and stores
4724/// them onto `Interp::doc_info` — LAST WRITE WINS (upstream's `register`,
4725/// `documentInformationDictionary.ml`), matching the `outline`/
4726/// `annotations`/`destinations` accumulator policy (`eval.rs`): reset per
4727/// trial (fresh `Interp`), the final trial's value drained into
4728/// `DocExtras::doc_info` (`lib.rs`) and emitted as the PDF `/Info`
4729/// dictionary by both writers (`rustyfi-pdf`'s `lib.rs`/`cid.rs`).
4730fn prim_register_document_information(
4731    interp: &mut Interp,
4732    mut args: Vec<Value>,
4733) -> Result<Value, EvalError> {
4734    let fields = match args.pop().unwrap() {
4735        Value::Record(m) => m,
4736        other => {
4737            return eval_error(format!(
4738                "register-document-information: expected a document-information-dictionary \
4739                 record, got {}",
4740                other.type_name()
4741            ))
4742        }
4743    };
4744    let record_name = "document-information-dictionary";
4745    let title = as_option_string(record_field(&fields, record_name, "title")?)?;
4746    let subject = as_option_string(record_field(&fields, record_name, "subject")?)?;
4747    let author = as_option_string(record_field(&fields, record_name, "author")?)?;
4748    let keywords = as_list(record_field(&fields, record_name, "keywords")?)?
4749        .into_iter()
4750        .map(as_str)
4751        .collect::<Result<Vec<_>, _>>()?;
4752    interp.doc_info = Some(DocInfo {
4753        title,
4754        subject,
4755        author,
4756        keywords,
4757    });
4758    Ok(Value::Unit)
4759}
4760
4761// ============================================================================
4762// ---- graphics primitives ------
4763// `start-path`/`line-to`/`terminate-path`/`close-with-line`/`fill`/`stroke`/
4764// `inline-graphics`. Argument order matches `tools/gencode/vminst.ml`
4765// (point-first for `line-to`, width-first for `stroke`).
4766// ============================================================================
4767
4768/// `start-path : point -> pre-path` (vminst.ml:713).
4769fn prim_start_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4770    let start = as_point(args.pop().unwrap())?;
4771    Ok(Value::PrePath(PrePath {
4772        start,
4773        segs: Vec::new(),
4774    }))
4775}
4776
4777/// `line-to : point -> pre-path -> pre-path` (vminst.ml:727) — appends a
4778/// straight segment to the pre-path's forward-accumulated `segs`.
4779fn prim_line_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4780    let mut pp = as_prepath(args.pop().unwrap())?;
4781    let pt = as_point(args.pop().unwrap())?;
4782    pp.segs.push(PathSeg::Line(pt));
4783    Ok(Value::PrePath(pp))
4784}
4785
4786/// `terminate-path : pre-path -> path` (vminst.ml:759) — finishes an OPEN
4787/// subpath (no closing segment).
4788fn prim_terminate_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4789    let pp = as_prepath(args.pop().unwrap())?;
4790    Ok(Value::Path(Path {
4791        subpaths: vec![Subpath {
4792            start: pp.start,
4793            segs: pp.segs,
4794            closing: Closing::Open,
4795        }],
4796    }))
4797}
4798
4799/// `close-with-line : pre-path -> path` (vminst.ml:773) — closes the subpath
4800/// with a straight segment back to its start (PDF `h`).
4801fn prim_close_with_line(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4802    let pp = as_prepath(args.pop().unwrap())?;
4803    Ok(Value::Path(Path {
4804        subpaths: vec![Subpath {
4805            start: pp.start,
4806            segs: pp.segs,
4807            closing: Closing::Line,
4808        }],
4809    }))
4810}
4811
4812/// `fill : color -> path -> graphics` (vminst.ml:2398) — a filled region;
4813/// the PDF writer (`place_graphics`, rustyfi-pdf) paints it with the
4814/// even-odd rule, matching upstream's `op_f'`.
4815fn prim_fill(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4816    let path = as_path(args.pop().unwrap())?;
4817    let color = as_color(args.pop().unwrap())?;
4818    Ok(Value::Graphics(GraphicsElem::Fill(color, path)))
4819}
4820
4821/// `stroke : length -> color -> path -> graphics` (vminst.ml:2381) — width
4822/// first, then color, then path.
4823fn prim_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4824    let path = as_path(args.pop().unwrap())?;
4825    let color = as_color(args.pop().unwrap())?;
4826    let wid = as_length(args.pop().unwrap())?;
4827    Ok(Value::Graphics(GraphicsElem::Stroke(wid, color, path)))
4828}
4829
4830/// Apply a graphics callback under the eager-call window, returning its
4831/// resolved elements with every `register-destination` it made appended as a
4832/// [`GraphicsElem::Destination`] marker. Markers go AFTER the real elements, so
4833/// ink z-order is untouched and a callback that registers nothing yields the
4834/// same `elems` as before.
4835///
4836/// The window is deliberately NOT opened inside a page-break walk
4837/// (`current_page` is `Some` — a deco can build an `inline-graphics` of its
4838/// own): a box built there is drawn straight into `page_graphics`, which
4839/// `fire_hooks` never re-walks, so a marker minted for it would be silently
4840/// dropped. There the direct registration is available and correct.
4841fn apply_graphics_callback(
4842    interp: &mut Interp,
4843    version: RustyfiVersion,
4844    apply: impl FnOnce(&mut Interp) -> Result<Value, EvalError>,
4845) -> Result<Vec<GraphicsElem>, EvalError> {
4846    let defer = interp.current_page.is_none();
4847    let saved = if defer {
4848        interp.pending_dests.replace(Vec::new())
4849    } else {
4850        None
4851    };
4852    let result = apply(interp).and_then(|v| coerce_graphics_result_for(version, v));
4853    let pending = if defer {
4854        interp.pending_dests.take().unwrap_or_default()
4855    } else {
4856        Vec::new()
4857    };
4858    if defer {
4859        interp.pending_dests = saved;
4860    }
4861    let mut elems = result?;
4862    elems.extend(
4863        pending
4864            .into_iter()
4865            .map(|(key, pt)| GraphicsElem::Destination { key, pt }),
4866    );
4867    Ok(elems)
4868}
4869
4870/// `inline-graphics : length -> length -> length -> (point -> graphics
4871/// list) -> inline-boxes` (vminst.ml:1872 `BackendInlineGraphics`) — a box
4872/// of size `(w, h, d)` carrying the callback's resolved graphics elements,
4873/// the minimal on-page sink for a `graphics` value.
4874///
4875/// **Eager-callback shortcut.** Upstream defers the callback until
4876/// the box's *placed* point is known on the page, then calls
4877/// `gfun(placed_point)`. A lang closure cannot live inside a backend box
4878/// (`PureHorzBox::Graphics` only holds resolved `GraphicsElem`s), and the
4879/// placed point isn't known until page-break/render time — so instead this
4880/// calls `gfun` immediately at `(0pt, 0pt)`, and the PDF writer
4881/// (`place_graphics`, rustyfi-pdf) translates the *whole* box to its placed
4882/// position via a single `cm` at render time. This equals upstream's
4883/// behavior if and only if `gfun` uses its point argument purely additively
4884/// (shift-covariant) — true of every real `Gr`/`deco` generator, but not
4885/// enforced by this signature.
4886///
4887/// **The one SIDE EFFECT that survives the shortcut** is
4888/// `register-destination`: at construction time there is no page, so
4889/// `annotation.ml:15`'s gate would refuse it and fail the document (azmath's
4890/// `equation.satyh` anchors every `\label`ed equation this way).
4891/// [`apply_graphics_callback`] turns each call into a
4892/// `GraphicsElem::Destination` marker riding in `elems` — rather than a field
4893/// of its own, so it inherits the `origin_independent` probe below and the
4894/// existing `fire_hooks`/`shift_graphics` pipeline.
4895fn prim_inline_graphics(
4896    interp: &mut Interp,
4897    version: RustyfiVersion,
4898    mut args: Vec<Value>,
4899) -> Result<Value, EvalError> {
4900    let gfun = args.pop().unwrap();
4901    let d = as_length(args.pop().unwrap())?;
4902    let h = as_length(args.pop().unwrap())?;
4903    let w = as_length(args.pop().unwrap())?;
4904    // The callback's result type is `list graphics` under v0.0.6, one
4905    // `graphics` collection under v0.1 — see `coerce_graphics_result`'s doc
4906    // comment.
4907    let gf = gfun.clone();
4908    let elems = apply_graphics_callback(interp, version, move |it| {
4909        let origin = make_point_value((Length::ZERO, Length::ZERO));
4910        it.apply(gf, origin)
4911    })?;
4912    // Detect a PAGE-ABSOLUTE callback: run it again at a far-off probe point
4913    // and compare. If the output is byte-identical the callback ignored its
4914    // placed-point argument (`fun _ -> …`, e.g. slydifi's frame background /
4915    // figbox's `draw-text pt`), so its coordinates are already page-absolute
4916    // and the PDF writer must NOT translate them by the box's placed position
4917    // (which is often a negative text-origin, shifting the decoration off the
4918    // page). A position-relative callback yields different output here, so
4919    // `origin_independent` stays false and the per-box `cm` applies as before.
4920    // Upstream (`handlePdf.ml`) always calls the callback with the true placed
4921    // point and never post-translates; this recovers that for the constant
4922    // case without a post-layout deferral. (The extra evaluation must be free
4923    // of observable side effects — true of every `Gr`/`draw-text` generator;
4924    // `register-destination` is captured per call rather than committed, so
4925    // the probe's copy is dropped.)
4926    //
4927    // The comparison includes the markers on purpose: an ANCHOR-ONLY callback
4928    // draws no ink at either point, so comparing ink alone would classify it
4929    // page-absolute and pin every anchor at the raw callback argument.
4930    let origin_independent = {
4931        let probe = make_point_value((Length::pt(4096.0), Length::pt(2731.0)));
4932        match apply_graphics_callback(interp, version, move |it| it.apply(gfun, probe)) {
4933            Ok(e2) => e2 == elems,
4934            Err(_) => false,
4935        }
4936    };
4937    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4938        PureHorzBox::Graphics {
4939            width: w,
4940            height: h,
4941            depth: d,
4942            elems,
4943            origin_independent,
4944        },
4945    )]))
4946}
4947
4948/// `inline-graphics-outer : length -> length -> (length -> point -> graphics
4949/// list) -> inline-boxes` (vminst.ml:1891 `BackendInlineGraphicsOuter`) — a
4950/// graphics box whose width stretches like `inline-fil` (upstream widinfo
4951/// `Fils(1)`). The callback needs the RESOLVED width, unknown until line
4952/// layout, so it is deferred through `Interp::outer_graphics` (the `HookId`
4953/// pattern) and fired by `resolve_outer_graphics_in_contents` (called from
4954/// `line-break`/`tabular`/`draw-text`) with the width `justify_line` wrote
4955/// into the box and the point `(0pt, 0pt)` — the same shift-covariance
4956/// shortcut as `inline-graphics` above (the writer's `cm` supplies the
4957/// placed point); the width argument is faithful.
4958fn prim_inline_graphics_outer(
4959    interp: &mut Interp,
4960    version: RustyfiVersion,
4961    mut args: Vec<Value>,
4962) -> Result<Value, EvalError> {
4963    let gfun = args.pop().unwrap();
4964    let d = as_length(args.pop().unwrap())?;
4965    let h = as_length(args.pop().unwrap())?;
4966    interp.outer_graphics.push((gfun, version));
4967    let fn_id = GraphicsFnId(interp.outer_graphics.len() - 1);
4968    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4969        PureHorzBox::GraphicsOuter {
4970            height: h,
4971            depth: d,
4972            width: Length::ZERO,
4973            fn_id,
4974        },
4975    )]))
4976}
4977
4978/// Fire every deferred `inline-graphics-outer` callback in an already-
4979/// justified run, replacing its `GraphicsOuter` marker with a resolved
4980/// `Graphics` box (see `prim_inline_graphics_outer`). Idempotent (a resolved
4981/// box no longer matches) and cheap when nothing matches (one pass, no
4982/// allocation).
4983fn resolve_outer_graphics_in_contents(
4984    interp: &mut Interp,
4985    contents: &mut [(Length, PureHorzBox)],
4986) -> Result<(), EvalError> {
4987    for (_, bx) in contents.iter_mut() {
4988        if let PureHorzBox::GraphicsOuter {
4989            height,
4990            depth,
4991            width,
4992            fn_id,
4993        } = bx
4994        {
4995            let (w, h, d) = (*width, *height, *depth);
4996            let (gfun, gver) = match interp.outer_graphics.get(fn_id.0) {
4997                Some((f, v)) => (f.clone(), *v),
4998                None => {
4999                    return eval_error(format!(
5000                        "inline-graphics-outer: dangling callback index {}",
5001                        fn_id.0
5002                    ))
5003                }
5004            };
5005            // Same per-version coercion as `prim_inline_graphics`
5006            // above, shared with `tabular`'s per-cell use. The generation is
5007            // the one the callback was REGISTERED under, carried alongside it in
5008            // `Interp::outer_graphics`: this pass is a DEFERRED one
5009            // (`line-break`/`tabular`/`draw-text`), so `interp.version` here
5010            // is the entry document's, not the callback author's.
5011            //
5012            // Deferred to LINE-BREAK time, not page break, so a
5013            // `register-destination` here still has no page: same capture as
5014            // `prim_inline_graphics`.
5015            let elems = apply_graphics_callback(interp, gver, move |it| {
5016                let partial = it.apply(gfun, Value::Length(w))?;
5017                it.apply(partial, make_point_value((Length::ZERO, Length::ZERO)))
5018            })?;
5019            *bx = PureHorzBox::Graphics {
5020                width: w,
5021                height: h,
5022                depth: d,
5023                elems,
5024                origin_independent: false,
5025            };
5026        }
5027    }
5028    Ok(())
5029}
5030
5031/// `tabular : (cell list) list -> (length list -> length list -> graphics
5032/// list) -> inline-boxes` (vminst.ml:539) — solve the grid (backend
5033/// `rustyfi_backend::tabular::main`) and eagerly drive the rule callback
5034/// with the solved box-local grid-line coordinates.
5035///
5036/// **Why eager is faithful here, unlike `inline-graphics`.** The callback's
5037/// arguments are the grid-line coordinates, fully determined by cell
5038/// content alone (`main` computes them before any placement) — so calling
5039/// it once at construction time with the true box-local `xs`/`ys` is exactly
5040/// what upstream's later, placement-time call produces once the PDF
5041/// writer's per-box `cm` translate (shared with `place_graphics`, see
5042/// `rustyfi-pdf`) shifts the resulting rule paths into position. No
5043/// shift-covariance caveat (contrast `prim_inline_graphics` above).
5044fn prim_tabular(
5045    interp: &mut Interp,
5046    version: RustyfiVersion,
5047    mut args: Vec<Value>,
5048) -> Result<Value, EvalError> {
5049    let rulesf = args.pop().unwrap();
5050    let rows = as_cell_grid(args.pop().unwrap())?;
5051    let mut solved = rustyfi_backend::tabular::main(rows);
5052    for cell in &mut solved.cells {
5053        resolve_outer_graphics_in_contents(interp, &mut cell.contents)?;
5054    }
5055
5056    let xs = make_length_list(&solved.xs);
5057    let ys = make_length_list(&solved.ys);
5058    let partial = interp.apply(rulesf, xs)?;
5059    let gval = interp.apply(partial, ys)?;
5060    // The rules callback returns `list graphics` under v0.0.6, one
5061    // `graphics` collection under v0.1 — per the CALLER's generation
5062    // (`version`), which is the one whose `tabular` type this call was
5063    // checked against.
5064    let rules = coerce_graphics_result_for(version, gval)?;
5065
5066    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5067        PureHorzBox::Tabular(TabularBox {
5068            width: solved.width,
5069            height: solved.height,
5070            depth: Length::ZERO,
5071            cells: solved.cells,
5072            rules,
5073        }),
5074    )]))
5075}
5076
5077// ============================================================================
5078// ---- gr.satyh graphics primitives -------------------------------------------
5079// ============================================================================
5080
5081/// `bezier-to : point -> point -> point -> pre-path -> pre-path`
5082/// (vminst.ml:742) — appends a cubic Bézier segment (`ptS`/`ptT` control
5083/// points, `pt1` destination) to the pre-path's forward-accumulated `segs`.
5084fn prim_bezier_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5085    let mut pp = as_prepath(args.pop().unwrap())?;
5086    let pt1 = as_point(args.pop().unwrap())?;
5087    let pt_t = as_point(args.pop().unwrap())?;
5088    let pt_s = as_point(args.pop().unwrap())?;
5089    pp.segs.push(PathSeg::Bezier(pt_s, pt_t, pt1));
5090    Ok(Value::PrePath(pp))
5091}
5092
5093/// `close-with-bezier : point -> point -> pre-path -> path` (vminst.ml:787)
5094/// — closes the subpath with a cubic Bézier back to its start (`ptS`/`ptT`
5095/// control points; the destination is always the subpath's own `start`, per
5096/// `Closing::Bezier`'s doc comment).
5097fn prim_close_with_bezier(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5098    let pp = as_prepath(args.pop().unwrap())?;
5099    let pt_t = as_point(args.pop().unwrap())?;
5100    let pt_s = as_point(args.pop().unwrap())?;
5101    Ok(Value::Path(Path {
5102        subpaths: vec![Subpath {
5103            start: pp.start,
5104            segs: pp.segs,
5105            closing: Closing::Bezier(pt_s, pt_t),
5106        }],
5107    }))
5108}
5109
5110/// `shift-path : point -> path -> path` (vminst.ml:663) — translate every
5111/// point of the path by the given vector (`rustyfi_backend::shift_path`).
5112fn prim_shift_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5113    let path = as_path(args.pop().unwrap())?;
5114    let v = as_point(args.pop().unwrap())?;
5115    Ok(Value::Path(shift_path(v, &path)))
5116}
5117
5118/// `linear-transform-path : float -> float -> float -> float -> path ->
5119/// path` (vminst.ml:678) — apply the 2x2 matrix `(a, b, c, d)` to every
5120/// point of the path (`rustyfi_backend::linear_transform_path`).
5121fn prim_linear_transform_path(
5122    _interp: &mut Interp,
5123    mut args: Vec<Value>,
5124) -> Result<Value, EvalError> {
5125    let path = as_path(args.pop().unwrap())?;
5126    let d = as_float(args.pop().unwrap())?;
5127    let c = as_float(args.pop().unwrap())?;
5128    let b = as_float(args.pop().unwrap())?;
5129    let a = as_float(args.pop().unwrap())?;
5130    Ok(Value::Path(linear_transform_path((a, b, c, d), &path)))
5131}
5132
5133/// `shift-graphics : point -> graphics -> graphics` (vminst.ml:2451) —
5134/// translate every point of the graphics element by the given vector.
5135fn prim_shift_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5136    let g = as_graphics(args.pop().unwrap())?;
5137    let v = as_point(args.pop().unwrap())?;
5138    Ok(Value::Graphics(shift_graphics(v, &g)))
5139}
5140
5141/// `linear-transform-graphics : float -> float -> float -> float -> graphics
5142/// -> graphics` (vminst.ml:2432). **Eager, unlike upstream**:
5143/// `graphicD.ml`'s `make_linear_trans` lazily wraps the element in a
5144/// `LinearTrans` node, deferring the matrix to a PDF `cm` operator at render
5145/// time — which also scales any wrapped `Stroke`/`DashedStroke`'s effective
5146/// line width (width is specified in the pre-transform coordinate space).
5147/// This port instead rewrites every point up front (a pure coordinate map,
5148/// no PDF change needed) and leaves `width` untouched, so
5149/// a non-uniform `scale-graphics` (`gr.satyh`) will NOT scale a stroke's
5150/// line width the way upstream does — invisible for pure rotation
5151/// (`rotate-graphics`, orthonormal, preserves lengths) and for `Fill`, which
5152/// is the only `GraphicsElem` shape any bundled package actually
5153/// strokes-then-scales.
5154fn prim_linear_transform_graphics(
5155    _interp: &mut Interp,
5156    mut args: Vec<Value>,
5157) -> Result<Value, EvalError> {
5158    let g = as_graphics(args.pop().unwrap())?;
5159    let d = as_float(args.pop().unwrap())?;
5160    let c = as_float(args.pop().unwrap())?;
5161    let b = as_float(args.pop().unwrap())?;
5162    let a = as_float(args.pop().unwrap())?;
5163    Ok(Value::Graphics(linear_transform_graphics((a, b, c, d), &g)))
5164}
5165
5166/// `get-graphics-bbox : graphics -> point * point` (v0.0.6 vminst.ml:2466)
5167/// — the v006 fork side. `.unwrap_or(…)` is UNREACHABLE under 0.0.6 (no
5168/// 0.0.6-visible constructor produces `Group`/`Clip`, so `graphics_bbox`
5169/// never returns `None` here); documented rather than `.expect`ed so a
5170/// future faithful `Group`/`Clip` leak (a bug) fails soft instead of
5171/// panicking.
5172fn prim_get_graphics_bbox_v006(
5173    _interp: &mut Interp,
5174    mut args: Vec<Value>,
5175) -> Result<Value, EvalError> {
5176    let g = as_graphics(args.pop().unwrap())?;
5177    let (pmin, pmax) =
5178        graphics_bbox(&g).unwrap_or(((Length::ZERO, Length::ZERO), (Length::ZERO, Length::ZERO)));
5179    Ok(Value::Tuple(vec![
5180        make_point_value(pmin),
5181        make_point_value(pmax),
5182    ]))
5183}
5184
5185/// `get-graphics-bbox : graphics -> option (point * point)` (dev-0-1-0
5186/// vminst.ml:2301) — the v01 fork side: `graphics` is a collection,
5187/// so an empty `unite-graphics []` (or an empty `Clip`'s contents-blind
5188/// bbox is still `Some`, but an empty `Group` folds to nothing)
5189/// legitimately has no bbox — surfaced as the SATySFi `option` variant,
5190/// the `probe-cross-reference` building pattern.
5191fn prim_get_graphics_bbox_v01(
5192    _interp: &mut Interp,
5193    mut args: Vec<Value>,
5194) -> Result<Value, EvalError> {
5195    let g = as_graphics(args.pop().unwrap())?;
5196    Ok(match graphics_bbox(&g) {
5197        Some((pmin, pmax)) => Value::Ctor(
5198            "Some".to_string(),
5199            Some(Box::new(Value::Tuple(vec![
5200                make_point_value(pmin),
5201                make_point_value(pmax),
5202            ]))),
5203        ),
5204        None => Value::Ctor("None".to_string(), None),
5205    })
5206}
5207
5208/// `unite-graphics : list graphics -> graphics` (dev-0-1-0 vminst.ml:3119)
5209/// — `GraphicD.concat` = `List.concat`, ported as the `Group` container.
5210/// `unite-graphics []` is legal and yields the
5211/// empty collection (the `None`-bbox witness `get-graphics-bbox` exercises
5212/// above).
5213fn prim_unite_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5214    let items = as_list(args.pop().unwrap())?;
5215    let mut elems = Vec::with_capacity(items.len());
5216    for it in items {
5217        elems.push(as_graphics(it)?);
5218    }
5219    Ok(Value::Graphics(GraphicsElem::Group(elems)))
5220}
5221
5222/// `clip-graphics-by-path : path -> graphics -> graphics` (dev-0-1-0
5223/// vminst.ml:3105) — `GraphicD.make_clip gr pathlst` = `Clip(paths, gr)`;
5224/// the port's single-element `g` (possibly itself a `Group`) IS the
5225/// collection upstream's `gr` argument names.
5226fn prim_clip_graphics_by_path(
5227    _interp: &mut Interp,
5228    mut args: Vec<Value>,
5229) -> Result<Value, EvalError> {
5230    let g = as_graphics(args.pop().unwrap())?;
5231    let path = as_path(args.pop().unwrap())?;
5232    Ok(Value::Graphics(GraphicsElem::Clip(path, vec![g])))
5233}
5234
5235/// `get-path-bbox : path -> point * point` (vminst.ml:696
5236/// `PathGetBoundingBox`) — `rustyfi_backend::path_bbox` (see that function's
5237/// doc comment for the exact cubic-extrema policy shared with
5238/// `get-graphics-bbox`).
5239fn prim_get_path_bbox(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5240    let path = as_path(args.pop().unwrap())?;
5241    let (pmin, pmax) = path_bbox(&path);
5242    Ok(Value::Tuple(vec![
5243        make_point_value(pmin),
5244        make_point_value(pmax),
5245    ]))
5246}
5247
5248/// `dashed-stroke : length -> (length*length*length) -> color -> path ->
5249/// graphics` (vminst.ml:2414) — width first, then the dash pattern, then
5250/// color, then path (mirrors `stroke`'s argument order with one extra
5251/// dash-pattern argument inserted).
5252fn prim_dashed_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5253    let path = as_path(args.pop().unwrap())?;
5254    let color = as_color(args.pop().unwrap())?;
5255    let dash = as_dash(args.pop().unwrap())?;
5256    let wid = as_length(args.pop().unwrap())?;
5257    Ok(Value::Graphics(GraphicsElem::DashedStroke(
5258        wid, dash, color, path,
5259    )))
5260}
5261
5262/// `draw-text : point -> inline-boxes -> graphics` (vminst.ml:2363
5263/// `PrimitiveDrawText`) — FAITHFUL: lays the run out at natural width
5264/// (upstream `LineBreak.natural`; here `natural_metrics` + `fit_cell` at that
5265/// width, so slack is 0 and every box keeps its natural advance) and stores
5266/// the placed run in `GraphicsElem::Text`. Also resolves any
5267/// `inline-graphics-outer` marker the run carries (`resolve_outer_graphics_
5268/// in_contents` — width 0 there, since slack is 0 at natural width, upstream
5269/// identical: `widperfil = 0`).
5270fn prim_draw_text(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5271    let ib = as_inline_boxes(args.pop().unwrap())?;
5272    let pt = as_point(args.pop().unwrap())?;
5273    let (width, height, depth) = natural_metrics(&ib);
5274    let (mut contents, _, _) = fit_cell(ib, width);
5275    resolve_outer_graphics_in_contents(interp, &mut contents)?;
5276    Ok(Value::Graphics(GraphicsElem::Text {
5277        pt,
5278        contents,
5279        width,
5280        height,
5281        depth,
5282        transform: None,
5283    }))
5284}
5285
5286// ============================================================================
5287// ---- pervasives.satyh prims -------------------
5288// ============================================================================
5289
5290/// `get-natural-metrics : inline-boxes -> length * length * length`
5291/// (vminst.ml:2020 `PrimitiveGetNaturalMetrics`) — FAITHFUL: delegates to
5292/// `rustyfi_backend::natural_metrics` (see that function's doc comment for
5293/// why no depth sign-flip is needed here, unlike upstream).
5294fn prim_get_natural_metrics(
5295    _interp: &mut Interp,
5296    mut args: Vec<Value>,
5297) -> Result<Value, EvalError> {
5298    let ib = as_inline_boxes(args.pop().unwrap())?;
5299    let (width, height, depth) = natural_metrics(&ib);
5300    Ok(Value::Tuple(vec![
5301        Value::Length(width),
5302        Value::Length(height),
5303        Value::Length(depth),
5304    ]))
5305}
5306
5307/// Build the atomic `PureHorzBox::Frame` for `inline-frame-outer`/`-inner`
5308/// (upstream keeps both atomic too; `-breakable` is transparent instead, see
5309/// [`prim_inline_frame_breakable`]): fit `inner` at its natural width
5310/// (`fit_cell` — the same
5311/// no-Context fit tabular cells use), pad the fitted run by `pads`, intern
5312/// `deco` into `interp.decos`. `deco` is fired lang-side, after
5313/// placement, by `fire_hooks`/`fire_inline_frame` — this constructor never
5314/// calls it.
5315fn make_inline_frame(
5316    interp: &mut Interp,
5317    version: RustyfiVersion,
5318    (pad_l, pad_r, pad_t, pad_b): (Length, Length, Length, Length),
5319    deco: Value,
5320    inner: Vec<HorzBox>,
5321) -> Value {
5322    let (w, _, _) = natural_metrics(&inner);
5323    let (contents, height, depth) = fit_cell(inner, w);
5324    let contents = contents.into_iter().map(|(x, b)| (x + pad_l, b)).collect();
5325    let id = DecoId(interp.decos.len());
5326    // `version` is the CALLING code's generation, threaded in by the
5327    // per-version prim rows below — fire time is a post-page-break pass with
5328    // no version context of its own, so this is the only moment the answer
5329    // is available. See `DecoEntry`'s doc comment.
5330    interp.decos.push(DecoEntry::Inline { deco, version });
5331    Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::Frame {
5332        width: pad_l + w + pad_r,
5333        height: height + pad_t,
5334        depth: depth + pad_b,
5335        deco: id,
5336        contents,
5337    })])
5338}
5339
5340/// `inline-frame-outer : paddings -> deco -> inline-boxes -> inline-boxes`
5341/// (vminst.ml:1787 `BackendOuterFrame`) — FAITHFUL: builds the atomic
5342/// `PureHorzBox::Frame`; see [`make_inline_frame`]. Upstream's
5343/// outer/inner distinction is glue participation in the enclosing line
5344/// (`PHGOuterFrame` vs `PHGInnerFrame`), which this atomic box model
5345/// collapses — both this and [`prim_inline_frame_inner`] build the exact
5346/// same box.
5347fn prim_inline_frame_outer(
5348    interp: &mut Interp,
5349    version: RustyfiVersion,
5350    mut args: Vec<Value>,
5351) -> Result<Value, EvalError> {
5352    let inner = as_inline_boxes(args.pop().unwrap())?;
5353    let deco = args.pop().unwrap();
5354    let pads = as_paddings(args.pop().unwrap())?;
5355    Ok(make_inline_frame(interp, version, pads, deco, inner))
5356}
5357
5358/// `inline-frame-inner : paddings -> deco -> inline-boxes -> inline-boxes`
5359/// (vminst.ml:1807 `BackendInnerFrame`) — same construction as
5360/// [`prim_inline_frame_outer`]; see that function's doc comment for the
5361/// outer/inner distinction this atomic model collapses.
5362fn prim_inline_frame_inner(
5363    interp: &mut Interp,
5364    version: RustyfiVersion,
5365    mut args: Vec<Value>,
5366) -> Result<Value, EvalError> {
5367    let inner = as_inline_boxes(args.pop().unwrap())?;
5368    let deco = args.pop().unwrap();
5369    let pads = as_paddings(args.pop().unwrap())?;
5370    Ok(make_inline_frame(interp, version, pads, deco, inner))
5371}
5372
5373/// `set-manual-rising : length -> context -> context` (vminst.ml:1661
5374/// `PrimitiveSetManualRising`) — FAITHFUL store into
5375/// `Context::manual_rising`, the same shape as `set-font-size`/
5376/// `set-leading` above. Read by `text_to_boxes`'s `flush_word`, which adds
5377/// it to the script font's own baseline raise; the default is
5378/// `Length::ZERO`, so a document that never calls this is unaffected.
5379fn prim_set_manual_rising(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5380    let ctx = as_context(args.pop().unwrap())?;
5381    let rising = as_length(args.pop().unwrap())?;
5382    Ok(Value::Context(Box::new(Context {
5383        manual_rising: rising,
5384        ..ctx
5385    })))
5386}
5387
5388/// `script-guard : script -> inline-boxes -> inline-boxes` (vminst.ml:1908
5389/// `BackendScriptGuard`).
5390///
5391/// STAND-IN: upstream wraps `hblst` in a `HorzScriptGuard` that tells the
5392/// line breaker which script to assume at each edge, for inter-script
5393/// spacing rules (`lineBreak.ml`'s script-boundary handling). This port's
5394/// line breaker has no script-aware spacing at all yet, so this is the
5395/// identity function: the `script` argument is accepted (so callers like
5396/// pervasives.satyh's `\SATySFi`/`\LaTeX`/`\TeX` type-check and run) and
5397/// discarded.
5398fn prim_script_guard(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5399    let ib = as_inline_boxes(args.pop().unwrap())?;
5400    let _script = args.pop().unwrap();
5401    Ok(Value::InlineBoxes(ib))
5402}
5403
5404/// Unwrap `inline-boxes`' `Vec<HorzBox>` down to the bare `Vec<PureHorzBox>`
5405/// a `PureHorzBox::Discretionary` slot stores (mirrors `prim_line_break`'s
5406/// identical unwrap, linebreak.rs's only other consumer of this shape).
5407fn into_pure(boxes: Vec<HorzBox>) -> Vec<PureHorzBox> {
5408    boxes.into_iter().map(|HorzBox::Pure(p)| p).collect()
5409}
5410
5411/// `discretionary : int -> inline-boxes -> inline-boxes -> inline-boxes ->
5412/// inline-boxes` (vminst.ml:1969 `BackendDiscretionary`), params `(pb,
5413/// hblst0, hblst1, hblst2)` — FAITHFUL: builds the same
5414/// `PureHorzBox::Discretionary` the UAX#14 line breaker already produces
5415/// internally. `hblst0` (`no_break`) renders when this point is NOT chosen
5416/// as a line break; `hblst1`/`hblst2` (`pre_break`/`post_break`) render at
5417/// the end/start of the two lines a break here would produce.
5418fn prim_discretionary(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5419    let post_break = as_inline_boxes(args.pop().unwrap())?;
5420    let pre_break = as_inline_boxes(args.pop().unwrap())?;
5421    let no_break = as_inline_boxes(args.pop().unwrap())?;
5422    let penalty = as_int(args.pop().unwrap())?;
5423    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5424        PureHorzBox::Discretionary {
5425            penalty: penalty.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
5426            pre_break: into_pure(pre_break),
5427            post_break: into_pure(post_break),
5428            no_break: into_pure(no_break),
5429        },
5430    )]))
5431}
5432
5433/// `get-axis-height : context -> length` (vminst.ml:1739
5434/// `PrimitiveGetAxisHeight`), needed by `picture.satyh`'s `Picture.node`
5435/// (Tier-2 decoration/graphics wave) — centers text vertically around the
5436/// math axis.
5437///
5438/// FAITHFUL: reads `axis_height` from `ctx.math_font`'s OpenType MATH
5439/// table via `MathC` (`FontInfo.get_axis_height mfabbrev fontsize`), falling
5440/// back to a fixed `0.25` ratio of `ctx.font_size` (`pervasives.satyh`'s
5441/// `\SATySFi`/`\LaTeX` manual-rising ratio) whenever the font has no MATH
5442/// table — so base-14/non-math output is unchanged.
5443fn prim_get_axis_height(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5444    let ctx = as_context(args.pop().unwrap())?;
5445    let mc = MathC::of(interp, &ctx);
5446    Ok(Value::Length(mc.axis(ctx.font_size)))
5447}
5448
5449// ============================================================================
5450// ---- page-break hooks + cross-references -----------------------------------
5451// ============================================================================
5452
5453/// `hook-page-break : (page-break-info -> point -> unit) -> inline-boxes`
5454/// (vminstdef.yaml:576). Pushes the closure argument onto `interp.hooks`
5455/// (the lang-side table `fire_hooks` reads back after placement) and
5456/// returns an inline box carrying only the opaque `HookId` — exactly
5457/// `prim_load_image`'s shape (`ImageId`/`interp.images`), applied to a
5458/// deferred *computation* instead of a resource. The backend places this
5459/// box like any other zero-width content and never sees the closure.
5460fn prim_hook_page_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5461    let closure = args.pop().unwrap();
5462    let id = HookId(interp.hooks.len());
5463    interp.hooks.push(closure);
5464    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5465        PureHorzBox::HookPageBreak { id },
5466    )]))
5467}
5468
5469/// `hook-page-break-block : (page-break-info -> point -> unit) ->
5470/// block-boxes` (vminst.ml:632 `BackendHookPageBreakBlock`) — the
5471/// block-level analog of `prim_hook_page_break` above, FAITHFUL: same
5472/// `interp.hooks` push, same opaque `HookId`, but wrapped in a
5473/// `VertBox::HookPageBreak` marker instead of an inline box. `chop_page`/
5474/// `place_block_at` (rustyfi-backend) place it as a zero-height
5475/// `PlacedLine` carrying the SAME `PureHorzBox::HookPageBreak` wrapper the
5476/// inline primitive uses, so `fire_hooks` (lib.rs) fires it through the
5477/// exact same scan with no changes of its own.
5478fn prim_hook_page_break_block(
5479    interp: &mut Interp,
5480    mut args: Vec<Value>,
5481) -> Result<Value, EvalError> {
5482    let closure = args.pop().unwrap();
5483    let id = HookId(interp.hooks.len());
5484    interp.hooks.push(closure);
5485    Ok(Value::BlockBoxes(vec![VertBox::HookPageBreak(id)]))
5486}
5487
5488/// `register-cross-reference : string -> string -> unit` (vminstdef.yaml:1793).
5489/// Callable anywhere (not just from a hook) — ordinary strict primitive
5490/// over the shared `crossrefs` table.
5491fn prim_register_cross_reference(
5492    interp: &mut Interp,
5493    mut args: Vec<Value>,
5494) -> Result<Value, EvalError> {
5495    let value = as_str(args.pop().unwrap())?;
5496    let key = as_str(args.pop().unwrap())?;
5497    interp.crossrefs.borrow_mut().register(key, value);
5498    Ok(Value::Unit)
5499}
5500
5501/// `get-cross-reference : string -> string option` (vminstdef.yaml:1808).
5502/// A miss is recorded (`CrossRefs::get`) so an unresolved forward reference
5503/// forces another fixpoint trial; the result surfaces as the SATySFi
5504/// `option` variant (`None` / `Some(string)`).
5505fn prim_get_cross_reference(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5506    let key = as_str(args.pop().unwrap())?;
5507    Ok(match interp.crossrefs.borrow_mut().get(&key) {
5508        Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5509        None => Value::Ctor("None".to_string(), None),
5510    })
5511}
5512
5513/// `probe-cross-reference : string -> string option` (vminst.ml:3043
5514/// `BackendProbeCrossReference`) — FAITHFUL: `get-cross-reference` minus the
5515/// miss bookkeeping (`CrossRefs::probe`, crossRef.ml:112), so a `None` here
5516/// never forces another fixpoint trial.
5517fn prim_probe_cross_reference(
5518    interp: &mut Interp,
5519    mut args: Vec<Value>,
5520) -> Result<Value, EvalError> {
5521    let key = as_str(args.pop().unwrap())?;
5522    Ok(match interp.crossrefs.borrow().probe(&key) {
5523        Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5524        None => Value::Ctor("None".to_string(), None),
5525    })
5526}
5527
5528// ============================================================================
5529// ---- annot.satyh's prim surface (link annotations + the frame/script
5530// stand-ins it needs) --------------------------------------------------------
5531// ============================================================================
5532
5533/// `get-leftmost-script`/`get-rightmost-script : inline-boxes -> script
5534/// option` (vminstdef.yaml:1754/1767 `BackendGetLeftmostScript`/
5535/// `BackendGetRightmostScript`) — STAND-IN: upstream inspects the actual
5536/// Unicode script of the first/last character in `hblst`
5537/// (`LineBreak.get_leftmost_script`/`get_rightmost_script`), which
5538/// `annot.satyh`'s `\href` uses to `script-guard` the link's edges so
5539/// inter-script spacing isn't inserted right at the boundary. This port's
5540/// `PureHorzBox::InnerString` carries no per-character script tag (no
5541/// script-aware line breaking at all yet — `script-guard` above is already
5542/// an identity stand-in for the same reason), so both primitives
5543/// unconditionally return `None`: `\href` then takes its `None` arm
5544/// (`inline-nil`, no guard inserted) — a safe, honest default rather than
5545/// fabricating a script this port cannot actually see.
5546fn prim_get_leftmost_script(
5547    _interp: &mut Interp,
5548    mut args: Vec<Value>,
5549) -> Result<Value, EvalError> {
5550    let _ib = as_inline_boxes(args.pop().unwrap())?;
5551    Ok(Value::Ctor("None".to_string(), None))
5552}
5553
5554/// See [`prim_get_leftmost_script`] — the rightmost-edge twin, identical
5555/// stand-in reasoning.
5556fn prim_get_rightmost_script(
5557    _interp: &mut Interp,
5558    mut args: Vec<Value>,
5559) -> Result<Value, EvalError> {
5560    let _ib = as_inline_boxes(args.pop().unwrap())?;
5561    Ok(Value::Ctor("None".to_string(), None))
5562}
5563
5564/// `inline-frame-breakable : paddings -> deco-set -> inline-boxes ->
5565/// inline-boxes` (vminstdef.yaml:1672 `BackendOuterFrameBreakable`) —
5566/// FAITHFUL: upstream's `HorzFrameBreakable` is *transparent* to the
5567/// paragraph breaker (`lineBreak.ml:1094` threads the enclosing width map
5568/// straight through the frame's contents), so the frame's own glue and
5569/// discretionaries are break candidates of the enclosing paragraph, and
5570/// `cut` (`:824`) re-frames the chosen fragments one line at a time —
5571/// `decoS` for a frame that came out unbroken, `decoH`/`decoM`/`decoT` per
5572/// fragment for one that split.
5573///
5574/// This port's breaker is a flat index DP rather than upstream's recursive
5575/// one, so transparency is spelled by SPLICING: the contents go straight into
5576/// the returned box list, bracketed by a zero-width
5577/// [`PureHorzBox::InlineFrameMarker`] pair that `fire_hooks` walks to
5578/// reassemble the fragments and fire the right closure for each. The
5579/// horizontal paddings become `FixedEmpty` boxes inside the bracket, exactly
5580/// upstream's `append_horz_padding` (`lineBreak.ml:79`); the vertical ones
5581/// ride on the markers (which is how they still reach the line's height and
5582/// depth) and are re-applied per fragment at fire time.
5583///
5584/// The atomic `PureHorzBox::Frame` is NOT usable here — it fires only
5585/// `decoS` and, being width-rigid, can neither break nor let an interior
5586/// `inline-fil` stretch. It is reserved for `inline-frame-outer`/`-inner`,
5587/// which upstream really does keep atomic.
5588fn prim_inline_frame_breakable(
5589    interp: &mut Interp,
5590    version: RustyfiVersion,
5591    mut args: Vec<Value>,
5592) -> Result<Value, EvalError> {
5593    let inner = as_inline_boxes(args.pop().unwrap())?;
5594    let decoset = as_decoset(args.pop().unwrap())?;
5595    let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
5596    let (_, height, depth) = natural_metrics(&inner);
5597    let id = DecoId(interp.decos.len());
5598    // `version` is the CALLING code's generation — see `make_inline_frame`'s
5599    // identical comment and `DecoEntry`'s doc comment.
5600    interp.decos.push(DecoEntry::InlineBreakable {
5601        pads: Paddings {
5602            l: pad_l,
5603            r: pad_r,
5604            t: pad_t,
5605            b: pad_b,
5606        },
5607        decoset,
5608        version,
5609    });
5610    let marker = |end| {
5611        HorzBox::Pure(PureHorzBox::InlineFrameMarker {
5612            id,
5613            end,
5614            height: height + pad_t,
5615            depth: depth + pad_b,
5616        })
5617    };
5618    let mut out = Vec::with_capacity(inner.len() + 4);
5619    out.push(marker(false));
5620    // Upstream emits both padding boxes unconditionally; a zero-width
5621    // `FixedEmpty` is inert everywhere in this port too, but skipping it keeps
5622    // the box stream (and every placed-line snapshot) unchanged for the
5623    // zero-padding callers, which is every bundled one.
5624    if pad_l != Length::ZERO {
5625        out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_l }));
5626    }
5627    out.extend(inner);
5628    if pad_r != Length::ZERO {
5629        out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_r }));
5630    }
5631    out.push(marker(true));
5632    Ok(Value::InlineBoxes(out))
5633}
5634
5635/// `deco-set` = `Value::Tuple` of 4 closures (`(decoS, decoH, decoM,
5636/// decoT)`, evalUtil.ml:169 `get_decoset`) — no type check on the elements
5637/// themselves (they're closures, applied later by `apply_deco`).
5638fn as_decoset(v: Value) -> Result<[Value; 4], EvalError> {
5639    match v {
5640        Value::Tuple(vs) if vs.len() == 4 => {
5641            let mut it = vs.into_iter();
5642            let a = it.next().unwrap();
5643            let b = it.next().unwrap();
5644            let c = it.next().unwrap();
5645            let d = it.next().unwrap();
5646            Ok([a, b, c, d])
5647        }
5648        other => eval_error(format!(
5649            "expected a deco-set (4-tuple of decorations), got {}",
5650            other.type_name()
5651        )),
5652    }
5653}
5654
5655/// 0.0.6 graphics-producing callbacks return `list graphics` (`tL tGR`);
5656/// 0.1's return one `graphics` collection (`tGR` — dev-0-1-0
5657/// `primitives.cppo.ml:75-85`). STRICT per
5658/// version: a 0.1 program returning a list here is a bug the type checker
5659/// already rejected; don't mask it with tolerant decoding. Shared by every
5660/// coercion site (`prim_inline_graphics`, `inline-graphics-outer`/
5661/// `tabular`'s `resolve_outer_graphics_in_contents`, `tabular`'s own rules
5662/// callback, and `apply_deco` below).
5663///
5664/// `version` is EXPLICIT rather than read off `interp.version`, and that is
5665/// the whole point. `interp.version` is one whole-program field, set once by
5666/// `lib.rs`'s `eval_document_trials`; in a cross-version program it names
5667/// the ENTRY document's generation, while the callback being decoded here
5668/// may have been written by a spliced 0.0.6 dependency. Every caller gets
5669/// the right answer from a place that genuinely knows it: the six carrier
5670/// prim bodies are registered per version
5671/// (`version_forked_prims!`, folded at compile time by
5672/// `compile.rs`'s `Ast::VersionScope` arm), and the two DEFERRED consumers
5673/// read the generation captured when the closure was interned
5674/// (`DecoEntry::version`, `Interp::outer_graphics`'s second component).
5675fn coerce_graphics_result_for(
5676    version: RustyfiVersion,
5677    v: Value,
5678) -> Result<Vec<GraphicsElem>, EvalError> {
5679    if version.graphics_is_collection() {
5680        Ok(vec![as_graphics(v)?])
5681    } else {
5682        as_list(v)?.into_iter().map(as_graphics).collect()
5683    }
5684}
5685
5686/// `make_frame_deco` (evalUtil.ml:604): apply a curried
5687/// `point -> length -> length -> length -> graphics list` deco and coerce
5688/// the result. Depths here are already user-sign (nonnegative), so no
5689/// negate (upstream negates because ITS internal depths are nonpositive).
5690/// The deco closure's result is `list graphics` under v0.0.6, one `graphics`
5691/// collection under v0.1 — see `coerce_graphics_result`'s doc comment.
5692///
5693/// `version` is the generation the closure was CAPTURED under
5694/// (`DecoEntry::version`), not `interp.version`: this runs from `lib.rs`'s
5695/// post-page-break firing pass, which is outside every `VersionScope`
5696/// window, so `interp.version` there is the entry document's generation. In
5697/// a single-version program the two are the same value.
5698pub(crate) fn apply_deco(
5699    interp: &mut Interp,
5700    version: RustyfiVersion,
5701    deco: Value,
5702    pt: Point,
5703    w: Length,
5704    h: Length,
5705    d: Length,
5706) -> Result<Vec<GraphicsElem>, EvalError> {
5707    let v = interp.apply(deco, make_point_value(pt))?;
5708    let v = interp.apply(v, Value::Length(w))?;
5709    let v = interp.apply(v, Value::Length(h))?;
5710    let v = interp.apply(v, Value::Length(d))?;
5711    coerce_graphics_result_for(version, v)
5712}
5713
5714/// `(length * color) option` — `register-link-to-uri`/`-to-location`'s
5715/// trailing border argument (vminstdef.yaml:2755/2775's `vborderopt`),
5716/// parsed the same way [`as_color`]/[`as_page`] read a `Value::Ctor`.
5717fn as_border_option(v: Value) -> Result<Option<(Length, Color)>, EvalError> {
5718    match v {
5719        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
5720            ("None", None) => Ok(None),
5721            ("Some", Some(Value::Tuple(vs))) if vs.len() == 2 => {
5722                let mut it = vs.into_iter();
5723                let w = as_length(it.next().unwrap())?;
5724                let c = as_color(it.next().unwrap())?;
5725                Ok(Some((w, c)))
5726            }
5727            (other, _) => eval_error(format!(
5728                "expected a border option (None / Some(length * color)), got variant '{other}'"
5729            )),
5730        },
5731        other => eval_error(format!("expected an option, got {}", other.type_name())),
5732    }
5733}
5734
5735/// `register-destination : string -> point -> unit` (vminstdef.yaml:2738) —
5736/// FAITHFUL: upstream `NamedDest.register` + `notify_pagebreak` collapsed
5737/// into one step, since our firing window (`fire_hooks`) already knows the
5738/// page. Errors outside that window (`annotation.ml:15`'s
5739/// `State.during_page_break` gate).
5740///
5741/// **ONE exception, a re-timing rather than a relaxation of the gate.** Inside
5742/// an eagerly-applied `inline-graphics` callback this port is running code
5743/// upstream would only run DURING page breaking (see `prim_inline_graphics`),
5744/// so refusing here would refuse a call upstream accepts. Such a call is
5745/// recorded in `Interp::pending_dests` instead, and the caller mints a
5746/// `GraphicsElem::Destination` marker from it. Outside that one window the gate
5747/// is unchanged: no page, no destination.
5748fn prim_register_destination(
5749    interp: &mut Interp,
5750    mut args: Vec<Value>,
5751) -> Result<Value, EvalError> {
5752    let (x, y) = as_point(args.pop().unwrap())?;
5753    let key = as_str(args.pop().unwrap())?;
5754    if interp.current_page.is_none() {
5755        if let Some(pending) = interp.pending_dests.as_mut() {
5756            pending.push((key, (x, y)));
5757            return Ok(Value::Unit);
5758        }
5759    }
5760    let Some(page) = interp.current_page else {
5761        return eval_error(
5762            "register-destination can only be called during page breaking \
5763             (from a page-break hook or a decoration)",
5764        );
5765    };
5766    let name = interp.dest_name(&key);
5767    // See `prim_register_link_to_uri`'s identical comment —
5768    // `register-location-frame`'s `decoR` fires this from inside a firing
5769    // block-frame deco.
5770    if let Some(deco_id) = interp.current_deco_id {
5771        interp.dest_decos.push((deco_id, name.clone()));
5772    }
5773    interp.destinations.push(NamedDest { page, name, x, y });
5774    Ok(Value::Unit)
5775}
5776
5777/// Shared body of `register-link-to-uri` / `register-link-to-location`
5778/// (vminstdef.yaml:2753/2773): pops the common `point/w/h/d/border` suffix,
5779/// builds `annotation.ml:22`'s rect `(x, y - d, x + w, y + h)` (PDF y-up
5780/// points; our depths are already nonnegative), and pushes the `Annot`.
5781fn register_link(
5782    interp: &mut Interp,
5783    mut args: Vec<Value>,
5784    prim_name: &str,
5785    make_action: impl FnOnce(&mut Interp, String) -> AnnotAction,
5786) -> Result<Value, EvalError> {
5787    let border = as_border_option(args.pop().unwrap())?;
5788    let dpt = as_length(args.pop().unwrap())?;
5789    let hgt = as_length(args.pop().unwrap())?;
5790    let wid = as_length(args.pop().unwrap())?;
5791    let (x, y) = as_point(args.pop().unwrap())?;
5792    let target = as_str(args.pop().unwrap())?;
5793    let Some(page) = interp.current_page else {
5794        return eval_error(format!(
5795            "{prim_name} can only be called during page breaking \
5796             (from a page-break hook or a decoration)"
5797        ));
5798    };
5799    let action = make_action(interp, target);
5800    // Tag this link with the DecoId of whatever deco closure is currently
5801    // firing (set by `fire_hooks`'s two `apply_deco` call sites, `lib.rs`) —
5802    // `annot.satyh`'s `\href` always calls this from inside one, so
5803    // `current_deco_id` is `Some` for every real `\href`; a hand-built test
5804    // calling this prim directly (not through a firing deco) legitimately
5805    // leaves it `None`, and the reflow backend just won't find a Frame to
5806    // wrap for that link.
5807    if let Some(deco_id) = interp.current_deco_id {
5808        interp.link_decos.push((deco_id, action.clone()));
5809    }
5810    interp.annotations.push(Annot {
5811        page,
5812        rect: (x, y - dpt, x + wid, y + hgt),
5813        action,
5814        border,
5815    });
5816    Ok(Value::Unit)
5817}
5818
5819/// `register-link-to-uri : string -> point -> length -> length -> length ->
5820/// (length * color) option -> unit` (vminstdef.yaml:2753
5821/// `BackendRegisterLinkToUri`) — FAITHFUL: see [`register_link`].
5822fn prim_register_link_to_uri(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
5823    register_link(interp, args, "register-link-to-uri", |_, uri| {
5824        AnnotAction::Uri(uri)
5825    })
5826}
5827
5828/// `register-link-to-location : string -> point -> length -> length ->
5829/// length -> (length * color) option -> unit` (vminstdef.yaml:2773
5830/// `BackendRegisterLinkToLocation`) — FAITHFUL: same shape as
5831/// [`prim_register_link_to_uri`], but upstream's action is
5832/// `GotoName(NamedDest.get name)` — the key goes through the SAME name table
5833/// as [`prim_register_destination`], so a link to a not-(yet-)registered
5834/// destination still mints a stable name (a viewer no-ops on it), exactly
5835/// like upstream.
5836fn prim_register_link_to_location(
5837    interp: &mut Interp,
5838    args: Vec<Value>,
5839) -> Result<Value, EvalError> {
5840    register_link(interp, args, "register-link-to-location", |interp, key| {
5841        AnnotAction::GotoName(interp.dest_name(&key))
5842    })
5843}
5844
5845// ============================================================================
5846// The faithful `Value::Math` primitive layer `math.satyh` is built
5847// out of. Every `math-*` primitive here builds or consumes a
5848// `Value::Math(Rc<Vec<Math>>)` (`value.rs`'s `Math`); a `math`-typed
5849// argument may equally arrive as a `Value::MathText` (a `${…}` literal —
5850// `as_math` accepts either, reflecting a `MathText`'s `MathElem` tree into
5851// `Math` nodes on the fly, see below).
5852// ============================================================================
5853
5854use crate::value::{Math, MathElement, MathVariantStyle};
5855
5856/// `math-class` = `Value::Ctor("MathOrd"|"MathBin"|…, None)` — mirrors
5857/// `as_color`/`as_page`'s shape exactly.
5858fn as_math_kind(v: Value) -> Result<MathKind, EvalError> {
5859    match v {
5860        Value::Ctor(name, None) => match name.as_str() {
5861            "MathOrd" => Ok(MathKind::Ord),
5862            "MathBin" => Ok(MathKind::Bin),
5863            "MathRel" => Ok(MathKind::Rel),
5864            "MathOp" => Ok(MathKind::Op),
5865            "MathPunct" => Ok(MathKind::Punct),
5866            "MathOpen" => Ok(MathKind::Open),
5867            "MathClose" => Ok(MathKind::Close),
5868            "MathPrefix" => Ok(MathKind::Prefix),
5869            "MathInner" => Ok(MathKind::Inner),
5870            other => eval_error(format!("expected a math-class constructor, got '{other}'")),
5871        },
5872        other => eval_error(format!("expected a math-class, got {}", other.type_name())),
5873    }
5874}
5875
5876/// `math-char-class` = `Value::Ctor("MathItalic"|…, None)`, resolved to the
5877/// backend's [`MathCharClass`] (see `value.rs`'s
5878/// `Math::ChangeCharClass` doc comment).
5879fn as_math_char_class(v: Value) -> Result<MathCharClass, EvalError> {
5880    match v {
5881        Value::Ctor(name, None) => match name.as_str() {
5882            "MathItalic" => Ok(MathCharClass::Italic),
5883            "MathBoldItalic" => Ok(MathCharClass::BoldItalic),
5884            "MathRoman" => Ok(MathCharClass::Roman),
5885            "MathBoldRoman" => Ok(MathCharClass::BoldRoman),
5886            "MathScript" => Ok(MathCharClass::Script),
5887            "MathBoldScript" => Ok(MathCharClass::BoldScript),
5888            "MathFraktur" => Ok(MathCharClass::Fraktur),
5889            "MathBoldFraktur" => Ok(MathCharClass::BoldFraktur),
5890            "MathDoubleStruck" => Ok(MathCharClass::DoubleStruck),
5891            // V0_1-only registration — these 5
5892            // ctor names are only ever declared by `builtin_variants` under
5893            // V0_1, so under V0_0 this arm is simply never reached: the
5894            // ctor name itself is rejected earlier, at typecheck, as
5895            // unknown.
5896            "MathSansSerif" => Ok(MathCharClass::SansSerif),
5897            "MathBoldSansSerif" => Ok(MathCharClass::BoldSansSerif),
5898            "MathItalicSansSerif" => Ok(MathCharClass::ItalicSansSerif),
5899            "MathBoldItalicSansSerif" => Ok(MathCharClass::BoldItalicSansSerif),
5900            "MathTypewriter" => Ok(MathCharClass::Typewriter),
5901            other => eval_error(format!(
5902                "expected a math-char-class constructor, got '{other}'"
5903            )),
5904        },
5905        other => eval_error(format!(
5906            "expected a math-char-class, got {}",
5907            other.type_name()
5908        )),
5909    }
5910}
5911
5912/// `math-variant-char`'s 9-field style record (`value.rs`'s
5913/// `MathVariantStyle`; `prim_types::t_math_variant_style`'s runtime
5914/// counterpart).
5915fn as_math_variant_style(v: Value) -> Result<MathVariantStyle, EvalError> {
5916    match v {
5917        Value::Record(mut fields) => {
5918            let mut take = |label: &str| -> Result<String, EvalError> {
5919                match fields.remove(label) {
5920                    Some(v) => as_str(v),
5921                    None => eval_error(format!(
5922                        "math-variant-char style record missing field '{label}'"
5923                    )),
5924                }
5925            };
5926            Ok(MathVariantStyle {
5927                italic: take("italic")?,
5928                bold_italic: take("bold-italic")?,
5929                roman: take("roman")?,
5930                bold_roman: take("bold-roman")?,
5931                script: take("script")?,
5932                bold_script: take("bold-script")?,
5933                fraktur: take("fraktur")?,
5934                bold_fraktur: take("bold-fraktur")?,
5935                double_struck: take("double-struck")?,
5936            })
5937        }
5938        other => eval_error(format!(
5939            "expected a math-variant-char style record, got {}",
5940            other.type_name()
5941        )),
5942    }
5943}
5944
5945/// A `math` argument: either an already-faithful `Value::Math` (built by
5946/// another `math-*` primitive), or a `${…}` literal `Value::MathText`,
5947/// reflected into `Math` nodes on the fly via [`reflect_math_elem`] — see
5948/// `value.rs`'s `Value::Math` doc comment for why both are interchangeable.
5949fn as_math(interp: &mut Interp, v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
5950    match v {
5951        Value::Math(m) => Ok(m),
5952        Value::MathText { elems, env } => {
5953            let mut out = Vec::new();
5954            for e in elems.iter() {
5955                reflect_math_elem(interp, e, &env, &mut out)?;
5956            }
5957            Ok(Rc::new(out))
5958        }
5959        other => eval_error(format!("expected math, got {}", other.type_name())),
5960    }
5961}
5962
5963/// Reflect one elaborated `${…}` literal `MathElem` (a fused,
5964/// math-class-free form) into zero-or-more faithful `Math` atoms, pushed
5965/// onto `out` — the "less churn" resolution:
5966/// `MathElem` stays the fast path for a bare `${x^2}` in prose
5967/// (`read_inline`'s `EmbedMath` arm, untouched), and only gets reflected
5968/// into `Value::Math` at a command/primitive boundary (here — whenever a
5969/// `${…}` literal is passed where a faithful `math` value is expected).
5970/// `Cmd`/`Embed` are resolved by actually evaluating them against `env` (the
5971/// literal's own captured environment) and recursively reflecting/flattening
5972/// the result — the "Embed of a `#…` program value that itself
5973/// evaluates to math" case.
5974fn reflect_math_elem(
5975    interp: &mut Interp,
5976    elem: &MathElem,
5977    env: &Env,
5978    out: &mut Vec<Math>,
5979) -> Result<(), EvalError> {
5980    match elem {
5981        MathElem::Chars(s) => {
5982            // One atom per MATHCHAR token ("one atom per run" —
5983            // the lexer already grouped a symbol run or a single latin
5984            // digit/letter into `s`); class + codepoint remap are both
5985            // deferred to `layout_math_atom`'s `VariantCharPending` arm,
5986            // where `Context::math_class_map`/`math_variant_char_map` and
5987            // the current font are available.
5988            out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
5989            Ok(())
5990        }
5991        MathElem::Group(elems) => {
5992            for e in elems {
5993                reflect_math_elem(interp, e, env, out)?;
5994            }
5995            Ok(())
5996        }
5997        MathElem::Sub(base, script) => {
5998            let mut base_v = Vec::new();
5999            reflect_math_elem(interp, base, env, &mut base_v)?;
6000            let mut script_v = Vec::new();
6001            for e in script {
6002                reflect_math_elem(interp, e, env, &mut script_v)?;
6003            }
6004            out.push(Math::Sub(base_v, script_v));
6005            Ok(())
6006        }
6007        MathElem::Sup(base, script) => {
6008            let mut base_v = Vec::new();
6009            reflect_math_elem(interp, base, env, &mut base_v)?;
6010            let mut script_v = Vec::new();
6011            for e in script {
6012                reflect_math_elem(interp, e, env, &mut script_v)?;
6013            }
6014            out.push(Math::Sup(base_v, script_v));
6015            Ok(())
6016        }
6017        MathElem::Primes(base, n) => {
6018            let mut base_v = Vec::new();
6019            reflect_math_elem(interp, base, env, &mut base_v)?;
6020            let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
6021            out.push(Math::Sup(
6022                base_v,
6023                vec![Math::Pure(MathElement::Char {
6024                    class: MathKind::Ord,
6025                    big: false,
6026                    chars: primes,
6027                })],
6028            ));
6029            Ok(())
6030        }
6031        MathElem::Cmd { cmd, args, .. } => {
6032            let mut v = cmd.run(env, interp)?;
6033            for arg in args {
6034                // `arg.opts` is always empty here — the math-mode application
6035                // grammar has no `?(l=e)` bundle form (see `MathElem::Cmd`'s
6036                // doc comment, `ast.rs`) — but fold through `apply_with_opts`
6037                // uniformly with `read_inline`/`read_block` regardless.
6038                let mut opt_vals = Vec::with_capacity(arg.opts.len());
6039                for (label, e) in &arg.opts {
6040                    opt_vals.push((label.clone(), e.run(env, interp)?));
6041                }
6042                let arg_v = arg.arg.run(env, interp)?;
6043                v = interp.apply_with_opts(v, opt_vals, arg_v)?;
6044            }
6045            let m = as_math(interp, v)?;
6046            out.extend(m.iter().cloned());
6047            Ok(())
6048        }
6049        MathElem::Embed { expr, span: _ } => {
6050            let v = expr.run(env, interp)?;
6051            let m = as_math(interp, v)?;
6052            out.extend(m.iter().cloned());
6053            Ok(())
6054        }
6055    }
6056}
6057
6058fn single_math(m: Math) -> Value {
6059    Value::Math(Rc::new(vec![m]))
6060}
6061
6062// ============================================================================
6063// V0_1's `math-text`/`math-boxes` split + `read-math`.
6064// Everything below is additive and V0_1-only — no 0.0.6 path calls any of
6065// this (`as_math`/`reflect_math_elem`/`single_math` above stay byte-
6066// identical and untouched).
6067// ============================================================================
6068
6069fn single_math_boxes(m: Math) -> Value {
6070    Value::MathBoxes(Rc::new(vec![m]))
6071}
6072
6073/// V0_1 strict `math-boxes` extractor: accepts only `Value::MathBoxes` — a
6074/// `math-text` literal reaching a V0_1 `math-*` primitive is a genuine 0.1
6075/// type error (well-typed programs never hit this; it's the runtime
6076/// fallback for a call built by hand, e.g. from a unit test).
6077fn as_math_boxes(v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
6078    match v {
6079        Value::MathBoxes(m) => Ok(m),
6080        other => eval_error(format!(
6081            "expected math-boxes, got {} (V0_1: math-text and math-boxes \
6082             are distinct types — bridge with `read-math`)",
6083            other.type_name()
6084        )),
6085    }
6086}
6087
6088/// V0_1 strict `math-text` extractor: accepts only `Value::MathText`,
6089/// returning its elements together with the environment they were captured
6090/// under (needed to evaluate any `#x` embed / math-command lookup inside).
6091fn as_math_text(v: Value) -> Result<(Rc<Vec<MathElem>>, Env), EvalError> {
6092    match v {
6093        Value::MathText { elems, env } => Ok((elems, env)),
6094        other => eval_error(format!("expected math-text, got {}", other.type_name())),
6095    }
6096}
6097
6098/// `option math-text` extractor (`None`/`Some math-text`) — `%math-attach-
6099/// scripts`' sub/sup arguments.
6100fn as_option_math_text(v: Value) -> Result<Option<(Rc<Vec<MathElem>>, Env)>, EvalError> {
6101    match v {
6102        Value::Ctor(name, None) if name == "None" => Ok(None),
6103        Value::Ctor(name, Some(payload)) if name == "Some" => {
6104            let (elems, env) = as_math_text(*payload)?;
6105            Ok(Some((elems, env)))
6106        }
6107        other => eval_error(format!(
6108            "expected an option (None/Some), got {}",
6109            other.type_name()
6110        )),
6111    }
6112}
6113
6114/// Wrap a raw (ambient-`env`-sharing) script `MathElem` slice as an `option
6115/// math-text` VALUE — `Cmd`'s uniform V0_1 calling convention always
6116/// passes its command's sub/sup arguments this way, never pre-reflected.
6117fn option_math_text_value(opt: Option<&[MathElem]>, env: &Env) -> Value {
6118    match opt {
6119        None => Value::Ctor("None".to_string(), None),
6120        Some(elems) => Value::Ctor(
6121            "Some".to_string(),
6122            Some(Box::new(Value::MathText {
6123                elems: Rc::new(elems.to_vec()),
6124                env: env.clone(),
6125            })),
6126        ),
6127    }
6128}
6129
6130/// `math-char-class` ctor-name mapper — the inverse of `as_math_char_class`
6131/// (above), used by `get-math-char-class` and by `set-math-variant-char`'s
6132/// V0_1 body (which must build a `math-char-class` VALUE to feed the
6133/// caller's selector closure).
6134fn math_char_class_ctor_name(c: MathCharClass) -> &'static str {
6135    match c {
6136        MathCharClass::Italic => "MathItalic",
6137        MathCharClass::BoldItalic => "MathBoldItalic",
6138        MathCharClass::Roman => "MathRoman",
6139        MathCharClass::BoldRoman => "MathBoldRoman",
6140        MathCharClass::Script => "MathScript",
6141        MathCharClass::BoldScript => "MathBoldScript",
6142        MathCharClass::Fraktur => "MathFraktur",
6143        MathCharClass::BoldFraktur => "MathBoldFraktur",
6144        MathCharClass::DoubleStruck => "MathDoubleStruck",
6145        MathCharClass::SansSerif => "MathSansSerif",
6146        MathCharClass::BoldSansSerif => "MathBoldSansSerif",
6147        MathCharClass::ItalicSansSerif => "MathItalicSansSerif",
6148        MathCharClass::BoldItalicSansSerif => "MathBoldItalicSansSerif",
6149        MathCharClass::Typewriter => "MathTypewriter",
6150    }
6151}
6152
6153fn math_char_class_value(c: MathCharClass) -> Value {
6154    Value::Ctor(math_char_class_ctor_name(c).to_string(), None)
6155}
6156
6157/// Port of `dev-0-1-0 src/frontend/context.ml:52-68`: bump `ctx`'s
6158/// `math_script_level` and scale `font_size`
6159/// accordingly. `Base -> Script`: scale by the font's MATH-table
6160/// `script_scale_down` (fallback `0.7`, consistent with the engine's other
6161/// fixed-fraction fallbacks). `Script -> ScriptScript`: scale by
6162/// `script_script_scale_down / script_scale_down` (fallback `5.0/7.0`).
6163/// `ScriptScript`: no-op — saturates at the deepest level, matching
6164/// upstream (no `ScriptScriptScript`).
6165fn enter_script(interp: &Interp, ctx: &Context) -> Context {
6166    let mc = MathC::of(interp, ctx);
6167    let (scale, next_level) = match ctx.math_script_level {
6168        MathScriptLevel::Base => (
6169            mc.c.map(|c| c.script_scale_down).unwrap_or(0.7),
6170            MathScriptLevel::Script,
6171        ),
6172        MathScriptLevel::Script => (
6173            mc.c.map(|c| c.script_script_scale_down / c.script_scale_down)
6174                .unwrap_or(5.0 / 7.0),
6175            MathScriptLevel::ScriptScript,
6176        ),
6177        MathScriptLevel::ScriptScript => return ctx.clone(),
6178    };
6179    Context {
6180        font_size: ctx.font_size * scale,
6181        math_script_level: next_level,
6182        ..ctx.clone()
6183    }
6184}
6185
6186/// Flatten a `Sub`/`Sup` `MathElem`'s (at most two-deep) nesting into `(base,
6187/// sub_opt, sup_opt)` — `elaborate.rs::fold_math_scripts` always builds a
6188/// both-scripts element as `Sup(Box::new(Sub(base, sub)), sup)` regardless
6189/// of source order (`x_a^b` and `x^b_a` both fold this way), so a bare
6190/// `Sub`/`Sup` and the fused two-level shape are the only cases to handle.
6191/// `elem` MUST be `MathElem::Sub` or `MathElem::Sup` — every caller already
6192/// matched on that.
6193fn flatten_math_scripts(elem: &MathElem) -> (&MathElem, Option<&[MathElem]>, Option<&[MathElem]>) {
6194    match elem {
6195        MathElem::Sup(base, sup) => match base.as_ref() {
6196            MathElem::Sub(inner, sub) => {
6197                (inner.as_ref(), Some(sub.as_slice()), Some(sup.as_slice()))
6198            }
6199            _ => (base.as_ref(), None, Some(sup.as_slice())),
6200        },
6201        MathElem::Sub(base, sub) => (base.as_ref(), Some(sub.as_slice()), None),
6202        _ => unreachable!("flatten_math_scripts called on a non-Sub/Sup MathElem"),
6203    }
6204}
6205
6206/// `attach_scripts` — mirrors upstream's
6207/// `append_sub_and_super_scripts` + its `enter_script` iteration
6208/// (`evaluator.cppo.ml:901-904`): reflects `sub_opt`/`sup_opt` (each an
6209/// already-extracted math-text payload — an ambient-env script slice for
6210/// the `reflect_scripted_v01` caller, or a genuine runtime `Value::MathText`
6211/// for the `%math-attach-scripts` primitive caller, both the SAME shape)
6212/// under `enter_script(interp, ctx)` — so commands *inside* a script observe
6213/// script-level context — then wraps `Math::Sub`/`Math::Sup` around `base`.
6214/// Both scripts present wraps as `Sup(Sub(base, sub), sup)`, matching the
6215/// shape `layout_math_atom`'s `check_subscript` already knows how to merge.
6216fn attach_scripts(
6217    interp: &mut Interp,
6218    ctx: &Context,
6219    base: Vec<Math>,
6220    sub_opt: Option<(Rc<Vec<MathElem>>, Env)>,
6221    sup_opt: Option<(Rc<Vec<MathElem>>, Env)>,
6222) -> Result<Vec<Math>, EvalError> {
6223    if sub_opt.is_none() && sup_opt.is_none() {
6224        return Ok(base);
6225    }
6226    let script_ctx = enter_script(interp, ctx);
6227    let mut cur = base;
6228    if let Some((elems, senv)) = sub_opt {
6229        let mut sub_v = Vec::new();
6230        for e in elems.iter() {
6231            reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sub_v)?;
6232        }
6233        cur = vec![Math::Sub(cur, sub_v)];
6234    }
6235    if let Some((elems, senv)) = sup_opt {
6236        let mut sup_v = Vec::new();
6237        for e in elems.iter() {
6238            reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sup_v)?;
6239        }
6240        cur = vec![Math::Sup(cur, sup_v)];
6241    }
6242    Ok(cur)
6243}
6244
6245/// One base `MathElem` (already stripped of any wrapping `Sub`/`Sup`) plus
6246/// its (possibly absent) `sub`/`sup` script slices — the shared tail of
6247/// `reflect_math_elem_v01`'s `Sub`/`Sup` arm (after flattening) AND its bare
6248/// `Cmd` arm (`sub = sup = None`). `base` a `Cmd`: route ctx+sub+sup into
6249/// the application per the uniform V0_1 calling convention — a
6250/// SEPARATE math-command value shape does not exist in this port, so every
6251/// V0_1 math command, scripted or not, is applied exactly this way. `base`
6252/// anything else: reflect it plainly, then `attach_scripts`.
6253fn reflect_scripted_v01(
6254    interp: &mut Interp,
6255    ctx: &Context,
6256    base: &MathElem,
6257    sub: Option<&[MathElem]>,
6258    sup: Option<&[MathElem]>,
6259    env: &Env,
6260    out: &mut Vec<Math>,
6261) -> Result<(), EvalError> {
6262    if let MathElem::Cmd { cmd, args, .. } = base {
6263        let mut v = cmd.run(env, interp)?;
6264        for arg in args {
6265            // `arg.opts` is always empty here too (see the bare-`Cmd` arm
6266            // above, `reflect_math_elem`) — folded through `apply_with_opts`
6267            // uniformly regardless.
6268            let mut opt_vals = Vec::with_capacity(arg.opts.len());
6269            for (label, e) in &arg.opts {
6270                opt_vals.push((label.clone(), e.run(env, interp)?));
6271            }
6272            let arg_v = arg.arg.run(env, interp)?;
6273            v = interp.apply_with_opts(v, opt_vals, arg_v)?;
6274        }
6275        // A 0.0.6-authored math command reached from a 0.1 document: the two
6276        // generations invoke a command differently, though `math` relabels
6277        // to `math-text` and both type-check. 0.0.6 gets `\cmd a1..an ->
6278        // math` with scripts attached STRUCTURALLY afterward
6279        // (`reflect_math_elem`'s `Sub`/`Sup` arms); 0.1 applies three extra
6280        // arguments (`ctx sub sup -> math-boxes`, `sub`/`sup : math-text
6281        // option`) so a command can typeset its own scripts. Applying those
6282        // three to a 0.0.6 command used to die with `cannot apply a value of
6283        // type math as a function`.
6284        //
6285        // Discrimination here is DYNAMIC, and total: after its declared
6286        // arguments a 0.1 command is by construction still a function (ends
6287        // `.. -> context -> ..`), so a math VALUE at this point can only be
6288        // a 0.0.6 command's result — a static check can't recover the
6289        // authoring generation, since `Ast::VersionScope` governs which
6290        // `PrimDef` the body folds to and nothing on the resulting closure
6291        // records where it came from. `as_math` runs 0.0.6's own reflection
6292        // (so nested commands in a returned `${..}` literal stay 0.0.6
6293        // commands), and its `Rc<Vec<Math>>` payload is byte-for-byte what
6294        // `Value::MathBoxes` carries, so crossing needs no conversion;
6295        // untaken scripts then attach via `attach_scripts`, the same
6296        // structural `Math::Sub`/`Math::Sup` shape 0.0.6's own reflector
6297        // would have built. A 0.0.6 command still can't RESTYLE its own
6298        // scripts — it never could, in 0.0.6 either.
6299        if matches!(v, Value::Math(_) | Value::MathText { .. }) {
6300            let base_v = as_math(interp, v)?.as_ref().clone();
6301            let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6302            let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6303            let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6304            out.extend(attached);
6305            return Ok(());
6306        }
6307        v = interp.apply(v, Value::Context(Box::new(ctx.clone())))?;
6308        v = interp.apply(v, option_math_text_value(sub, env))?;
6309        v = interp.apply(v, option_math_text_value(sup, env))?;
6310        let m = as_math_boxes(v)?;
6311        out.extend(m.iter().cloned());
6312        return Ok(());
6313    }
6314    let mut base_v = Vec::new();
6315    reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6316    let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6317    let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6318    let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6319    out.extend(attached);
6320    Ok(())
6321}
6322
6323/// V0_1 twin of `reflect_math_elem` (differs only where upstream's
6324/// `read_pdf_mode_math_text` (`evaluator.cppo.ml:887-930`) differs from
6325/// 0.0.6 reflection): `Chars`/`Group`/`Primes` are
6326/// identical to the v006 arms (class/variant resolution stays deferred to
6327/// layout, where `ctx`'s maps live); `Sub`/`Sup` flatten and route through
6328/// [`reflect_scripted_v01`]; a bare `Cmd` also routes through it (with
6329/// `sub = sup = None`) so the uniform ctx+sub+sup calling convention
6330/// applies uniformly, scripted or not; `Embed` (`#x`) requires the embedded
6331/// value to be `math-text` (it typechecked as `math-text`) and
6332/// recurses — upstream `MathTextValueGroup` (`evaluator.cppo.ml:944-949`);
6333/// scripts on an embed attach via [`reflect_scripted_v01`]'s generic
6334/// (non-`Cmd`) path, same as any other non-command base.
6335fn reflect_math_elem_v01(
6336    interp: &mut Interp,
6337    ctx: &Context,
6338    elem: &MathElem,
6339    env: &Env,
6340    out: &mut Vec<Math>,
6341) -> Result<(), EvalError> {
6342    match elem {
6343        MathElem::Chars(s) => {
6344            out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
6345            Ok(())
6346        }
6347        MathElem::Group(elems) => {
6348            for e in elems {
6349                reflect_math_elem_v01(interp, ctx, e, env, out)?;
6350            }
6351            Ok(())
6352        }
6353        MathElem::Primes(base, n) => {
6354            let mut base_v = Vec::new();
6355            reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6356            let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
6357            out.push(Math::Sup(
6358                base_v,
6359                vec![Math::Pure(MathElement::Char {
6360                    class: MathKind::Ord,
6361                    big: false,
6362                    chars: primes,
6363                })],
6364            ));
6365            Ok(())
6366        }
6367        MathElem::Sub(_, _) | MathElem::Sup(_, _) => {
6368            let (base, sub, sup) = flatten_math_scripts(elem);
6369            reflect_scripted_v01(interp, ctx, base, sub, sup, env, out)
6370        }
6371        MathElem::Cmd { .. } => reflect_scripted_v01(interp, ctx, elem, None, None, env, out),
6372        MathElem::Embed { expr, span: _ } => {
6373            let v = expr.run(env, interp)?;
6374            let (elems2, env2) = as_math_text(v)?;
6375            for e in elems2.iter() {
6376                reflect_math_elem_v01(interp, ctx, e, &env2, out)?;
6377            }
6378            Ok(())
6379        }
6380    }
6381}
6382
6383/// `read-math : context -> math-text -> math-boxes` (dev-0-1-0
6384/// vminst.ml:790-793). Reflects every element of
6385/// `mt` under `ctx` via [`reflect_math_elem_v01`], then wraps the whole run
6386/// in a single `Math::WithContext` node so `ctx` (including any color/font/
6387/// size override the caller composed onto it) reaches the layout engine —
6388/// see [`layout_math_list`]'s `Math::WithContext` arm.
6389fn prim_read_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6390    let mt = args.pop().unwrap();
6391    let ctx = as_context(args.pop().unwrap())?;
6392    let (elems, env) = as_math_text(mt)?;
6393    let mut out = Vec::new();
6394    for e in elems.iter() {
6395        reflect_math_elem_v01(interp, &ctx, e, &env, &mut out)?;
6396    }
6397    Ok(Value::MathBoxes(Rc::new(vec![Math::WithContext(
6398        Box::new(ctx),
6399        out,
6400    )])))
6401}
6402
6403/// `stringify-math : text-info -> math-text -> string` (vminst.ml:858) —
6404/// STAND-IN: the text-mode backend is out of scope for this PDF port (same
6405/// scoping note as `prim_convert_string_for_math`'s doc comment); registered
6406/// so 0.1 packages that reference it still typecheck.
6407fn prim_stringify_math(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6408    let _mt = args.pop().unwrap();
6409    let _tctx = args.pop().unwrap();
6410    eval_error(
6411        "stringify-math: the text-mode backend is out of scope for this PDF port \
6412         (see primitives.rs's prim_convert_string_for_math doc comment)"
6413            .to_string(),
6414    )
6415}
6416
6417/// `set-math-char : int -> int -> math-class -> context -> context`
6418/// (vminst.ml:59) — REAL: inserts `(char(cp_from)) -> (char(cp_to), kind)`
6419/// into `Context::math_class_map` (single-char string key, matching the
6420/// map's existing token-keying convention — see `prim_convert_string_for_
6421/// math`'s doc comment on how that map is consulted).
6422fn prim_set_math_char(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6423    let mut ctx = as_context(args.pop().unwrap())?;
6424    let kind = as_math_kind(args.pop().unwrap())?;
6425    let cpto = as_int(args.pop().unwrap())?;
6426    let cpfrom = as_int(args.pop().unwrap())?;
6427    let from = u32::try_from(cpfrom)
6428        .ok()
6429        .and_then(char::from_u32)
6430        .ok_or_else(|| EvalError {
6431            span: None,
6432            msg: format!("set-math-char: {cpfrom} is not a valid Unicode codepoint"),
6433        })?;
6434    let to = u32::try_from(cpto)
6435        .ok()
6436        .and_then(char::from_u32)
6437        .ok_or_else(|| EvalError {
6438            span: None,
6439            msg: format!("set-math-char: {cpto} is not a valid Unicode codepoint"),
6440        })?;
6441    Arc::make_mut(&mut ctx.math_class_map).insert(from.to_string(), (to.to_string(), kind));
6442    Ok(Value::Context(Box::new(ctx)))
6443}
6444
6445/// `set-math-char-class : math-char-class -> context -> context`
6446/// (vminst.ml:445) — REAL: sets `Context::math_char_class`.
6447fn prim_set_math_char_class(
6448    _interp: &mut Interp,
6449    mut args: Vec<Value>,
6450) -> Result<Value, EvalError> {
6451    let ctx = as_context(args.pop().unwrap())?;
6452    let cls = as_math_char_class(args.pop().unwrap())?;
6453    Ok(Value::Context(Box::new(Context {
6454        math_char_class: cls,
6455        ..ctx
6456    })))
6457}
6458
6459/// `get-math-char-class : context -> math-char-class` (vminst.ml:459) —
6460/// REAL: inverse of `as_math_char_class`.
6461fn prim_get_math_char_class(
6462    _interp: &mut Interp,
6463    mut args: Vec<Value>,
6464) -> Result<Value, EvalError> {
6465    let ctx = as_context(args.pop().unwrap())?;
6466    Ok(math_char_class_value(ctx.math_char_class))
6467}
6468
6469/// `embed-inline-to-math : math-class -> inline-boxes -> math-boxes`
6470/// (vminst.ml:432) — REAL data, stand-in render (`MathElement::
6471/// EmbeddedBoxes`'s doc comment).
6472fn prim_embed_inline_to_math(
6473    _interp: &mut Interp,
6474    mut args: Vec<Value>,
6475) -> Result<Value, EvalError> {
6476    let ib = as_inline_boxes(args.pop().unwrap())?;
6477    let class = as_math_kind(args.pop().unwrap())?;
6478    Ok(single_math_boxes(Math::Pure(MathElement::EmbeddedBoxes {
6479        class,
6480        boxes: ib,
6481    })))
6482}
6483
6484/// `get-math-axis-height-ratio : context -> float` (vminst.ml:1305) — REAL:
6485/// the axis-height ratio `MathC` already scales font sizes by
6486/// (`MathC::axis`).
6487fn prim_get_math_axis_height_ratio(
6488    interp: &mut Interp,
6489    mut args: Vec<Value>,
6490) -> Result<Value, EvalError> {
6491    let ctx = as_context(args.pop().unwrap())?;
6492    let ratio = MathC::of(interp, &ctx)
6493        .c
6494        .map(|c| c.axis_height)
6495        .unwrap_or(0.25);
6496    Ok(Value::Float(ratio))
6497}
6498
6499/// `%math-attach-scripts : context -> math-boxes -> option math-text ->
6500/// option math-text -> math-boxes` — hidden:
6501/// the synthesized script-attacher `val math` commands WITHOUT `with sub
6502/// sup` lower to. Body = [`attach_scripts`] directly — the same function
6503/// `reflect_scripted_v01`'s non-`Cmd` path calls.
6504fn prim_math_attach_scripts(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6505    let sup_v = args.pop().unwrap();
6506    let sub_v = args.pop().unwrap();
6507    let base_v = args.pop().unwrap();
6508    let ctx = as_context(args.pop().unwrap())?;
6509    let base = as_math_boxes(base_v)?;
6510    let sub_opt = as_option_math_text(sub_v)?;
6511    let sup_opt = as_option_math_text(sup_v)?;
6512    let out = attach_scripts(interp, &ctx, (*base).clone(), sub_opt, sup_opt)?;
6513    Ok(Value::MathBoxes(Rc::new(out)))
6514}
6515
6516/// `load-hyphenation-dictionary : string -> hyphenation` (`vminst.ml`'s
6517/// `LoadHyphenationDictionary`: upstream calls `LoadHyph.main abspath` to
6518/// build a `BCHyphenation` constant). REAL: unlike upstream, which
6519/// loads a dictionary from an on-disk `.rustyfi-hyph` path, this port has no
6520/// filesystem-loaded pattern data — the argument is instead treated as a
6521/// dictionary NAME (`"english"`/`"en-US"`, matching the `hyph-english.satyh`
6522/// stdlib package's usage) and mapped to the compiled-in `HyphenLang` tag.
6523/// An unrecognized name is a hard error rather than a silent no-op, since a
6524/// document that asks for a dictionary and gets none would silently render
6525/// without hyphenation. The heavy `hyphenation::Standard` dictionary itself
6526/// is not loaded here — only the lightweight tag is; the actual load is
6527/// deferred (load-once, cached) to `crate::hyphenation::hyphenate_word`'s
6528/// first call for that tag.
6529fn prim_load_hyphenation_dictionary(
6530    _interp: &mut Interp,
6531    mut args: Vec<Value>,
6532) -> Result<Value, EvalError> {
6533    let arg = as_str(args.pop().unwrap())?;
6534    // Accept either a bare dictionary NAME ("english"/"en-US") or an
6535    // upstream-style PATH ending `.../<name>.rustyfi-hyph` — this is what
6536    // the real, vendored `hyph-english.satyh` stand-in package actually
6537    // passes (`here ^ "/../hyph/english.rustyfi-hyph"`, mirroring
6538    // upstream's `LoadHyph.main abspath` convention). This port has no
6539    // on-disk pattern-file loader (the dictionary is compiled in via
6540    // `embed_en-us`), so the path's file stem doubles as the dictionary
6541    // name.
6542    let stem = std::path::Path::new(&arg)
6543        .file_stem()
6544        .and_then(|s| s.to_str())
6545        .unwrap_or(arg.as_str())
6546        .to_ascii_lowercase();
6547    let tag = match stem.as_str() {
6548        "english" | "en-us" => HyphenLang::EnglishUS,
6549        // en-GB (en-GB option): "british"/"en-GB"/"british-english",
6550        // mirroring the "english"/ "en-US" naming pair above.
6551        "british" | "en-gb" | "british-english" => HyphenLang::EnglishGB,
6552        _ => {
6553            return eval_error(format!(
6554                "load-hyphenation-dictionary: unknown dictionary {arg:?} \
6555                 (supported: \"english\"/\"en-US\", \"british\"/\"en-GB\"/\"british-english\", \
6556                 bare or as a `.../<name>.rustyfi-hyph`-style path)"
6557            ))
6558        }
6559    };
6560    Ok(Value::Hyphenation(tag))
6561}
6562
6563/// `load-unicode-char-database : string -> string -> string ->
6564/// unicode-char-database` (`vminst.ml`'s `LoadUnicodeCharDatabase`:
6565/// upstream builds `(ScriptDataMap, LineBreakDataMap)` from the three
6566/// Unicode data file paths into a `BCUnidata` constant). STAND-IN: no-op,
6567/// same rationale as `prim_load_hyphenation_dictionary` above — all three
6568/// paths are popped and dropped.
6569fn prim_load_unicode_char_database(
6570    _interp: &mut Interp,
6571    mut args: Vec<Value>,
6572) -> Result<Value, EvalError> {
6573    args.truncate(0);
6574    Ok(Value::Unit)
6575}
6576
6577/// `set-hyphenation-dictionary : hyphenation -> context -> context`
6578/// (`vminst.ml`'s setter: upstream stores `{ ctx with hyphen_dictionary }`).
6579/// REAL: writes `Context::hyphen_dictionary = Some(tag)`. This is the
6580/// ONLY way a `Context` acquires a dictionary — `Context::initial` seeds
6581/// `None`, so a document that never calls this gets no hyphenation at all.
6582fn prim_set_hyphenation_dictionary(
6583    _interp: &mut Interp,
6584    mut args: Vec<Value>,
6585) -> Result<Value, EvalError> {
6586    let ctx = as_context(args.pop().unwrap())?;
6587    let tag = as_hyphenation(args.pop().unwrap())?;
6588    Ok(Value::Context(Box::new(Context {
6589        hyphen_dictionary: Some(tag),
6590        ..ctx
6591    })))
6592}
6593
6594/// `set-unicode-char-database : unicode-char-database -> context ->
6595/// context` (`vminst.ml`'s setter: upstream stores `{ ctx with script_map;
6596/// line_break_map }`). STAND-IN no-op, same shape as
6597/// `prim_set_hyphenation_dictionary` above.
6598fn prim_set_unicode_char_database(
6599    _interp: &mut Interp,
6600    mut args: Vec<Value>,
6601) -> Result<Value, EvalError> {
6602    let ctx = args.pop().unwrap();
6603    let _db = args.pop().unwrap();
6604    Ok(ctx)
6605}
6606
6607fn prim_math_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6608    let s = as_str(args.pop().unwrap())?;
6609    let class = as_math_kind(args.pop().unwrap())?;
6610    let _ = interp;
6611    Ok(single_math(Math::Pure(MathElement::Char {
6612        class,
6613        big: false,
6614        chars: s,
6615    })))
6616}
6617
6618/// `math-char : context -> math-class -> string -> math-boxes` (dev-0-1-0
6619/// vminst.ml:358) — ctx ACCEPTED, not stored on the atom (coarse,
6620/// `read-math`-granularity context capture only).
6621fn prim_math_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6622    let s = as_str(args.pop().unwrap())?;
6623    let class = as_math_kind(args.pop().unwrap())?;
6624    let _ctx = as_context(args.pop().unwrap())?;
6625    let _ = interp;
6626    Ok(single_math_boxes(Math::Pure(MathElement::Char {
6627        class,
6628        big: false,
6629        chars: s,
6630    })))
6631}
6632
6633fn prim_math_big_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6634    let s = as_str(args.pop().unwrap())?;
6635    let class = as_math_kind(args.pop().unwrap())?;
6636    let _ = interp;
6637    Ok(single_math(Math::Pure(MathElement::Char {
6638        class,
6639        big: true,
6640        chars: s,
6641    })))
6642}
6643
6644/// `math-big-char : context -> math-class -> string -> math-boxes`
6645/// (vminst.ml:374) — same fork as `math-char`.
6646fn prim_math_big_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6647    let s = as_str(args.pop().unwrap())?;
6648    let class = as_math_kind(args.pop().unwrap())?;
6649    let _ctx = as_context(args.pop().unwrap())?;
6650    let _ = interp;
6651    Ok(single_math_boxes(Math::Pure(MathElement::Char {
6652        class,
6653        big: true,
6654        chars: s,
6655    })))
6656}
6657
6658fn prim_math_char_with_kern_v006(
6659    interp: &mut Interp,
6660    mut args: Vec<Value>,
6661) -> Result<Value, EvalError> {
6662    let kern_r = args.pop().unwrap();
6663    let kern_l = args.pop().unwrap();
6664    let s = as_str(args.pop().unwrap())?;
6665    let class = as_math_kind(args.pop().unwrap())?;
6666    let _ = interp;
6667    Ok(single_math(Math::Pure(MathElement::CharWithKern {
6668        class,
6669        big: false,
6670        chars: s,
6671        kern_l: Box::new(kern_l),
6672        kern_r: Box::new(kern_r),
6673    })))
6674}
6675
6676/// `math-char-with-kern : context -> math-class -> string -> kernf -> kernf
6677/// -> math-boxes` (vminst.ml:390).
6678fn prim_math_char_with_kern_v01(
6679    interp: &mut Interp,
6680    mut args: Vec<Value>,
6681) -> Result<Value, EvalError> {
6682    let kern_r = args.pop().unwrap();
6683    let kern_l = args.pop().unwrap();
6684    let s = as_str(args.pop().unwrap())?;
6685    let class = as_math_kind(args.pop().unwrap())?;
6686    let _ctx = as_context(args.pop().unwrap())?;
6687    let _ = interp;
6688    Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
6689        class,
6690        big: false,
6691        chars: s,
6692        kern_l: Box::new(kern_l),
6693        kern_r: Box::new(kern_r),
6694    })))
6695}
6696
6697fn prim_math_big_char_with_kern_v006(
6698    interp: &mut Interp,
6699    mut args: Vec<Value>,
6700) -> Result<Value, EvalError> {
6701    let kern_r = args.pop().unwrap();
6702    let kern_l = args.pop().unwrap();
6703    let s = as_str(args.pop().unwrap())?;
6704    let class = as_math_kind(args.pop().unwrap())?;
6705    let _ = interp;
6706    Ok(single_math(Math::Pure(MathElement::CharWithKern {
6707        class,
6708        big: true,
6709        chars: s,
6710        kern_l: Box::new(kern_l),
6711        kern_r: Box::new(kern_r),
6712    })))
6713}
6714
6715/// `math-big-char-with-kern : context -> math-class -> string -> kernf ->
6716/// kernf -> math-boxes` (vminst.ml:411) — same fork as
6717/// `math-char-with-kern`.
6718fn prim_math_big_char_with_kern_v01(
6719    interp: &mut Interp,
6720    mut args: Vec<Value>,
6721) -> Result<Value, EvalError> {
6722    let kern_r = args.pop().unwrap();
6723    let kern_l = args.pop().unwrap();
6724    let s = as_str(args.pop().unwrap())?;
6725    let class = as_math_kind(args.pop().unwrap())?;
6726    let _ctx = as_context(args.pop().unwrap())?;
6727    let _ = interp;
6728    Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
6729        class,
6730        big: true,
6731        chars: s,
6732        kern_l: Box::new(kern_l),
6733        kern_r: Box::new(kern_r),
6734    })))
6735}
6736
6737/// `math-concat : math -> math -> math` (vminst.ml:193) — FAITHFUL: a plain
6738/// list append (`math` is always a flat sequence of atoms; see `value.rs`'s
6739/// `Value::Math` doc comment).
6740fn prim_math_concat_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6741    let m2 = args.pop().unwrap();
6742    let m1 = args.pop().unwrap();
6743    let m1 = as_math(interp, m1)?;
6744    let m2 = as_math(interp, m2)?;
6745    let mut out = (*m1).clone();
6746    out.extend((*m2).iter().cloned());
6747    Ok(Value::Math(Rc::new(out)))
6748}
6749
6750/// `math-concat : math-boxes -> math-boxes -> math-boxes` (vminst.ml:181).
6751fn prim_math_concat_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6752    let m2 = as_math_boxes(args.pop().unwrap())?;
6753    let m1 = as_math_boxes(args.pop().unwrap())?;
6754    let mut out = (*m1).clone();
6755    out.extend((*m2).iter().cloned());
6756    Ok(Value::MathBoxes(Rc::new(out)))
6757}
6758
6759fn prim_math_group_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6760    let m = args.pop().unwrap();
6761    let cls2 = as_math_kind(args.pop().unwrap())?;
6762    let cls1 = as_math_kind(args.pop().unwrap())?;
6763    let inner = as_math(interp, m)?;
6764    Ok(single_math(Math::Group(cls1, cls2, (*inner).clone())))
6765}
6766
6767/// `math-group : math-class -> math-class -> math-boxes -> math-boxes`
6768/// (vminst.ml:194).
6769fn prim_math_group_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6770    let m = as_math_boxes(args.pop().unwrap())?;
6771    let cls2 = as_math_kind(args.pop().unwrap())?;
6772    let cls1 = as_math_kind(args.pop().unwrap())?;
6773    Ok(single_math_boxes(Math::Group(cls1, cls2, (*m).clone())))
6774}
6775
6776fn prim_math_sup_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6777    let m2 = args.pop().unwrap();
6778    let m1 = args.pop().unwrap();
6779    let base = as_math(interp, m1)?;
6780    let script = as_math(interp, m2)?;
6781    Ok(single_math(Math::Sup((*base).clone(), (*script).clone())))
6782}
6783
6784/// `math-sup : context -> math-boxes -> (context -> math-boxes) ->
6785/// math-boxes` (vminst.ml:208) — the script argument is a context-taking
6786/// callback, run under `enter_script`.
6787fn prim_math_sup_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6788    let f = args.pop().unwrap();
6789    let base_v = args.pop().unwrap();
6790    let ctx = as_context(args.pop().unwrap())?;
6791    let base = as_math_boxes(base_v)?;
6792    let script_ctx = enter_script(interp, &ctx);
6793    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6794    let script = as_math_boxes(script_v)?;
6795    Ok(single_math_boxes(Math::Sup(
6796        (*base).clone(),
6797        (*script).clone(),
6798    )))
6799}
6800
6801fn prim_math_sub_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6802    let m2 = args.pop().unwrap();
6803    let m1 = args.pop().unwrap();
6804    let base = as_math(interp, m1)?;
6805    let script = as_math(interp, m2)?;
6806    Ok(single_math(Math::Sub((*base).clone(), (*script).clone())))
6807}
6808
6809/// `math-sub : context -> math-boxes -> (context -> math-boxes) ->
6810/// math-boxes` (vminst.ml:228) — same shape as `math-sup`.
6811fn prim_math_sub_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6812    let f = args.pop().unwrap();
6813    let base_v = args.pop().unwrap();
6814    let ctx = as_context(args.pop().unwrap())?;
6815    let base = as_math_boxes(base_v)?;
6816    let script_ctx = enter_script(interp, &ctx);
6817    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6818    let script = as_math_boxes(script_v)?;
6819    Ok(single_math_boxes(Math::Sub(
6820        (*base).clone(),
6821        (*script).clone(),
6822    )))
6823}
6824
6825fn prim_math_frac_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6826    let m2 = args.pop().unwrap();
6827    let m1 = args.pop().unwrap();
6828    let num = as_math(interp, m1)?;
6829    let den = as_math(interp, m2)?;
6830    Ok(single_math(Math::Fraction((*num).clone(), (*den).clone())))
6831}
6832
6833/// `math-frac : context -> math-boxes -> math-boxes -> math-boxes`
6834/// (vminst.ml:248).
6835fn prim_math_frac_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6836    let m2 = as_math_boxes(args.pop().unwrap())?;
6837    let m1 = as_math_boxes(args.pop().unwrap())?;
6838    let _ctx = as_context(args.pop().unwrap())?;
6839    Ok(single_math_boxes(Math::Fraction(
6840        (*m1).clone(),
6841        (*m2).clone(),
6842    )))
6843}
6844
6845/// `math-radical : math option -> math -> math` (vminst.ml:274) — `None`
6846/// degree is `\sqrt`; upstream's `MathRadicalWithDegree` (`\sqrt[n]`) is
6847/// unimplemented too (`math.ml:886`), carried faithfully but not rendered
6848/// specially, matching upstream by parity (see `value.rs`'s `Math::Radical`).
6849fn prim_math_radical_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6850    let m2 = args.pop().unwrap();
6851    let opt = args.pop().unwrap();
6852    let radicand = as_math(interp, m2)?;
6853    let degree = match opt {
6854        Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
6855        Value::Ctor(name, Some(payload)) if name == "Some" => {
6856            Some((*as_math(interp, *payload)?).clone())
6857        }
6858        other => {
6859            return eval_error(format!(
6860                "expected a math option (None/Some), got {}",
6861                other.type_name()
6862            ))
6863        }
6864    };
6865    Ok(single_math(Math::Radical(degree, (*radicand).clone())))
6866}
6867
6868/// `math-radical : context -> option math-boxes -> math-boxes ->
6869/// math-boxes` (vminst.ml:262).
6870fn prim_math_radical_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6871    let m2 = args.pop().unwrap();
6872    let opt = args.pop().unwrap();
6873    let _ctx = as_context(args.pop().unwrap())?;
6874    let radicand = as_math_boxes(m2)?;
6875    let degree = match opt {
6876        Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
6877        Value::Ctor(name, Some(payload)) if name == "Some" => {
6878            Some((*as_math_boxes(*payload)?).clone())
6879        }
6880        other => {
6881            return eval_error(format!(
6882                "expected a math-boxes option (None/Some), got {}",
6883                other.type_name()
6884            ))
6885        }
6886    };
6887    Ok(single_math_boxes(Math::Radical(
6888        degree,
6889        (*radicand).clone(),
6890    )))
6891}
6892
6893fn prim_math_lower_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6894    let m2 = args.pop().unwrap();
6895    let m1 = args.pop().unwrap();
6896    let base = as_math(interp, m1)?;
6897    let lower = as_math(interp, m2)?;
6898    Ok(single_math(Math::LowerLimit(
6899        (*base).clone(),
6900        (*lower).clone(),
6901    )))
6902}
6903
6904/// `math-lower : context -> math-boxes -> (context -> math-boxes) ->
6905/// math-boxes` (vminst.ml:338) — same script-callback shape as `math-sup`.
6906fn prim_math_lower_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6907    let f = args.pop().unwrap();
6908    let base_v = args.pop().unwrap();
6909    let ctx = as_context(args.pop().unwrap())?;
6910    let base = as_math_boxes(base_v)?;
6911    let script_ctx = enter_script(interp, &ctx);
6912    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6913    let lower = as_math_boxes(script_v)?;
6914    Ok(single_math_boxes(Math::LowerLimit(
6915        (*base).clone(),
6916        (*lower).clone(),
6917    )))
6918}
6919
6920fn prim_math_upper_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6921    let m2 = args.pop().unwrap();
6922    let m1 = args.pop().unwrap();
6923    let base = as_math(interp, m1)?;
6924    let upper = as_math(interp, m2)?;
6925    Ok(single_math(Math::UpperLimit(
6926        (*base).clone(),
6927        (*upper).clone(),
6928    )))
6929}
6930
6931/// `math-upper : context -> math-boxes -> (context -> math-boxes) ->
6932/// math-boxes` (vminst.ml:318) — same script-callback shape as `math-sup`.
6933fn prim_math_upper_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6934    let f = args.pop().unwrap();
6935    let base_v = args.pop().unwrap();
6936    let ctx = as_context(args.pop().unwrap())?;
6937    let base = as_math_boxes(base_v)?;
6938    let script_ctx = enter_script(interp, &ctx);
6939    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6940    let upper = as_math_boxes(script_v)?;
6941    Ok(single_math_boxes(Math::UpperLimit(
6942        (*base).clone(),
6943        (*upper).clone(),
6944    )))
6945}
6946
6947/// `math-pull-in-scripts : math-class -> math-class -> (math option -> math
6948/// option -> math) -> math` (vminst.ml:368) — FAITHFUL construction: the
6949/// resolver closure is stored opaquely here, only ever invoked by
6950/// `layout_pull_in_scripts` — with
6951/// the subscript/superscript actually pulled in off an enclosing `Sub`/`Sup`
6952/// (`{scripts} m^{sup}`-style), or with `(None, None)` for the common
6953/// unscripted case (a bare `\sum`/`\int` with nothing pulled in).
6954fn prim_math_pull_in_scripts(
6955    interp: &mut Interp,
6956    mut args: Vec<Value>,
6957) -> Result<Value, EvalError> {
6958    let resolver = args.pop().unwrap();
6959    let cls2 = as_math_kind(args.pop().unwrap())?;
6960    let cls1 = as_math_kind(args.pop().unwrap())?;
6961    let _ = interp;
6962    Ok(single_math(Math::PullInScripts(
6963        cls1,
6964        cls2,
6965        Box::new(resolver),
6966    )))
6967}
6968
6969fn prim_math_color(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6970    let m = args.pop().unwrap();
6971    let color = as_color(args.pop().unwrap())?;
6972    let inner = as_math(interp, m)?;
6973    Ok(single_math(Math::ChangeColor(color, (*inner).clone())))
6974}
6975
6976fn prim_math_char_class(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6977    let m = args.pop().unwrap();
6978    let cls = as_math_char_class(args.pop().unwrap())?;
6979    let inner = as_math(interp, m)?;
6980    Ok(single_math(Math::ChangeCharClass(cls, (*inner).clone())))
6981}
6982
6983fn prim_math_variant_char(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6984    let style = as_math_variant_style(args.pop().unwrap())?;
6985    let class = as_math_kind(args.pop().unwrap())?;
6986    let _ = interp;
6987    Ok(single_math(Math::Pure(MathElement::VariantChar {
6988        class,
6989        big: false,
6990        style: Box::new(style),
6991    })))
6992}
6993
6994fn prim_math_paren_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6995    let m = args.pop().unwrap();
6996    let paren_r = args.pop().unwrap();
6997    let paren_l = args.pop().unwrap();
6998    let inner = as_math(interp, m)?;
6999    Ok(single_math(Math::Paren(
7000        Box::new(paren_l),
7001        Box::new(paren_r),
7002        (*inner).clone(),
7003    )))
7004}
7005
7006/// `math-paren : context -> paren -> paren -> math-boxes -> math-boxes`
7007/// (vminst.ml:279).
7008fn prim_math_paren_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7009    let m = args.pop().unwrap();
7010    let paren_r = args.pop().unwrap();
7011    let paren_l = args.pop().unwrap();
7012    let _ctx = as_context(args.pop().unwrap())?;
7013    let inner = as_math_boxes(m)?;
7014    Ok(single_math_boxes(Math::Paren(
7015        Box::new(paren_l),
7016        Box::new(paren_r),
7017        (*inner).clone(),
7018    )))
7019}
7020
7021fn prim_math_paren_with_middle_v006(
7022    interp: &mut Interp,
7023    mut args: Vec<Value>,
7024) -> Result<Value, EvalError> {
7025    let mlst = args.pop().unwrap();
7026    let middle = args.pop().unwrap();
7027    let paren_r = args.pop().unwrap();
7028    let paren_l = args.pop().unwrap();
7029    let items = as_list(mlst)?;
7030    let mut mlstlst = Vec::with_capacity(items.len());
7031    for it in items {
7032        mlstlst.push((*as_math(interp, it)?).clone());
7033    }
7034    Ok(single_math(Math::ParenWithMiddle(
7035        Box::new(paren_l),
7036        Box::new(paren_r),
7037        Box::new(middle),
7038        mlstlst,
7039    )))
7040}
7041
7042/// `math-paren-with-middle : context -> paren -> paren -> paren -> list
7043/// math-boxes -> math-boxes` (vminst.ml:297).
7044fn prim_math_paren_with_middle_v01(
7045    _interp: &mut Interp,
7046    mut args: Vec<Value>,
7047) -> Result<Value, EvalError> {
7048    let mlst = args.pop().unwrap();
7049    let middle = args.pop().unwrap();
7050    let paren_r = args.pop().unwrap();
7051    let paren_l = args.pop().unwrap();
7052    let _ctx = as_context(args.pop().unwrap())?;
7053    let items = as_list(mlst)?;
7054    let mut mlstlst = Vec::with_capacity(items.len());
7055    for it in items {
7056        mlstlst.push((*as_math_boxes(it)?).clone());
7057    }
7058    Ok(single_math_boxes(Math::ParenWithMiddle(
7059        Box::new(paren_l),
7060        Box::new(paren_r),
7061        Box::new(middle),
7062        mlstlst,
7063    )))
7064}
7065
7066fn prim_text_in_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7067    let body = args.pop().unwrap();
7068    let class = as_math_kind(args.pop().unwrap())?;
7069    let _ = interp;
7070    Ok(single_math(Math::Pure(MathElement::EmbeddedText {
7071        class,
7072        body: Box::new(body),
7073    })))
7074}
7075
7076/// `convert-string-for-math : context -> math-char-class -> string ->
7077/// string` (`vminstdef.yaml` `PrimitiveConvertStringForMath`). Faithful to
7078/// upstream: it overrides the context's `math_char_class` with the passed
7079/// `mccls`, then runs `MathContext.convert_math_variant_char`
7080/// (`types.cppo.ml:1602`) over the whole string —
7081///  1. if the WHOLE string is a key of the (token-level) `math_class_map`
7082///     (`default_math_class_map`, e.g. `"-"` → `"−"` U+2212), return its
7083///     replacement codepoints; else
7084///  2. remap each char via the runtime `math_variant_char_map`
7085///     (`set-math-variant-char` overrides, keyed by `(char, mccls)`) first,
7086///     then the built-in `default_math_variant_char` table (the
7087///     Mathematical-Alphanumeric-Symbols remap), keeping any char with no
7088///     mapping.
7089/// Unlike the *rendering*-path `resolve_variant_char`, this string primitive
7090/// does NOT gate on font glyph availability (upstream's
7091/// `convert_math_variant_char` never does — it returns codepoints, not
7092/// glyphs), so `abc` under `MathItalic` yields U+1D44E/44F/450 regardless of
7093/// the active font.
7094fn prim_convert_string_for_math(
7095    _interp: &mut Interp,
7096    mut args: Vec<Value>,
7097) -> Result<Value, EvalError> {
7098    let s = as_str(args.pop().unwrap())?;
7099    let class = as_math_char_class(args.pop().unwrap())?;
7100    let ctx = as_context(args.pop().unwrap())?;
7101    // (1) whole-token class-map hit -> its replacement codepoints verbatim.
7102    if let Some((target, _mk)) = ctx.math_class_map.get(&s) {
7103        return Ok(Value::Str(target.clone()));
7104    }
7105    // (2) per-char variant remap under the PASSED class (which upstream
7106    // installs as the effective `math_char_class` before converting).
7107    let mut out = String::with_capacity(s.len());
7108    for ch in s.chars() {
7109        let mapped = ctx
7110            .math_variant_char_map
7111            .get(&(ch, class))
7112            .copied()
7113            .or_else(|| default_math_variant_char(class, ch))
7114            .unwrap_or(ch);
7115        out.push(mapped);
7116    }
7117    Ok(Value::Str(out))
7118}
7119
7120/// `set-math-variant-char : math-char-class -> int -> int -> context ->
7121/// context` — FAITHFUL: installs a per-`(source char, style)`
7122/// override into `Context::math_variant_char_map`, consulted by
7123/// `resolve_variant_char` BEFORE the built-in `default_math_variant_char`
7124/// table. `Arc::make_mut` copy-on-writes the map so contexts that never
7125/// call this keep sharing one `Arc`-refcounted empty table.
7126fn prim_set_math_variant_char_v006(
7127    _interp: &mut Interp,
7128    mut args: Vec<Value>,
7129) -> Result<Value, EvalError> {
7130    let mut ctx = as_context(args.pop().unwrap())?;
7131    let cpto = as_int(args.pop().unwrap())?;
7132    let cpfrom = as_int(args.pop().unwrap())?;
7133    let cls = as_math_char_class(args.pop().unwrap())?;
7134    let from = u32::try_from(cpfrom)
7135        .ok()
7136        .and_then(char::from_u32)
7137        .ok_or_else(|| EvalError {
7138            span: None,
7139            msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
7140        })?;
7141    let to = u32::try_from(cpto)
7142        .ok()
7143        .and_then(char::from_u32)
7144        .ok_or_else(|| EvalError {
7145            span: None,
7146            msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
7147        })?;
7148    Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
7149    Ok(Value::Context(Box::new(ctx)))
7150}
7151
7152/// `set-math-variant-char : int -> (math-char-class -> int) -> context ->
7153/// context` (vminst.ml:36) — the v01 body applies the selector once per
7154/// each of the 9 `MathCharClass` values and inserts into `math_variant_
7155/// char_map` (an eager materialization of upstream's stored selector
7156/// closure; the observable map is the same either way).
7157fn prim_set_math_variant_char_v01(
7158    interp: &mut Interp,
7159    mut args: Vec<Value>,
7160) -> Result<Value, EvalError> {
7161    let mut ctx = as_context(args.pop().unwrap())?;
7162    let selector = args.pop().unwrap();
7163    let cpfrom = as_int(args.pop().unwrap())?;
7164    let from = u32::try_from(cpfrom)
7165        .ok()
7166        .and_then(char::from_u32)
7167        .ok_or_else(|| EvalError {
7168            span: None,
7169            msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
7170        })?;
7171    const CLASSES: [MathCharClass; 9] = [
7172        MathCharClass::Italic,
7173        MathCharClass::BoldItalic,
7174        MathCharClass::Roman,
7175        MathCharClass::BoldRoman,
7176        MathCharClass::Script,
7177        MathCharClass::BoldScript,
7178        MathCharClass::Fraktur,
7179        MathCharClass::BoldFraktur,
7180        MathCharClass::DoubleStruck,
7181    ];
7182    for cls in CLASSES {
7183        let cpto_v = interp.apply(selector.clone(), math_char_class_value(cls))?;
7184        let cpto = as_int(cpto_v)?;
7185        let to = u32::try_from(cpto)
7186            .ok()
7187            .and_then(char::from_u32)
7188            .ok_or_else(|| EvalError {
7189                span: None,
7190                msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
7191            })?;
7192        Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
7193    }
7194    Ok(Value::Context(Box::new(ctx)))
7195}
7196
7197/// The `MathKind` one `MathElement` atom presents as its own boundary class
7198/// — `Char`/`CharWithKern`/`EmbeddedText`/`VariantChar` carry an explicit
7199/// `class` field; `VariantCharPending` (not yet resolved to a class
7200/// at this point in the tree) consults `ctx.math_class_map` the same way
7201/// `layout_math_atom`'s own arm does, defaulting to `Ord` when the token
7202/// isn't a whole-token class-map entry (mirrors `layout_math_atom`'s
7203/// fallback path, whose per-char variant remap never changes the class).
7204fn math_element_kind(ctx: &Context, me: &MathElement) -> MathKind {
7205    match me {
7206        MathElement::Char { class, .. }
7207        | MathElement::CharWithKern { class, .. }
7208        | MathElement::EmbeddedText { class, .. }
7209        | MathElement::VariantChar { class, .. }
7210        | MathElement::EmbeddedBoxes { class, .. } => *class,
7211        MathElement::VariantCharPending(s) => ctx
7212            .math_class_map
7213            .get(s.as_str())
7214            .map(|(_, kind)| *kind)
7215            .unwrap_or(MathKind::Ord),
7216    }
7217}
7218
7219/// Upstream `get_left_math_kind`/`get_right_math_kind` (math.ml:481-524),
7220/// fused into one direction-parameterized walk over a `&[Math]` list's
7221/// FIRST (`left = true`) or LAST (`left = false`) element: `Pure` atoms
7222/// report their own class (`math_element_kind`); `Group`/`PullInScripts`
7223/// present an explicit, possibly-asymmetric left/right pair; `Sup`/`Sub`/
7224/// `UpperLimit`/`LowerLimit` recurse into their `base`; `Fraction`/
7225/// `Radical` are always `Inner`; `Paren`/`ParenWithMiddle` are always
7226/// `Open`/`Close`; `ChangeColor`/`ChangeCharClass` recurse into `inner`; an
7227/// empty list is the synthetic `End` boundary sentinel (`MathKind::End`,
7228/// `horzBox.ml:134`) — `make_math_class_option_value` maps that to `None`,
7229/// same as upstream's own list-boundary handling.
7230fn boundary_math_kind(ctx: &Context, ms: &[Math], left: bool) -> MathKind {
7231    let m = if left { ms.first() } else { ms.last() };
7232    let Some(m) = m else {
7233        return MathKind::End;
7234    };
7235    match m {
7236        Math::Pure(me) => math_element_kind(ctx, me),
7237        Math::Group(cls1, cls2, _) => {
7238            if left {
7239                *cls1
7240            } else {
7241                *cls2
7242            }
7243        }
7244        Math::PullInScripts(cls1, cls2, _) => {
7245            if left {
7246                *cls1
7247            } else {
7248                *cls2
7249            }
7250        }
7251        Math::Sup(base, _)
7252        | Math::Sub(base, _)
7253        | Math::UpperLimit(base, _)
7254        | Math::LowerLimit(base, _) => boundary_math_kind(ctx, base, left),
7255        Math::Fraction(..) | Math::Radical(..) => MathKind::Inner,
7256        Math::Paren(..) | Math::ParenWithMiddle(..) => {
7257            if left {
7258                MathKind::Open
7259            } else {
7260                MathKind::Close
7261            }
7262        }
7263        Math::ChangeColor(_, inner) | Math::ChangeCharClass(_, inner) => {
7264            boundary_math_kind(ctx, inner, left)
7265        }
7266        // V0_1 only (`read-math`): the boundary class is a property of the
7267        // wrapped content, not of which context laid it out under, so
7268        // recurse into `inner` with the SAME probing `ctx` (mirrors the
7269        // `ChangeColor`/`ChangeCharClass` arms above, which also recurse
7270        // with the ambient `ctx` rather than switching to their own stored
7271        // state).
7272        Math::WithContext(_, inner) => boundary_math_kind(ctx, inner, left),
7273    }
7274}
7275
7276fn left_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7277    boundary_math_kind(ctx, ms, true)
7278}
7279
7280fn right_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7281    boundary_math_kind(ctx, ms, false)
7282}
7283
7284/// `math-class option` — `MathKind::End` (the empty-list sentinel) becomes
7285/// `None`; every real class becomes `Some(<ctor>)`, round-tripping exactly
7286/// with `as_math_kind`'s ctor names.
7287fn make_math_class_option_value(mk: MathKind) -> Value {
7288    let name = match mk {
7289        MathKind::Ord => "MathOrd",
7290        MathKind::Bin => "MathBin",
7291        MathKind::Rel => "MathRel",
7292        MathKind::Op => "MathOp",
7293        MathKind::Punct => "MathPunct",
7294        MathKind::Open => "MathOpen",
7295        MathKind::Close => "MathClose",
7296        MathKind::Prefix => "MathPrefix",
7297        MathKind::Inner => "MathInner",
7298        MathKind::End => return Value::Ctor("None".to_string(), None),
7299    };
7300    Value::Ctor(
7301        "Some".to_string(),
7302        Some(Box::new(Value::Ctor(name.to_string(), None))),
7303    )
7304}
7305
7306/// `get-left-math-class : context -> math -> math-class option`.
7307fn prim_get_left_math_class_v006(
7308    interp: &mut Interp,
7309    mut args: Vec<Value>,
7310) -> Result<Value, EvalError> {
7311    let m = as_math(interp, args.pop().unwrap())?;
7312    let ctx = as_context(args.pop().unwrap())?;
7313    Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7314}
7315
7316/// `get-left-math-class : math-boxes -> math-class option` (vminst.ml:128)
7317/// — ctx DROPPED (matches upstream, which takes no context at all here).
7318/// The boundary-class probe still needs SOME `Context` to resolve an
7319/// unresolved `VariantCharPending` token's whole-token class map
7320/// (`math_element_kind`) — this port's own deferred-resolution design, not
7321/// upstream's, since upstream's `math` atoms already carry a resolved
7322/// class — so a bare default context stands in.
7323fn prim_get_left_math_class_v01(
7324    _interp: &mut Interp,
7325    mut args: Vec<Value>,
7326) -> Result<Value, EvalError> {
7327    let m = as_math_boxes(args.pop().unwrap())?;
7328    let ctx = Context::initial(Length::ZERO);
7329    Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7330}
7331
7332/// `get-right-math-class : context -> math -> math-class option`.
7333fn prim_get_right_math_class_v006(
7334    interp: &mut Interp,
7335    mut args: Vec<Value>,
7336) -> Result<Value, EvalError> {
7337    let m = as_math(interp, args.pop().unwrap())?;
7338    let ctx = as_context(args.pop().unwrap())?;
7339    Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7340}
7341
7342/// `get-right-math-class : math-boxes -> math-class option` (vminst.ml:146)
7343/// — same fork as `get-left-math-class`.
7344fn prim_get_right_math_class_v01(
7345    _interp: &mut Interp,
7346    mut args: Vec<Value>,
7347) -> Result<Value, EvalError> {
7348    let m = as_math_boxes(args.pop().unwrap())?;
7349    let ctx = Context::initial(Length::ZERO);
7350    Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7351}
7352
7353/// `set-math-command : [math] inline-cmd -> context -> context`
7354/// FAITHFUL: installs the command `read_inline`'s `EmbedMath` arm applies
7355/// to bare `${…}`.
7356fn prim_set_math_command(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7357    let mut ctx = as_context(args.pop().unwrap())?;
7358    let cmd = args.pop().unwrap();
7359    ctx.math_command = Some(interp.register_math_command(cmd));
7360    Ok(Value::Context(Box::new(ctx)))
7361}
7362
7363/// Resolve a font abbrev to one of the 3 base faces by name heuristic — the
7364/// only font-name resolution this port has. Shared by set-font/set-math-font.
7365fn resolve_font_abbrev(abbrev: &str) -> FontKey {
7366    let lower = abbrev.to_ascii_lowercase();
7367    if lower.contains("bold") {
7368        FONT_BOLD
7369    } else if lower.contains("it") || lower.contains("obl") || lower.contains("slant") {
7370        FONT_OBLIQUE
7371    } else {
7372        FONT_REGULAR
7373    }
7374}
7375
7376/// `set-math-font : string -> context -> context` (0.0.6
7377/// `vminstdef.yaml:1364`) — `abbrev` resolves through the font metrics
7378/// provider's registry first (the same upgrade as `set-font`), falling back
7379/// to the 3-face name heuristic, so a math OTF configured under
7380/// any abbrev (not just the CLI regular face) can be selected.
7381fn prim_set_math_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7382    let ctx = as_context(args.pop().unwrap())?;
7383    let abbrev = as_str(args.pop().unwrap())?;
7384    let math_font = interp
7385        .metrics
7386        .resolve_font_abbrev(&abbrev)
7387        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7388    Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7389}
7390
7391/// `set-math-font : font -> context -> context` (saphe-split
7392/// `tools/gencode/vminst.ml:1462`, whose body is
7393/// `ctx with math_font_key = Some(mathkey)`) — the 0.1 arm takes the opaque
7394/// handle, so there is no abbrev left to resolve. The bundled 0.1 corpus
7395/// already calls it that way (`std-ja.satyh`'s `set-math-font
7396/// FontLatinModernMath.main`).
7397fn prim_set_math_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7398    let ctx = as_context(args.pop().unwrap())?;
7399    let math_font = as_font_key(args.pop().unwrap())?;
7400    Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7401}
7402
7403/// `load-single-font : string -> font` — LOCAL, non-upstream, V0_1-only.
7404///
7405/// Upstream has no surface name for this: `envelopeChecker.ml`'s
7406/// `check_font_envelope` synthesizes one binding per `files[]` row of a font
7407/// ENVELOPE, typed `BaseType(FontType)`, whose right-hand side is the
7408/// internal `LoadSingleFont{ path; used_as_math_font }` node — and
7409/// `evaluator.cppo.ml:427-434` evaluates that to `BaseConstant(BCFontKey
7410/// (FontInfo.add_single path))`. This port's bundled 0.1 font envelopes are
7411/// ordinary `.satyh` stand-ins (`dist-v01/packages/font-*.satyh`) rather
7412/// than envelopes the loader synthesizes bindings from, so they need a
7413/// spelling for the same step; this is it. Same LOCAL-primitive precedent as
7414/// `set-font-key`.
7415///
7416/// The argument stands in for upstream's font-file PATH: it is the port's
7417/// font-store key, resolved here through exactly the ladder `set-font` used
7418/// to run per call — the metrics provider's registry
7419/// (`FontMetrics::resolve_font_abbrev`, a real `TtfFontStore` built from
7420/// `fonts.satysfi-hash`), falling back to the 3-face name heuristic. Doing
7421/// it HERE rather than at `set-font` time is what makes the resulting `font`
7422/// a genuine handle: resolution is a pure function of the abbrev and the
7423/// provider (`&self`, no interior mutation), so moving it earlier is
7424/// observationally identical.
7425fn prim_load_single_font(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7426    let abbrev = as_str(args.pop().unwrap())?;
7427    let key = interp
7428        .metrics
7429        .resolve_font_abbrev(&abbrev)
7430        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7431    Ok(Value::Font(key))
7432}
7433
7434/// `space-between-maths : context -> math -> math -> inline-boxes option`
7435/// (vminst.ml:173) — STAND-IN: the real inter-atom glue is the full
7436/// `space_between_math_kinds` table (`math.ml:319-410`); always returns
7437/// `None` (no extra glue), used by `math.satyh`'s
7438/// `+align` — never invoked eagerly (that binding is a `let-block` closure).
7439fn prim_space_between_maths_v006(
7440    _interp: &mut Interp,
7441    mut args: Vec<Value>,
7442) -> Result<Value, EvalError> {
7443    let _m2 = args.pop().unwrap();
7444    let _m1 = args.pop().unwrap();
7445    let _ctx = as_context(args.pop().unwrap())?;
7446    Ok(Value::Ctor("None".to_string(), None))
7447}
7448
7449/// `space-between-maths : context -> math-boxes -> math-boxes -> inline-
7450/// boxes option` (vminst.ml:164) — shared STAND-IN body, only the extractor
7451/// forks (`as_math_boxes` vs `as_math`).
7452fn prim_space_between_maths_v01(
7453    _interp: &mut Interp,
7454    mut args: Vec<Value>,
7455) -> Result<Value, EvalError> {
7456    let _m2 = as_math_boxes(args.pop().unwrap())?;
7457    let _m1 = as_math_boxes(args.pop().unwrap())?;
7458    let _ctx = as_context(args.pop().unwrap())?;
7459    Ok(Value::Ctor("None".to_string(), None))
7460}
7461
7462/// `raise-inline : length -> inline-boxes -> inline-boxes` — STAND-IN: the
7463/// line model has no per-box vertical-offset wrapper outside
7464/// `PureHorzBox::Math`'s own per-glyph `dy` ("structural difference"
7465/// note); returns the boxes unshifted (used by `math.satyh`'s `\cases`,
7466/// never invoked eagerly).
7467fn prim_raise_inline(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7468    let ib = as_inline_boxes(args.pop().unwrap())?;
7469    let _len = as_length(args.pop().unwrap())?;
7470    Ok(Value::InlineBoxes(ib))
7471}
7472
7473/// `embed-block-breakable : context -> block-boxes -> inline-boxes`
7474/// (vminst.ml:973; upstream `HorzEmbeddedVertBreakable`) — a MANDATORY
7475/// break on both sides: upstream's `LBEmbeddedVertBreakable` resets the
7476/// width map to this breakpoint alone (`lineBreak.ml:1076-1087`), flushes
7477/// the accumulated line, emits the block as its own vertical item, then
7478/// starts a fresh line (`lineBreak.ml:809-818`).
7479///
7480/// Modelled here as a forced `Discretionary` either side of the block —
7481/// without them the block was just an inline box, so latexcmds'
7482/// `\linebreak` (`inline-fil ++ embed-block-breakable ctx (block-skip
7483/// gap)`, `latexcmds.satyh:150`) never broke: the `inline-fil` swallowed
7484/// the line's whole slack and shoved everything after it off the page
7485/// edge, silently losing it (`このように`/`使い`/`すぎると`/`読みにくく`
7486/// all vanished from the render).
7487fn prim_embed_block_breakable(
7488    _interp: &mut Interp,
7489    mut args: Vec<Value>,
7490) -> Result<Value, EvalError> {
7491    let bb = as_block_boxes(args.pop().unwrap())?;
7492    let ctx = as_context(args.pop().unwrap())?;
7493    // Embed the block inline, top-anchored (the block's FIRST line sits on the
7494    // surrounding text baseline — same as `embed-block-top`).
7495    // `make_embedded_block` splits the box's height/depth around the first line
7496    // so the pager accounts for the embedded figure's extent.
7497    let block = match make_embedded_block(ctx.paragraph_width, bb, false, true) {
7498        Value::InlineBoxes(boxes) => boxes,
7499        other => return Ok(other),
7500    };
7501    let forced = || {
7502        HorzBox::Pure(PureHorzBox::Discretionary {
7503            penalty: FORCED_BREAK_PENALTY,
7504            pre_break: Vec::new(),
7505            post_break: Vec::new(),
7506            no_break: Vec::new(),
7507        })
7508    };
7509    let mut out = vec![forced()];
7510    out.extend(block);
7511    out.push(forced());
7512    Ok(Value::InlineBoxes(out))
7513}
7514
7515/// `unite-path : path -> path -> path` — FAITHFUL: `path` is upstream's
7516/// `path list` (a list of independently-closed subpaths — see
7517/// `graphics.rs`'s `Path` doc comment), so uniting two is a plain
7518/// subpath-list append. Used by `math.satyh`'s `\norm` (two parallel bars).
7519fn prim_unite_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7520    let p2 = as_path(args.pop().unwrap())?;
7521    let p1 = as_path(args.pop().unwrap())?;
7522    let mut subpaths = p1.subpaths;
7523    subpaths.extend(p2.subpaths);
7524    Ok(Value::Path(Path { subpaths }))
7525}
7526
7527/// `set-min-gap-of-lines : length -> context -> context` (vminst.ml:1291) —
7528/// STAND-IN: no separate `min_gap_of_lines` field on `Context` yet (see
7529/// `set-leading`'s own comment on why IT, not this, is the baseline-distance
7530/// setter); accepted and dropped. Used by `math.satyh`'s `+math-list`, never
7531/// invoked eagerly.
7532fn prim_set_min_gap_of_lines(
7533    _interp: &mut Interp,
7534    mut args: Vec<Value>,
7535) -> Result<Value, EvalError> {
7536    let ctx = as_context(args.pop().unwrap())?;
7537    let _len = as_length(args.pop().unwrap())?;
7538    Ok(Value::Context(Box::new(ctx)))
7539}
7540
7541/// `embed-math : context -> math -> inline-boxes` (vminst.ml:520) — the
7542/// bridge to the page: the faithful, primitive-driven analog of `read_math`,
7543/// operating on a `Value::Math` tree instead. FAITHFUL for the atoms
7544/// `read_math` already draws (plain/kerned/variant chars, groups, sup/sub);
7545/// the structural forms (fraction/radical/paren/limits/pull-in-scripts/
7546/// embedded-text) get a deliberately cheap, documented stand-in rendering
7547/// rather than an error, so `${…}`-shaped math built through these
7548/// primitives is never *unusable*, just not yet typographically faithful.
7549fn prim_embed_math_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7550    let m = args.pop().unwrap();
7551    let ctx = as_context(args.pop().unwrap())?;
7552    let elems = as_math(interp, m)?;
7553    let boxed = layout_math_value(interp, &ctx, &elems)?;
7554    Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7555}
7556
7557/// `embed-math : context -> math-boxes -> inline-boxes` (vminst.ml:472) —
7558/// `as_math_boxes` then the SAME `layout_math_value` (:5165 below) — the
7559/// whole MATH-engine reuse in one primitive.
7560fn prim_embed_math_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7561    let m = args.pop().unwrap();
7562    let ctx = as_context(args.pop().unwrap())?;
7563    let elems = as_math_boxes(m)?;
7564    let boxed = layout_math_value(interp, &ctx, &elems)?;
7565    Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7566}
7567
7568/// Lay out a faithful `&[Math]` run into one `PureHorzBox::Math`, mirroring
7569/// `read_math`'s glyph-emission shape (fixed-constant super/subscript
7570/// shift/scale, the same minimal `Bin`/`Rel` spacer) but keyed on each
7571/// atom's own EXPLICIT class (from `math-char`/`math-group`/…) rather than
7572/// `ascii_math_kind`'s inference.
7573fn layout_math_value(
7574    interp: &mut Interp,
7575    ctx: &Context,
7576    elems: &[Math],
7577) -> Result<PureHorzBox, EvalError> {
7578    let (glyphs, rules, width, _left, _right) =
7579        layout_math_list(interp, ctx, elems, ctx.font_size)?;
7580    let mut height = Length::ZERO;
7581    let mut depth = Length::ZERO;
7582    for g in &glyphs {
7583        height = height.max(g.dy + g.height);
7584        depth = depth.max(g.depth - g.dy);
7585    }
7586    // A fraction bar/radical sign is a `Fill` with no `MathGlyph` backing it
7587    // at all, so the glyph-only aggregation above would silently undercount
7588    // a run whose bar/sign extends above every glyph's own ink (e.g.
7589    // `${\sqrt{2}}`'s `l_extra` ascender). Fold every rule's own (y-up,
7590    // box-local — same frame as `MathGlyph::dy`) bounding box in too.
7591    for r in &rules {
7592        // `graphics_bbox` -> `Option`; a `None` rule (unreachable here
7593        // under 0.0.6 math rules) contributes nothing.
7594        if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
7595            height = height.max(max_y);
7596            depth = depth.max(-min_y);
7597        }
7598    }
7599    Ok(PureHorzBox::Math {
7600        width,
7601        height,
7602        depth,
7603        glyphs,
7604        rules,
7605    })
7606}
7607
7608/// Lay out a flat `&[Math]` list at `size`, threading inter-atom spacing
7609/// (`space_before`, a minimal spacer) and returning the glyphs (at
7610/// LOCAL coordinates starting at `x = 0`), any graphics `rules` an atom
7611/// pushed (shifted horizontally by the same running `x` a glyph gets
7612/// — `layout_math_list` never shifts an atom vertically, only
7613/// `shift_and_append`'s callers do), the total width, and the boundary
7614/// classes on either end (needed by a `Group` ancestor, which can present
7615/// different left/right classes — see `Math::Group`'s doc comment).
7616fn layout_math_list(
7617    interp: &mut Interp,
7618    ctx: &Context,
7619    elems: &[Math],
7620    size: Length,
7621) -> Result<
7622    (
7623        Vec<MathGlyph>,
7624        Vec<GraphicsElem>,
7625        Length,
7626        MathKind,
7627        MathKind,
7628    ),
7629    EvalError,
7630> {
7631    // Lay every atom out FIRST, because `normalize_math_kind` below needs each
7632    // one's NEIGHBOURS' raw classes — upstream's `convert_to_low` passes
7633    // `mkprev`/`mknext` into `convert_to_low_single` for exactly this
7634    // (`math.ml:753-765`, via `get_right_math_kind`/`get_left_math_kind`).
7635    // The layout of an atom does not depend on its class, only the SPACING
7636    // between atoms does, so splitting the walk in two moves no glyph.
7637    let mut laid: Vec<(
7638        Vec<MathGlyph>,
7639        Vec<GraphicsElem>,
7640        Length,
7641        MathKind,
7642        MathKind,
7643    )> = Vec::with_capacity(elems.len());
7644    for atom in elems {
7645        laid.push(layout_math_atom(interp, ctx, atom, size)?);
7646    }
7647
7648    let mut glyphs = Vec::new();
7649    let mut rules = Vec::new();
7650    let mut x = Length::ZERO;
7651    let mut last_kind: Option<MathKind> = None;
7652    let mut first_kind: Option<MathKind> = None;
7653    let in_script = math_in_script(ctx, size);
7654    for (i, (atom_glyphs, atom_rules, atom_width, left_raw, right_raw)) in
7655        laid.iter().cloned().enumerate()
7656    {
7657        // `mkprev`/`mknext` are the neighbours' RAW classes (upstream never
7658        // feeds a normalized class back in), and the ends of the list are
7659        // `MathEnd` — `math.ml:1270`'s `convert_to_low mathctx MathEnd MathEnd`.
7660        let prev_raw = if i == 0 { MathKind::End } else { laid[i - 1].4 };
7661        let next_raw = laid.get(i + 1).map_or(MathKind::End, |a| a.3);
7662        let left = normalize_math_kind(prev_raw, next_raw, left_raw);
7663        let right = normalize_math_kind(prev_raw, next_raw, right_raw);
7664        if let Some(prev) = last_kind {
7665            x += space_before(prev, left, in_script, size);
7666        }
7667        first_kind.get_or_insert(left);
7668        let base_x = x;
7669        for mut g in atom_glyphs {
7670            g.dx = base_x + g.dx;
7671            glyphs.push(g);
7672        }
7673        for r in &atom_rules {
7674            rules.push(shift_graphics((base_x, Length::ZERO), r));
7675        }
7676        x = base_x + atom_width;
7677        last_kind = Some(right);
7678    }
7679    let left = first_kind.unwrap_or(MathKind::Ord);
7680    let right = last_kind.unwrap_or(MathKind::Ord);
7681    Ok((glyphs, rules, x, left, right))
7682}
7683
7684/// Upstream `check_subscript` (math.ml:682-699): if a superscript base's
7685/// LAST element is itself a `Sub`, strip it — returning `(subscript script,
7686/// new base)` where the new base is the preceding elements followed by the
7687/// inner `Sub`'s own base, so `{x_1}^2` becomes one base carrying both a
7688/// sub and a sup. Recurses through `ChangeColor`/`ChangeCharClass`.
7689fn check_subscript(base: &[Math]) -> Option<(Vec<Math>, Vec<Math>)> {
7690    let (last, head) = base.split_last()?;
7691    match last {
7692        Math::Sub(inner_base, sub_script) => {
7693            let mut new_base = head.to_vec();
7694            new_base.extend(inner_base.iter().cloned());
7695            Some((sub_script.clone(), new_base))
7696        }
7697        Math::ChangeColor(color, inner) => {
7698            let (sub_script, inner_new) = check_subscript(inner)?;
7699            let mut new_base = head.to_vec();
7700            new_base.push(Math::ChangeColor(color.clone(), inner_new));
7701            Some((vec![Math::ChangeColor(color.clone(), sub_script)], new_base))
7702        }
7703        Math::ChangeCharClass(cls, inner) => {
7704            let (sub_script, inner_new) = check_subscript(inner)?;
7705            let mut new_base = head.to_vec();
7706            new_base.push(Math::ChangeCharClass(cls.clone(), inner_new));
7707            Some((
7708                vec![Math::ChangeCharClass(cls.clone(), sub_script)],
7709                new_base,
7710            ))
7711        }
7712        _ => None,
7713    }
7714}
7715
7716/// Upstream `invoke_pull_in_scripts` (math.ml:957-966): call a
7717/// `math-pull-in-scripts` resolver with the actual pulled-in scripts —
7718/// `resolver : math option -> math option -> math`, SUBSCRIPT option first,
7719/// SUPERSCRIPT second — then splice the returned math after the remaining
7720/// base as ONE `Group(cls1, cls2, …)` atom and lay the whole list out.
7721#[allow(clippy::too_many_arguments)]
7722fn layout_pull_in_scripts(
7723    interp: &mut Interp,
7724    ctx: &Context,
7725    head: &[Math],
7726    cls1: MathKind,
7727    cls2: MathKind,
7728    resolver: &Value,
7729    sub: Option<&[Math]>,
7730    sup: Option<&[Math]>,
7731    size: Length,
7732) -> Result<
7733    (
7734        Vec<MathGlyph>,
7735        Vec<GraphicsElem>,
7736        Length,
7737        MathKind,
7738        MathKind,
7739    ),
7740    EvalError,
7741> {
7742    let opt_math = |o: Option<&[Math]>| match o {
7743        Some(m) => Value::Ctor(
7744            "Some".to_string(),
7745            Some(Box::new(Value::Math(Rc::new(m.to_vec())))),
7746        ),
7747        None => Value::Ctor("None".to_string(), None),
7748    };
7749    let partial = interp.apply(resolver.clone(), opt_math(sub))?;
7750    let result = interp.apply(partial, opt_math(sup))?;
7751    let resolved = as_math(interp, result)?;
7752    let mut items: Vec<Math> = head.to_vec();
7753    items.push(Math::Group(cls1, cls2, (*resolved).clone()));
7754    layout_math_list(interp, ctx, &items, size)
7755}
7756
7757/// The metrics-probe fallback policy: resolve `c` under `ctx`'s current
7758/// `math_char_class` (checking the runtime override map first, then the
7759/// built-in `default_math_variant_char` table), but only actually EMIT the
7760/// remapped codepoint if the current font can render it
7761/// (`interp.metrics.advance` returns `Some`) — otherwise fall back to the
7762/// source char `c` (its class, from `Context::math_class_map`/
7763/// `ascii_math_kind`-style inference, is kept regardless). This is what
7764/// keeps base-14/WinAnsi documents byte-identical (`Base14Metrics` returns
7765/// `None` outside ASCII 32-126) while a math-capable TTF, or a permissive
7766/// test stub, gets the real Mathematical-Alphanumeric glyph automatically.
7767fn resolve_variant_char(interp: &Interp, ctx: &Context, c: char, size: Length) -> char {
7768    let mapped = ctx
7769        .math_variant_char_map
7770        .get(&(c, ctx.math_char_class))
7771        .copied()
7772        .or_else(|| default_math_variant_char(ctx.math_char_class, c));
7773    match mapped {
7774        Some(m) if math_char_available(interp, ctx, m, size) => m,
7775        _ => c,
7776    }
7777}
7778
7779/// Invoke ONE `paren` closure (`math.satyh`'s `paren-left`/
7780/// `paren-right`/`abs-left`/`brace-left`/…) exactly the way upstream's
7781/// `make_paren` does (`math.ml:644-649`): 5 CURRIED args in order — inner
7782/// height `h_in` (≥0), inner depth SIGNED (≤0, hence `-d_in` — this port
7783/// carries depths as non-negative magnitudes, see this function's `d_in`
7784/// param doc below), the axis height at the local size, the local
7785/// (script-scaled) size, and the current text color — then unpack the
7786/// returned `(inline-boxes, length -> length)` 2-tuple and harvest the
7787/// boxes' glyphs/rules/width via `math_boxes_of_inline_boxes` (the
7788/// graphics-harvesting sibling of `math_glyphs_of_inline_boxes`, since a
7789/// closure's delimiter is drawn `Fill`/`Stroke` ink via `inline-graphics`,
7790/// not a font glyph). The kernf itself is returned un-invoked (callers
7791/// re-derive/discard it as `math.ml:923` does for `ParenWithMiddle`'s own
7792/// middle).
7793///
7794/// `d_in`: this port's non-negative ink-depth MAGNITUDE (`inner_ink_extent`'s
7795/// second component). Upstream's own box depths are non-positive internally
7796/// (`convert_to_low`'s `dC` folds via `Length.min`, always ≤ `Length.zero`),
7797/// and `half-length` (`math.satyh:1023-1026`) computes the below-axis need
7798/// as `hgtaxis +' dpt` on that SIGNED value — so passing the magnitude
7799/// directly would OVERSIZE every delimiter below the axis (double-counts
7800/// the depth on the wrong side). Negating here is what keeps the closure's
7801/// own arithmetic faithful without changing this port's magnitude
7802/// convention everywhere else.
7803fn make_paren_run(
7804    interp: &mut Interp,
7805    ctx: &Context,
7806    paren: &Value,
7807    h_in: Length,
7808    d_in: Length,
7809    axis: Length,
7810    size: Length,
7811) -> Result<(Vec<MathGlyph>, Vec<GraphicsElem>, Length, Value), EvalError> {
7812    let mut v = paren.clone();
7813    if interp.version.math_is_split() {
7814        // 0.1 protocol (math.ml:640-642): `paren h d ictx` — (height, SIGNED
7815        // depth, context). The closure extracts fontsize / axis-ratio (via
7816        // `get-math-axis-height-ratio`) / color FROM the context instead of
7817        // receiving them as separate explicit arguments (the 0.0.6→0.1
7818        // delta, `t_paren`'s doc comment). Upstream's `ictx` is already
7819        // scaled to the local (script-level) size at this call site; this
7820        // port threads `size` as a separate parameter, so clone-and-set —
7821        // BIGGEST RISK: forgetting this silently
7822        // oversizes script-level delimiters (the closure would read the
7823        // OUTER context's font_size instead of the local scaled one).
7824        let mut c2 = ctx.clone();
7825        c2.font_size = size;
7826        let args = [
7827            Value::Length(h_in),
7828            Value::Length(-d_in),
7829            Value::Context(Box::new(c2)),
7830        ];
7831        for a in args {
7832            v = interp.apply(v, a)?;
7833        }
7834    } else {
7835        // 0.0.6 protocol.
7836        let args = [
7837            Value::Length(h_in),
7838            Value::Length(-d_in),
7839            Value::Length(axis),
7840            Value::Length(size),
7841            make_color_value(ctx.text_color),
7842        ];
7843        for a in args {
7844            v = interp.apply(v, a)?;
7845        }
7846    }
7847    let (boxes_v, kernf) = match v {
7848        Value::Tuple(mut items) if items.len() == 2 => {
7849            let kernf = items.pop().unwrap();
7850            (items.pop().unwrap(), kernf)
7851        }
7852        other => {
7853            return eval_error(format!(
7854                "math-paren: a paren closure must return (inline-boxes, length -> length), got {}",
7855                other.type_name()
7856            ))
7857        }
7858    };
7859    let boxes = as_inline_boxes(boxes_v)?;
7860    let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
7861    Ok((glyphs, rules, width, kernf))
7862}
7863
7864/// The original MATH-native stretchy-delimiter body, extracted verbatim as
7865/// the fallback `Math::Paren`/`Math::ParenWithMiddle` now take when the
7866/// closure route (`make_paren_run`, primary — upstream-faithful) errors:
7867/// every delimiter renders as a correctly-SIZED `(`/`)`/`|` regardless of
7868/// the requested paren kind (identity-wrong, but usable, for any closure
7869/// that can't be run — a synthetic/ill-shaped test closure, or a real error
7870/// from a malformed user-supplied one).
7871fn paren_variant_fallback(
7872    interp: &mut Interp,
7873    ctx: &Context,
7874    parts: Vec<(Vec<MathGlyph>, Vec<GraphicsElem>, Length)>,
7875    h_in: Length,
7876    d_in: Length,
7877    axis: Length,
7878    size: Length,
7879) -> Result<
7880    (
7881        Vec<MathGlyph>,
7882        Vec<GraphicsElem>,
7883        Length,
7884        MathKind,
7885        MathKind,
7886    ),
7887    EvalError,
7888> {
7889    let target = (h_in - axis).max(axis + d_in) * 2.0;
7890    let mut glyphs = Vec::new();
7891    let mut rules = Vec::new();
7892    let mut x = Length::ZERO;
7893    push_delimiter_glyph(interp, ctx, '(', size, target, axis, &mut glyphs, &mut x)?;
7894    for (i, (pg, pr, pw)) in parts.into_iter().enumerate() {
7895        if i > 0 {
7896            push_delimiter_glyph(interp, ctx, '|', size, target, axis, &mut glyphs, &mut x)?;
7897        }
7898        append_at(&mut glyphs, &mut rules, &mut x, pg, pr, pw);
7899    }
7900    push_delimiter_glyph(interp, ctx, ')', size, target, axis, &mut glyphs, &mut x)?;
7901    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
7902}
7903
7904/// Re-derive a paren base's TRAILING (right) delimiter's dense math
7905/// kern function by re-invoking its closure at the script-attachment site
7906/// (`superscript_kern`'s glyph-corner sampling doesn't apply to a paren
7907/// base — it has no single "last glyph" to sample italic-correction/corner
7908/// kerns off; the closure itself IS the source of truth for how much a
7909/// script should tuck into it, exactly upstream's `lp_math_kern_scheme`,
7910/// `math.ml:906`/`922`). Closures are pure (`math.satyh`'s bundled ones
7911/// have no side effects), so re-invoking with the SAME `(h_in, d_in, axis,
7912/// size)` the original `Math::Paren`/`ParenWithMiddle` layout used yields
7913/// the identical `kernf` value. Returns `None` when `base`'s last atom
7914/// isn't a paren, or when re-running its closure(s) errors (the delimiter
7915/// fallback path carries no math-kern scheme at all — `dense_kern`'s
7916/// caller then falls back to zero, matching that stand-in's own
7917/// `kerninfo _ = 0pt` shape).
7918fn paren_trailing_kernf(
7919    interp: &mut Interp,
7920    ctx: &Context,
7921    base: &[Math],
7922    size: Length,
7923) -> Option<Value> {
7924    let (r, h_in, d_in) = match base.last()? {
7925        Math::Paren(_, r, inner) => {
7926            let (g, ru, ..) = layout_math_list(interp, ctx, inner, size).ok()?;
7927            let (h, d) = inner_ink_extent(&g, &ru);
7928            (r, h, d)
7929        }
7930        Math::ParenWithMiddle(_, r, _, parts) => {
7931            let mut h = Length::ZERO;
7932            let mut d = Length::ZERO;
7933            for p in parts {
7934                let (g, ru, ..) = layout_math_list(interp, ctx, p, size).ok()?;
7935                let (ph, pd) = inner_ink_extent(&g, &ru);
7936                h = h.max(ph);
7937                d = d.max(pd);
7938            }
7939            (r, h, d)
7940        }
7941        _ => return None,
7942    };
7943    let mc = MathC::of(interp, ctx);
7944    let axis = mc.axis(size);
7945    let (_, _, _, kernf) = make_paren_run(interp, ctx, r, h_in, d_in, axis, size).ok()?;
7946    Some(kernf)
7947}
7948
7949/// `fontInfo.ml:361`'s `DenseMathKern` branch: `Length.negate (kernf
7950/// corrhgt)` — the closure returns a POSITIVE tuck amount (how far to slide
7951/// the script INTO the delimiter's hollow), and the engine negates it into
7952/// a kern (negative = closer to the previous glyph, `get_math_kern`'s own
7953/// doc comment). Any failure (wrong-shaped return, closure error) collapses
7954/// to `Length::ZERO` — no kern, not a layout error; matches
7955/// `paren_trailing_kernf`'s own `None`-on-error contract.
7956fn dense_kern(interp: &mut Interp, kernf: &Value, corrhgt: Length) -> Length {
7957    match interp.apply(kernf.clone(), Value::Length(corrhgt)) {
7958        Ok(Value::Length(l)) => -l,
7959        _ => Length::ZERO,
7960    }
7961}
7962
7963/// Lay out one `Math` atom at `size` (LOCAL coordinates, `x` starting at
7964/// 0), returning its glyphs, any graphics `rules` it pushed (only the
7965/// `Fraction`/`Radical` arms produce any; every other arm forwards its
7966/// children's), width, and left/right boundary class.
7967fn layout_math_atom(
7968    interp: &mut Interp,
7969    ctx: &Context,
7970    atom: &Math,
7971    size: Length,
7972) -> Result<
7973    (
7974        Vec<MathGlyph>,
7975        Vec<GraphicsElem>,
7976        Length,
7977        MathKind,
7978        MathKind,
7979    ),
7980    EvalError,
7981> {
7982    match atom {
7983        Math::Pure(MathElement::Char { class, big, chars })
7984        | Math::Pure(MathElement::CharWithKern {
7985            class, big, chars, ..
7986        }) => {
7987            let mut glyphs = Vec::new();
7988            let mut x = Length::ZERO;
7989            for c in chars.chars() {
7990                if *big {
7991                    push_big_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
7992                } else {
7993                    push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
7994                }
7995            }
7996            Ok((glyphs, Vec::new(), x, *class, *class))
7997        }
7998        Math::Pure(MathElement::VariantChar { class, style, .. }) => {
7999            // Select the target codepoints by the CURRENT restyling
8000            // (`Context::math_char_class`, set by `ChangeCharClass`'s
8001            // layout arm below) rather than always `style.italic` — these
8002            // are explicit per-style codepoints the caller built
8003            // (`math-variant-char`), so no metrics-probe fallback (unlike
8004            // `resolve_variant_char`): `push_char_glyph` errors like any
8005            // other explicit-codepoint atom if the font can't render it.
8006            let text = match ctx.math_char_class {
8007                MathCharClass::Italic => &style.italic,
8008                MathCharClass::BoldItalic => &style.bold_italic,
8009                MathCharClass::Roman => &style.roman,
8010                MathCharClass::BoldRoman => &style.bold_roman,
8011                MathCharClass::Script => &style.script,
8012                MathCharClass::BoldScript => &style.bold_script,
8013                MathCharClass::Fraktur => &style.fraktur,
8014                MathCharClass::BoldFraktur => &style.bold_fraktur,
8015                MathCharClass::DoubleStruck => &style.double_struck,
8016                // `MathVariantStyle` (this
8017                // 9-field record) is deliberately NOT widened to 14 fields
8018                // — it models the 0.0.6 `math-variant-char` prim's record
8019                // shape, which upstream itself never grew sans-
8020                // serif/typewriter fields for either (only `math-char-class`
8021                // itself widened, `horzBox.ml:98-113`). This arm is
8022                // unreachable in practice: `math-variant-char`/
8023                // `MathElement::VariantChar` is a V0_0-only prim
8024                // (registered `v006` only, `primitives.rs`'s prim table),
8025                // and the 5 new `MathCharClass` ctors are V0_1-only
8026                // (`prim_types.rs::math_char_class_decl`) — the two can
8027                // never co-occur. Closest-analog fallback, purely to keep
8028                // the match exhaustive.
8029                MathCharClass::SansSerif | MathCharClass::Typewriter => &style.roman,
8030                MathCharClass::ItalicSansSerif => &style.italic,
8031                MathCharClass::BoldSansSerif => &style.bold_roman,
8032                MathCharClass::BoldItalicSansSerif => &style.bold_italic,
8033            };
8034            let mut glyphs = Vec::new();
8035            let mut x = Length::ZERO;
8036            for c in text.chars() {
8037                push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8038            }
8039            Ok((glyphs, Vec::new(), x, *class, *class))
8040        }
8041        Math::Pure(MathElement::VariantCharPending(s)) => {
8042            // One MATHCHAR token, resolved now that `ctx` (font +
8043            // math_char_class + both override maps) is available: first
8044            // try the whole-TOKEN class map (`=`, `-`, `,`, … ->
8045            // (replacement, MathKind)); if the token isn't there, fall back
8046            // to a per-char variant remap (the metrics-probe policy)
8047            // with `MathKind::Ord`.
8048            let mut glyphs = Vec::new();
8049            let mut x = Length::ZERO;
8050            if let Some((target, kind)) = ctx.math_class_map.get(s.as_str()) {
8051                let kind = *kind;
8052                let all_renderable = target
8053                    .chars()
8054                    .all(|c| math_char_available(interp, ctx, c, size));
8055                let chosen = if all_renderable {
8056                    target.clone()
8057                } else {
8058                    s.clone()
8059                };
8060                for c in chosen.chars() {
8061                    push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8062                }
8063                return Ok((glyphs, Vec::new(), x, kind, kind));
8064            }
8065            for c in s.chars() {
8066                let chosen = resolve_variant_char(interp, ctx, c, size);
8067                push_char_glyph(interp, ctx, chosen, size, &mut glyphs, &mut x)?;
8068            }
8069            Ok((glyphs, Vec::new(), x, MathKind::Ord, MathKind::Ord))
8070        }
8071        Math::Pure(MathElement::EmbeddedText { class, body }) => {
8072            let v = interp.apply((**body).clone(), Value::Context(Box::new(ctx.clone())))?;
8073            let boxes = as_inline_boxes(v)?;
8074            // `math_boxes_of_inline_boxes`, not the glyphs-only walk: embedded
8075            // inline content can carry its ink as GRAPHICS rather than glyphs.
8076            // latexcmds' `\underset`/`\overset` are exactly that — they reduce
8077            // to `text-in-math (… \normal-underset …)`, which draws through
8078            // `inline-graphics` + `draw-text`. Harvesting glyphs alone kept the
8079            // box's WIDTH and threw the drawing away, so the Schrödinger-equation
8080            // example rendered as `[−     + V(x)]Ψ`: a correctly-sized hole where
8081            // the fraction and its under-text should be.
8082            let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
8083            Ok((glyphs, rules, width, *class, *class))
8084        }
8085        Math::Pure(MathElement::EmbeddedBoxes { class, boxes }) => {
8086            // V0_1 `embed-inline-to-math`: eager, already-materialized
8087            // boxes, so no closure application (contrast `EmbeddedText`
8088            // above) — but the same graphics-bearing content is possible.
8089            let (glyphs, rules, width) = math_boxes_of_inline_boxes(boxes);
8090            Ok((glyphs, rules, width, *class, *class))
8091        }
8092        Math::Group(cls1, cls2, inner) => {
8093            let (glyphs, rules, width, _, _) = layout_math_list(interp, ctx, inner, size)?;
8094            Ok((glyphs, rules, width, *cls1, *cls2))
8095        }
8096        Math::Sup(base, script) => {
8097            // Upstream MathSuperscript: (1) check_subscript merges a
8098            // base-tail `Sub` into one base + (sub, sup) pair;
8099            // (2) check_pull_in hands the script(s) to a base-tail
8100            // `PullInScripts` resolver.
8101            if let Some((sub_script, new_base)) = check_subscript(base) {
8102                if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) =
8103                    new_base.split_last()
8104                {
8105                    return layout_pull_in_scripts(
8106                        interp,
8107                        ctx,
8108                        head,
8109                        *cls1,
8110                        *cls2,
8111                        resolver,
8112                        Some(&sub_script),
8113                        Some(script),
8114                        size,
8115                    );
8116                }
8117                // No pull-in (`{x_1}^2`): one sub+sup pair on the same base.
8118                let (mut glyphs, mut rules, base_width, left, _) =
8119                    layout_math_list(interp, ctx, &new_base, size)?;
8120                let mc = MathC::of(interp, ctx);
8121                let script_size = size * mc.script_scale();
8122                // Re-derive ONCE (a paren base's dense math
8123                // kern function, if `new_base`'s trailing atom is a paren —
8124                // `paren_trailing_kernf`'s doc comment) and reuse it for
8125                // BOTH the sup and sub kerns below, mirroring
8126                // `lp_math_kern_scheme`'s single scheme feeding both corner
8127                // attachments upstream.
8128                let paren_kernf = paren_trailing_kernf(interp, ctx, &new_base, size);
8129                // Subscripts are always cramped; the superscript inherits
8130                // the ambient cramped state unchanged (do NOT flip/reset it
8131                // here).
8132                let sub_ctx = Context {
8133                    math_cramped: true,
8134                    ..ctx.clone()
8135                };
8136                let (sub_glyphs, sub_rules, sub_width, _, _) =
8137                    layout_math_list(interp, &sub_ctx, &sub_script, script_size)?;
8138                let (sup_glyphs, sup_rules, sup_width, _, _) =
8139                    layout_math_list(interp, ctx, script, script_size)?;
8140                let (h_base, d_base) = inner_ink_extent(&glyphs, &rules);
8141                let (_, d_sup) = inner_ink_extent(&sup_glyphs, &sup_rules);
8142                let (h_sub, _) = inner_ink_extent(&sub_glyphs, &sub_rules);
8143                let sup_shift_raw = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
8144                let sub_shift_raw = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
8145                let (sup_shift, sub_shift) = mc.correct_script_gap(
8146                    ctx.font_size,
8147                    d_sup,
8148                    h_sub,
8149                    sup_shift_raw,
8150                    sub_shift_raw,
8151                );
8152                let kern = match &paren_kernf {
8153                    Some(kf) => dense_kern(interp, kf, sup_shift - d_sup),
8154                    None => superscript_kern(
8155                        interp,
8156                        ctx,
8157                        size,
8158                        script_size,
8159                        &glyphs,
8160                        &sup_glyphs,
8161                        sup_shift,
8162                        h_base,
8163                        d_sup,
8164                    ),
8165                };
8166                let sub_kern = paren_kernf
8167                    .as_ref()
8168                    .map(|kf| dense_kern(interp, kf, h_sub - d_base))
8169                    .unwrap_or(Length::ZERO);
8170                shift_and_append(
8171                    &mut glyphs,
8172                    &mut rules,
8173                    sub_glyphs,
8174                    sub_rules,
8175                    base_width + sub_kern,
8176                    -sub_shift,
8177                );
8178                shift_and_append(
8179                    &mut glyphs,
8180                    &mut rules,
8181                    sup_glyphs,
8182                    sup_rules,
8183                    base_width + kern,
8184                    sup_shift,
8185                );
8186                return Ok((
8187                    glyphs,
8188                    rules,
8189                    base_width + (sub_kern + sub_width).max(kern + sup_width),
8190                    left,
8191                    MathKind::Ord,
8192                ));
8193            }
8194            if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
8195                return layout_pull_in_scripts(
8196                    interp,
8197                    ctx,
8198                    head,
8199                    *cls1,
8200                    *cls2,
8201                    resolver,
8202                    None,
8203                    Some(script),
8204                    size,
8205                );
8206            }
8207            let (mut glyphs, mut rules, base_width, left, _) =
8208                layout_math_list(interp, ctx, base, size)?;
8209            let mc = MathC::of(interp, ctx);
8210            let script_size = size * mc.script_scale();
8211            let (script_glyphs, script_rules, script_width, _, _) =
8212                layout_math_list(interp, ctx, script, script_size)?;
8213            let (h_base, _) = inner_ink_extent(&glyphs, &rules);
8214            let (_, d_sup) = inner_ink_extent(&script_glyphs, &script_rules);
8215            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
8216            // A paren base has no italic correction / glyph
8217            // corner kern to sample (`superscript_kern`'s own last-glyph
8218            // sampling would hit the INNER run's last glyph, not the
8219            // delimiter) — its closure's dense kern REPLACES
8220            // `superscript_kern` outright rather than adding to it.
8221            let kern = match paren_trailing_kernf(interp, ctx, base, size) {
8222                Some(kf) => dense_kern(interp, &kf, sup_shift - d_sup),
8223                None => superscript_kern(
8224                    interp,
8225                    ctx,
8226                    size,
8227                    script_size,
8228                    &glyphs,
8229                    &script_glyphs,
8230                    sup_shift,
8231                    h_base,
8232                    d_sup,
8233                ),
8234            };
8235            shift_and_append(
8236                &mut glyphs,
8237                &mut rules,
8238                script_glyphs,
8239                script_rules,
8240                base_width + kern,
8241                sup_shift,
8242            );
8243            Ok((
8244                glyphs,
8245                rules,
8246                base_width + kern + script_width,
8247                left,
8248                MathKind::Ord,
8249            ))
8250        }
8251        Math::Sub(base, script) => {
8252            // Upstream MathSubscript: a `PullInScripts` at the base list's
8253            // TAIL receives the subscript itself instead of a corner script.
8254            if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
8255                return layout_pull_in_scripts(
8256                    interp,
8257                    ctx,
8258                    head,
8259                    *cls1,
8260                    *cls2,
8261                    resolver,
8262                    Some(script),
8263                    None,
8264                    size,
8265                );
8266            }
8267            let (mut glyphs, mut rules, base_width, left, _) =
8268                layout_math_list(interp, ctx, base, size)?;
8269            let mc = MathC::of(interp, ctx);
8270            let script_size = size * mc.script_scale();
8271            // Subscripts are always cramped.
8272            let sub_ctx = Context {
8273                math_cramped: true,
8274                ..ctx.clone()
8275            };
8276            let (script_glyphs, script_rules, script_width, _, _) =
8277                layout_math_list(interp, &sub_ctx, script, script_size)?;
8278            let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8279            let (h_sub, _) = inner_ink_extent(&script_glyphs, &script_rules);
8280            let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
8281            // Non-paren subscripts carry no kern (`kern = Length::ZERO`); a
8282            // paren base's closure supplies one via `paren_trailing_kernf`'s
8283            // `Some` arm.
8284            let kern = match paren_trailing_kernf(interp, ctx, base, size) {
8285                Some(kf) => dense_kern(interp, &kf, h_sub - d_base),
8286                None => Length::ZERO,
8287            };
8288            shift_and_append(
8289                &mut glyphs,
8290                &mut rules,
8291                script_glyphs,
8292                script_rules,
8293                base_width + kern,
8294                -sub_shift,
8295            );
8296            Ok((
8297                glyphs,
8298                rules,
8299                base_width + kern + script_width,
8300                left,
8301                MathKind::Ord,
8302            ))
8303        }
8304        Math::ChangeColor(_, inner) => {
8305            // STAND-IN: color restyling doesn't affect glyph rendering yet
8306            // — just render the content.
8307            let (glyphs, rules, width, left, right) = layout_math_list(interp, ctx, inner, size)?;
8308            Ok((glyphs, rules, width, left, right))
8309        }
8310        Math::ChangeCharClass(cls, inner) => {
8311            // Lay `inner` out under a
8312            // context with `math_char_class` set to `cls`, which is what
8313            // `VariantCharPending`/`VariantChar`'s arms above consult.
8314            let ctx2 = Context {
8315                math_char_class: *cls,
8316                ..ctx.clone()
8317            };
8318            let (glyphs, rules, width, left, right) = layout_math_list(interp, &ctx2, inner, size)?;
8319            Ok((glyphs, rules, width, left, right))
8320        }
8321        Math::Fraction(num, den) => {
8322            // Real numerator/denominator placement (`math.ml:574-594`
8323            // `numerator_baseline_height`/ `denominator_baseline_depth`)
8324            // plus a bar `Fill` — replaces the ASCII "num / den" stand-in.
8325            // `num`/`den` are laid out at the SAME `size` as this atom (no
8326            // script-scale reduction — a fraction's own
8327            // numerator/denominator aren't scripts, matching upstream's
8328            // `convert_to_low` call with the ambient `mathctx` unchanged).
8329            let (num_glyphs, num_rules, num_w, ..) = layout_math_list(interp, ctx, num, size)?;
8330            // The denominator is always cramped; the numerator inherits the
8331            // ambient cramped state unchanged.
8332            let den_ctx = Context {
8333                math_cramped: true,
8334                ..ctx.clone()
8335            };
8336            let (den_glyphs, den_rules, den_w, ..) = layout_math_list(interp, &den_ctx, den, size)?;
8337            let w = num_w.max(den_w);
8338            // Center the narrower of the two over/under the wider
8339            // (`math.ml:1140-1155`'s symmetric padding).
8340            let num_dx = (w - num_w) * 0.5;
8341            let den_dx = (w - den_w) * 0.5;
8342            let (_, d_numer) = inner_ink_extent(&num_glyphs, &num_rules);
8343            let (h_denom, _) = inner_ink_extent(&den_glyphs, &den_rules);
8344            let mc = MathC::of(interp, ctx);
8345            let numer_shift = mc.frac_numer_shift(size, d_numer);
8346            let denom_shift = mc.frac_denom_shift(size, h_denom);
8347            let axis = mc.axis(size);
8348            let rule = mc.frac_rule(size);
8349            let mut glyphs = Vec::new();
8350            let mut rules = Vec::new();
8351            // `num dy>0` (raised above the axis), `den dy<0` (`frac_denom_
8352            // shift` is already signed negative — see that method's doc
8353            // comment) — both applied via the SAME up-positive `dy_shift`
8354            // `shift_and_append` uses for Sup/Sub.
8355            shift_and_append(
8356                &mut glyphs,
8357                &mut rules,
8358                num_glyphs,
8359                num_rules,
8360                num_dx,
8361                numer_shift,
8362            );
8363            shift_and_append(
8364                &mut glyphs,
8365                &mut rules,
8366                den_glyphs,
8367                den_rules,
8368                den_dx,
8369                denom_shift,
8370            );
8371            // The bar itself: `rect x∈[0,w], y∈[axis·s, axis·s+rule·s]`
8372            // (a deliberate simplification of
8373            // upstream's own `Rectangle((xpos, ypos+h_bar+t_bar/2), (wid,
8374            // t_bar))`, which centers the rule on its OWN half-thickness
8375            // rather than sitting flush on the axis; this port picks the
8376            // simpler flush-on-axis placement instead).
8377            rules.push(GraphicsElem::Fill(
8378                ctx.text_color,
8379                rect_path((Length::ZERO, axis), (w, rule)),
8380            ));
8381            Ok((glyphs, rules, w, MathKind::Inner, MathKind::Inner))
8382        }
8383        Math::Radical(_degree, inner) => {
8384            // Real bar metrics (`math.ml:620-626` `radical_bar_
8385            // metrics`) plus a ported `default_radical` checkmark `Fill`
8386            // (`primitives.cppo.ml:311-355`) and an overbar rect `Fill` —
8387            // replaces the U+221A stand-in. `RadicalWithDegree` (`_degree =
8388            // Some(..)`, `\sqrt[n]{..}`) stays unimplemented — the degree is
8389            // carried faithfully in the
8390            // `Math` value but silently NOT drawn, matching upstream's own
8391            // parity note (`math.ml:886-899`'s `failwith "unsupported"` is
8392            // upstream's harder failure mode; this port's own stand-in
8393            // policy already chose "render the radicand
8394            // without the degree" over erroring, unchanged since).
8395            // The radicand is always cramped.
8396            let radicand_ctx = Context {
8397                math_cramped: true,
8398                ..ctx.clone()
8399            };
8400            let (inner_glyphs, inner_rules, inner_w, ..) =
8401                layout_math_list(interp, &radicand_ctx, inner, size)?;
8402            let (h_cont, d_cont) = inner_ink_extent(&inner_glyphs, &inner_rules);
8403            let mc = MathC::of(interp, ctx);
8404            let (h_bar, t_bar, l_extra) = mc.radical_bar_metrics(size, h_cont);
8405            // `_nonnegdpt` (the sign's own, slightly deeper, ink extent —
8406            // `default_radical`'s downward checkmark stroke pads `d_cont` by
8407            // `size*0.1`, upstream's own `nonnegdpt`) isn't threaded into
8408            // this atom's reported `depth` directly: unlike upstream's own
8409            // `d_whole = d_cont` (`math.ml:884`, a "temporary" simplification
8410            // per its own comment there), this port's `layout_math_value`
8411            // folds every rule's `graphics_bbox` into the OUTER box's
8412            // height/depth (a correctness fix, `PureHorzBox::Math`'s doc
8413            // comment), so the sign's real ink depth reaches the top-level
8414            // box automatically THROUGH the drawn `Fill` — no separate
8415            // manual accounting needed here.
8416            let (sign_path, sign_w, _nonnegdpt) = radical_sign_geometry(size, h_bar, t_bar, d_cont);
8417            let mut rules = vec![GraphicsElem::Fill(ctx.text_color, sign_path)];
8418            // Overbar + radicand share the same x-range right after the
8419            // sign (`math.ml:1163-1176`'s `hbbar`/`hbback`/`hblstC`); the
8420            // radicand itself stays at `dy = 0` (its own baseline), exactly
8421            // upstream — `h_bar` already clears it via the vertical-gap add
8422            // in `radical_bar_metrics`, so no raise is needed here.
8423            rules.push(GraphicsElem::Fill(
8424                ctx.text_color,
8425                rect_path((sign_w, h_bar), (inner_w, t_bar)),
8426            ));
8427            // `l_extra`: the extra ascender ABOVE the bar this run reports
8428            // to its container (upstream `h_whole = h_rad +% l_extra`,
8429            // `math.ml:882`) — no ink of its own, just headroom, so there's
8430            // no glyph/fill shape to naturally carry it. A single-point
8431            // "extent marker" `Fill` (a subpath with a `move_to` and no
8432            // further segments paints nothing — PDF's `f` on a degenerate
8433            // zero-length path is a no-op) reports it through the SAME
8434            // `graphics_bbox` fold `layout_math_value` already does for
8435            // every rule, without adding a new return channel just for this
8436            // one field.
8437            rules.push(GraphicsElem::Fill(
8438                ctx.text_color,
8439                Path {
8440                    subpaths: vec![Subpath {
8441                        start: (Length::ZERO, h_bar + t_bar + l_extra),
8442                        segs: Vec::new(),
8443                        closing: Closing::Open,
8444                    }],
8445                },
8446            ));
8447            let mut glyphs = Vec::new();
8448            for mut g in inner_glyphs {
8449                g.dx = sign_w + g.dx;
8450                glyphs.push(g);
8451            }
8452            for r in &inner_rules {
8453                rules.push(shift_graphics((sign_w, Length::ZERO), r));
8454            }
8455            Ok((
8456                glyphs,
8457                rules,
8458                sign_w + inner_w,
8459                MathKind::Inner,
8460                MathKind::Inner,
8461            ))
8462        }
8463        Math::Paren(l, r, inner) => {
8464            // PRIMARY route is upstream's own `make_paren` closure
8465            // invocation (`math.ml:644-649`, `make_paren_run` above) —
8466            // identity (a `\paren` drawing round parens vs. an `\abs`
8467            // drawing vertical bars, etc.) lives ENTIRELY in the `l`/`r`
8468            // closures (`math.satyh`'s `paren-left`/`abs-left`/…), so
8469            // running them is what makes different delimiter kinds actually
8470            // look different. Falls back to the MATH-native
8471            // stretchy-variant stand-in (`paren_variant_fallback`) only if
8472            // either closure errors (synthetic/ill-shaped test closures, or
8473            // a real user error) — that fallback's own delimiter kind is
8474            // always `(`/`)` regardless of what was requested. Inner is laid
8475            // out OUTSIDE the closure
8476            // route so an inner layout error still propagates normally
8477            // (only closure-route errors trigger the fallback); splice
8478            // order `lg ++ inner ++ rg` matches upstream's own
8479            // `LowMathParen(lpL, lpR, lmC)` (`math.ml:909`).
8480            let (inner_glyphs, inner_rules, inner_w, ..) =
8481                layout_math_list(interp, ctx, inner, size)?;
8482            let (h_in, d_in) = inner_ink_extent(&inner_glyphs, &inner_rules);
8483            let mc = MathC::of(interp, ctx);
8484            let axis = mc.axis(size);
8485            let closure_route =
8486                make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8487                    let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8488                    Ok((left, right))
8489                });
8490            match closure_route {
8491                Ok(((lg, lr, lw, _), (rg, rr, rw, _))) => {
8492                    let mut glyphs = Vec::new();
8493                    let mut rules = Vec::new();
8494                    let mut x = Length::ZERO;
8495                    append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8496                    append_at(
8497                        &mut glyphs,
8498                        &mut rules,
8499                        &mut x,
8500                        inner_glyphs,
8501                        inner_rules,
8502                        inner_w,
8503                    );
8504                    append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8505                    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8506                }
8507                Err(_) => paren_variant_fallback(
8508                    interp,
8509                    ctx,
8510                    vec![(inner_glyphs, inner_rules, inner_w)],
8511                    h_in,
8512                    d_in,
8513                    axis,
8514                    size,
8515                ),
8516            }
8517        }
8518        Math::ParenWithMiddle(l, r, m, mlstlst) => {
8519            // Same closure-primary/fallback policy as `Math::Paren`,
8520            // but ONE shared `(h_in, d_in)` over every part (the tallest
8521            // part's ink drives the size of every delimiter, including the
8522            // middle separator(s)) — mirrors upstream's own
8523            // `MathParenWithMiddle` fold (`math.ml:912-916`). The middle
8524            // closure's own kernf is DISCARDED (`math.ml:923`: `let
8525            // (hblstmiddle, _) = make_paren mathctx middle hC dC in ...`) —
8526            // a separator never tucks a script into itself.
8527            let mut parts = Vec::with_capacity(mlstlst.len());
8528            let mut h_in = Length::ZERO;
8529            let mut d_in = Length::ZERO;
8530            for part in mlstlst {
8531                let (part_glyphs, part_rules, part_w, ..) =
8532                    layout_math_list(interp, ctx, part, size)?;
8533                let (h, d) = inner_ink_extent(&part_glyphs, &part_rules);
8534                h_in = h_in.max(h);
8535                d_in = d_in.max(d);
8536                parts.push((part_glyphs, part_rules, part_w));
8537            }
8538            let mc = MathC::of(interp, ctx);
8539            let axis = mc.axis(size);
8540            let closure_route =
8541                make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8542                    let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8543                    let middle = make_paren_run(interp, ctx, m, h_in, d_in, axis, size)?;
8544                    Ok((left, right, middle))
8545                });
8546            match closure_route {
8547                Ok(((lg, lr, lw, _), (rg, rr, rw, _), (mg, mr, mw, _))) => {
8548                    let mut glyphs = Vec::new();
8549                    let mut rules = Vec::new();
8550                    let mut x = Length::ZERO;
8551                    append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8552                    for (i, (part_glyphs, part_rules, part_w)) in parts.into_iter().enumerate() {
8553                        if i > 0 {
8554                            append_at(&mut glyphs, &mut rules, &mut x, mg.clone(), mr.clone(), mw);
8555                        }
8556                        append_at(
8557                            &mut glyphs,
8558                            &mut rules,
8559                            &mut x,
8560                            part_glyphs,
8561                            part_rules,
8562                            part_w,
8563                        );
8564                    }
8565                    append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8566                    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8567                }
8568                Err(_) => paren_variant_fallback(interp, ctx, parts, h_in, d_in, axis, size),
8569            }
8570        }
8571        Math::UpperLimit(base, upper) => {
8572            let (mut glyphs, mut rules, base_width, left, right) =
8573                layout_math_list(interp, ctx, base, size)?;
8574            let mc = MathC::of(interp, ctx);
8575            let script_size = size * mc.script_scale();
8576            let (script_glyphs, script_rules, script_width, _, _) =
8577                layout_math_list(interp, ctx, upper, script_size)?;
8578            let (h_base, _) = inner_ink_extent(&glyphs, &rules);
8579            let (_, d_up) = inner_ink_extent(&script_glyphs, &script_rules);
8580            let up_shift = mc.upper_limit_shift(ctx.font_size, h_base, d_up);
8581            // A LIMIT is CENTERED over its base, not set beside it
8582            // (`math.ml:1219-1231`: upstream pads the narrower of the two with
8583            // half the difference on each side, so the whole is
8584            // `max(w_base, w_up)` wide). Placing it at `base_width` — i.e. to
8585            // the right, widening the box to the SUM — set `\sum_a^b`'s limits
8586            // off the operator's shoulder instead of above and below it.
8587            let (base_dx, script_dx) = center_offsets(base_width, script_width);
8588            shift_existing(&mut glyphs, &mut rules, base_dx);
8589            shift_and_append(
8590                &mut glyphs,
8591                &mut rules,
8592                script_glyphs,
8593                script_rules,
8594                script_dx,
8595                up_shift,
8596            );
8597            Ok((glyphs, rules, base_width.max(script_width), left, right))
8598        }
8599        Math::LowerLimit(base, lower) => {
8600            let (mut glyphs, mut rules, base_width, left, right) =
8601                layout_math_list(interp, ctx, base, size)?;
8602            let mc = MathC::of(interp, ctx);
8603            let script_size = size * mc.script_scale();
8604            let (script_glyphs, script_rules, script_width, _, _) =
8605                layout_math_list(interp, ctx, lower, script_size)?;
8606            let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8607            let (h_low, _) = inner_ink_extent(&script_glyphs, &script_rules);
8608            let low_shift = mc.lower_limit_shift(ctx.font_size, d_base, h_low);
8609            // Centered under the base — see the `UpperLimit` arm above.
8610            let (base_dx, script_dx) = center_offsets(base_width, script_width);
8611            shift_existing(&mut glyphs, &mut rules, base_dx);
8612            shift_and_append(
8613                &mut glyphs,
8614                &mut rules,
8615                script_glyphs,
8616                script_rules,
8617                script_dx,
8618                -low_shift,
8619            );
8620            Ok((glyphs, rules, base_width.max(script_width), left, right))
8621        }
8622        Math::PullInScripts(cls1, cls2, resolver) => {
8623            // Not consumed by an enclosing Sub/Sup (bare `\sum` with no
8624            // scripts): resolver gets (None, None).
8625            layout_pull_in_scripts(interp, ctx, &[], *cls1, *cls2, resolver, None, None, size)
8626        }
8627        // V0_1 only (`read-math`): lay `inner` out
8628        // with ambient context = the STORED context, and size = the
8629        // stored context's OWN `font_size` — an ABSOLUTE override, not a
8630        // further multiply of the caller's `size`. This is deliberate: a
8631        // `WithContext` built under an `enter_script`-shrunk context
8632        // already carries the script-shrunk `font_size` in `stored`, so
8633        // laying it out at `stored.font_size` (rather than at this call's
8634        // `size`) means the engine's own Sup/Sub shrink is never applied a
8635        // second time on top of it.
8636        Math::WithContext(stored, inner) => {
8637            layout_math_list(interp, stored, inner, stored.font_size)
8638        }
8639    }
8640}
8641
8642/// Append `glyphs`/`rules` (already at LOCAL coordinates relative to their
8643/// own run) onto `out_glyphs`/`out_rules` at the running `*x`, advancing `*x`
8644/// past them — the no-spacing-adjustment sibling of `layout_math_list`'s
8645/// per-atom loop, used by the structural stand-ins above (paren) that
8646/// concatenate sub-runs directly rather than through the spacing table.
8647/// `rules` shifts horizontally only (`shift_graphics` with a zero `dy` —
8648/// `append_at`'s callers never raise/lower a sub-run, only `dx`-place it;
8649/// contrast `shift_and_append` below, which does both).
8650fn append_at(
8651    out_glyphs: &mut Vec<MathGlyph>,
8652    out_rules: &mut Vec<GraphicsElem>,
8653    x: &mut Length,
8654    glyphs: Vec<MathGlyph>,
8655    rules: Vec<GraphicsElem>,
8656    width: Length,
8657) {
8658    let base_x = *x;
8659    for mut g in glyphs {
8660        g.dx = base_x + g.dx;
8661        out_glyphs.push(g);
8662    }
8663    for r in &rules {
8664        out_rules.push(shift_graphics((base_x, Length::ZERO), r));
8665    }
8666    *x = base_x + width;
8667}
8668
8669/// Horizontal offsets that CENTER a limit against its base: half the width
8670/// difference goes to whichever of the two is narrower, so the pair occupies
8671/// `max(base, script)` (upstream `math.ml:1219-1231`).
8672fn center_offsets(base_width: Length, script_width: Length) -> (Length, Length) {
8673    if base_width < script_width {
8674        ((script_width - base_width) * 0.5, Length::ZERO)
8675    } else {
8676        (Length::ZERO, (base_width - script_width) * 0.5)
8677    }
8678}
8679
8680/// Slide already-emitted glyphs/rules right by `dx` — used when a limit is
8681/// WIDER than its base, so the base itself has to move to stay centered.
8682fn shift_existing(glyphs: &mut [MathGlyph], rules: &mut [GraphicsElem], dx: Length) {
8683    if dx == Length::ZERO {
8684        return;
8685    }
8686    for g in glyphs.iter_mut() {
8687        g.dx = g.dx + dx;
8688    }
8689    for r in rules.iter_mut() {
8690        *r = shift_graphics((dx, Length::ZERO), r);
8691    }
8692}
8693
8694/// Append `glyphs`/`rules` (LOCAL coordinates, from an isolated
8695/// `layout_math_list` call) onto `out_glyphs`/`out_rules`, shifting every
8696/// glyph/rule right by `dx_shift` (its base's own width — placing the
8697/// script/numerator/denominator/radicand right after the preceding content)
8698/// and up/down by `dy_shift` (`> 0` raises, `< 0` lowers) — the `Math`-atom
8699/// analog of `place_script`, which instead threads a single
8700/// running `x` across a flat `MathElem` list. `rules` go through the SAME
8701/// `shift_graphics` a standalone `inline-graphics` box's `shift-graphics`
8702/// primitive uses — box-local, y-**up** coordinates, exactly
8703/// `MathGlyph::dy`'s sign convention (a critical correctness note: get
8704/// this sign wrong and a fraction bar/ radical mirrors instead of landing
8705/// at the axis).
8706fn shift_and_append(
8707    out_glyphs: &mut Vec<MathGlyph>,
8708    out_rules: &mut Vec<GraphicsElem>,
8709    glyphs: Vec<MathGlyph>,
8710    rules: Vec<GraphicsElem>,
8711    dx_shift: Length,
8712    dy_shift: Length,
8713) {
8714    for mut g in glyphs {
8715        g.dx = dx_shift + g.dx;
8716        g.dy = g.dy + dy_shift;
8717        out_glyphs.push(g);
8718    }
8719    for r in &rules {
8720        out_rules.push(shift_graphics((dx_shift, dy_shift), r));
8721    }
8722}
8723
8724/// An axis-aligned rectangle `Fill` path, box-local (y-**up**): bottom-left
8725/// corner `origin`, extending `size.0` right and `size.1` up. Shared by the
8726/// fraction bar and the radical overbar — both are exactly this
8727/// shape, just at different `y`/width.
8728fn rect_path(origin: Point, size: (Length, Length)) -> Path {
8729    let (x, y) = origin;
8730    let (w, h) = size;
8731    Path {
8732        subpaths: vec![Subpath {
8733            start: (x, y),
8734            segs: vec![
8735                PathSeg::Line((x + w, y)),
8736                PathSeg::Line((x + w, y + h)),
8737                PathSeg::Line((x, y + h)),
8738            ],
8739            closing: Closing::Line,
8740        }],
8741    }
8742}
8743
8744/// Port of `default_radical` (`primitives.cppo.ml:311-355`): the radical
8745/// checkmark's `GeneralPath`, plus its own natural advance (`wid`, upstream's
8746/// `PHGFixedGraphics`'s declared width) and `nonnegdpt` (its own depth
8747/// extent, upstream's declared `depth` — returned for completeness though
8748/// The overall `Math::Radical` depth uses `d_cont` directly, matching
8749/// upstream's own "temporary" simplification, see that arm's call site).
8750/// `size` is the ambient LOCAL nesting size (upstream `fontsize`); `hgt_bar`/
8751/// `t_bar` come from `MathC::radical_bar_metrics`; `dpt` is the radicand's
8752/// own depth (a NON-NEGATIVE magnitude, this port's convention — see
8753/// `sup_shift_clamped`'s doc comment; upstream's signed `Length.negate dpt`
8754/// becomes a plain ADD of `dpt` here).
8755///
8756/// Box-local origin `(0, 0)` = this atom's own baseline-left corner (where
8757/// upstream's `graphics (xpos, ypos)` closure is finally called with the
8758/// box's placed anchor — every point below is relative to that same origin,
8759/// matching `PathSeg`/`Subpath`'s y-**up** convention).
8760fn radical_sign_geometry(
8761    size: Length,
8762    hgt_bar: Length,
8763    t_bar: Length,
8764    dpt: Length,
8765) -> (Path, Length, Length) {
8766    let w_m = size * 0.02;
8767    let w1 = size * 0.1;
8768    let w2 = size * 0.15;
8769    let w3 = size * 0.4;
8770    let w_a = size * 0.18;
8771    let h1 = size * 0.3;
8772    let h2 = size * 0.375;
8773
8774    let nonnegdpt = dpt + size * 0.1;
8775    let l_r = hgt_bar + nonnegdpt;
8776
8777    let wid = w_m + w1 + w2 + w3;
8778    let a1 = (h2 - h1) / w1;
8779    let a2 = h2 / w2;
8780    let a3 = l_r / w3;
8781    let t1 = t_bar * (1.0 + a1 * a1).sqrt();
8782    let t3 = t_bar * (((1.0 + a3 * a3).sqrt() - 1.0) / a3);
8783    let h_a = h1 + t1 + w_a * a1;
8784    let w_b = (l_r + t_bar - h_a - (w1 + w2 + w3 - t3 - w_a) * a3) * (-1.0 / (a2 + a3));
8785    let h_b = h_a - w_b * a2;
8786
8787    let path = Path {
8788        subpaths: vec![Subpath {
8789            start: (wid, hgt_bar),
8790            segs: vec![
8791                PathSeg::Line((w_m + w1 + w2, -nonnegdpt)),
8792                PathSeg::Line((w_m + w1, -nonnegdpt + h2)),
8793                PathSeg::Line((w_m, -nonnegdpt + h1)),
8794                PathSeg::Line((w_m, -nonnegdpt + h1 + t1)),
8795                PathSeg::Line((w_m + w_a, -nonnegdpt + h_a)),
8796                PathSeg::Line((w_m + w_a + w_b, -nonnegdpt + h_b)),
8797                PathSeg::Line((wid - t3, hgt_bar + t_bar)),
8798                PathSeg::Line((wid, hgt_bar + t_bar)),
8799            ],
8800            closing: Closing::Line,
8801        }],
8802    };
8803    (path, wid, nonnegdpt)
8804}
8805
8806/// Flatten `text-in-math`'s embedded `inline-boxes` (already laid out
8807/// by `read_inline` against the math atom's own context) into `MathGlyph`s
8808/// nestable in a math run — the box-in-math bridge `layout_math_atom`'s
8809/// `EmbeddedText` arm needs. Mirrors `linebreak.rs`'s `natural_metrics`
8810/// exhaustive `PureHorzBox` walk EXACTLY (same variant list, same "what
8811/// advances `x`" choice per variant) so an added/renamed `PureHorzBox`
8812/// variant can't silently drop content here without also breaking that
8813/// walk. Caveats (faithful to what's actually renderable here): only
8814/// `InnerString`/nested `Math` boxes contribute real glyphs (hence height/
8815/// depth, computed by the caller from the returned glyphs); every other
8816/// box kind (`Image`/`Graphics`/`Tabular`/`EmbeddedBlock`/…) keeps its
8817/// horizontal space but contributes no ink; text run at full (non-script)
8818/// size regardless of the math run's own `size` (upstream-faithful — a
8819/// `text-in-math` body is laid out once, by `read_inline`, before this
8820/// function ever sees it).
8821// UNWIRED. `layout_script` builds the same `(Vec<MathGlyph>, Length)` on the
8822// live path, so nothing calls this. Kept rather than deleted because
8823// `math_boxes_of_inline_boxes` below is documented as its sibling, and
8824// because it is the upstream-faithful flattening a `text-in-math` body needs
8825// if that path is ever wired back up.
8826#[allow(dead_code)]
8827fn math_glyphs_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Length) {
8828    fn go(pure: &PureHorzBox, out: &mut Vec<MathGlyph>, x: &mut Length) {
8829        match pure {
8830            PureHorzBox::InnerString {
8831                info,
8832                text,
8833                width,
8834                height,
8835                depth,
8836            } => {
8837                out.push(MathGlyph {
8838                    info: info.clone(),
8839                    text: text.clone(),
8840                    gid: None,
8841                    dx: *x,
8842                    dy: Length::ZERO,
8843                    width: *width,
8844                    height: *height,
8845                    depth: *depth,
8846                });
8847                *x += *width;
8848            }
8849            PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
8850            PureHorzBox::OuterFil => {}
8851            PureHorzBox::FixedEmpty { width } => *x += *width,
8852            PureHorzBox::Image { width, .. } => *x += *width,
8853            PureHorzBox::Discretionary { no_break, .. } => {
8854                for p in no_break {
8855                    go(p, out, x);
8856                }
8857            }
8858            PureHorzBox::Graphics { width, .. } => *x += *width,
8859            // An unresolved `inline-graphics-outer` marker has zero width
8860            // (fil semantics, see the variant's doc comment) and no glyph
8861            // representation this walk can extract — advance past it like
8862            // `Image`/`Tabular` (a resolved one is an ordinary `Graphics`,
8863            // handled by the arm above).
8864            PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
8865            PureHorzBox::Math { width, glyphs, .. } => {
8866                for g in glyphs {
8867                    let mut g = g.clone();
8868                    g.dx = *x + g.dx;
8869                    out.push(g);
8870                }
8871                *x += *width;
8872            }
8873            PureHorzBox::HookPageBreak { .. } => {}
8874            PureHorzBox::Tabular(tab) => *x += tab.width,
8875            PureHorzBox::EmbeddedBlock { width, .. } => *x += *width,
8876            // A frame in a math context has no glyph representation this
8877            // walk can extract — advance past it like `Image`/`Tabular`.
8878            PureHorzBox::Frame { width, .. } => *x += *width,
8879            PureHorzBox::FrameMarker { .. } => {}
8880            // Zero-width bracket; its contents are spliced siblings, already
8881            // walked by this same loop.
8882            PureHorzBox::InlineFrameMarker { .. } => {}
8883            // Zero-width marker; no glyph representation. Same treatment
8884            // as `HookPageBreak`.
8885            PureHorzBox::Footnote { .. } => {}
8886            // inert reflow marker, no glyph representation — same
8887            // treatment as `HookPageBreak`/`FrameMarker`/`Footnote`
8888            // above.
8889            PureHorzBox::InlineMark(_) => {}
8890        }
8891    }
8892    let mut glyphs = Vec::new();
8893    let mut x = Length::ZERO;
8894    for HorzBox::Pure(p) in boxes {
8895        go(p, &mut glyphs, &mut x);
8896    }
8897    (glyphs, x)
8898}
8899
8900/// `math_glyphs_of_inline_boxes`'s graphics-harvesting sibling — the
8901/// shape a `make_paren` closure's result needs, since a delimiter drawn via
8902/// `inline-graphics` (`math.satyh`'s `paren-left`/`abs-left`/…, `fill`/
8903/// `stroke` a path) carries its ink as a `PureHorzBox::Graphics` box, not a
8904/// `MathGlyph`. The same exhaustive `PureHorzBox` variant list as
8905/// `math_glyphs_of_inline_boxes` (do NOT modify that function — every OTHER
8906/// caller still wants glyphs-only, e.g. `EmbeddedText`), but additionally
8907/// harvests `Graphics::elems` (`dx`-shifted via `shift_graphics`, the box's
8908/// own local-origin convention — see `PureHorzBox::Graphics`'s doc comment)
8909/// and forwards BOTH the glyphs AND `rules` out of any nested
8910/// `PureHorzBox::Math` box (a paren closure could, in principle, embed one
8911/// via `text-in-math`/`embed-math`).
8912///
8913/// ONE arm diverges from that sibling and makes this walk VERTICAL too: `dy`
8914/// (y-up, box-local — `MathGlyph::dy`'s own frame) carries the offset of the
8915/// stacked line a nested `EmbeddedBlock`'s content sits on. `Frame` and
8916/// `Tabular` still contribute width alone; they could be descended into the
8917/// same way, but nothing in the corpus puts either inside a `text-in-math`
8918/// body, so neither has a measured shape to be faithful to.
8919fn math_boxes_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Vec<GraphicsElem>, Length) {
8920    fn go(
8921        pure: &PureHorzBox,
8922        out: &mut Vec<MathGlyph>,
8923        rules: &mut Vec<GraphicsElem>,
8924        x: &mut Length,
8925        dy: Length,
8926    ) {
8927        match pure {
8928            PureHorzBox::InnerString {
8929                info,
8930                text,
8931                width,
8932                height,
8933                depth,
8934            } => {
8935                out.push(MathGlyph {
8936                    info: info.clone(),
8937                    text: text.clone(),
8938                    gid: None,
8939                    dx: *x,
8940                    dy,
8941                    width: *width,
8942                    height: *height,
8943                    depth: *depth,
8944                });
8945                *x += *width;
8946            }
8947            PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
8948            PureHorzBox::OuterFil => {}
8949            PureHorzBox::FixedEmpty { width } => *x += *width,
8950            PureHorzBox::Image { width, .. } => *x += *width,
8951            PureHorzBox::Discretionary { no_break, .. } => {
8952                for p in no_break {
8953                    go(p, out, rules, x, dy);
8954                }
8955            }
8956            PureHorzBox::Graphics { width, elems, .. } => {
8957                for e in elems {
8958                    rules.push(shift_graphics((*x, dy), e));
8959                }
8960                *x += *width;
8961            }
8962            // See `math_glyphs_of_inline_boxes`'s matching arm.
8963            PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
8964            PureHorzBox::Math {
8965                width,
8966                glyphs,
8967                rules: inner_rules,
8968                ..
8969            } => {
8970                for g in glyphs {
8971                    let mut g = g.clone();
8972                    g.dx = *x + g.dx;
8973                    g.dy += dy;
8974                    out.push(g);
8975                }
8976                for r in inner_rules {
8977                    rules.push(shift_graphics((*x, dy), r));
8978                }
8979                *x += *width;
8980            }
8981            PureHorzBox::HookPageBreak { .. } => {}
8982            PureHorzBox::Tabular(tab) => *x += tab.width,
8983            // A `line-stack-top`/`-bottom` (or `embed-block-top`/`-bottom`) box
8984            // handed BACK to math through `text-in-math` — azmath's
8985            // `\overbrace`/`\underbrace` (`parens.satyh:533`/`:561`) stack the
8986            // brace over the braced formula this way. Placed with
8987            // `place_embedded_block`'s (rustyfi-pdf) geometry but in this walk's
8988            // y-UP frame: `place_block_at` seats the stack at a page-y-DOWN
8989            // origin, the anchored line lands on the math baseline
8990            // (`anchor_last`: the LAST for `-bottom`, the FIRST for `-top` —
8991            // upstream's `adjust_to_last_line`/`adjust_to_first_line`), and
8992            // every other line is offset by the NEGATED difference of their
8993            // placed baselines. That is the same split `make_embedded_block`
8994            // measured the box's own height/depth from, so what
8995            // `layout_math_value` folds back up agrees with the box metrics the
8996            // rest of the pipeline already saw.
8997            PureHorzBox::EmbeddedBlock {
8998                width,
8999                block,
9000                anchor_last,
9001                ..
9002            } => {
9003                let placed = place_block_at((Length::ZERO, Length::ZERO), block.clone());
9004                let anchor = if *anchor_last {
9005                    placed.last()
9006                } else {
9007                    placed.first()
9008                };
9009                if let Some(anchor) = anchor {
9010                    let anchor_y = anchor.baseline_y;
9011                    for line in &placed {
9012                        let line_dy = dy - (line.baseline_y - anchor_y);
9013                        for (cdx, cbx) in &line.contents {
9014                            // Each stacked line has its own horizontal origin,
9015                            // and must not advance the OUTER run's pen — the
9016                            // block's own `width` accounts for that once below.
9017                            let mut cx = *x + line.x + *cdx;
9018                            go(cbx, out, rules, &mut cx, line_dy);
9019                        }
9020                    }
9021                }
9022                *x += *width;
9023            }
9024            // See `math_glyphs_of_inline_boxes`'s matching arm.
9025            PureHorzBox::Frame { width, .. } => *x += *width,
9026            PureHorzBox::FrameMarker { .. } => {}
9027            // See `math_glyphs_of_inline_boxes`'s matching arm.
9028            PureHorzBox::InlineFrameMarker { .. } => {}
9029            // See `math_glyphs_of_inline_boxes`'s matching arm.
9030            PureHorzBox::Footnote { .. } => {}
9031            // See `math_glyphs_of_inline_boxes`'s matching arm.
9032            PureHorzBox::InlineMark(_) => {}
9033        }
9034    }
9035    let mut glyphs = Vec::new();
9036    let mut rules = Vec::new();
9037    let mut x = Length::ZERO;
9038    for HorzBox::Pure(p) in boxes {
9039        go(p, &mut glyphs, &mut rules, &mut x, Length::ZERO);
9040    }
9041    (glyphs, rules, x)
9042}
9043
9044// ============================================================================
9045// ---- context-setter + box-combinator prims `code.satyh`/`itemize.satyh`
9046// need. ------------------------------------------------------------------
9047// ============================================================================
9048
9049/// The inverse of `as_color` (mirrors `evalUtil.ml:124`'s `get_color` the
9050/// other way) — `get-text-color`'s result, which `itemize.satyh` feeds
9051/// straight into `fill`, so the tag/payload shape must match `as_color`
9052/// exactly (see that primitive's doc comment).
9053fn make_color_value(c: Color) -> Value {
9054    match c {
9055        Color::Gray(g) => Value::Ctor("Gray".to_string(), Some(Box::new(Value::Float(g)))),
9056        Color::Rgb(r, g, b) => Value::Ctor(
9057            "RGB".to_string(),
9058            Some(Box::new(Value::Tuple(vec![
9059                Value::Float(r),
9060                Value::Float(g),
9061                Value::Float(b),
9062            ]))),
9063        ),
9064        Color::Cmyk(c, m, y, k) => Value::Ctor(
9065            "CMYK".to_string(),
9066            Some(Box::new(Value::Tuple(vec![
9067                Value::Float(c),
9068                Value::Float(m),
9069                Value::Float(y),
9070                Value::Float(k),
9071            ]))),
9072        ),
9073    }
9074}
9075
9076/// `font` = `Value::Tuple([string, float, float])` in `(abbrev, size_ratio,
9077/// rising_ratio)` order (vminst.ml's `tFONT`) — `set-font`'s second argument.
9078fn as_font(v: Value) -> Result<(String, f64, f64), EvalError> {
9079    match v {
9080        Value::Tuple(vs) if vs.len() == 3 => {
9081            let mut it = vs.into_iter();
9082            let abbrev = as_str(it.next().unwrap())?;
9083            let size_ratio = as_float(it.next().unwrap())?;
9084            let rising_ratio = as_float(it.next().unwrap())?;
9085            Ok((abbrev, size_ratio, rising_ratio))
9086        }
9087        other => eval_error(format!(
9088            "expected a font (string * float * float), got {}",
9089            other.type_name()
9090        )),
9091    }
9092}
9093
9094/// [`as_font`]'s V0_1 twin — saphe-split's `tFONTWR = font * float * float`,
9095/// whose head is the opaque handle rather than an abbrev.
9096fn as_font_with_ratio(v: Value) -> Result<(FontKey, f64, f64), EvalError> {
9097    match v {
9098        Value::Tuple(vs) if vs.len() == 3 => {
9099            let mut it = vs.into_iter();
9100            let key = as_font_key(it.next().unwrap())?;
9101            let size_ratio = as_float(it.next().unwrap())?;
9102            let rising_ratio = as_float(it.next().unwrap())?;
9103            Ok((key, size_ratio, rising_ratio))
9104        }
9105        other => eval_error(format!(
9106            "expected a font (font * float * float), got {}",
9107            other.type_name()
9108        )),
9109    }
9110}
9111
9112/// The opaque V0_1 `font` handle (upstream's `BCFontKey of FontKey.t`).
9113fn as_font_key(v: Value) -> Result<FontKey, EvalError> {
9114    match v {
9115        Value::Font(key) => Ok(key),
9116        other => eval_error(format!("expected a font, got {}", other.type_name())),
9117    }
9118}
9119
9120/// `set-text-color : color -> context -> context` (vminst.ml:1603) —
9121/// FAITHFUL store (`Context::text_color`, the `set-font-size` shape); it
9122/// rides on every `HorzStringInfo` and both PDF writers emit `rg`/`g`
9123/// before `Tj` for a non-black run.
9124fn prim_set_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9125    let ctx = as_context(args.pop().unwrap())?;
9126    let color = as_color(args.pop().unwrap())?;
9127    Ok(Value::Context(Box::new(Context {
9128        text_color: color,
9129        ..ctx
9130    })))
9131}
9132
9133/// `get-text-color : context -> color` (vminst.ml:1618) — FAITHFUL and
9134/// load-bearing: `itemize.satyh`'s `make-bullet` feeds this straight into
9135/// `fill color (Gr.circle …)`, so it must round-trip exactly what
9136/// `set-text-color` stored (see `make_color_value`'s doc comment).
9137fn prim_get_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9138    let ctx = as_context(args.pop().unwrap())?;
9139    Ok(make_color_value(ctx.text_color))
9140}
9141
9142/// `set-hyphen-penalty : int -> context -> context` (vminst.ml:1692) —
9143/// FAITHFUL store (`Context::hyphen_badness`), now a real consumer:
9144/// `text_to_boxes`'s `flush_word` uses this as each injected
9145/// `Discretionary`'s `penalty`, but only when a dictionary is installed via
9146/// `set-hyphenation-dictionary` — with no dictionary installed (the
9147/// default), this is stored but has no layout effect, same as before.
9148/// `code.satyh`'s `set-hyphen-penalty 100000` still works as "disable
9149/// hyphenation" (huge positive penalty, DP avoids it).
9150fn prim_set_hyphen_penalty(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9151    let ctx = as_context(args.pop().unwrap())?;
9152    let n = as_int(args.pop().unwrap())?;
9153    Ok(Value::Context(Box::new(Context {
9154        hyphen_badness: n,
9155        ..ctx
9156    })))
9157}
9158
9159/// `set-hyphen-min : int -> int -> context -> context` (upstream
9160/// `vminstdef.yaml:1163-1177`) — writes
9161/// `Context::left_hyphen_min`/`right_hyphen_min`, each clamped to `>= 0`
9162/// (mirrors `set-space-ratio`'s `.max(0.0)` clamping style; a negative
9163/// minimum would be meaningless to the min-fragment filter in
9164/// `crate::hyphenation::hyphenate_word`).
9165fn prim_set_hyphen_min(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9166    let ctx = as_context(args.pop().unwrap())?;
9167    let right = as_int(args.pop().unwrap())?.max(0);
9168    let left = as_int(args.pop().unwrap())?.max(0);
9169    Ok(Value::Context(Box::new(Context {
9170        left_hyphen_min: left,
9171        right_hyphen_min: right,
9172        ..ctx
9173    })))
9174}
9175
9176/// `set-space-ratio : float -> float -> float -> context -> context`
9177/// (vminst.ml:1309), params `(natural, shrink, stretch)` — FAITHFUL store
9178/// (`Context::space_natural`/`space_shrink`/`space_stretch`, clamped to
9179/// `>= 0.0` like upstream), read by `text_to_boxes`'s interword-glue
9180/// computation.
9181fn prim_set_space_ratio(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9182    let ctx = as_context(args.pop().unwrap())?;
9183    let stretch = as_float(args.pop().unwrap())?.max(0.0);
9184    let shrink = as_float(args.pop().unwrap())?.max(0.0);
9185    let natural = as_float(args.pop().unwrap())?.max(0.0);
9186    Ok(Value::Context(Box::new(Context {
9187        space_natural: natural,
9188        space_shrink: shrink,
9189        space_stretch: stretch,
9190        ..ctx
9191    })))
9192}
9193
9194/// `set-space-ratio-between-scripts : float -> float -> float -> script ->
9195/// script -> context -> context` (`vminstdef.yaml:1230-1250`) — writes one
9196/// ordered script pair's entry of `ctx.script_space_map`
9197/// (`convertText.ml:34-50` reads it back).
9198///
9199/// Only the NATURAL ratio is stored. The other two arguments are accepted and
9200/// dropped, because upstream drops them too: `pure_space_between_scripts`
9201/// misplaces them into `LBAtom`'s height and depth slots, so no
9202/// `set-space-ratio-between-scripts` call in any document has ever been able
9203/// to give this glue stretch or shrink. See [`interscript_glue`].
9204///
9205/// This was a STAND-IN that ignored all three, on the reasoning that slydifi
9206/// only ever calls it with `0. 0. 0.` to SUPPRESS the spacing and the port
9207/// inserted none anyway. The port has inserted Latin↔CJK glue for some time
9208/// now, so ignoring the call left slydifi with a 0.24em space at every
9209/// Japanese/Latin junction that upstream does not set.
9210fn prim_set_space_ratio_between_scripts(
9211    _interp: &mut Interp,
9212    mut args: Vec<Value>,
9213) -> Result<Value, EvalError> {
9214    let ctx = as_context(args.pop().unwrap())?;
9215    let script2 = as_script(args.pop().unwrap())?;
9216    let script1 = as_script(args.pop().unwrap())?;
9217    let _stretch = as_float(args.pop().unwrap())?;
9218    let _shrink = as_float(args.pop().unwrap())?;
9219    // `max 0.`, as `vminstdef.yaml`'s own `set_space_ratio_between_scripts`
9220    // clamps each ratio before storing it.
9221    let natural = as_float(args.pop().unwrap())?.max(0.0);
9222    let mut script_space_map = ctx.script_space_map;
9223    script_space_map[script1 as usize][script2 as usize] = natural;
9224    Ok(Value::Context(Box::new(Context {
9225        script_space_map,
9226        ..ctx
9227    })))
9228}
9229
9230/// `split-into-lines : string -> (int * string) list` (vminst.ml:2269) —
9231/// FAITHFUL: splits on `'\n'` and, per line, counts the leading ASCII spaces
9232/// `i` and returns `(i, rest_after_indent)` — exactly `evalUtil.ml:36`'s
9233/// `chop_space_indent`. Pure string op: no context, no box, no new type.
9234fn prim_split_into_lines(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9235    let s = as_str(args.pop().unwrap())?;
9236    let mut out = Vec::new();
9237    for line in s.split('\n') {
9238        let indent = line.chars().take_while(|c| *c == ' ').count();
9239        let rest: String = line.chars().skip(indent).collect();
9240        out.push(Value::Tuple(vec![
9241            Value::Int(indent as i64),
9242            Value::Str(rest),
9243        ]));
9244    }
9245    Ok(Value::List(out))
9246}
9247
9248/// Shift every content box in `block`'s `Line`s right by `pad_l`
9249/// (`block-frame-breakable`'s left-indent, point 4) — the simplest of the
9250/// two options that section names: adjusting each box's own `x` offset
9251/// directly rather than prepending an extra `FixedEmpty` box (`Skip`s carry
9252/// no `x` offsets to shift, so they pass through unchanged).
9253fn indent_left(block: Vec<VertBox>, pad_l: Length) -> Vec<VertBox> {
9254    block
9255        .into_iter()
9256        .map(|vb| match vb {
9257            VertBox::Line {
9258                height,
9259                depth,
9260                leading,
9261                contents,
9262            } => VertBox::Line {
9263                height,
9264                depth,
9265                leading,
9266                contents: contents
9267                    .into_iter()
9268                    .map(|(x, bx)| (x + pad_l, bx))
9269                    .collect(),
9270            },
9271            // `Skip`/`ClearPage`/`HookPageBreak` carry no `x` offsets to shift.
9272            other => other,
9273        })
9274        .collect()
9275}
9276
9277/// `block-frame-breakable : context -> paddings -> deco-set -> (context ->
9278/// block-boxes) -> block-boxes` (vminst.ml:1090) — the `inline-frame-outer`
9279/// playbook, one dimension up:
9280/// `paddingL`/`paddingR` shrink the inner `reducef` closure's context width,
9281/// and the result is indented and top/bottom-padded with plain `Skip`s,
9282/// bracketed by a `FrameStart(id)`/`FrameEnd(id)` marker pair — the frame's
9283/// pads/width/deco-set are interned into `interp.decos` under `id`
9284/// (`DecoEntry::Block`), and `fire_hooks`'s block-fragment pass fires
9285/// `decoS` once the frame's whole single-page fragment is placed (the first
9286/// cut: multi-page fragments/`decoH`/`decoM`/`decoT` are a documented
9287/// follow-up, see `fire_hooks`'s doc comment).
9288/// Drop the margin boxes at either END of a `block-frame-breakable`'s body —
9289/// the first inner block's top margin and the last inner block's bottom
9290/// margin, which upstream's `normalize` never produces for a frame's contents
9291/// (`pageBreak.ml:664` and `:582-585`; see the call site). Margins in the
9292/// MIDDLE of the body are untouched: those are real inter-block gaps, squashed
9293/// there exactly as they are outside a frame.
9294fn strip_outer_margins(body: &mut Vec<VertBox>) {
9295    let is_margin = |vb: &VertBox| matches!(vb, VertBox::Skip(_) | VertBox::ParagTop(_));
9296    if body.first().is_some_and(is_margin) {
9297        body.remove(0);
9298    }
9299    if body.last().is_some_and(is_margin) {
9300        body.pop();
9301    }
9302}
9303
9304fn prim_block_frame_breakable(
9305    interp: &mut Interp,
9306    version: RustyfiVersion,
9307    mut args: Vec<Value>,
9308) -> Result<Value, EvalError> {
9309    let k = args.pop().unwrap();
9310    let decoset = as_decoset(args.pop().unwrap())?;
9311    let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
9312    let ctx = as_context(args.pop().unwrap())?;
9313    let id = DecoId(interp.decos.len());
9314    interp.decos.push(DecoEntry::Block {
9315        pads: Paddings {
9316            l: pad_l,
9317            r: pad_r,
9318            t: pad_t,
9319            b: pad_b,
9320        },
9321        width: ctx.paragraph_width,
9322        decoset,
9323        // See `make_inline_frame`'s identical capture.
9324        version,
9325    });
9326    let inner_ctx = Context {
9327        paragraph_width: ctx.paragraph_width - pad_l - pad_r,
9328        ..ctx
9329    };
9330    let inner = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9331    let mut indented = indent_left(inner, pad_l);
9332    // THE FRAME CARRIES THE MARGINS, ITS BODY DOES NOT. Upstream normalizes a
9333    // frame's contents STANDALONE — `aux None TopMarginProhibited Alist.empty
9334    // vblstsub` (`pageBreak.ml:664`) — so the first inner block's `margin_top`
9335    // is never appended, and the last inner block's `margin_bottom` goes
9336    // through `squash_margins _ []`, whose empty-list arm (`:582-585`) emits
9337    // no skip at all. What surrounds the frame instead is the frame's OWN
9338    // `margins`, taken from the OUTER context (`vminstdef.yaml`'s
9339    // `BackendVertFrame`: `margin_top = ctx.paragraph_top`, `margin_bottom =
9340    // ctx.paragraph_bottom`), which `squash_margins` max-collapses against the
9341    // neighbouring blocks' margins exactly as `chop_page` collapses adjacent
9342    // `Skip`s.
9343    //
9344    // The distinction is not cosmetic: the body's `ParagTop` carries
9345    // `min_first_line_ascender` folded in (`prim_line_break`,
9346    // `lineBreak.ml:855-857`), and the frame's margin does NOT. Keeping the
9347    // body's made the advance INTO a frame a constant — `max(0, 9pt - hgt)`
9348    // cancels the first line's own height — where upstream's tracks the ink:
9349    // measured on `layout-tests/probes/code_line_height.saty` against real
9350    // SATySFi 0.0.11, `+code(`ooo`)` / `lll` / `ggg` advance 29.114 / 31.166 /
9351    // 29.138pt upstream and a flat 32.835pt here.
9352    strip_outer_margins(&mut indented);
9353    let mut out = Vec::with_capacity(indented.len() + 6);
9354    out.push(VertBox::Skip(ctx.paragraph_top));
9355    out.push(VertBox::FrameStart(id));
9356    out.push(VertBox::FramePad(pad_t));
9357    out.extend(indented);
9358    out.push(VertBox::FramePad(pad_b));
9359    out.push(VertBox::FrameEnd(id));
9360    out.push(VertBox::Skip(ctx.paragraph_bottom));
9361    Ok(Value::BlockBoxes(out))
9362}
9363
9364/// Build the `PureHorzBox::EmbeddedBlock` shared by `embed-block-top`
9365/// (vminst.ml:1145) and `embed-block-bottom` (vminst.ml:1185). FAITHFUL:
9366/// `anchor_last` selects which of `block`'s lines lands on the surrounding
9367/// text baseline — the FIRST for top (upstream's `adjust_to_first_line`) or
9368/// the LAST for bottom (`adjust_to_last_line`) — computed by placing the
9369/// block once (`place_block_at`) to find where each line's baseline falls,
9370/// then splitting the box's total vertical extent around the anchored line:
9371/// around the first line for TOP, so the box hangs DOWN from the baseline;
9372/// around the last line for BOTTOM, so it hangs UP. A degenerate line-less
9373/// block (only skips, no baseline to anchor) falls back to
9374/// `measure_block`'s skip-as-height sum for both.
9375fn make_embedded_block(
9376    width: Length,
9377    block: Vec<VertBox>,
9378    anchor_last: bool,
9379    breakable: bool,
9380) -> Value {
9381    let first_line_height = block.iter().find_map(|vb| match vb {
9382        VertBox::Line { height, .. } => Some(*height),
9383        _ => None,
9384    });
9385    let last_line_depth = block.iter().rev().find_map(|vb| match vb {
9386        VertBox::Line { depth, .. } => Some(*depth),
9387        _ => None,
9388    });
9389    let (height, depth) = match (first_line_height, last_line_depth) {
9390        // Place once to learn each line's baseline, then split the box's
9391        // total vertical extent around the anchored line: `place_block_at`
9392        // seats the first baseline at `first_h` (origin 0), so the block
9393        // spans `[0, last_baseline + last_d]`.
9394        (Some(first_h), Some(last_d)) => {
9395            let placed = place_block_at((Length::ZERO, Length::ZERO), block.clone());
9396            let last_baseline = placed.last().map(|l| l.baseline_y).unwrap_or(first_h);
9397            let bottom_edge = last_baseline + last_d;
9398            if anchor_last {
9399                (last_baseline, last_d)
9400            } else {
9401                (first_h, bottom_edge - first_h)
9402            }
9403        }
9404        // A degenerate line-less block (only skips — no baseline to anchor):
9405        // keep `measure_block` (its skip-as-height fallback is right there).
9406        _ => measure_block(&block),
9407    };
9408    Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::EmbeddedBlock {
9409        width,
9410        height,
9411        depth,
9412        block,
9413        anchor_last,
9414        breakable,
9415    })])
9416}
9417
9418/// `embed-block-top : context -> length -> (context -> block-boxes) ->
9419/// inline-boxes` (vminst.ml:1145) — see [`make_embedded_block`].
9420fn prim_embed_block_top(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9421    let k = args.pop().unwrap();
9422    let wid = as_length(args.pop().unwrap())?;
9423    let ctx = as_context(args.pop().unwrap())?;
9424    let inner_ctx = Context {
9425        paragraph_width: wid,
9426        ..ctx
9427    };
9428    let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9429    Ok(make_embedded_block(wid, block, false, false))
9430}
9431
9432/// `embed-block-bottom : context -> length -> (context -> block-boxes) ->
9433/// inline-boxes` (vminst.ml:1185) — see [`make_embedded_block`]; anchors the
9434/// LAST line, used by latexcmds' `\parbox?:(Bottom)`.
9435fn prim_embed_block_bottom(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9436    let k = args.pop().unwrap();
9437    let wid = as_length(args.pop().unwrap())?;
9438    let ctx = as_context(args.pop().unwrap())?;
9439    let inner_ctx = Context {
9440        paragraph_width: wid,
9441        ..ctx
9442    };
9443    let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9444    Ok(make_embedded_block(wid, block, true, false))
9445}
9446
9447/// `line-stack-bottom : inline-boxes list -> inline-boxes` (vminst.ml:1229,
9448/// `evalUtil.ml`'s `make_line_stack`) — FAITHFUL: each `inline-boxes` in the
9449/// list becomes exactly one line, fit (not broken) to the widest line's
9450/// natural width via `fit_cell` (this port's `LineBreak.fit`, already used
9451/// by the tabular grid solver — same "no `Context`, `natural_metrics`
9452/// height/depth" fallback upstream's `make_line_stack` needs since it too
9453/// has no context to lean on). Lines are stacked with zero extra margin
9454/// (upstream's `VertParagraph`s all have `margin_top`/`margin_bottom =
9455/// None`): each line's `leading` is set to the previous line's depth plus
9456/// this line's height, so consecutive baselines sit exactly
9457/// `prev_depth + this_height` apart (see `pagebreak.rs`'s
9458/// `leading.max(height)` placement formula — this choice makes that `max`
9459/// always resolve to our computed `leading`). See `line_stack` for the
9460/// shared body and [`prim_line_stack_top`] for the other half.
9461fn prim_line_stack_bottom(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9462    line_stack(args.pop().unwrap(), true)
9463}
9464
9465/// `line-stack-top : inline-boxes list -> inline-boxes`
9466/// (vminstdef.yaml:1109 `BackendLineStackTop`) — FAITHFUL: the same
9467/// `make_line_stack` construction as [`prim_line_stack_bottom`], differing
9468/// only in which stacked line's baseline becomes the result's — one shared
9469/// body and one flag rather than two copies that could drift.
9470///
9471/// `ruby` calls this to sit its annotation above the base run.
9472fn prim_line_stack_top(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9473    line_stack(args.pop().unwrap(), false)
9474}
9475
9476/// `evalUtil.ml`'s `make_line_stack` — shared body of the two `line-stack-*`
9477/// prims; `anchor_last` picks upstream's `adjust_to_last_line` (`true`) or
9478/// `adjust_to_first_line` (`false`).
9479fn line_stack(arg: Value, anchor_last: bool) -> Result<Value, EvalError> {
9480    let hblstlst = as_list(arg)?
9481        .into_iter()
9482        .map(as_inline_boxes)
9483        .collect::<Result<Vec<_>, _>>()?;
9484    let wid = hblstlst
9485        .iter()
9486        .map(|hbs| natural_metrics(hbs).0)
9487        .fold(Length::ZERO, |acc, w| if w > acc { w } else { acc });
9488    let mut block = Vec::with_capacity(hblstlst.len());
9489    let mut prev_depth = Length::ZERO;
9490    for (idx, hbs) in hblstlst.into_iter().enumerate() {
9491        let (contents, height, depth) = fit_cell(hbs, wid);
9492        let leading = if idx == 0 {
9493            height + depth
9494        } else {
9495            prev_depth + height
9496        };
9497        block.push(VertBox::Line {
9498            height,
9499            depth,
9500            leading,
9501            contents,
9502        });
9503        prev_depth = depth;
9504    }
9505    // `anchor_last` IS upstream's `adjust_to_last_line`/`adjust_to_first_line`
9506    // choice: `line-stack-bottom` is BOTTOM-anchored (SATySFi vminst.ml:1229 —
9507    // the result baseline is the LAST stacked line's baseline), so the box's
9508    // height spans everything above that last line and its depth is the last
9509    // line's depth. Top-anchoring it instead put the baseline at the FIRST
9510    // line, which dropped the whole stack below the baseline — e.g. figbox's
9511    // `margin`/`hvmargin` (a `line-stack-bottom` of [top-mgn; content;
9512    // bot-mgn]) had its content rendered below its frame (the E=mc² bug). For
9513    // `line-stack-top` the first line IS the right anchor, which is the whole
9514    // difference between the two prims.
9515    Ok(make_embedded_block(wid, block, anchor_last, false))
9516}
9517
9518/// `add-footnote : block-boxes -> inline-boxes` (vminst.ml:1130
9519/// `BackendAddFootnote`) — FAITHFUL: wraps the block in a zero-metric
9520/// `PureHorzBox::Footnote` marker (upstream `PHGFootnote`,
9521/// vminstdef.yaml:1034-1044; the upstream body's `PageBreak.solidify` is a
9522/// no-op here because this port's block-boxes are already solid
9523/// `Vec<VertBox>`). `chop_page` (rustyfi-backend) extracts the marker when
9524/// its line is committed to a page, reserves the stack's height at the
9525/// column bottom, and places the block bottom-aligned there — see that
9526/// function's doc comment. The cross-trial `changed`-flag protocol
9527/// `footnote-scheme.satyh` layers on top rides the crossref fixpoint.
9528fn prim_add_footnote(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9529    let block = as_block_boxes(args.pop().unwrap())?;
9530    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
9531        PureHorzBox::Footnote { block },
9532    )]))
9533}
9534
9535/// `set-font : script -> string * float * float -> context -> context`
9536/// (0.0.6 `vminstdef.yaml:1335`, `tFONT` head) — real per-script wiring.
9537/// `abbrev` resolves through the font metrics
9538/// provider's registry first (`FontMetrics::resolve_font_abbrev` — a real
9539/// `TtfFontStore` built from `fonts.satysfi-hash`), falling back to
9540/// the 3-face name heuristic (`resolve_font_abbrev` free fn)
9541/// when the provider has no registry entry for it (an abbrev the config
9542/// doesn't name) — never an error, matching this
9543/// port's existing accept-and-degrade stance on unresolvable font names.
9544///
9545/// **Resolution rule (back-compat critical).** `Latin`-script text keeps
9546/// reading `Context::font` directly rather than `font_scheme[Latin]` (see
9547/// that field's doc comment) — `set-font Latin f` therefore writes BOTH so
9548/// the two stay in sync, but `set-font` on any OTHER script only touches
9549/// `font_scheme`, leaving `ctx.font` (and hence `set-font-key`/`\bold`/
9550/// `\emph`, which only ever read `ctx.font`) untouched.
9551fn prim_set_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9552    let mut ctx = as_context(args.pop().unwrap())?;
9553    let (abbrev, size_ratio, rising_ratio) = as_font(args.pop().unwrap())?;
9554    let script = as_script(args.pop().unwrap())?;
9555    let font = interp
9556        .metrics
9557        .resolve_font_abbrev(&abbrev)
9558        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
9559    install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9560    Ok(Value::Context(Box::new(ctx)))
9561}
9562
9563/// `set-font : script -> font * float * float -> context -> context`
9564/// (saphe-split `tools/gencode/vminst.ml:1433`, `tFONTWR` head). Identical
9565/// to [`prim_set_font_v006`] except that the triple's head is ALREADY a
9566/// resolved handle — 0.1 has no abbrev at this point and nothing to resolve;
9567/// `load-single-font` did that when the font envelope's member was minted.
9568fn prim_set_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9569    let mut ctx = as_context(args.pop().unwrap())?;
9570    let (font, size_ratio, rising_ratio) = as_font_with_ratio(args.pop().unwrap())?;
9571    let script = as_script(args.pop().unwrap())?;
9572    install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9573    Ok(Value::Context(Box::new(ctx)))
9574}
9575
9576/// `get-font : script -> context -> string * float * float`
9577/// (vminstdef.yaml:1350 `PrimitiveGetFont`) — FAITHFUL: `script_font` IS
9578/// upstream's `get_font_with_ratio` (normalize the script, then read the
9579/// scheme slot), and the triple is `evalUtil.ml:196`'s `make_font_value`.
9580///
9581/// The head is a font ABBREV. Upstream's `font_scheme` stores abbrevs and
9582/// resolves them to files only at render time; this port resolves eagerly in
9583/// [`prim_set_font_v006`] and stores a `FontKey`, so the name comes back from
9584/// the store that minted it (`FontMetrics::font_abbrev`) and is `""` when the
9585/// key was never named by a registry — see that method for exactly when, and
9586/// why the corpus does not care (every caller in it, and upstream's own
9587/// `convertText.ml:78`, writes `let (_, ratio, _) =` and uses the RATIO,
9588/// which is exact).
9589///
9590/// This is what `ruby` and `quotation` need: the CJK face's size ratio, so a
9591/// ruby annotation or a two-em Japanese indent scales with the face rather
9592/// than with the Latin `get-font-size`.
9593fn prim_get_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9594    let ctx = as_context(args.pop().unwrap())?;
9595    let script = as_script(args.pop().unwrap())?;
9596    let sf = script_font(&ctx, script);
9597    let abbrev = interp.metrics.font_abbrev(sf.font).unwrap_or_default();
9598    Ok(Value::Tuple(vec![
9599        Value::Str(abbrev),
9600        Value::Float(sf.ratio),
9601        Value::Float(sf.rising),
9602    ]))
9603}
9604
9605/// `get-font : script -> context -> font * float * float` — the 0.1 arm,
9606/// mirroring [`prim_set_font_v006`]/[`prim_set_font_v01`]'s split. 0.1's
9607/// `font` IS the opaque handle this port already stores, so unlike the 0.0.6
9608/// arm there is nothing to recover: the value round-trips exactly.
9609fn prim_get_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9610    let ctx = as_context(args.pop().unwrap())?;
9611    let script = as_script(args.pop().unwrap())?;
9612    let sf = script_font(&ctx, script);
9613    Ok(Value::Tuple(vec![
9614        Value::Font(sf.font),
9615        Value::Float(sf.ratio),
9616        Value::Float(sf.rising),
9617    ]))
9618}
9619
9620/// The half of `set-font` that is NOT version-forked — the `Context` write
9621/// both arms end at, kept in one place so the 0.0.6 behaviour cannot drift
9622/// when the 0.1 one changes. See `prim_set_font_v006`'s "Resolution rule".
9623fn install_script_font(ctx: &mut Context, script: Script, font: FontKey, ratio: f64, rising: f64) {
9624    ctx.font_scheme[script as usize] = ScriptFont {
9625        font,
9626        ratio,
9627        rising,
9628    };
9629    if script == Script::Latin {
9630        ctx.font = font;
9631    }
9632}
9633
9634/// `set-code-text-command : [string] inline-cmd -> context -> context`
9635/// (`stdja:116`; no vminst.ml entry to cite). STAND-IN, same
9636/// shape as `set-math-command`/`set-math-font` above: `(command \cmd)`
9637/// means a real program CAN build a `[string]
9638/// inline-cmd` value to pass here — but `Context` (`rustyfi-backend`) still
9639/// cannot hold an arbitrary lang-side `Value` without a reverse crate
9640/// dependency, and the one seam this codebase uses for that indirection
9641/// (`Interp::hooks`'s ID-table, `eval.rs`) sits outside this file's
9642/// boundary — so the command argument is accepted (to keep the
9643/// arity/signature faithful) and dropped.
9644fn prim_set_code_text_command(
9645    interp: &mut Interp,
9646    mut args: Vec<Value>,
9647) -> Result<Value, EvalError> {
9648    let mut ctx = as_context(args.pop().unwrap())?;
9649    let cmd = args.pop().unwrap();
9650    ctx.code_text_command = Some(interp.register_math_command(cmd));
9651    Ok(Value::Context(Box::new(ctx)))
9652}
9653
9654/// `get-natural-length : block-boxes -> length` (vminst.ml:2040) —
9655/// FAITHFUL: `get-natural-width`'s block sibling (`get-natural-width` itself
9656/// is a `pervasives.satyh` wrapper over `get-natural-metrics`, not a
9657/// primitive). A block's own "natural length" is its total vertical extent
9658/// — `measure_block`'s two components (height above the nominal top, depth
9659/// of the last line) summed into one length.
9660fn prim_get_natural_length(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9661    let bb = as_block_boxes(args.pop().unwrap())?;
9662    let (height, depth) = measure_block(&bb);
9663    Ok(Value::Length(height + depth))
9664}
9665
9666/// `set-dominant-wide-script : script -> context -> context`
9667/// (vminst.ml:1511 `PrimitiveSetDominantWideScript`) — FAITHFUL store,
9668/// consumed by `get-dominant-wide-script` now, by CJK script normalization
9669/// later.
9670fn prim_set_dominant_wide_script(
9671    _interp: &mut Interp,
9672    mut args: Vec<Value>,
9673) -> Result<Value, EvalError> {
9674    let ctx = as_context(args.pop().unwrap())?;
9675    let dominant_wide_script = as_script(args.pop().unwrap())?;
9676    Ok(Value::Context(Box::new(Context {
9677        dominant_wide_script,
9678        ..ctx
9679    })))
9680}
9681
9682/// `set-dominant-narrow-script : script -> context -> context`
9683/// (vminst.ml:1539) — FAITHFUL store, mirror of the wide setter.
9684fn prim_set_dominant_narrow_script(
9685    _interp: &mut Interp,
9686    mut args: Vec<Value>,
9687) -> Result<Value, EvalError> {
9688    let ctx = as_context(args.pop().unwrap())?;
9689    let dominant_narrow_script = as_script(args.pop().unwrap())?;
9690    Ok(Value::Context(Box::new(Context {
9691        dominant_narrow_script,
9692        ..ctx
9693    })))
9694}
9695
9696/// `set-language : script -> language -> context -> context`
9697/// (vminst.ml:1568 `PrimitiveSetLangSys`) — FAITHFUL per-script map insert
9698/// (`langsys_scheme |> ScriptSchemeMap.add script langsys` upstream; a
9699/// 4-slot array write here).
9700fn prim_set_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9701    let ctx = as_context(args.pop().unwrap())?;
9702    let langsys = as_language(args.pop().unwrap())?;
9703    let script = as_script(args.pop().unwrap())?;
9704    let mut langsys_scheme = ctx.langsys_scheme;
9705    langsys_scheme[script as usize] = langsys;
9706    Ok(Value::Context(Box::new(Context {
9707        langsys_scheme,
9708        ..ctx
9709    })))
9710}
9711
9712/// `get-dominant-wide-script : context -> script` (vminst.ml:1526) — FAITHFUL.
9713fn prim_get_dominant_wide_script(
9714    _interp: &mut Interp,
9715    mut args: Vec<Value>,
9716) -> Result<Value, EvalError> {
9717    let ctx = as_context(args.pop().unwrap())?;
9718    Ok(make_script_value(ctx.dominant_wide_script))
9719}
9720
9721/// `get-dominant-narrow-script : context -> script` (vminst.ml:1555) — FAITHFUL.
9722fn prim_get_dominant_narrow_script(
9723    _interp: &mut Interp,
9724    mut args: Vec<Value>,
9725) -> Result<Value, EvalError> {
9726    let ctx = as_context(args.pop().unwrap())?;
9727    Ok(make_script_value(ctx.dominant_narrow_script))
9728}
9729
9730/// `get-language : script -> context -> language` (vminst.ml:1587
9731/// `PrimitiveGetLangSys`) — FAITHFUL. Upstream routes through
9732/// `get_language_system`, whose `normalize_script` step is the identity on
9733/// every script a VALUE can carry (only the char-decoder-internal
9734/// CommonNarrow/CommonWide/Inherited normalize, horzBox.ml:470-479), so
9735/// this is a plain indexed read; absent-entry default `NoLanguageSystem`
9736/// is baked into the array's initial value.
9737fn prim_get_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9738    let ctx = as_context(args.pop().unwrap())?;
9739    let script = as_script(args.pop().unwrap())?;
9740    Ok(make_language_value(ctx.langsys_scheme[script as usize]))
9741}
9742
9743/// `set-every-word-break : inline-boxes -> inline-boxes -> context -> context`
9744/// (vminst.ml:3007 `PrimitiveSetEveryWordBreak`) — sets the inline-boxes
9745/// inserted before/after every inter-word break (mdja.satyh uses it for a
9746/// CJK word-break strut). STAND-IN: accepted and dropped (no per-context
9747/// every-word-break state yet), same pattern as `prim_set_language` above.
9748fn prim_set_every_word_break(
9749    _interp: &mut Interp,
9750    mut args: Vec<Value>,
9751) -> Result<Value, EvalError> {
9752    let ctx = as_context(args.pop().unwrap())?;
9753    let _after = args.pop().unwrap();
9754    let _before = args.pop().unwrap();
9755    Ok(Value::Context(Box::new(ctx)))
9756}
9757
9758/// `register-outline : (int * string * string * bool) list -> unit`
9759/// (vminstdef.yaml:2794 `BackendRegisterOutline`) — FAITHFUL: upstream
9760/// REPLACES the whole registered list (`outline.ml`: `registered_outline :=
9761/// ol`), it does not append; and it is callable anywhere (no
9762/// during-page-break gate — upstream's `Outline.register` has no `State`
9763/// check). Keys resolve through [`Interp::dest_name`] (upstream
9764/// `make_entry`'s `NamedDest.get key`).
9765fn prim_register_outline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9766    let entries = as_list(args.pop().unwrap())?;
9767    let mut out = Vec::with_capacity(entries.len());
9768    for e in entries {
9769        let Value::Tuple(vs) = e else {
9770            return eval_error("register-outline expects a list of (int * string * string * bool)");
9771        };
9772        if vs.len() != 4 {
9773            return eval_error("register-outline expects 4-tuples (level, text, key, is-open)");
9774        }
9775        let mut it = vs.into_iter();
9776        let level = as_int(it.next().unwrap())?;
9777        let text = as_str(it.next().unwrap())?;
9778        let key = as_str(it.next().unwrap())?;
9779        let is_open = as_bool(it.next().unwrap())?;
9780        let dest_name = interp.dest_name(&key);
9781        out.push(OutlineEntry {
9782            level,
9783            text,
9784            dest_name,
9785            is_open,
9786        });
9787    }
9788    interp.outline = out; // replace, not extend
9789    Ok(Value::Unit)
9790}
9791
9792/// Recursive `extract_one` helper for [`prim_extract_string`] — mirrors
9793/// `horzBox.ml`'s `extract_string`'s `extract_one`: an `InnerString`
9794/// contributes its own text, a `Discretionary` recurses into `no_break`
9795/// (the "not yet broken" reading), every other box contributes nothing.
9796/// This port's box vocabulary has no separate Rising/Frame/ScriptGuard
9797/// wrapper (`inline-frame-breakable` et al. already flatten their padding
9798/// into the same flat `Vec<HorzBox>` — see `prim_inline_frame_breakable`),
9799/// so there is nothing else to recurse into.
9800fn extract_string_pure_one(phb: &PureHorzBox) -> String {
9801    match phb {
9802        PureHorzBox::InnerString { text, .. } => text.clone(),
9803        PureHorzBox::Discretionary { no_break, .. } => {
9804            no_break.iter().map(extract_string_pure_one).collect()
9805        }
9806        // Upstream `extract_string` recurses into frames.
9807        PureHorzBox::Frame { contents, .. } => contents
9808            .iter()
9809            .map(|(_, b)| extract_string_pure_one(b))
9810            .collect(),
9811        _ => String::new(),
9812    }
9813}
9814
9815fn extract_string_one(hb: &HorzBox) -> String {
9816    match hb {
9817        HorzBox::Pure(phb) => extract_string_pure_one(phb),
9818    }
9819}
9820
9821/// `extract-string : inline-boxes -> string` (vminstdef.yaml:1565
9822/// `PrimitiveExtract`) — FAITHFUL (see [`extract_string_one`]).
9823fn prim_extract_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9824    let boxes = as_inline_boxes(args.pop().unwrap())?;
9825    let s: String = boxes.iter().map(extract_string_one).collect();
9826    Ok(Value::Str(s))
9827}
9828
9829/// `get-initial-text-info : unit -> text-info` (v0.0.6 vminst.ml:953
9830/// `TextGetInitialTextModeContext`) — FAITHFUL:
9831/// `TextBackend.get_initial_text_mode_context` is `{ indent = 0;
9832/// escape_list = [] }` (textBackend.ml:9-12); escape_list is omitted from
9833/// the port's `TextInfo` (see its doc comment). The v0.0.6 fork side.
9834fn prim_get_initial_text_info_v006(
9835    _interp: &mut Interp,
9836    mut args: Vec<Value>,
9837) -> Result<Value, EvalError> {
9838    let _unit = args.pop().unwrap();
9839    Ok(Value::TextInfo(TextInfo { indent: 0 }))
9840}
9841
9842/// `get-initial-text-info : inline [math-text] -> (string -> option string
9843/// -> option string -> string) -> text-info` (dev-0-1-0 vminst.ml:904-925)
9844/// — the v0.1 fork side. STAND-IN: pops and
9845/// drops both new arguments (the text-mode default math command and the
9846/// math-scripts stringifier) — this port's `TextInfo` carries no text-mode
9847/// command state, same degenerate policy as `stringify-math`. Returns the
9848/// same `TextInfo{indent: 0}` as the v0.0.6 side.
9849fn prim_get_initial_text_info_v01(
9850    _interp: &mut Interp,
9851    mut args: Vec<Value>,
9852) -> Result<Value, EvalError> {
9853    let _stringifier = args.pop().unwrap();
9854    let _default_math_cmd = args.pop().unwrap();
9855    Ok(Value::TextInfo(TextInfo { indent: 0 }))
9856}
9857
9858/// `deepen-indent : int -> text-info -> text-info` (vminst.ml:921
9859/// `TextDeepenIndent`) — FAITHFUL: `indent + max i 0`
9860/// (`TextBackend.deepen_indent`, textBackend.ml:15-16 — the INCREMENT is
9861/// clamped, not the total).
9862fn prim_deepen_indent(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9863    let tinfo = as_text_info(args.pop().unwrap())?;
9864    let i = as_int(args.pop().unwrap())?;
9865    Ok(Value::TextInfo(TextInfo {
9866        indent: tinfo.indent + i.max(0),
9867    }))
9868}
9869
9870/// `break : text-info -> string` (vminst.ml:935 `TextBreak`) — FAITHFUL:
9871/// `"\n" ^ String.make indent ' '` (`TextBackend.get_indent`).
9872fn prim_break(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9873    let tinfo = as_text_info(args.pop().unwrap())?;
9874    let mut s = String::with_capacity(1 + tinfo.indent as usize);
9875    s.push('\n');
9876    for _ in 0..tinfo.indent {
9877        s.push(' ');
9878    }
9879    Ok(Value::Str(s))
9880}
9881
9882// ============================================================================
9883// unit tests: `as_page` (every paper-size ctor),
9884// `read_content_scheme`/`read_parts_scheme` (field extraction +
9885// missing-field errors). These extractors are private, so the tests live
9886// in-module rather than in `tests/`, same pattern as `crossref.rs`'s own
9887// `#[cfg(test)] mod tests`.
9888// ============================================================================
9889#[cfg(test)]
9890mod page_model_tests {
9891    use super::*;
9892
9893    #[test]
9894    fn as_page_maps_every_nullary_ctor_to_the_right_paper_size() {
9895        let cases: &[(&str, PaperSize)] = &[
9896            ("A0Paper", PaperSize::A0),
9897            ("A1Paper", PaperSize::A1),
9898            ("A2Paper", PaperSize::A2),
9899            ("A3Paper", PaperSize::A3),
9900            ("A4Paper", PaperSize::A4),
9901            ("A5Paper", PaperSize::A5),
9902            ("USLetter", PaperSize::USLetter),
9903            ("USLegal", PaperSize::USLegal),
9904        ];
9905        for (name, expected) in cases {
9906            let v = Value::Ctor((*name).to_string(), None);
9907            assert_eq!(as_page(v).unwrap(), *expected, "ctor {name}");
9908        }
9909    }
9910
9911    #[test]
9912    fn as_page_unwraps_user_defined_papers_tuple_payload() {
9913        let v = Value::Ctor(
9914            "UserDefinedPaper".to_string(),
9915            Some(Box::new(Value::Tuple(vec![
9916                Value::Length(Length::pt(100.0)),
9917                Value::Length(Length::pt(200.0)),
9918            ]))),
9919        );
9920        assert_eq!(
9921            as_page(v).unwrap(),
9922            PaperSize::UserDefined(Length::pt(100.0), Length::pt(200.0))
9923        );
9924    }
9925
9926    #[test]
9927    fn a4_paper_dims_are_595_by_842_points() {
9928        let (w, h) = PaperSize::A4.dims();
9929        assert!((w.0 - 595.0).abs() < 1.0, "width: {}", w.0);
9930        assert!((h.0 - 842.0).abs() < 1.0, "height: {}", h.0);
9931    }
9932
9933    #[test]
9934    fn read_content_scheme_extracts_origin_and_height() {
9935        let mut fields = BTreeMap::new();
9936        fields.insert(
9937            "text-origin".to_string(),
9938            Value::Tuple(vec![
9939                Value::Length(Length::pt(10.0)),
9940                Value::Length(Length::pt(20.0)),
9941            ]),
9942        );
9943        fields.insert("text-height".to_string(), Value::Length(Length::pt(300.0)));
9944        let (origin, height) = read_content_scheme(Value::Record(fields)).unwrap();
9945        assert_eq!(origin, (Length::pt(10.0), Length::pt(20.0)));
9946        assert_eq!(height, Length::pt(300.0));
9947    }
9948
9949    #[test]
9950    fn read_content_scheme_errors_on_a_missing_field() {
9951        let mut fields = BTreeMap::new();
9952        fields.insert(
9953            "text-origin".to_string(),
9954            Value::Tuple(vec![
9955                Value::Length(Length::ZERO),
9956                Value::Length(Length::ZERO),
9957            ]),
9958        );
9959        let err = read_content_scheme(Value::Record(fields)).unwrap_err();
9960        assert!(
9961            err.msg.contains("text-height"),
9962            "error should name the missing field: {}",
9963            err.msg
9964        );
9965    }
9966
9967    #[test]
9968    fn read_parts_scheme_extracts_all_four_fields() {
9969        let mut fields = BTreeMap::new();
9970        fields.insert(
9971            "header-origin".to_string(),
9972            Value::Tuple(vec![
9973                Value::Length(Length::ZERO),
9974                Value::Length(Length::ZERO),
9975            ]),
9976        );
9977        fields.insert("header-content".to_string(), Value::BlockBoxes(Vec::new()));
9978        fields.insert(
9979            "footer-origin".to_string(),
9980            Value::Tuple(vec![
9981                Value::Length(Length::pt(1.0)),
9982                Value::Length(Length::pt(2.0)),
9983            ]),
9984        );
9985        fields.insert("footer-content".to_string(), Value::BlockBoxes(Vec::new()));
9986        let (horg, hbb, forg, fbb) = read_parts_scheme(Value::Record(fields)).unwrap();
9987        assert_eq!(horg, (Length::ZERO, Length::ZERO));
9988        assert!(hbb.is_empty());
9989        assert_eq!(forg, (Length::pt(1.0), Length::pt(2.0)));
9990        assert!(fbb.is_empty());
9991    }
9992
9993    #[test]
9994    fn read_parts_scheme_errors_on_a_missing_field() {
9995        let err = read_parts_scheme(Value::Record(BTreeMap::new())).unwrap_err();
9996        assert!(
9997            err.msg.contains("header-origin"),
9998            "error should name the missing field: {}",
9999            err.msg
10000        );
10001    }
10002}
10003
10004/// `enter_script_scales_and_saturates`:
10005/// `enter_script` is crate-private, so this lives here rather than in the
10006/// external `tests/v01_math.rs` integration suite, which can only reach
10007/// `pub` items.
10008#[cfg(test)]
10009mod math_split_tests {
10010    use super::*;
10011    use rustyfi_backend::FontMetrics;
10012
10013    /// A `FontMetrics` stub with NO MATH table (`math_constants` defaults
10014    /// to `None`) — exercises `enter_script`'s documented fallback
10015    /// constants (`0.7`, `5.0/7.0`), the shape every other base-14 fixture
10016    /// in this crate already relies on.
10017    struct NoMath;
10018    impl FontMetrics for NoMath {
10019        fn advance(&self, _f: FontKey, c: char, size: Length) -> Option<Length> {
10020            if c.is_ascii() {
10021                Some(size * 0.5)
10022            } else {
10023                None
10024            }
10025        }
10026        fn ascender(&self, _f: FontKey, size: Length) -> Length {
10027            size * 0.75
10028        }
10029        fn descender(&self, _f: FontKey, size: Length) -> Length {
10030            size * 0.25
10031        }
10032    }
10033
10034    #[test]
10035    fn enter_script_scales_and_saturates() {
10036        let metrics = NoMath;
10037        let interp = Interp::new(&metrics);
10038        let ctx = Context::initial(Length::pt(400.0));
10039        assert_eq!(ctx.math_script_level, MathScriptLevel::Base);
10040        assert_eq!(ctx.font_size, Length::pt(12.0));
10041
10042        // Base -> Script: font_size * script_scale_down (fallback 0.7).
10043        let s1 = enter_script(&interp, &ctx);
10044        assert_eq!(s1.math_script_level, MathScriptLevel::Script);
10045        assert!(
10046            (s1.font_size.0 - ctx.font_size.0 * 0.7).abs() < 1e-9,
10047            "expected {} * 0.7, got {}",
10048            ctx.font_size.0,
10049            s1.font_size.0
10050        );
10051
10052        // Script -> ScriptScript: font_size * (script_script_scale_down /
10053        // script_scale_down) (fallback 5.0/7.0).
10054        let s2 = enter_script(&interp, &s1);
10055        assert_eq!(s2.math_script_level, MathScriptLevel::ScriptScript);
10056        assert!(
10057            (s2.font_size.0 - s1.font_size.0 * (5.0 / 7.0)).abs() < 1e-9,
10058            "expected {} * 5/7, got {}",
10059            s1.font_size.0,
10060            s2.font_size.0
10061        );
10062
10063        // ScriptScript saturates: no further shrink, level stays put.
10064        let s3 = enter_script(&interp, &s2);
10065        assert_eq!(s3.math_script_level, MathScriptLevel::ScriptScript);
10066        assert_eq!(s3.font_size, s2.font_size);
10067    }
10068}