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, ImportedObjects, InlineMarkKind, Language, Length, ListMarkKind, MathCharClass,
23    MathConstants, MathCorner, MathGlyph, MathKind, MathScriptLevel, NamedDest, ObjRepr,
24    OutlineEntry, Paddings, Page, PageGeometry, PaperSize, Path, PathSeg, PdfPageResource, Point,
25    PrePath, PureHorzBox, Script, ScriptFont, Subpath, TabularBox, VertBox, VertVariantPolicy,
26    FORCED_BREAK_PENALTY, MIN_FIRST_ASCENDER, NO_BREAK_PENALTY,
27};
28use rustyfi_syntax::RustyfiVersion;
29use std::collections::{BTreeMap, BTreeSet};
30use std::rc::Rc;
31use std::sync::Arc;
32// UAX #15 normalization / UAX #29 grapheme segmentation, for
33// `normalize-string-to-nf{c,d}`/`split-grapheme-cluster`.
34use unicode_normalization::UnicodeNormalization;
35use unicode_segmentation::UnicodeSegmentation;
36
37/// Font keys agreed with this port's base-14 metrics provider.
38const FONT_REGULAR: FontKey = FontKey(0);
39const FONT_BOLD: FontKey = FontKey(1);
40const FONT_OBLIQUE: FontKey = FontKey(2);
41
42/// Which target version(s) a `PrimDef` row is registered under. Mirrors
43/// `RustyfiVersion`'s two-variant shape today; `#[non_exhaustive]` for the
44/// same reason `RustyfiVersion` is (a future third generation gets a new
45/// arm here, not a redesign) — every `match` on this type needs a wildcard.
46#[non_exhaustive]
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum VersionSpan {
49    /// Registered under every version this port implements. The default
50    /// for every `prims!` line that omits a tag.
51    Both,
52    V0_0Only,
53    V0_1Only,
54}
55
56impl VersionSpan {
57    /// Whether a `PrimDef`/type-table row tagged `self` should be visible
58    /// under `version`. `Both` always allows; `V0_0Only`/`V0_1Only` allow
59    /// exactly their own version — no partial/future-version fallback (a
60    /// third generation gets its own new `VersionSpan` arm, not silent
61    /// inclusion under an existing one).
62    pub fn allows(self, version: RustyfiVersion) -> bool {
63        match (self, version) {
64            (VersionSpan::Both, _) => true,
65            (VersionSpan::V0_0Only, RustyfiVersion::V0_0) => true,
66            (VersionSpan::V0_1Only, RustyfiVersion::V0_1) => true,
67            _ => false,
68        }
69    }
70}
71
72pub struct PrimDef {
73    pub name: &'static str,
74    pub arity: usize,
75    pub run: fn(&mut Interp, Vec<Value>) -> Result<Value, EvalError>,
76    pub version: VersionSpan,
77}
78
79impl std::fmt::Debug for PrimDef {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(
82            f,
83            "PrimDef({}/{}, {:?})",
84            self.name, self.arity, self.version
85        )
86    }
87}
88
89macro_rules! prims {
90    ($($($tag:ident)? $name:literal ($arity:literal) => $f:path;)*) => {
91        static PRIM_DEFS: &[PrimDef] = &[
92            $(PrimDef {
93                name: $name,
94                arity: $arity,
95                run: $f,
96                version: prims!(@span $($tag)?),
97            },)*
98        ];
99    };
100    (@span) => { VersionSpan::Both };
101    (@span v006) => { VersionSpan::V0_0Only };
102    (@span v01) => { VersionSpan::V0_1Only };
103}
104
105/// Generate the `_v006`/`_v01` `PrimDef`-shaped pair for a primitive body
106/// that needs to know the generation of the code that CALLED it.
107///
108/// The graphics-callback family (the primitives below, and the deco family
109/// behind them) needs this: those bodies decide whether a callback returns `graphics
110/// list` (0.0.6) or one `graphics` collection (0.1). Do NOT read
111/// `interp.version` for that — it is a single whole-program field naming the
112/// ENTRY document's generation, while a spliced 0.0.6 package calls these
113/// primitives with its OWN convention.
114///
115/// Registering the body twice fixes it at compile time: `compile.rs`'s
116/// `Ast::VersionScope` arm folds a primitive reference against the innermost
117/// enclosing scope's version, so a call inside a 0.0.6 dependency picks the
118/// `_v006` row and one in the 0.1 entry picks `_v01`, with nothing threaded
119/// through the interpreter. The two rows share one type-table entry per
120/// version (`prim_types::primitive_type_with_version`).
121macro_rules! version_forked_prims {
122    ($($v006:ident, $v01:ident => $body:path;)*) => {$(
123        fn $v006(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
124            $body(interp, RustyfiVersion::V0_0, args)
125        }
126        fn $v01(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
127            $body(interp, RustyfiVersion::V0_1, args)
128        }
129    )*};
130}
131
132version_forked_prims! {
133    prim_inline_graphics_v006, prim_inline_graphics_v01 => prim_inline_graphics;
134    prim_inline_graphics_outer_v006, prim_inline_graphics_outer_v01
135        => prim_inline_graphics_outer;
136    prim_tabular_v006, prim_tabular_v01 => prim_tabular;
137    prim_inline_frame_outer_v006, prim_inline_frame_outer_v01 => prim_inline_frame_outer;
138    prim_inline_frame_inner_v006, prim_inline_frame_inner_v01 => prim_inline_frame_inner;
139    prim_inline_frame_breakable_v006, prim_inline_frame_breakable_v01
140        => prim_inline_frame_breakable;
141    prim_block_frame_breakable_v006, prim_block_frame_breakable_v01
142        => prim_block_frame_breakable;
143}
144
145prims! {
146    "read-inline" (2) => prim_read_inline;
147    "read-block" (2) => prim_read_block;
148    // v0.0.6 (vminst.ml `BackendLineBreaking`): `bool -> bool -> context ->
149    // inline-boxes -> block-boxes` — the two leading bools select whether
150    // the paragraph's top/bottom edge is breakable across a page boundary.
151    "line-break" (4) => prim_line_break;
152    // `page -> (pbinfo -> page-content-scheme) -> (pbinfo -> page-parts) ->
153    // block-boxes -> document` (vminst.ml:1024, `BackendPageBreaking`) —
154    // v0.0.6's `page` ADT argument. 0.1's `page` ADT is gone; the v01 arm's
155    // first argument becomes a plain `length * length` instead, same arity
156    // and same `page_break_core` backing loop.
157    v006 "page-break" (4) => prim_page_break_v006;
158    v01  "page-break" (4) => prim_page_break_v01;
159
160    // `page-break-multicolumn` (vminst.ml:1065
161    // `BackendPageBreakingMultiColumn`) / `page-break-two-column`
162    // (vminst.ml:1041 `BackendPageBreakingTwoColumn`): same v006/v01 fork
163    // shape as `page-break` above.
164    v006 "page-break-multicolumn" (7) => prim_page_break_multicolumn_v006;
165    v01  "page-break-multicolumn" (7) => prim_page_break_multicolumn_v01;
166    v006 "page-break-two-column" (6) => prim_page_break_two_column_v006;
167    v01  "page-break-two-column" (6) => prim_page_break_two_column_v01;
168
169    // ---- int arithmetic (vminst.ml: Plus/Minus/Times/Divides/Mod) --------
170    "+" (2) => prim_int_add;
171    "-" (2) => prim_int_sub;
172    "*" (2) => prim_int_mul;
173    "/" (2) => prim_int_div;
174    "mod" (2) => prim_int_mod;
175
176    // ---- int comparisons (vminst.ml: EqualTo/GreaterThan/LessThan; the "<>"/">="/"<=" trio comes from primitives.cppo.ml's `general_table`,
177    // defined there as `LogicalNot (EqualTo ..)` / `LogicalNot (LessThan ..)`
178    // / `LogicalNot (GreaterThan ..)`, typed `int -> int -> bool`) ----------
179    "==" (2) => prim_int_eq;
180    "<>" (2) => prim_int_ne;
181    "<" (2) => prim_int_lt;
182    ">" (2) => prim_int_gt;
183    "<=" (2) => prim_int_le;
184    ">=" (2) => prim_int_ge;
185
186    // ---- 0.1 bitwise ops (dev-0-1-0 vminst.ml: PrimitiveBitShiftLeft :2495, PrimitiveBitShiftRight :2477, PrimitiveBand :2527,
187    // PrimitiveBor :2541, PrimitiveBxor :2513, PrimitiveBnot :2556).
188    // 0.0.6 upstream has none of these; `<<`/`>>` lex as ordinary
189    // BinopLt/BinopGt opsymbol runs under BOTH versions (lexer.rs:634-653)
190    // and simply stay unbound names under 0.0.6. --
191    v01 "<<" (2) => prim_bit_shift_left;
192    v01 ">>" (2) => prim_bit_shift_right;
193    v01 "band" (2) => prim_band;
194    v01 "bor" (2) => prim_bor;
195    v01 "bxor" (2) => prim_bxor;
196    v01 "bnot" (1) => prim_bnot;
197
198    // ---- bool (vminst.ml: LogicalAnd/LogicalOr/LogicalNot) ----------------
199    // NOTE: registered here as strict 2-arg primitives (both arguments are
200    // evaluated before the call, since primitive application is call-by-
201    // value). Real SATySFi short-circuits "&&"/"||" via elaboration
202    // (build-in `if`); that desugaring lives in the (out-of-scope) elaborator.
203    "&&" (2) => prim_bool_and;
204    "||" (2) => prim_bool_or;
205    "not" (1) => prim_bool_not;
206
207    // ---- float (vminst.ml: FloatPlus/FloatMinus/FloatTimes/FloatDivides, PrimitiveFloat, PrimitiveRound) ---------------------------------------
208    "+." (2) => prim_float_add;
209    "-." (2) => prim_float_sub;
210    "*." (2) => prim_float_mul;
211    "/." (2) => prim_float_div;
212    "float" (1) => prim_float_of_int;
213    "round" (1) => prim_round;
214
215    // ---- 0.1 float comparisons (saphe-split@b836d512 vminst.ml:2679-2740:
216    // PrimitiveFloatGreaterThan/-LessThan/-GreaterThanOrEqualTo/
217    // -LessThanOrEqualTo, named ">."/"<."/">=."/"<=."). Confirmed absent
218    // from 0.0.6 upstream (0 hits in either its v0.0.6 tag or dev-0-1-0's
219    // vminst.ml/primitives.cppo.ml) — genuinely v01-only, unlike "+."/"-."/
220    // "*."/"/." above; float.satyg's `abs`/`max`/`min` need `>=.`/`<=.`.
221    // All four lex as ordinary BinopGt/BinopLt opsymbol runs under both
222    // versions (lexer.rs:634-653), same as the bitwise "<<"/">>" above.
223    v01 ">." (2) => prim_float_gt;
224    v01 "<." (2) => prim_float_lt;
225    v01 ">=." (2) => prim_float_ge;
226    v01 "<=." (2) => prim_float_le;
227
228    // ---- length arithmetic (vminst.ml: LengthPlus/LengthMinus/LengthTimes/ LengthDivides/LengthLessThan/LengthGreaterThan) -----------------------
229    "+'" (2) => prim_length_add;
230    "-'" (2) => prim_length_sub;
231    "*'" (2) => prim_length_scale;
232    "/'" (2) => prim_length_div;
233    "<'" (2) => prim_length_lt;
234    ">'" (2) => prim_length_gt;
235
236    // ---- string (vminst.ml: Concat, PrimitiveArabic, PrimitiveSame) -------
237    "^" (2) => prim_string_concat;
238    "arabic" (1) => prim_arabic;
239    "string-same" (2) => prim_string_same;
240
241    // ---- list cons ----------------------------------------------------------
242    // Upstream makes `::` syntax (`UTListCons`/`ListCons`), not a primitive.
243    // This port's elaborator flattens every binary operator into
244    // `Apply(Apply(Var(op_text), lhs), rhs)` (see `elaborate.rs`'s
245    // operator-precedence fold), so `::` needs an env-bound primitive like
246    // `+`/`^`.
247    "::" (2) => prim_list_cons;
248
249    // ---- mutable-cell dereference (evaluator.cppo.ml `Dereference`) --------
250    // Upstream's "!" *constructs* a `Dereference` AST node that a later pass
251    // reduces (primitives.cppo.ml: `lambda1 (fun v1 -> Dereference(v1))`);
252    // this port has no such two-step split, so "!" is an ordinary strict
253    // primitive that dereferences directly — structural deviation only.
254    "!" (1) => prim_deref;
255
256    // ---- string, continued (vminst.ml: PrimitiveStringLength/StringSub/ StringExplode; low-priority additions verified against vminst.ml) ----
257    "string-length" (1) => prim_string_length;
258    "string-sub" (3) => prim_string_sub;
259    "string-explode" (1) => prim_string_explode;
260    "regexp-of-string" (1) => prim_regexp_of_string;
261    "string-match" (2) => prim_string_match;
262    "split-on-regexp" (2) => prim_split_on_regexp;
263
264    // ---- text embedding (vminst.ml:1707 PrimitiveEmbed: string -> inline- text; the interp body wraps the string as a one-element quoted text) --
265    "embed-string" (1) => prim_embed_string;
266
267    // ---- context ops -----------------------------------------------------
268    //
269    // vminst.ml:1434 `PrimitiveSetFontSize`: `~% (tLN @-> tCTX @-> tCTX)`.
270    "set-font-size" (2) => prim_set_font_size;
271    // vminst.ml:1449 `PrimitiveGetFontSize`: `~% (tCTX @-> tLN)`.
272    "get-font-size" (1) => prim_get_font_size;
273    // vminst.ml:1633 `PrimitiveSetLeading`: `~% (tLN @-> tCTX @-> tCTX)`,
274    // sets `ctx.leading` — the baseline-to-baseline distance, which is
275    // exactly our existing `Context::leading` field. (There is *also* a
276    // `set-min-gap-of-lines`, vminst.ml:1291-1292, which sets a *different*
277    // field, `min_gap_of_lines` — the minimum extra gap between two lines'
278    // bounding boxes, on top of `leading`. We don't model that separate
279    // field, so `set-leading` is the one that matches "baseline distance"
280    // and an existing Context field.)
281    "set-leading" (2) => prim_set_leading;
282    // vminst.ml:1396 `PrimitiveSetParagraphMargin`:
283    // `~% (tLN @-> tLN @-> tCTX @-> tCTX)`. Sets the new `paragraph_top`/
284    // `paragraph_bottom` fields (see context.rs); not wired into any
285    // box-producing primitive yet (a future `+p` would consult them).
286    "set-paragraph-margin" (3) => prim_set_paragraph_margin;
287    // vminst.ml:1648 `PrimitiveGetTextWidth`: `~% (tCTX @-> tLN)`.
288    "get-text-width" (1) => prim_get_text_width;
289    // vminst.ml:1247 `PrimitiveGetInitialContext`:
290    // `~% (tLN @-> tICMD tMATH @-> tCTX)` — a paragraph width and the
291    // *default math command* (the handler used for bare `${...}` math
292    // embedded directly in inline text). FAITHFUL: the second argument is
293    // interned via `Interp::register_math_command` and installed as
294    // `Context::math_command`, consulted by `read_inline`'s `EmbedMath` arm.
295    "get-initial-context" (2) => prim_get_initial_context;
296    // LOCAL, non-upstream primitive: `set-font-key : int -> context ->
297    // context`, sets `Context::font` directly to `FontKey(n)`. v0.0.6 has no
298    // primitive shaped like this at all — real font switching there goes
299    // through `set-font : script -> (string * float * float) -> context ->
300    // context` (choosing a font *by name* per script, vminst.ml's
301    // `PrimitiveSetFont`), which is far richer than this port's
302    // base-14-metrics-by-`FontKey` model can support. `set-font-key` is the
303    // minimal faithful-enough stand-in the `stdja-mini` stdlib package
304    // (lib-rustyfi/dist/packages/stdja-mini.satyh) needs to implement
305    // `\emph`/`\bold` by switching to the oblique/bold base-14 face
306    // (`FONT_OBLIQUE`/`FONT_BOLD` above) without inventing a whole font-name
307    // resolution layer. Out-of-range keys are accepted as-is (there is no
308    // registry to validate against yet); an unknown `FontKey` simply fails
309    // later, when a font metrics lookup for it comes up empty.
310    "set-font-key" (2) => prim_set_font_key;
311
312    // ---- box combinators (vminst.ml `HorzConcat`/`VertConcat`/ `BackendVertSkip`/`BackendFixedEmpty`/`BackendOuterEmpty`) ----------
313    //
314    // vminst.ml:803 `HorzConcat`: `~% (tIB @-> tIB @-> tIB)`.
315    "++" (2) => prim_inline_concat;
316    // vminst.ml:818 `VertConcat`: `~% (tBB @-> tBB @-> tBB)`.
317    "+++" (2) => prim_block_concat;
318    // vminst.ml:1757 `BackendFixedEmpty`: `~% (tLN @-> tIB)` — a fixed-width
319    // box with no stretch/shrink (`PureHorzBox::FixedEmpty`, hbox.rs).
320    "inline-skip" (1) => prim_inline_skip;
321    // vminst.ml:1771 `BackendOuterEmpty`: `~% (tLN @-> tLN @-> tLN @-> tIB)`,
322    // params `(widnat, widshrink, widstretch)` in that order — exactly the
323    // (natural, shrinkable, stretchable) field order `PureHorzBox::OuterEmpty`
324    // already uses, so this is a direct wrap, no new box variant needed.
325    "inline-glue" (3) => prim_inline_glue;
326    // vminst.ml:1171 `BackendVertSkip`: `~% (tLN @-> tBB)`, builds
327    // `VertFixedBreakable(len)` — our existing `VertBox::Skip(len)`.
328    "block-skip" (1) => prim_block_skip;
329
330    // ---- the reflow marker-box
331    // constructors. No vminst.ml entry — these are NEW primitives (not an
332    // upstream port), the minimal hook that is unavoidable since
333    // list/emphasis structure is 100% interpreted `.satyh` with no existing
334    // Rust interception point. Both take a plain `int` tag (there is no
335    // surface syntax to pass a Rust enum literal from `.satyh` source) —
336    // see `prim_list_mark`/`prim_inline_mark`'s doc comments for the exact
337    // tag encoding. Registered for `Both` versions (harmless/unused under
338    // 0.0.6 today; the 0.0.6 `itemize.satyh` may be wired to them later). ----
339    "list-mark" (1) => prim_list_mark;
340    "inline-mark" (1) => prim_inline_mark;
341
342    // `|>` (reverse application) is NOT a primitive: it is elaborated
343    // directly to `Apply(f, x)` (see `elaborate.rs`'s `climb`).
344
345    // ---- float trig / log / exp / rounding (vminst.ml 2729-2880) ----------
346    "sin" (1) => prim_sin;
347    "asin" (1) => prim_asin;
348    "cos" (1) => prim_cos;
349    "acos" (1) => prim_acos;
350    "tan" (1) => prim_tan;
351    "atan" (1) => prim_atan;
352    "atan2" (2) => prim_atan2;
353    "log" (1) => prim_log;
354    "exp" (1) => prim_exp;
355    // vminst.ml:2865/2880 `PrimitiveCeil`/`PrimitiveFloor`: both `float ->
356    // float` (NOT `int` — easy to mistype; contrast `round`, above, which
357    // does return `int`).
358    "ceil" (1) => prim_ceil;
359    "floor" (1) => prim_floor;
360    // vminst.ml:2319 `PrimitiveShowFloat`: `float -> string`, OCaml's
361    // `string_of_float`.
362    "show-float" (1) => prim_show_float;
363
364    // ---- byte-indexed string ops (vminst.ml 2056-2196) ---------------------
365    // vminst.ml:2159 `PrimitiveStringByteLength`: counts UTF-8 BYTES, unlike
366    // `string-length`'s Unicode-scalar-value count above.
367    "string-byte-length" (1) => prim_string_byte_length;
368    // vminst.ml:2123 `PrimitiveStringSubBytes`: byte-indexed `string-sub`.
369    "string-sub-bytes" (3) => prim_string_sub_bytes;
370    // vminst.ml:2196 `PrimitiveStringUnexplode`: inverse of `string-explode`.
371    "string-unexplode" (1) => prim_string_unexplode;
372
373    // ---- 0.1 Unicode string prims (dev-0-1-0 vminst.ml :2050/:2066/:2082),
374    // via the `unicode-normalization`/`unicode-segmentation` crates. --------
375    v01 "normalize-string-to-nfc" (1) => prim_normalize_string_to_nfc;
376    v01 "normalize-string-to-nfd" (1) => prim_normalize_string_to_nfd;
377    v01 "split-grapheme-cluster" (1) => prim_split_grapheme_cluster;
378
379    // ---- diagnostics (vminst.ml 2056, 3133) --------------------------------
380    // vminst.ml:2056 `PrimitiveDisplayMessage`: `string -> unit`. Upstream
381    // prints to stdout (`print_endline`); see `prim_display_message`'s doc
382    // comment for why this port deliberately prints to stderr instead.
383    "display-message" (1) => prim_display_message;
384    // vminst.ml:3133 `AbortWithMessage`: `string -> 'a` — raises a dynamic
385    // error carrying the message verbatim.
386    "abort-with-message" (1) => prim_abort_with_message;
387    // ---- images (raster images). Mirrors v0.0.6 vminstdef.yaml:540/:554. -
388    "load-image"          (1) => prim_load_image;         // string -> image
389    "use-image-by-width"  (2) => prim_use_image_by_width; // image -> length -> inline-boxes
390    // `load-pdf-image : string -> int -> image` (v0.0.6 vminstdef.yaml:525;
391    // dev-0-1-0 `PrimitiveLoadPdfImage` — same name/type/body across both
392    // versions).
393    "load-pdf-image" (2) => prim_load_pdf_image;
394    // `read-file : string -> list string` (dev-0-1-0 vminst.ml :3073) —
395    // REAL, `load-image`'s cwd-relative-path precedent
396    // (`prim_load_image`'s doc comment above): job-directory resolution
397    // isn't plumbed into `Interp` at all yet, so this resolves against the
398    // process cwd instead of upstream's job directory — documented
399    // deviation, see `prim_read_file`'s own doc comment.
400    v01 "read-file" (1) => prim_read_file;
401    // `register-document-information : document-information-dictionary ->
402    // unit` (dev-0-1-0 vminst.ml :2978) — REAL:
403    // stores into `Interp::doc_info` (last-write-wins), drained into
404    // `DocExtras::doc_info`, emitted as the PDF `/Info` dictionary by both
405    // writers.
406    v01 "register-document-information" (1) => prim_register_document_information;
407    // ==== graphics primitives ====
408    // Paths, fill/stroke, and the `inline-graphics` on-page sink. Argument
409    // order transcribed from `tools/gencode/vminst.ml`: `start-path` :713,
410    // `line-to` :727, `terminate-path` :759, `close-with-line` :773,
411    // `fill` :2398, `stroke` :2381, `inline-graphics` :1872.
412    "start-path" (1) => prim_start_path;
413    "line-to" (2) => prim_line_to;
414    "terminate-path" (1) => prim_terminate_path;
415    "close-with-line" (1) => prim_close_with_line;
416    "fill" (2) => prim_fill;
417    "stroke" (3) => prim_stroke;
418    // These three take a graphics-producing CALLBACK whose result shape
419    // forks (`graphics list` vs one `graphics` collection) — see
420    // `version_forked_prims!`'s doc comment.
421    v006 "inline-graphics" (4) => prim_inline_graphics_v006;
422    v01  "inline-graphics" (4) => prim_inline_graphics_v01;
423    // `tabular : (cell list) list -> (length list -> length list ->
424    // graphics list) -> inline-boxes` (vminst.ml:539);
425    v006 "tabular" (2) => prim_tabular_v006;
426    v01  "tabular" (2) => prim_tabular_v01;
427    // `inline-graphics-outer : length -> length -> (length -> point ->
428    // graphics list) -> inline-boxes` (vminst.ml:1891
429    // `BackendInlineGraphicsOuter`).
430    v006 "inline-graphics-outer" (3) => prim_inline_graphics_outer_v006;
431    v01  "inline-graphics-outer" (3) => prim_inline_graphics_outer_v01;
432    // ---- gr.satyh prims — see tools/gencode/vminst.ml for exact
433    // signatures: `bezier-to` :742, `close-with-bezier` :787, `shift-path`
434    // :663, `linear-transform-path` :678, `shift-graphics` :2451,
435    // `linear-transform-graphics` :2432, `get-graphics-bbox` :2466,
436    // `get-path-bbox` :696, `dashed-stroke` :2414, `draw-text` :2363.
437    "bezier-to" (4) => prim_bezier_to;
438    "close-with-bezier" (3) => prim_close_with_bezier;
439    "shift-path" (2) => prim_shift_path;
440    "linear-transform-path" (5) => prim_linear_transform_path;
441    "shift-graphics" (2) => prim_shift_graphics;
442    "linear-transform-graphics" (5) => prim_linear_transform_graphics;
443    // `get-graphics-bbox`: v0.0.6 = un-optioned pair (vminst.ml:2466); v0.1
444    // wraps `option` (dev-0-1-0 vminst.ml:2301).
445    v006 "get-graphics-bbox" (1) => prim_get_graphics_bbox_v006;
446    v01  "get-graphics-bbox" (1) => prim_get_graphics_bbox_v01;
447    "get-path-bbox" (1) => prim_get_path_bbox;
448    "dashed-stroke" (4) => prim_dashed_stroke;
449    "draw-text" (2) => prim_draw_text;
450    // ---- 0.1 graphics-collection prims (dev-0-1-0 vminst.ml :3105/:3119).
451    // `graphics` is a collection under 0.1 — these two build/wrap it; the 6
452    // hidden callback-result retypes that make a `graphics`-producing
453    // callback return ONE collection instead of `list graphics` live at
454    // their existing (untagged `Both`) rows below, coerced per-version by
455    // `coerce_graphics_result`.
456    v01 "unite-graphics" (1) => prim_unite_graphics;
457    v01 "clip-graphics-by-path" (2) => prim_clip_graphics_by_path;
458
459    // ==== `pervasives.satyh` prims. Argument order transcribed from
460    // `tools/gencode/vminst.ml`: `get-natural-metrics` :2020,
461    // `inline-frame-outer` :1787, `set-manual-rising` :1661,
462    // `script-guard` :1908, `discretionary` :1969. ====
463    "get-natural-metrics" (1) => prim_get_natural_metrics;
464    // A `deco`'s result shape forks the same way, and its closure fires
465    // LONG after (a post-page-break pass), so the generation must be
466    // captured here — see `version_forked_prims!`/`DecoEntry`.
467    v006 "inline-frame-outer" (3) => prim_inline_frame_outer_v006;
468    v01  "inline-frame-outer" (3) => prim_inline_frame_outer_v01;
469    // vminst.ml:1807 `BackendInnerFrame`: same `tPADS @-> tDECO @-> tIB @->
470    // tIB` as `inline-frame-outer`.
471    v006 "inline-frame-inner" (3) => prim_inline_frame_inner_v006;
472    v01  "inline-frame-inner" (3) => prim_inline_frame_inner_v01;
473    "set-manual-rising" (2) => prim_set_manual_rising;
474    "script-guard" (2) => prim_script_guard;
475    "discretionary" (4) => prim_discretionary;
476
477    // `get-axis-height` (vminst.ml:1739 `PrimitiveGetAxisHeight`) —
478    // STAND-IN, see body; REMOVED in 0.1 (superseded by
479    // `get-math-axis-height-ratio`).
480    v006 "get-axis-height" (1) => prim_get_axis_height;
481
482    // ==== page-break-hook callback seam + cross-reference fixpoint ====
483    "hook-page-break" (1) => prim_hook_page_break;
484    "hook-page-break-block" (1) => prim_hook_page_break_block;
485    "register-cross-reference" (2) => prim_register_cross_reference;
486    "get-cross-reference" (1) => prim_get_cross_reference;
487    "probe-cross-reference" (1) => prim_probe_cross_reference;
488
489    // ==== `annot.satyh`'s prim surface (link annotations + the frame/
490    // script stand-ins it needs to type-check) ====
491    "get-leftmost-script" (1) => prim_get_leftmost_script;
492    "get-rightmost-script" (1) => prim_get_rightmost_script;
493    v006 "inline-frame-breakable" (3) => prim_inline_frame_breakable_v006;
494    v01  "inline-frame-breakable" (3) => prim_inline_frame_breakable_v01;
495    "register-destination" (2) => prim_register_destination;
496    "register-link-to-uri" (6) => prim_register_link_to_uri;
497    "register-link-to-location" (6) => prim_register_link_to_location;
498
499    // ==== the faithful `Value::Math` primitive layer `math.satyh` is built
500    // out of. 19 fork into v006/v01 pairs (v006 = zero behavior change; v01
501    // consumes/produces `Value::MathBoxes`); 5 more are REMOVED in 0.1
502    // outright (v006-tagged, untouched bodies). ====
503    v006 "math-char" (2) => prim_math_char_v006;
504    v01  "math-char" (3) => prim_math_char_v01;
505    v006 "math-big-char" (2) => prim_math_big_char_v006;
506    v01  "math-big-char" (3) => prim_math_big_char_v01;
507    v006 "math-char-with-kern" (4) => prim_math_char_with_kern_v006;
508    v01  "math-char-with-kern" (5) => prim_math_char_with_kern_v01;
509    v006 "math-big-char-with-kern" (4) => prim_math_big_char_with_kern_v006;
510    v01  "math-big-char-with-kern" (5) => prim_math_big_char_with_kern_v01;
511    v006 "math-concat" (2) => prim_math_concat_v006;
512    v01  "math-concat" (2) => prim_math_concat_v01;
513    v006 "math-group" (3) => prim_math_group_v006;
514    v01  "math-group" (3) => prim_math_group_v01;
515    v006 "math-sup" (2) => prim_math_sup_v006;
516    v01  "math-sup" (3) => prim_math_sup_v01;
517    v006 "math-sub" (2) => prim_math_sub_v006;
518    v01  "math-sub" (3) => prim_math_sub_v01;
519    v006 "math-frac" (2) => prim_math_frac_v006;
520    v01  "math-frac" (3) => prim_math_frac_v01;
521    v006 "math-radical" (2) => prim_math_radical_v006;
522    v01  "math-radical" (3) => prim_math_radical_v01;
523    v006 "math-lower" (2) => prim_math_lower_v006;
524    v01  "math-lower" (3) => prim_math_lower_v01;
525    v006 "math-upper" (2) => prim_math_upper_v006;
526    v01  "math-upper" (3) => prim_math_upper_v01;
527    // REMOVED in 0.1 outright — v006-tagged, untouched bodies.
528    v006 "math-pull-in-scripts" (3) => prim_math_pull_in_scripts;
529    v006 "math-color" (2) => prim_math_color;
530    v006 "math-char-class" (2) => prim_math_char_class;
531    v006 "math-variant-char" (2) => prim_math_variant_char;
532    // ==== the `set-math-variant-char`/`get-left-math-class`/
533    // `get-right-math-class` trio: no bundled `.satyh` consumer needed yet,
534    // built on `Context::math_variant_char_map` + `VariantCharPending`.
535    // Forked v006/v01. ====
536    v006 "set-math-variant-char" (4) => prim_set_math_variant_char_v006;
537    v01  "set-math-variant-char" (3) => prim_set_math_variant_char_v01;
538    v006 "get-left-math-class" (2) => prim_get_left_math_class_v006;
539    v01  "get-left-math-class" (1) => prim_get_left_math_class_v01;
540    v006 "get-right-math-class" (2) => prim_get_right_math_class_v006;
541    v01  "get-right-math-class" (1) => prim_get_right_math_class_v01;
542    v006 "math-paren" (3) => prim_math_paren_v006;
543    v01  "math-paren" (4) => prim_math_paren_v01;
544    v006 "math-paren-with-middle" (4) => prim_math_paren_with_middle_v006;
545    v01  "math-paren-with-middle" (5) => prim_math_paren_with_middle_v01;
546    // REMOVED in 0.1 outright.
547    v006 "text-in-math" (2) => prim_text_in_math;
548    "convert-string-for-math" (3) => prim_convert_string_for_math;
549    v006 "embed-math" (2) => prim_embed_math_v006;
550    v01  "embed-math" (2) => prim_embed_math_v01;
551    "set-math-command" (2) => prim_set_math_command;
552    // `set-math-font` forks in its argument, not its effect: 0.0.6 takes the
553    // math face's ABBREV (`string`), saphe-split takes the opaque `font`
554    // handle (`tFONTKEY`). Both end at the same `Context::math_font`.
555    v006 "set-math-font" (2) => prim_set_math_font_v006;
556    v01  "set-math-font" (2) => prim_set_math_font_v01;
557    // LOCAL, non-upstream, V0_1-only — the port's spelling for upstream's
558    // internal `LoadSingleFont{path}` node; see `prim_load_single_font`.
559    v01  "load-single-font" (1) => prim_load_single_font;
560    v006 "space-between-maths" (3) => prim_space_between_maths_v006;
561    v01  "space-between-maths" (3) => prim_space_between_maths_v01;
562    // ==== NEW in 0.1 — `math-text`/`math-boxes` split + `read-math` + the
563    // hidden `val math`-without-scripts wrapper prim. ====
564    v01 "read-math" (2) => prim_read_math;
565    v01 "stringify-math" (2) => prim_stringify_math;
566    v01 "set-math-char" (4) => prim_set_math_char;
567    v01 "set-math-char-class" (2) => prim_set_math_char_class;
568    v01 "get-math-char-class" (1) => prim_get_math_char_class;
569    v01 "embed-inline-to-math" (2) => prim_embed_inline_to_math;
570    v01 "get-math-axis-height-ratio" (1) => prim_get_math_axis_height_ratio;
571    v01 "%math-attach-scripts" (4) => prim_math_attach_scripts;
572
573    // ==== hyphenation/unidata loader + setter stand-ins, V0_1-only
574    // (genuinely absent from 0.0.6 upstream). FAITHFUL types
575    // (`prim_types.rs`); ACCEPT-AND-RETURN bodies, not hard-error
576    // stand-ins like `stringify-math` above — std-ja evaluates `val
577    // unidata = load-unicode-char-database …` at module LOAD time, so an
578    // erroring stand-in would break every consumer at load, not just at
579    // use. ====
580    v01 "load-hyphenation-dictionary" (1) => prim_load_hyphenation_dictionary;
581    v01 "load-unicode-char-database"  (3) => prim_load_unicode_char_database;
582    v01 "set-hyphenation-dictionary"  (2) => prim_set_hyphenation_dictionary;
583    v01 "set-unicode-char-database"   (2) => prim_set_unicode_char_database;
584
585    "raise-inline" (2) => prim_raise_inline;
586    "embed-block-breakable" (2) => prim_embed_block_breakable;
587    "unite-path" (2) => prim_unite_path;
588    "set-min-gap-of-lines" (2) => prim_set_min_gap_of_lines;
589
590    // ==== context-setter + box-combinator prims `code.satyh`/
591    // `itemize.satyh` need. Argument order from `tools/gencode/vminst.ml`:
592    // `set-text-color` :1603, `get-text-color` :1618, `set-hyphen-penalty`
593    // :1692, `set-space-ratio` :1309, `split-into-lines` :2269,
594    // `block-frame-breakable` :1090, `embed-block-top` :1145, `set-font`
595    // :1463; `set-code-text-command`/`get-natural-length` have no
596    // vminst.ml entry. ====
597    "set-text-color" (2) => prim_set_text_color;
598    "get-text-color" (1) => prim_get_text_color;
599    "set-hyphen-penalty" (2) => prim_set_hyphen_penalty;
600    // `set-hyphen-min : int -> int -> context -> context` (left_hyphen_min,
601    // right_hyphen_min).
602    "set-hyphen-min" (3) => prim_set_hyphen_min;
603    "set-space-ratio" (4) => prim_set_space_ratio;
604    "set-space-ratio-between-scripts" (6) => prim_set_space_ratio_between_scripts;
605    "split-into-lines" (1) => prim_split_into_lines;
606    v006 "block-frame-breakable" (4) => prim_block_frame_breakable_v006;
607    v01  "block-frame-breakable" (4) => prim_block_frame_breakable_v01;
608    "embed-block-top" (3) => prim_embed_block_top;
609    // `set-font` forks in its SECOND argument's head only: 0.0.6's
610    // `string * float * float` vs saphe-split's `font * float * float`.
611    v006 "set-font" (3) => prim_set_font_v006;
612    v01  "set-font" (3) => prim_set_font_v01;
613    // `get-font` (vminstdef.yaml:1350) forks in its RESULT's head, for the
614    // same reason and along the same seam.
615    v006 "get-font" (2) => prim_get_font_v006;
616    v01  "get-font" (2) => prim_get_font_v01;
617    "set-code-text-command" (2) => prim_set_code_text_command;
618    "get-natural-length" (1) => prim_get_natural_length;
619
620    // ==== `set-dominant-wide-script`/`set-dominant-narrow-script`/
621    // `set-language` are FAITHFUL stores with real getter round-trips
622    // below; `register-outline` is likewise FAITHFUL (drives real PDF
623    // `/Outlines` bookmarks). Only `set-every-word-break` remains a
624    // STAND-IN (accepted and dropped). ====
625    "set-dominant-wide-script" (2) => prim_set_dominant_wide_script;
626    "set-dominant-narrow-script" (2) => prim_set_dominant_narrow_script;
627    "set-language" (3) => prim_set_language;
628    "get-dominant-wide-script" (1) => prim_get_dominant_wide_script;
629    "get-dominant-narrow-script" (1) => prim_get_dominant_narrow_script;
630    "get-language" (2) => prim_get_language;
631    "set-every-word-break" (3) => prim_set_every_word_break;
632    "register-outline" (1) => prim_register_outline;
633    "extract-string" (1) => prim_extract_string;
634
635    // ==== proof.satyh/footnote-scheme.satyh prims: `embed-block-bottom`
636    // :1185, `line-stack-bottom` :1229 (both `tools/gencode/vminst.ml`),
637    // `add-footnote` :1130. ====
638    "embed-block-bottom" (3) => prim_embed_block_bottom;
639    "line-stack-bottom" (1) => prim_line_stack_bottom;
640    "line-stack-top" (1) => prim_line_stack_top;
641    "add-footnote" (1) => prim_add_footnote;
642
643    // ==== three PURE text-info prims — `get-initial-text-info` :953,
644    // `deepen-indent` :921, `break` :935 (tools/gencode/vminst.ml,
645    // text-mode). The text/html backends are OUT of scope for this PDF
646    // port, so all three live in the single shared env (upstream keys
647    // prims per mode).
648    //
649    // `get-initial-text-info` forks: v0.0.6 (vminst.ml:953) is `unit ->
650    // text-info`; v0.1 (dev-0-1-0 vminst.ml:904-925) threads a text-mode
651    // default math command + math-scripts stringifier into `tctxsub`. The
652    // v01 body ACCEPTS AND DROPS both (STAND-IN, same degenerate policy as
653    // `stringify-math`) — both bodies return `TextInfo{indent: 0}`. ====
654    v006 "get-initial-text-info" (1) => prim_get_initial_text_info_v006;
655    v01  "get-initial-text-info" (2) => prim_get_initial_text_info_v01;
656    "deepen-indent" (2) => prim_deepen_indent;
657    "break" (1) => prim_break;
658}
659
660/// The base environment v0.0.6 `document` programs start in. Back-compat
661/// wrapper over `base_env_with_version(V0_0)`.
662pub fn base_env() -> BaseEnv {
663    base_env_with_version(RustyfiVersion::V0_0)
664}
665
666/// The base environment for a given target version — filters `PRIM_DEFS` by
667/// `VersionSpan::allows`, so e.g. a `V0_1` env binds `prim_page_break_v01`
668/// under the name `"page-break"`, never `prim_page_break_v006`. The five
669/// bare-constant `env.define`s below (`inline-fil`/`inline-nil`/`block-nil`/
670/// `omit-skip-after`/`clear-page`) live outside `PrimDef`/`VersionSpan` and
671/// stay unconditional — all five exist in 0.1 upstream too (audited against
672/// `dev-0-1-0:src/frontend/primitives.cppo.ml`); `tests/v01_prims_scalar.rs`'s
673/// `bare_constants_bound_under_v01` proves it.
674pub fn base_env_with_version(version: RustyfiVersion) -> BaseEnv {
675    let mut env = BaseEnv::new();
676    for def in PRIM_DEFS {
677        if !def.version.allows(version) {
678            continue;
679        }
680        env.define(
681            def.name,
682            Value::Prim {
683                def,
684                applied: Vec::new(),
685            },
686        );
687    }
688    env.define(
689        "inline-fil",
690        Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::OuterFil)]),
691    );
692    // `inline-nil`/`block-nil`: no vminst.ml entry — v0.0.6 gets the empty
693    // list for free from literal `{}`/`<>` syntax, which this port's syntax
694    // layer doesn't produce standalone; these constants are the equivalent
695    // value bound to a name.
696    env.define("inline-nil", Value::InlineBoxes(Vec::new()));
697    env.define("block-nil", Value::BlockBoxes(Vec::new()));
698    // `omit-skip-after : inline-boxes` (`primitives.cppo.ml:567`) — a bare
699    // CONSTANT marking `HorzOmitSkipAfter`, a line-breaking hint to drop the
700    // interword glue that would otherwise follow (used at the tail of
701    // `math.satyh`'s `\eqn`/`\math-list`/`\align`). STAND-IN: this port's
702    // line-breaker has no such marker box, so it's the empty `inline-boxes`
703    // list — never consulted, since none of those wrappers is called by
704    // the file itself.
705    env.define("omit-skip-after", Value::InlineBoxes(Vec::new()));
706    // `clear-page : block-boxes` (`primitives.cppo.ml:569`) — a single-
707    // element list carrying `VertBox::ClearPage`, which `chop_page`
708    // (rustyfi-backend) treats as "end this page here". FAITHFUL.
709    env.define("clear-page", Value::BlockBoxes(vec![VertBox::ClearPage]));
710    // `here : string` — upstream `here` is a LEXER keyword expanding at lex
711    // time to the source file's directory (`Filename.dirname`). This port
712    // has no such lexer entry (`here` lexes as a plain `Token::Var`), so
713    // it's a V0_1-only nullary CONSTANT bound to the empty string. Never
714    // dereferenced as a real path: its consumers (`unidata.satyh`/
715    // `hyph-english.satyh`) feed `here ^ …` into the `load-*` stand-ins
716    // above, which drop the path unread.
717    if version == RustyfiVersion::V0_1 {
718        env.define("here", Value::Str(String::new()));
719    }
720    env
721}
722
723// ---- argument extractors ------------------------------------------------------
724
725fn as_context(v: Value) -> Result<Context, EvalError> {
726    match v {
727        Value::Context(c) => Ok(*c),
728        other => eval_error(format!("expected a context, got {}", other.type_name())),
729    }
730}
731
732fn as_text_info(v: Value) -> Result<TextInfo, EvalError> {
733    match v {
734        Value::TextInfo(t) => Ok(t),
735        other => eval_error(format!("expected a text-info, got {}", other.type_name())),
736    }
737}
738
739fn as_hyphenation(v: Value) -> Result<HyphenLang, EvalError> {
740    match v {
741        Value::Hyphenation(tag) => Ok(tag),
742        other => eval_error(format!("expected a hyphenation, got {}", other.type_name())),
743    }
744}
745
746fn as_inline_text(v: Value) -> Result<(Rc<Vec<IText>>, Env), EvalError> {
747    match v {
748        Value::InlineText { elems, env } => Ok((elems, env)),
749        other => eval_error(format!("expected inline-text, got {}", other.type_name())),
750    }
751}
752
753fn as_block_text(v: Value) -> Result<(Rc<Vec<BText>>, Env), EvalError> {
754    match v {
755        Value::BlockText { elems, env } => Ok((elems, env)),
756        other => eval_error(format!("expected block-text, got {}", other.type_name())),
757    }
758}
759
760fn as_inline_boxes(v: Value) -> Result<Vec<HorzBox>, EvalError> {
761    match v {
762        Value::InlineBoxes(b) => Ok(b),
763        other => eval_error(format!("expected inline-boxes, got {}", other.type_name())),
764    }
765}
766
767fn as_block_boxes(v: Value) -> Result<Vec<VertBox>, EvalError> {
768    match v {
769        Value::BlockBoxes(b) => Ok(b),
770        other => eval_error(format!("expected block-boxes, got {}", other.type_name())),
771    }
772}
773
774fn as_int(v: Value) -> Result<i64, EvalError> {
775    match v {
776        Value::Int(n) => Ok(n),
777        other => eval_error(format!("expected int, got {}", other.type_name())),
778    }
779}
780
781fn as_float(v: Value) -> Result<f64, EvalError> {
782    match v {
783        Value::Float(x) => Ok(x),
784        other => eval_error(format!("expected float, got {}", other.type_name())),
785    }
786}
787
788fn as_bool(v: Value) -> Result<bool, EvalError> {
789    match v {
790        Value::Bool(b) => Ok(b),
791        other => eval_error(format!("expected bool, got {}", other.type_name())),
792    }
793}
794
795fn as_str(v: Value) -> Result<String, EvalError> {
796    match v {
797        Value::Str(s) => Ok(s),
798        other => eval_error(format!("expected string, got {}", other.type_name())),
799    }
800}
801
802// `regexp-of-string : string -> regexp` — the port models a `regexp` as its
803// underlying pattern string, so this is the identity on the string.
804fn prim_regexp_of_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
805    let s = as_str(args.pop().unwrap())?;
806    Ok(Value::Str(s))
807}
808
809// `string-match : regexp -> string -> bool` — whether `input` matches the
810// pattern in full (anchored). Only the character-class subset `satysfi-base`'s
811// `char.satyg` uses (`[…]`, with `a-z` ranges and an optional leading `^`
812// negation) is modeled; any other pattern is compared literally.
813fn prim_string_match(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
814    let input = as_str(args.pop().unwrap())?;
815    let pattern = as_str(args.pop().unwrap())?;
816    Ok(Value::Bool(regexp_full_match(&pattern, &input)))
817}
818
819fn regexp_full_match(pattern: &str, input: &str) -> bool {
820    let p: Vec<char> = pattern.chars().collect();
821    if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
822        // A character class matches exactly one character.
823        let mut chars = input.chars();
824        match (chars.next(), chars.next()) {
825            (Some(c), None) => char_in_class(&p[1..p.len() - 1], c),
826            _ => false,
827        }
828    } else {
829        input == pattern
830    }
831}
832
833// `split-on-regexp : regexp -> string -> (int * string) list` — split `input`
834// at every character matching the (single-character) pattern, pairing each
835// resulting segment with its starting code-point offset. Handles the pattern
836// forms base uses: a `[…]` class, an escaped literal (`\.`), or a bare
837// literal character; anything else never matches (one segment = the whole
838// string).
839fn prim_split_on_regexp(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
840    let input = as_str(args.pop().unwrap())?;
841    let pattern = as_str(args.pop().unwrap())?;
842    let is_delim = single_char_matcher(&pattern);
843    let mut segments: Vec<Value> = Vec::new();
844    let mut seg_start = 0usize;
845    let mut cur = String::new();
846    for (idx, c) in input.chars().enumerate() {
847        if is_delim(c) {
848            segments.push(Value::Tuple(vec![
849                Value::Int(seg_start as i64),
850                Value::Str(std::mem::take(&mut cur)),
851            ]));
852            seg_start = idx + 1;
853        } else {
854            cur.push(c);
855        }
856    }
857    segments.push(Value::Tuple(vec![
858        Value::Int(seg_start as i64),
859        Value::Str(cur),
860    ]));
861    Ok(Value::List(segments))
862}
863
864/// A predicate matching one character against a `regexp` pattern's single-char
865/// forms (a `[…]` class, an escaped literal `\X`, or a bare literal char).
866fn single_char_matcher(pattern: &str) -> Box<dyn Fn(char) -> bool> {
867    let p: Vec<char> = pattern.chars().collect();
868    if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
869        let cls: Vec<char> = p[1..p.len() - 1].to_vec();
870        Box::new(move |c| char_in_class(&cls, c))
871    } else if p.len() == 2 && p[0] == '\\' {
872        let lit = p[1];
873        Box::new(move |c| c == lit)
874    } else if p.len() == 1 {
875        let lit = p[0];
876        Box::new(move |c| c == lit)
877    } else {
878        Box::new(|_| false)
879    }
880}
881
882fn char_in_class(cls: &[char], c: char) -> bool {
883    let (neg, cls) = match cls.first() {
884        Some('^') => (true, &cls[1..]),
885        _ => (false, cls),
886    };
887    let mut i = 0;
888    let mut found = false;
889    while i < cls.len() {
890        if i + 2 < cls.len() && cls[i + 1] == '-' {
891            if cls[i] <= c && c <= cls[i + 2] {
892                found = true;
893            }
894            i += 3;
895        } else {
896            if cls[i] == c {
897                found = true;
898            }
899            i += 1;
900        }
901    }
902    found ^ neg
903}
904
905fn as_length(v: Value) -> Result<Length, EvalError> {
906    match v {
907        Value::Length(l) => Ok(l),
908        other => eval_error(format!("expected length, got {}", other.type_name())),
909    }
910}
911
912fn as_list(v: Value) -> Result<Vec<Value>, EvalError> {
913    match v {
914        Value::List(items) => Ok(items),
915        other => eval_error(format!("expected list, got {}", other.type_name())),
916    }
917}
918
919fn as_image(v: Value) -> Result<ImageId, EvalError> {
920    match v {
921        Value::Image(id) => Ok(id),
922        other => eval_error(format!("expected image, got {}", other.type_name())),
923    }
924}
925
926// ---- graphics argument extractors ------------------------------------------
927
928/// `point` = `Value::Tuple([Length, Length])` (mirrors `evalUtil.ml:228`'s
929/// point extraction).
930fn as_point(v: Value) -> Result<Point, EvalError> {
931    match v {
932        Value::Tuple(vs) if vs.len() == 2 => {
933            let mut it = vs.into_iter();
934            let x = as_length(it.next().unwrap())?;
935            let y = as_length(it.next().unwrap())?;
936            Ok((x, y))
937        }
938        other => eval_error(format!(
939            "expected a point (length * length), got {}",
940            other.type_name()
941        )),
942    }
943}
944
945/// `color` = `Value::Ctor("Gray"|"RGB"|"CMYK", ..)` (mirrors
946/// `evalUtil.ml:124`'s `get_color` exactly — a wrong shape here would
947/// surface only at draw time).
948fn as_color(v: Value) -> Result<Color, EvalError> {
949    match v {
950        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
951            ("Gray", Some(p)) => Ok(Color::Gray(as_float(p)?)),
952            ("RGB", Some(Value::Tuple(vs))) if vs.len() == 3 => {
953                let mut it = vs.into_iter();
954                let r = as_float(it.next().unwrap())?;
955                let g = as_float(it.next().unwrap())?;
956                let b = as_float(it.next().unwrap())?;
957                Ok(Color::Rgb(r, g, b))
958            }
959            ("CMYK", Some(Value::Tuple(vs))) if vs.len() == 4 => {
960                let mut it = vs.into_iter();
961                let c = as_float(it.next().unwrap())?;
962                let m = as_float(it.next().unwrap())?;
963                let y = as_float(it.next().unwrap())?;
964                let k = as_float(it.next().unwrap())?;
965                Ok(Color::Cmyk(c, m, y, k))
966            }
967            (other, _) => eval_error(format!(
968                "expected a color (Gray/RGB/CMYK), got variant '{other}'"
969            )),
970        },
971        other => eval_error(format!("expected a color, got {}", other.type_name())),
972    }
973}
974
975/// `script` = nullary `Value::Ctor` (prim_types.rs `script_decl`); mirrors
976/// upstream `get_script` (evalUtil.ml:235-241).
977fn as_script(v: Value) -> Result<Script, EvalError> {
978    match v {
979        Value::Ctor(name, None) => match name.as_str() {
980            "HanIdeographic" => Ok(Script::HanIdeographic),
981            "Kana" => Ok(Script::Kana),
982            "Latin" => Ok(Script::Latin),
983            "OtherScript" => Ok(Script::OtherScript),
984            other => eval_error(format!("expected a script, got variant '{other}'")),
985        },
986        other => eval_error(format!("expected a script, got {}", other.type_name())),
987    }
988}
989
990/// Inverse of [`as_script`] (upstream `make_script_value`, evalUtil.ml:244).
991fn make_script_value(s: Script) -> Value {
992    let name = match s {
993        Script::HanIdeographic => "HanIdeographic",
994        Script::Kana => "Kana",
995        Script::Latin => "Latin",
996        Script::OtherScript => "OtherScript",
997    };
998    Value::Ctor(name.to_string(), None)
999}
1000
1001/// `language` = nullary `Value::Ctor` (prim_types.rs `language_decl`);
1002/// mirrors upstream `get_language_system` (evalUtil.ml:262).
1003fn as_language(v: Value) -> Result<Language, EvalError> {
1004    match v {
1005        Value::Ctor(name, None) => match name.as_str() {
1006            "Japanese" => Ok(Language::Japanese),
1007            "English" => Ok(Language::English),
1008            "NoLanguageSystem" => Ok(Language::NoLanguageSystem),
1009            other => eval_error(format!("expected a language, got variant '{other}'")),
1010        },
1011        other => eval_error(format!("expected a language, got {}", other.type_name())),
1012    }
1013}
1014
1015/// Inverse of [`as_language`] (upstream `make_language_system_value`).
1016fn make_language_value(l: Language) -> Value {
1017    let name = match l {
1018        Language::Japanese => "Japanese",
1019        Language::English => "English",
1020        Language::NoLanguageSystem => "NoLanguageSystem",
1021    };
1022    Value::Ctor(name.to_string(), None)
1023}
1024
1025/// `page` = `Value::Ctor("A4Paper"|.., None | Some(Tuple[Length;2]))`
1026/// — `page-break`'s first argument, mapped to the backend's
1027/// `PaperSize`.
1028fn as_page(v: Value) -> Result<PaperSize, EvalError> {
1029    match v {
1030        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1031            ("A0Paper", None) => Ok(PaperSize::A0),
1032            ("A1Paper", None) => Ok(PaperSize::A1),
1033            ("A2Paper", None) => Ok(PaperSize::A2),
1034            ("A3Paper", None) => Ok(PaperSize::A3),
1035            ("A4Paper", None) => Ok(PaperSize::A4),
1036            ("A5Paper", None) => Ok(PaperSize::A5),
1037            ("USLetter", None) => Ok(PaperSize::USLetter),
1038            ("USLegal", None) => Ok(PaperSize::USLegal),
1039            ("UserDefinedPaper", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1040                let mut it = vs.into_iter();
1041                let w = as_length(it.next().unwrap())?;
1042                let h = as_length(it.next().unwrap())?;
1043                Ok(PaperSize::UserDefined(w, h))
1044            }
1045            (other, _) => eval_error(format!(
1046                "expected a page (A4Paper/.../UserDefinedPaper), got variant '{other}'"
1047            )),
1048        },
1049        other => eval_error(format!("expected a page, got {}", other.type_name())),
1050    }
1051}
1052
1053/// v0.1's `page-break`'s first argument: a plain `(length * length)` tuple
1054/// — the `page` ADT (`as_page` above) no longer exists upstream in 0.1.
1055/// Maps straight into `PaperSize::UserDefined`, the exact same backend
1056/// value `as_page`'s own `UserDefinedPaper` arm produces: the retype drops
1057/// the ADT wrapper without changing what geometry `page-break` can
1058/// express, so only the source `Value` shape differs.
1059fn as_page_v01(v: Value) -> Result<PaperSize, EvalError> {
1060    match v {
1061        Value::Tuple(vs) if vs.len() == 2 => {
1062            let mut it = vs.into_iter();
1063            let w = as_length(it.next().unwrap())?;
1064            let h = as_length(it.next().unwrap())?;
1065            Ok(PaperSize::UserDefined(w, h))
1066        }
1067        other => eval_error(format!(
1068            "expected a page as (length * length), got {}",
1069            other.type_name()
1070        )),
1071    }
1072}
1073
1074/// `paddings` = `Value::Tuple([Length; 4])` in `(paddingL, paddingR,
1075/// paddingT, paddingB)` order (mirrors `evalUtil.ml`'s `get_paddings`).
1076/// `inline-frame-outer`'s first argument.
1077fn as_paddings(v: Value) -> Result<(Length, Length, Length, Length), EvalError> {
1078    match v {
1079        Value::Tuple(vs) if vs.len() == 4 => {
1080            let mut it = vs.into_iter();
1081            let l = as_length(it.next().unwrap())?;
1082            let r = as_length(it.next().unwrap())?;
1083            let t = as_length(it.next().unwrap())?;
1084            let b = as_length(it.next().unwrap())?;
1085            Ok((l, r, t, b))
1086        }
1087        other => eval_error(format!(
1088            "expected paddings (length * length * length * length), got {}",
1089            other.type_name()
1090        )),
1091    }
1092}
1093
1094/// `cell` = `Value::Ctor("NormalCell"|"EmptyCell"|"MultiCell", ..)` (mirrors
1095/// `evalUtil.ml:102`'s `get_cell`) — `tabular`'s grid entries;
1096fn as_cell(v: Value) -> Result<Cell, EvalError> {
1097    match v {
1098        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1099            ("NormalCell", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1100                let mut it = vs.into_iter();
1101                let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1102                let ib = as_inline_boxes(it.next().unwrap())?;
1103                Ok(Cell::Normal(Paddings { l, r, t, b }, ib))
1104            }
1105            ("EmptyCell", None) => Ok(Cell::Empty),
1106            ("MultiCell", Some(Value::Tuple(vs))) if vs.len() == 4 => {
1107                let mut it = vs.into_iter();
1108                let numrow = as_int(it.next().unwrap())?;
1109                let numcol = as_int(it.next().unwrap())?;
1110                let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1111                let ib = as_inline_boxes(it.next().unwrap())?;
1112                Ok(Cell::Multi(
1113                    numrow.max(0) as usize,
1114                    numcol.max(0) as usize,
1115                    Paddings { l, r, t, b },
1116                    ib,
1117                ))
1118            }
1119            (other, _) => eval_error(format!(
1120                "expected a cell (NormalCell/EmptyCell/MultiCell), got variant '{other}'"
1121            )),
1122        },
1123        other => eval_error(format!("expected a cell, got {}", other.type_name())),
1124    }
1125}
1126
1127/// `(cell list) list` — `tabular`'s first argument.
1128fn as_cell_grid(v: Value) -> Result<Vec<Vec<Cell>>, EvalError> {
1129    as_list(v)?
1130        .into_iter()
1131        .map(|row| -> Result<Vec<Cell>, EvalError> {
1132            as_list(row)?.into_iter().map(as_cell).collect()
1133        })
1134        .collect()
1135}
1136
1137fn as_prepath(v: Value) -> Result<PrePath, EvalError> {
1138    match v {
1139        Value::PrePath(p) => Ok(p),
1140        other => eval_error(format!("expected pre-path, got {}", other.type_name())),
1141    }
1142}
1143
1144fn as_path(v: Value) -> Result<Path, EvalError> {
1145    match v {
1146        Value::Path(p) => Ok(p),
1147        other => eval_error(format!("expected path, got {}", other.type_name())),
1148    }
1149}
1150
1151fn as_graphics(v: Value) -> Result<GraphicsElem, EvalError> {
1152    match v {
1153        Value::Graphics(g) => Ok(g),
1154        other => eval_error(format!("expected graphics, got {}", other.type_name())),
1155    }
1156}
1157
1158/// `dash` = `length * length * length` (mirrors `evalUtil.ml`'s `get_tuple3
1159/// get_length`) — `dashed-stroke`'s 2nd argument, `(d1, d2, d0)` = on-length,
1160/// off-length, phase.
1161fn as_dash(v: Value) -> Result<Dash, EvalError> {
1162    match v {
1163        Value::Tuple(vs) if vs.len() == 3 => {
1164            let mut it = vs.into_iter();
1165            let d1 = as_length(it.next().unwrap())?;
1166            let d2 = as_length(it.next().unwrap())?;
1167            let d0 = as_length(it.next().unwrap())?;
1168            Ok((d1, d2, d0))
1169        }
1170        other => eval_error(format!(
1171            "expected a dash pattern (length * length * length), got {}",
1172            other.type_name()
1173        )),
1174    }
1175}
1176
1177/// The inverse of `as_point` (mirrors `evalUtil.ml:228`'s point
1178/// construction) — used by `inline-graphics` to build the `(0pt, 0pt)`
1179/// origin its callback is (eagerly) invoked with; see that primitive's doc
1180/// comment for the shift-covariance caveat this stands in for.
1181fn make_point_value(pt: Point) -> Value {
1182    Value::Tuple(vec![Value::Length(pt.0), Value::Length(pt.1)])
1183}
1184
1185/// `length list` construction (mirrors `evalUtil.ml:709`) — builds the
1186/// box-local grid-line coordinates `tabular`'s rule callback is (eagerly)
1187/// invoked with; see `prim_tabular`'s doc comment.
1188fn make_length_list(lens: &[Length]) -> Value {
1189    Value::List(lens.iter().map(|l| Value::Length(*l)).collect())
1190}
1191
1192// ---- primitive-body macros ----------------------------------------------------
1193//
1194// The arithmetic, comparison, boolean, and unary-conversion primitives all
1195// share one strict-call shape: the (already-evaluated) operands are popped
1196// right-to-left through a type extractor, then the result is re-wrapped as a
1197// `Value`. These macros capture that shape so each primitive is a single line.
1198// The vminst.ml citations for each stay on the `prims!` registration table
1199// above; per-primitive notes ride along on the invocations below.
1200
1201/// A strict binary primitive. Pops `b` then `a` (i.e. rightmost argument
1202/// first, matching application order) through the given extractor(s) and wraps
1203/// `body` as `Value::$ctor`. Accepts either one extractor for both operands or
1204/// a `(as_a, as_b)` pair when the operands have different types.
1205macro_rules! binop_prim {
1206    ($name:ident, ($as_a:path, $as_b:path), $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1207        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1208            let $b = $as_b(args.pop().unwrap())?;
1209            let $a = $as_a(args.pop().unwrap())?;
1210            Ok(Value::$ctor($body))
1211        }
1212    };
1213    ($name:ident, $as:path, $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1214        binop_prim!($name, ($as, $as), $ctor, |$a, $b| $body);
1215    };
1216}
1217
1218/// A strict binary comparison: like `binop_prim!` but always wraps as
1219/// `Value::Bool`.
1220macro_rules! cmp_prim {
1221    ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1222        binop_prim!($name, ($as, $as), Bool, |$a, $b| $body);
1223    };
1224}
1225
1226/// A strict unary primitive: pops one operand through `as` and wraps `body`
1227/// as `Value::$ctor`.
1228macro_rules! unop_prim {
1229    ($name:ident, $as:path, $ctor:ident, |$a:ident| $body:expr) => {
1230        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1231            let $a = $as(args.pop().unwrap())?;
1232            Ok(Value::$ctor($body))
1233        }
1234    };
1235}
1236
1237/// A strict binary primitive with a fallible body: `body` is the function's
1238/// tail expression and must itself yield `Result<Value, EvalError>`, so it can
1239/// guard cases like division by zero.
1240macro_rules! binop_prim_try {
1241    ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1242        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1243            let $b = $as(args.pop().unwrap())?;
1244            let $a = $as(args.pop().unwrap())?;
1245            $body
1246        }
1247    };
1248}
1249
1250// ---- text conversion ----------------------------------------------------------
1251
1252/// Convert quoted inline text to boxes under `ctx` (the core of
1253/// `read-inline`): words become measured `InnerString`s, whitespace becomes
1254/// glue, embedded commands are applied to `ctx` and their arguments.
1255pub fn read_inline(
1256    interp: &mut Interp,
1257    ctx: &Context,
1258    elems: &[IText],
1259    env: &Env,
1260) -> Result<Vec<HorzBox>, EvalError> {
1261    let mut out = Vec::new();
1262    for elem in elems {
1263        match elem {
1264            IText::Text(text) => text_to_boxes(interp, ctx, text, &mut out)?,
1265            // `ImInputHorzEmbeddedCodeText` (`evaluator.cppo.ml:768-779`): hand
1266            // the literal to the context's code-text command if one is
1267            // installed, else set it as ordinary text
1268            // (`DefaultCodeTextCommand`).
1269            IText::CodeText(text) => match ctx.code_text_command {
1270                Some(id) => {
1271                    let cmd = interp.math_commands[id.0].clone();
1272                    let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1273                    let v = interp.apply(v, Value::Str(text.clone()))?;
1274                    out.extend(as_inline_boxes(v)?);
1275                }
1276                None => text_to_boxes(interp, ctx, text, &mut out)?,
1277            },
1278            IText::Cmd { cmd, args } => {
1279                // Resolved at compile time (`crate::quoted`); running it can
1280                // still raise the same "unbound inline command" error for the
1281                // defensive case the compiler could not resolve.
1282                let cmd = cmd.run(env, interp)?;
1283                let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1284                for arg in args {
1285                    let mut opt_vals = Vec::with_capacity(arg.opts.len());
1286                    for (label, e) in &arg.opts {
1287                        opt_vals.push((label.clone(), e.run(env, interp)?));
1288                    }
1289                    let arg_v = arg.arg.run(env, interp)?;
1290                    v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1291                }
1292                out.extend(as_inline_boxes(v)?);
1293            }
1294            IText::Embed { expr, span } => {
1295                let v = expr.run(env, interp)?;
1296                match v {
1297                    Value::InlineText {
1298                        elems: sub_elems,
1299                        env: cap_env,
1300                    } => {
1301                        out.extend(read_inline(interp, ctx, &sub_elems, &cap_env)?);
1302                    }
1303                    other => {
1304                        return Err(EvalError {
1305                            span: Some(*span),
1306                            msg: format!(
1307                                "expected inline-text in '#…;' embed, got {}",
1308                                other.type_name()
1309                            ),
1310                        });
1311                    }
1312                }
1313            }
1314            IText::EmbedMath { elems, .. } => {
1315                // Upstream: a bare `${…}` in inline text evaluates by
1316                // applying the context's installed `[math] inline-cmd` to
1317                // (ctx, the math value) — `apply(cmd, ctx)` then
1318                // `apply(_, math)`, exactly like `IText::Cmd` above.
1319                let installed = ctx
1320                    .math_command
1321                    .and_then(|id| interp.math_commands.get(id.0).cloned());
1322                match installed {
1323                    Some(cmd) => {
1324                        let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1325                        let v = interp.apply(
1326                            v,
1327                            Value::MathText {
1328                                elems: Rc::clone(elems),
1329                                env: env.clone(),
1330                            },
1331                        )?;
1332                        out.extend(as_inline_boxes(v)?);
1333                    }
1334                    None => {
1335                        // No installed command (contexts built by
1336                        // `Context::initial` directly, i.e. unit tests):
1337                        // reflect + lay out through the faithful engine so
1338                        // `\cmd`/`#var` still evaluate — the same machinery
1339                        // `+math(${…})` uses via `as_math`. This fallback
1340                        // dispatches on `interp.version`
1341                        // — the installed-command path above is version-
1342                        // blind already (an ordinary `[math-text] inline-
1343                        // cmd` applied to `(ctx, math-text)`).
1344                        let mut atoms = Vec::new();
1345                        if interp.version.math_is_split() {
1346                            for e in elems.iter() {
1347                                reflect_math_elem_v01(interp, ctx, e, env, &mut atoms)?;
1348                            }
1349                        } else {
1350                            for e in elems.iter() {
1351                                reflect_math_elem(interp, e, env, &mut atoms)?;
1352                            }
1353                        }
1354                        out.push(HorzBox::Pure(layout_math_value(interp, ctx, &atoms)?));
1355                    }
1356                }
1357            }
1358        }
1359    }
1360    // Space inline `\code(…)`/`${…}` boxes against adjacent CJK prose the way
1361    // SATySFi does (the text-run glue in `text_to_boxes` can't see these
1362    // cross-element boundaries). Idempotent — a boundary already carrying glue
1363    // is skipped.
1364    Ok(insert_box_interscript_glue(out, ctx))
1365}
1366
1367/// Convert quoted block text to vertical boxes (the core of `read-block`).
1368fn read_block(
1369    interp: &mut Interp,
1370    ctx: &Context,
1371    elems: &[BText],
1372    env: &Env,
1373) -> Result<Vec<VertBox>, EvalError> {
1374    let mut out = Vec::new();
1375    for elem in elems {
1376        match elem {
1377            BText::Cmd { cmd, args } => {
1378                let cmd = cmd.run(env, interp)?;
1379                let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1380                for arg in args {
1381                    let mut opt_vals = Vec::with_capacity(arg.opts.len());
1382                    for (label, e) in &arg.opts {
1383                        opt_vals.push((label.clone(), e.run(env, interp)?));
1384                    }
1385                    let arg_v = arg.arg.run(env, interp)?;
1386                    v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1387                }
1388                out.extend(as_block_boxes(v)?);
1389            }
1390            BText::Embed { expr, span } => {
1391                let v = expr.run(env, interp)?;
1392                match v {
1393                    Value::BlockText {
1394                        elems: sub_elems,
1395                        env: cap_env,
1396                    } => {
1397                        out.extend(read_block(interp, ctx, &sub_elems, &cap_env)?);
1398                    }
1399                    other => {
1400                        return Err(EvalError {
1401                            span: Some(*span),
1402                            msg: format!(
1403                                "expected block-text in '#…;' embed, got {}",
1404                                other.type_name()
1405                            ),
1406                        });
1407                    }
1408                }
1409            }
1410        }
1411    }
1412    Ok(out)
1413}
1414
1415/// UAX#14 byte offsets in `text` that are a real, content-driven break
1416/// candidate: every `break_opportunities` boundary except the one always
1417/// reported at `text.len()` (the segmenter's "always break at the end of
1418/// text" convention — an artifact of segmenting this one run in isolation,
1419/// not a signal about what follows it in the paragraph, since
1420/// `text_to_boxes` is called once per `IText::Text` leaf and more content
1421/// may follow via a sibling `Cmd`, `Embed`, or `EmbedMath`).
1422fn uax14_boundaries(text: &str) -> Vec<Option<BreakKind>> {
1423    let mut boundary = vec![None; text.len() + 1];
1424    for (offset, kind) in break_opportunities(text) {
1425        if offset < text.len() {
1426            boundary[offset] = Some(kind);
1427        }
1428    }
1429    boundary
1430}
1431
1432/// This run's `(font, size, rising)` for `script` (see `Context::font_scheme`'s
1433/// doc comment): `Latin` reads `ctx.font` itself (NOT `font_scheme[Latin].font`)
1434/// so `set-font-key`/`\bold`/`\emph` keep working unchanged, while still
1435/// picking up `font_scheme[Latin]`'s ratio/rising (written in lockstep by
1436/// `set-font Latin ..`).
1437///
1438/// `OtherScript` first goes through `normalize_script` (`horzBox.ml:472`):
1439/// upstream's `CommonNarrow`/`Inherited` resolve to `ctx.dominant_narrow_script`
1440/// rather than to a scheme slot of their own; this port's `char_script` has no
1441/// separate Common bucket, so everything outside Latin-1..Latin-Ext-B and the
1442/// CJK ranges lands in `OtherScript` and gets the same treatment — the only
1443/// WIDE Common chars (`U+3000` fullwidth forms) already fall in
1444/// `char_script`'s `HanIdeographic` range, so this costs nothing there.
1445///
1446/// Real effect, not a niceness: without `set-dominant-narrow-script Kana`, a
1447/// document's `□`/`✓` both resolve to a Latin face with NEITHER glyph, degrade
1448/// to the same `.notdef` glyph id and `ToUnicode` entry, and one of the two
1449/// simply vanishes from the extracted text — enumitem's three missing `✓`,
1450/// each overprinted onto a `□` by the document's own `ooalign`.
1451///
1452/// Defaults to `OtherScript` (`Context::initial`, matching upstream), so a
1453/// document that never calls the primitive is unaffected and the recursion is
1454/// one step deep at most.
1455fn script_font(ctx: &Context, script: Script) -> ScriptFont {
1456    if script == Script::OtherScript && ctx.dominant_narrow_script != Script::OtherScript {
1457        return script_font(ctx, ctx.dominant_narrow_script);
1458    }
1459    if script == Script::Latin {
1460        ScriptFont {
1461            font: ctx.font,
1462            ..ctx.font_scheme[Script::Latin as usize]
1463        }
1464    } else {
1465        ctx.font_scheme[script as usize]
1466    }
1467}
1468
1469/// Measure `text` (already known to be one script run) at `size` under
1470/// `font`, falling back per-glyph to `fallback_font` (`ctx.font`) when
1471/// `font` has no glyph for a character — the "CJK per-glyph metrics path
1472/// stubbed" case: a character within a script-run's bucket
1473/// that its assigned font happens to lack (e.g. a fullwidth-form character
1474/// absent from a narrow CJK face) still measures via the Latin default
1475/// rather than failing the whole run. Errors (both fonts lack the glyph)
1476/// name the offending character and font key.
1477///
1478/// **Known limitation** (documented, not fixed): the
1479/// measurement here can fall back per-glyph, but `PureHorzBox::InnerString`
1480/// carries one `HorzStringInfo::font` for its WHOLE text run — so if a
1481/// fallback glyph is actually used, the PDF writer's `emit_box` still tries
1482/// to look it up in `font`'s face at render time and fails there instead.
1483/// Splitting a run into sub-boxes at the source-font-only/fallback boundary
1484/// (a faithful fix) is future work; every stdja default face configuration
1485/// covers its script's whole repertoire, so this path is not expected to
1486/// trigger in practice.
1487fn measure_run(
1488    interp: &Interp,
1489    font: FontKey,
1490    fallback_font: FontKey,
1491    text: &str,
1492    size: Length,
1493) -> Result<Length, EvalError> {
1494    let mut width = Length::ZERO;
1495    for c in text.chars() {
1496        // A character absent from BOTH the run font and the fallback degrades
1497        // to a `.notdef`-style box (half-em advance) rather than aborting the
1498        // whole document — the way real typesetters render an uncovered glyph.
1499        // (satysfi-base's `enumitem`/the SATySFi Book use a few glyphs — `□`,
1500        // `〚` — that the bundled Latin face lacks; a faithful per-glyph
1501        // font-fallback via run-splitting is the documented follow-up.) This
1502        // only ever changes behavior for a glyph that would otherwise be a
1503        // hard error, so covered-glyph documents are byte-identical.
1504        let advance = interp
1505            .metrics
1506            .advance(font, c, size)
1507            .or_else(|| interp.metrics.advance(fallback_font, c, size))
1508            .unwrap_or(size * 0.5);
1509        width += advance;
1510    }
1511    Ok(width)
1512}
1513
1514/// Build one `InnerString` box for `text`, measured through [`measure_run`]
1515/// with `sf`'s font/size/rising — the single construction site shared by
1516/// `text_to_boxes`'s `flush_word` for both the plain (no-hyphenation) path
1517/// and each hyphenated fragment / hyphen glyph. Factored out so both paths
1518/// measure/build identically — this is part of what makes the width-identity
1519/// argument hold: `measure_run` is purely additive per char (no
1520/// kerning/ligatures), so concatenating the fragments this produces
1521/// reconstructs exactly the box a single un-split call would have produced.
1522fn make_inner_string_pure_box(
1523    interp: &Interp,
1524    ctx: &Context,
1525    sf: ScriptFont,
1526    size: Length,
1527    rising: Length,
1528    text: String,
1529) -> Result<PureHorzBox, EvalError> {
1530    let width = measure_run(interp, sf.font, ctx.font, &text, size)?;
1531    // SATySFi measures a run's height/depth from the ACTUAL per-glyph bounding
1532    // boxes (fontInfo.ml `get_metrics_of_word`), not the font-level
1533    // ascender/descender — so a no-descender run (CJK, digits, TOC dots) is
1534    // shorter and packs tighter at block boundaries.
1535    let (height, depth) = interp.metrics.run_vextent(sf.font, &text, size);
1536    Ok(PureHorzBox::InnerString {
1537        info: HorzStringInfo {
1538            font: sf.font,
1539            size,
1540            rising,
1541            color: ctx.text_color,
1542        },
1543        height,
1544        depth,
1545        text,
1546        width,
1547    })
1548}
1549
1550/// Whether two adjacent runs' scripts form a Latin↔CJK boundary that gets
1551/// SATySFi's default inter-script glue (`primitives.ml:517-524`: entries for
1552/// `(Latin, Kana)`, `(Kana, Latin)`, `(Latin, Han)`, `(Han, Latin)` only —
1553/// NOT Kana↔Han, and not same-script).
1554fn is_latin_cjk_boundary(a: Script, b: Script) -> bool {
1555    let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
1556    (a == Script::Latin && is_cjk(b)) || (is_cjk(a) && b == Script::Latin)
1557}
1558
1559/// Upstream `is_open_punctuation` (`charBasis.ml:133`: `OP | QU | JLOP`) —
1560/// opening brackets and quotes. Consulted for the LEFT edge of a script
1561/// boundary only.
1562fn is_open_punct(c: char) -> bool {
1563    matches!(
1564        c,
1565        '(' | '['
1566            | '{'
1567            | '"'
1568            | '\''
1569            | '('
1570            | '「'
1571            | '『'
1572            | '【'
1573            | '〔'
1574            | '〈'
1575            | '《'
1576            | '['
1577            | '{'
1578            | '〖'
1579            | '〘'
1580            | '〚'
1581            | '“'
1582            | '‘'
1583    )
1584}
1585
1586/// Upstream `is_close_punctuation` (`charBasis.ml:139`: `CL | CP | QU | NS |
1587/// JLCP | JLNS | JLCM | JLFS`) — closing brackets, quotes, and the kuten/touten
1588/// family. Consulted for the RIGHT edge of a script boundary only.
1589///
1590/// Two families the port used to list are NOT in that set, and their absence is
1591/// upstream's own, not an oversight:
1592/// - `!` `?` `!` `?` are line-break class `EX` (`LineBreak.txt:2566,2582` for
1593///   the fullwidth pair), which appears in no arm of `is_close_punctuation`;
1594/// - `,` `.` `;` `:` are `IS`, likewise absent.
1595///
1596/// Their FULLWIDTH cousins are a different matter and stay: `,`/`.` are
1597/// overridden to `JLCM`/`JLFS` (`lineBreakDataMap.ml:95-96`) and `:`/`;` are
1598/// `NS` (`LineBreak.txt:2580`).
1599///
1600/// Listing the six suppressed the 0.24em inter-script glue — and, since that
1601/// glue is the boundary's only break candidate, the break opportunity with it —
1602/// before every sentence-final mark that is not a kuten.
1603fn is_close_punct(c: char) -> bool {
1604    matches!(
1605        c,
1606        ')' | ']'
1607            | '}'
1608            | '"'
1609            | '\''
1610            | ')'
1611            | '」'
1612            | '』'
1613            | '】'
1614            | '〕'
1615            | '〉'
1616            | '》'
1617            | ']'
1618            | '}'
1619            | '〗'
1620            | '〙'
1621            | '〛'
1622            | '”'
1623            | '’'
1624            | '、'
1625            | '。'
1626            | ','
1627            | '.'
1628            | '・'
1629            | ':'
1630            | ';'
1631    )
1632}
1633
1634/// Whether SATySFi's default inter-script glue is suppressed between a
1635/// left-hand character `l` and a right-hand `r`.
1636///
1637/// `pure_space_between_scripts` (`convertText.ml:31`) drops the glue when
1638/// `is_open_punctuation lbc1 || is_close_punctuation lbc2` — the LEFT edge being
1639/// OPENING punctuation, or the RIGHT edge being CLOSING punctuation. The aki
1640/// that would otherwise sit there is supplied by the separate JLreq
1641/// class-spacing layer.
1642///
1643/// The port used to test one symmetric "is punctuation" predicate against BOTH
1644/// edges, which suppressed far more than upstream: `、` before a Latin/math run
1645/// is a *closing* mark on the LEFT, which upstream does not suppress. Since this
1646/// glue is also the only break opportunity at such a boundary, suppressing it
1647/// left the breaker with nowhere to break — latexcmds ran
1648/// `…、${dropcolor}` 32pt past the margin because there was no legal break
1649/// between the touten and the math box.
1650fn interscript_glue_suppressed(l: char, r: char) -> bool {
1651    is_open_punct(l) || is_close_punct(r)
1652}
1653
1654/// JLreq character classes SATySFi's inter-CJK spacing distinguishes
1655/// (`charBasis.ml:116-122`). Only the classes that actually change spacing are
1656/// modelled; every other CJK character is `None` ("ordinary").
1657#[derive(Clone, Copy, PartialEq, Eq)]
1658enum JlClass {
1659    /// cl-01, fullwidth OPEN punctuation — carries a leading half-width kern.
1660    Open,
1661    /// cl-02, fullwidth CLOSE punctuation — trailing half-width kern.
1662    Close,
1663    /// cl-06, kuten (fullwidth full stop) — trailing half-width kern.
1664    FullStop,
1665    /// cl-07, touten (fullwidth comma) — trailing half-width kern.
1666    Comma,
1667    /// cl-05, nakaten (fullwidth middle dot) — quarter-width kern BOTH sides.
1668    MiddleDot,
1669}
1670
1671fn jl_class(c: char) -> Option<JlClass> {
1672    match c {
1673        '(' | '「' | '『' | '【' | '〔' | '〈' | '《' | '[' | '{' | '〖' | '〘' | '〚' => {
1674            Some(JlClass::Open)
1675        }
1676        ')' | '」' | '』' | '】' | '〕' | '〉' | '》' | ']' | '}' | '〗' | '〙' | '〛' => {
1677            Some(JlClass::Close)
1678        }
1679        '。' | '.' => Some(JlClass::FullStop),
1680        '、' | ',' => Some(JlClass::Comma),
1681        '・' | ':' | ';' => Some(JlClass::MiddleDot),
1682        _ => None,
1683    }
1684}
1685
1686/// `ideographic_single`'s TRAILING kern for `c` (`convertText.ml:266-283`), as a
1687/// negative ratio of `font_size`: JLCP/JLFS/JLCM are `[glyph; hwkern]`, JLMD is
1688/// `[qwkern; glyph; qwkern]`.
1689///
1690/// A kern belongs to the CHARACTER, not to the boundary — `ideographic_single`
1691/// runs per chunk and never consults its neighbours. Hence one char per
1692/// function, even though the only caller today is the pair-shaped
1693/// [`cjk_pair_space`]: at a CJK↔Latin boundary the CJK side still carries its own
1694/// kern upstream, and this port does not yet emit it there (see the
1695/// `PreventBreak` arm of `text_to_boxes` for what that costs and what unblocking
1696/// it needs).
1697fn cjk_trailing_kern(c: char) -> f64 {
1698    match jl_class(c) {
1699        Some(JlClass::Close) | Some(JlClass::FullStop) | Some(JlClass::Comma) => -0.5,
1700        Some(JlClass::MiddleDot) => -0.25,
1701        _ => 0.0,
1702    }
1703}
1704
1705/// `ideographic_single`'s LEADING kern for `c` — JLOP is `[hwkern; glyph]`,
1706/// JLMD `[qwkern; glyph; qwkern]`. See [`cjk_trailing_kern`].
1707fn cjk_leading_kern(c: char) -> f64 {
1708    match jl_class(c) {
1709        Some(JlClass::Open) => -0.5,
1710        Some(JlClass::MiddleDot) => -0.25,
1711        _ => 0.0,
1712    }
1713}
1714
1715/// The glue SATySFi puts between two directly adjacent CJK characters, as
1716/// `(natural, shrink, stretch)` ratios of `font_size` — `space_between_chunks`
1717/// (`convertText.ml:220`) with `ideographic_single`'s compensating kerns
1718/// (`convertText.ml:266`) folded in.
1719///
1720/// Upstream renders CJK punctuation at its full em and kerns it back:
1721/// `。`/`、`/`)` carry a trailing −0.5em kern, `(` a leading one, `・` −0.25em
1722/// on both sides. `pure_space_between_classes` (`convertText.ml:194`) then adds
1723/// a half-width space back — natural 0.5em, stretch 0.25em, shrink 0.25em
1724/// unless the pair is "hard" (after a full stop). Net natural width is
1725/// unchanged, but each punctuation mark contributes **0.25em of stretch** — ten
1726/// times the 0.025em `adjacent_stretch` between ordinary characters, and the
1727/// bulk of a Japanese line's elasticity. Two punctuation marks in a row
1728/// (`」、`, `」。`) get NO space back, so the pair sets 0.5em tighter.
1729///
1730/// KNOWN, MEASURED deviation: every ratio here applies to `ctx.font_size`,
1731/// where upstream applies the kerns and all four `pure_halfwidth_space_*` sizes
1732/// to `get_corrected_font_size ctx script` (`convertText.ml:76`) — font size
1733/// TIMES the script's ratio (0.88 for stdja's CJK face, so a half-width kern at
1734/// 12pt is −5.28pt not −6pt). Only `adjacent_space` (`:102`) and the
1735/// inter-script glue (`:225`) take the RAW size.
1736///
1737/// Correcting it was written and measured, and does not land: it moves NO line
1738/// break on any corpus document (identical to four decimals across all six),
1739/// and fails the fidelity gate on word-count (xpath 5 -> 8, easytable 100 ->
1740/// 110), because a smaller class space closes gaps upstream doesn't have fewer
1741/// of. The missing width is elsewhere — the per-character kerns this port does
1742/// not emit at a CJK↔Latin boundary or run edge (see [`cjk_trailing_kern`]).
1743/// The two changes are a package; do NOT re-derive either half from
1744/// `convertText.ml` alone.
1745fn cjk_pair_space(a: char, b: char, adjacent_stretch: f64) -> (f64, f64, f64) {
1746    use JlClass::*;
1747    let (ca, cb) = (jl_class(a), jl_class(b));
1748    // Kerns from `ideographic_single`, as a NEGATIVE ratio of font_size. Between
1749    // two CJK characters the pair's kern is exactly `a`'s trailing plus `b`'s
1750    // leading one, which is what makes the pair form equivalent to upstream's
1751    // per-character one here.
1752    let kern = cjk_trailing_kern(a) + cjk_leading_kern(b);
1753    // `pure_space_between_classes`, in its own match order.
1754    let hwsoft = (0.5, 0.25, 0.25);
1755    let hwhard = (0.5, 0.0, 0.25);
1756    let cls = match (ca, cb) {
1757        (Some(Close), Some(Open)) | (Some(Comma), Some(Open)) => Some(hwsoft),
1758        (Some(FullStop), Some(Open)) => Some(hwhard),
1759        (_, Some(Open)) => Some(hwsoft),
1760        (Some(Close), Some(Comma)) | (Some(Close), Some(FullStop)) => None,
1761        (Some(Close), _) | (Some(Comma), _) => Some(hwsoft),
1762        (Some(FullStop), _) => Some(hwhard),
1763        _ => None,
1764    };
1765    match cls {
1766        Some((n, sh, st)) => (kern + n, sh, st),
1767        // No class space: `adjacent_space` (natural 0, shrink 0, stretch
1768        // `adjacent_stretch`), plus whatever kern the pair carries.
1769        None => (kern, 0.0, adjacent_stretch),
1770    }
1771}
1772
1773/// A box's LEADING glyph for inter-script spacing, or `None` for
1774/// glue/discretionary/skip/image (a "transparent" separator — an inter-script
1775/// space is never inserted adjacent to one) and for math (reported as a Latin
1776/// `'x'`, matching SATySFi where a `${…}` chunk spaces against CJK like Western
1777/// text). The char lets the caller apply the `is_interscript_punct` guard.
1778fn box_leading_char(b: &HorzBox) -> Option<char> {
1779    match b {
1780        HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().next(),
1781        HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1782        _ => None,
1783    }
1784}
1785
1786/// A box's TRAILING glyph (see `box_leading_char`).
1787fn box_trailing_char(b: &HorzBox) -> Option<char> {
1788    match b {
1789        HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().last(),
1790        HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1791        _ => None,
1792    }
1793}
1794
1795/// Insert SATySFi's inter-script glue (`default_script_space_map`) between two
1796/// DIRECTLY-adjacent boxes whose touching edges are Latin↔CJK — the boundary
1797/// that `text_to_boxes` can't see because it spans separate inline elements: a
1798/// `\code(…)`/`${…}` box against surrounding CJK prose ("cellfmt 型", "𝑛 番目").
1799/// A boundary already carrying a glue/discretionary reads as `None` on one edge
1800/// and is skipped, so this is idempotent and never doubles the text-run glue.
1801fn insert_box_interscript_glue(boxes: Vec<HorzBox>, ctx: &Context) -> Vec<HorzBox> {
1802    if boxes.len() < 2 {
1803        return boxes;
1804    }
1805    let mut out: Vec<HorzBox> = Vec::with_capacity(boxes.len());
1806    for b in boxes {
1807        if let (Some(pc), Some(cc)) = (out.last().and_then(box_trailing_char), box_leading_char(&b))
1808        {
1809            if is_latin_cjk_boundary(char_script(pc), char_script(cc))
1810                && !interscript_glue_suppressed(pc, cc)
1811            {
1812                out.push(HorzBox::Pure(PureHorzBox::OuterEmpty {
1813                    natural: ctx.font_size * 0.24,
1814                    shrinkable: ctx.font_size * 0.08,
1815                    stretchable: ctx.font_size * 0.16,
1816                }));
1817            }
1818        }
1819        out.push(b);
1820    }
1821    out
1822}
1823
1824fn text_to_boxes(
1825    interp: &mut Interp,
1826    ctx: &Context,
1827    text: &str,
1828    out: &mut Vec<HorzBox>,
1829) -> Result<(), EvalError> {
1830    // Interword glue is upstream's
1831    // `context_main.space_natural`/`space_shrink`/`space_stretch`
1832    // (`set-space-ratio`), each a ratio of `font_size` — NOT a measured
1833    // glyph-advance of the space character and NOT a fraction of the natural
1834    // width. `ctx.space_*` always carries a value (defaults 0.33/0.08/0.16,
1835    // matching `Context::initial`'s own upstream-faithful defaults), so this
1836    // is a plain formula, no fallback needed.
1837    let space_width = ctx.font_size * ctx.space_natural;
1838    let boundary = uax14_boundaries(text);
1839    let mut word = String::new();
1840    let flush_word =
1841        |word: &mut String, script: Script, out: &mut Vec<HorzBox>| -> Result<(), EvalError> {
1842            if word.is_empty() {
1843                return Ok(());
1844            }
1845            let sf = script_font(ctx, script);
1846            let size = ctx.font_size * sf.ratio;
1847            // The script-font's own baseline raise (a ratio of font_size) PLUS the
1848            // manual raise from `set-manual-rising` (`ctx.manual_rising`, an
1849            // absolute Length). Both feed `HorzStringInfo.rising`, which every
1850            // render path adds to the baseline before `Tj`. `manual_rising`
1851            // defaults to `Length::ZERO` (`Context::initial`), so a document that
1852            // never calls `set-manual-rising` is byte-identical. Real effect: the
1853            // `\SATySFi`/`\LaTeX`/`\TeX` logo kerning.
1854            let rising = ctx.font_size * sf.rising + ctx.manual_rising;
1855
1856            // Knuth-Liang hyphenation opt-in injection: fires ONLY when a
1857            // dictionary has been installed (`ctx.hyphen_dictionary ==
1858            // Some(tag)`) and the run's script is Latin. With `hyphen_dictionary
1859            // == None` (the `Context::initial` default), `breaks` is always
1860            // empty and the code below falls straight through to the
1861            // single-`InnerString` path.
1862            let breaks = match ctx.hyphen_dictionary {
1863                Some(tag) if script == Script::Latin => {
1864                    // An explicit soft hyphen (U+00AD) authored in the word
1865                    // takes priority over dictionary-derived breaks (matches the
1866                    // `hyphenation` crate's own `Standard::hyphenate` priority
1867                    // rule). Only reachable here with a soft hyphen still
1868                    // embedded in `word` because the tokenizer above
1869                    // (`text_to_boxes`'s per-char loop) defers to this branch
1870                    // instead of splitting on it as an ordinary UAX#14 boundary
1871                    // — gated on this same `Some(tag) && Latin` condition, so
1872                    // `hyphen_dictionary == None` never reaches
1873                    // `strip_soft_hyphens` and reproduces exactly today's
1874                    // split-at-soft-hyphen behavior.
1875                    let (clean, shy_breaks) = crate::hyphenation::strip_soft_hyphens(word);
1876                    if !shy_breaks.is_empty() {
1877                        *word = clean;
1878                        shy_breaks
1879                    } else {
1880                        crate::hyphenation::hyphenate_word(
1881                            tag,
1882                            word,
1883                            ctx.left_hyphen_min.max(0) as usize,
1884                            ctx.right_hyphen_min.max(0) as usize,
1885                        )
1886                    }
1887                }
1888                _ => Vec::new(),
1889            };
1890
1891            if breaks.is_empty() {
1892                out.push(HorzBox::Pure(make_inner_string_pure_box(
1893                    interp,
1894                    ctx,
1895                    sf,
1896                    size,
1897                    rising,
1898                    std::mem::take(word),
1899                )?));
1900                return Ok(());
1901            }
1902
1903            // Width-identity invariant (also see
1904            // `make_inner_string_pure_box`'s doc comment): `measure_run` is
1905            // purely additive per char (no
1906            // kerning/ligatures), so splitting `word` into fragments here and
1907            // rejoining them via empty-slot `Discretionary`s (taken only at a
1908            // chosen line break) reproduces the exact width/height/depth of the
1909            // un-split box when no break is actually taken — only words the DP
1910            // *does* break render differently, which is the intended new
1911            // behavior, confined to documents that opt in.
1912            let chars: Vec<char> = word.chars().collect();
1913            let penalty = ctx.hyphen_badness.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
1914            let mut prev = 0usize;
1915            for &b in &breaks {
1916                let fragment: String = chars[prev..b].iter().collect();
1917                out.push(HorzBox::Pure(make_inner_string_pure_box(
1918                    interp, ctx, sf, size, rising, fragment,
1919                )?));
1920                let hyphen_box =
1921                    make_inner_string_pure_box(interp, ctx, sf, size, rising, "-".to_string())?;
1922                out.push(HorzBox::Pure(PureHorzBox::Discretionary {
1923                    penalty,
1924                    pre_break: vec![hyphen_box],
1925                    post_break: Vec::new(),
1926                    no_break: Vec::new(),
1927                }));
1928                prev = b;
1929            }
1930            let tail: String = chars[prev..].iter().collect();
1931            out.push(HorzBox::Pure(make_inner_string_pure_box(
1932                interp, ctx, sf, size, rising, tail,
1933            )?));
1934            word.clear();
1935            Ok(())
1936        };
1937    // `Some(s)` exactly when `word` is non-empty — the script of the run
1938    // currently being accumulated (a run also breaks on a script
1939    // change, not just on whitespace/UAX#14, see `char_script`).
1940    let mut word_script: Option<Script> = None;
1941    // The script of the immediately-preceding *typeset* character (persists
1942    // across the UAX#14 discretionary flushing that resets `word_script`), so
1943    // an inter-script boundary can be detected even between two single-char
1944    // CJK/Latin runs. Reset by an explicit space (no auto inter-script glue is
1945    // added adjacent to a real space). See the `is_latin_cjk_boundary` insert.
1946    let mut prev_script: Option<Script> = None;
1947    // The preceding typeset char itself, for the `is_interscript_punct` guard.
1948    let mut prev_char: Option<char> = None;
1949    for (i, c) in text.char_indices() {
1950        // Whitespace normalization around CJK — upstream's rewrite table
1951        // (`lineBreakDataMap.ml:143-157`, applied before any box is built):
1952        //
1953        //   CJK + (SP|BR) + Latin -> deleted      Latin + (SP|BR) + CJK -> deleted
1954        //   CJK + BR      + CJK   -> deleted      CJK   + SP      + CJK -> KEPT
1955        //   any remaining (SP|BR) touching CJK -> deleted; a leftover BR -> space
1956        //
1957        // Every space/line break adjacent to CJK is dropped EXCEPT a single
1958        // literal space between two CJK characters — the Latin/CJK boundary's
1959        // spacing is supplied by the inter-script glue below (0.24em), not by
1960        // the author's whitespace, so keeping it both double-counted that
1961        // boundary and turned every source line break into a space (the port
1962        // set `あります。 1 つは`/`これは 指定した` where SATySFi sets both
1963        // tight — figbox `manual.saty:116-120`). Deleting is a plain
1964        // `continue`: the characters either side still space against each
1965        // other through the inter-script rule, as if the whitespace had never
1966        // been written.
1967        if c == ' ' || c == '\n' {
1968            let is_cjk_script = |s| matches!(s, Script::HanIdeographic | Script::Kana);
1969            let prev_cjk = prev_script.is_some_and(is_cjk_script);
1970            let rest = &text[i + c.len_utf8()..];
1971            // Whether more whitespace follows: upstream's rules only ever match
1972            // ONE space between the two CJK characters (a longer run falls
1973            // through to the delete-everything rules), so a run collapses away.
1974            let run_continues = rest
1975                .chars()
1976                .next()
1977                .is_some_and(|ch| matches!(ch, ' ' | '\n'));
1978            let next_cjk = rest
1979                .chars()
1980                .find(|ch| !matches!(ch, ' ' | '\n'))
1981                .is_some_and(|ch| is_cjk_script(char_script(ch)));
1982            if prev_cjk || next_cjk {
1983                let keep = c == ' ' && !run_continues && prev_cjk && next_cjk;
1984                if !keep {
1985                    continue;
1986                }
1987            }
1988        }
1989        if c == ' ' || c == '\n' {
1990            if let Some(s) = word_script.take() {
1991                flush_word(&mut word, s, out)?;
1992            }
1993            prev_script = None;
1994            prev_char = None;
1995            // Avoid piling up doubled glue at text-run boundaries — but ONLY
1996            // for elastic (prose) spaces. A RIGID space (shrink == stretch == 0,
1997            // i.e. `code.satyh`'s `set-space-ratio (charwid/fs) 0. 0.`) is a
1998            // fixed-width verbatim column: SATySFi never collapses consecutive
1999            // ones, so the aligned source in a `+code` block keeps its spacing
2000            // (`| How       | I`, not the collapsed `| How | I`). Collapsing them
2001            // shortened code lines and let the port pack code blocks too tight.
2002            let rigid_space = ctx.space_shrink == 0.0 && ctx.space_stretch == 0.0;
2003            if rigid_space
2004                || !matches!(
2005                    out.last(),
2006                    Some(HorzBox::Pure(PureHorzBox::OuterEmpty { .. }))
2007                )
2008            {
2009                out.push(HorzBox::Pure(PureHorzBox::OuterEmpty {
2010                    natural: space_width,
2011                    // Upstream derives shrink/stretch directly as a ratio
2012                    // of `font_size` (`ctx.space_shrink`/`space_stretch`),
2013                    // NOT as a fraction of `space_width` — the previous
2014                    // `space_width * 0.25`/`* 0.5` was a port-invented
2015                    // approximation.
2016                    shrinkable: ctx.font_size * ctx.space_shrink,
2017                    stretchable: ctx.font_size * ctx.space_stretch,
2018                }));
2019            }
2020            continue;
2021        }
2022        let script = char_script(c);
2023        // Inter-script glue (`primitives.ml:517-524` `default_script_space_map`,
2024        // applied in `convertText.ml` `pure_space_between_scripts`): SATySFi's
2025        // default context inserts `(natural 0.24, shrink 0.08, stretch 0.16) *
2026        // size` glue between a Latin run and an adjacent CJK (Kana/Han) run —
2027        // the space visible as "2 つ" / "+easytable は" that the port otherwise
2028        // packs tight ("2つ"). Emitted at the boundary using `prev_script` (a
2029        // CJK char resets `word_script` via its UAX#14 discretionary, so this
2030        // can't rely on `word_script` alone). The glue is also an
2031        // `is_break_point`, matching upstream (the boundary is a legal break).
2032        if let (Some(prev), Some(pc)) = (prev_script, prev_char) {
2033            if is_latin_cjk_boundary(prev, script) && !interscript_glue_suppressed(pc, c) {
2034                if let Some(s) = word_script.take() {
2035                    if !word.is_empty() {
2036                        flush_word(&mut word, s, out)?;
2037                    }
2038                }
2039                out.push(HorzBox::Pure(PureHorzBox::OuterEmpty {
2040                    natural: ctx.font_size * 0.24,
2041                    shrinkable: ctx.font_size * 0.08,
2042                    stretchable: ctx.font_size * 0.16,
2043                }));
2044            }
2045        }
2046        if let Some(cur) = word_script {
2047            if cur != script {
2048                flush_word(&mut word, cur, out)?;
2049            }
2050        }
2051        word_script = Some(script);
2052        prev_script = Some(script);
2053        prev_char = Some(c);
2054        word.push(c);
2055        // Only non-ASCII text gets UAX#14 discretionaries: plain ASCII stays
2056        // on exactly today's space/newline-only splitter, so existing Latin
2057        // fixtures wrap identically (a real, tested divergence otherwise —
2058        // UAX#14 allows a break after a hyphen, which would fragment e.g.
2059        // "SATySFi-in-Rust" into three `InnerString`s instead of one,
2060        // changing the PDF content stream even though the zero-width
2061        // discretionaries between them render no differently when unchosen).
2062        // CJK and other non-ASCII scripts have no such existing behavior to
2063        // preserve, and are exactly where UAX#14 breaking is the whole point
2064        // (no interword glue at all otherwise, see `is_break_point`'s doc).
2065        // A soft hyphen (U+00AD) inside a run that the Knuth-Liang injection
2066        // above will consume (dictionary installed, Latin script) must NOT
2067        // be split here as an ordinary UAX#14 break-after point — doing so
2068        // would flush/fragment the word right at the soft hyphen before
2069        // `flush_word`'s hyphenation branch ever sees the whole word,
2070        // pre-empting `strip_soft_hyphens`'s explicit-break handling.
2071        // Instead let it accumulate into `word` like any other Latin letter.
2072        // Gated on the exact same `Some(_) && Latin` condition as that
2073        // branch, so `hyphen_dictionary == None` (or a non-Latin run)
2074        // reproduces exactly today's split-at-soft-hyphen behavior.
2075        let is_gated_soft_hyphen =
2076            c == '\u{ad}' && script == Script::Latin && ctx.hyphen_dictionary.is_some();
2077        // UAX#14 break opportunities apply to ALL text, ASCII included — that
2078        // is simply what upstream's line-break engine does (it runs over the
2079        // whole run with no script gate). Do NOT narrow this to non-ASCII, or
2080        // to the explicit hyphen — both approximations leave a load-bearing
2081        // gap:
2082        //
2083        //   - `+fig-center` (54.7pt, unbreakable) made the candidate widths jump
2084        //     clean over the feasible window — 400.32pt (ratio 2.72, dropped) to
2085        //     455.00pt (overfull) with nothing between — so the breaker fell back
2086        //     to a degenerate one-character line.
2087        //   - a `+code` line `…?:(drop) ?:(dropcolor)` ran 80pt past the column
2088        //     and clean off the paper, because the only break the port allowed
2089        //     was at a space, and breaking there left a rigid line 7.7pt short
2090        //     (dropped). UAX#14 grants a break between `:` and `(` — offset 2 of
2091        //     `?:(drop)…` — which is exactly where SATySFi breaks it.
2092        //
2093        // Cost: an ASCII run is now split into one `InnerString` per break
2094        // opportunity. Widths are unaffected (`measure_run` is purely additive,
2095        // see `make_inner_string_pure_box`), so this only changes how the text
2096        // is CHUNKED, not where any glyph lands.
2097        if !is_gated_soft_hyphen {
2098            let after = i + c.len_utf8();
2099            // The inter-chunk spacing between two DIRECTLY ADJACENT CJK
2100            // characters: `cjk_pair_space` folds `pure_space_between_classes` /
2101            // `adjacent_space` (`convertText.ml:101/194`) together with
2102            // `ideographic_single`'s compensating kerns (`convertText.ml:266`).
2103            //
2104            // The elastic part is the give a Japanese line justifies with.
2105            // Without it a CJK line's only give was whatever incidental Latin
2106            // spaces it happened to contain — a handful of points across a whole
2107            // line — so the breaker could neither fill to the column nor accept a
2108            // break that needed a hair of stretch.
2109            //
2110            // Only between two CJK characters: a CJK/Latin boundary is
2111            // `pure_space_between_scripts`'s job (the inter-script glue
2112            // above), and upstream falls through to `adjacent_space` only
2113            // once that has returned `None` (`space_between_chunks`,
2114            // `convertText.ml:220`).
2115            let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
2116            let next_char = text[after..].chars().next();
2117            let next_is_cjk = next_char.is_some_and(|nc| is_cjk(char_script(nc)));
2118            let pair = if is_cjk(script) && next_is_cjk {
2119                Some(cjk_pair_space(
2120                    c,
2121                    next_char.expect("checked"),
2122                    ctx.adjacent_stretch,
2123                ))
2124            } else {
2125                None
2126            };
2127            // `discretionary_if_breakable alw badns lphb`
2128            // (`convertText.ml:183-190`) — the ONE decision upstream makes at a
2129            // chunk boundary. The spacing is computed the same way either way;
2130            // only its container depends on whether UAX#14 grants a break:
2131            //
2132            //   AllowBreak    -> LBDiscretionary(badns, id, [glue], [], [])
2133            //   PreventBreak  -> LBPure(glue)
2134            //
2135            // The port used to emit the `AllowBreak` arm and *nothing* for
2136            // `PreventBreak`, so at every prohibited boundary — and in Japanese
2137            // prose that is one boundary in several, since LB13 forbids a break
2138            // before `、`/`。`/`」`/`)` and LB14 after `(`/`「` — a CJK line
2139            // carried give only at the subset of its boundaries that happened to
2140            // be breakable. A line with no give has to FILL its measure with
2141            // characters, which is part of why the port packs more per line than
2142            // SATySFi.
2143            match boundary[after] {
2144                Some(kind) => {
2145                    flush_word(&mut word, script, out)?;
2146                    word_script = None;
2147                    let mut no_break = Vec::new();
2148                    if let Some((n, sh, st)) = pair {
2149                        // The kern part is RIGID and must never be a break point,
2150                        // so it rides as a `FixedEmpty` rather than as glue.
2151                        if n != 0.0 {
2152                            no_break.push(PureHorzBox::FixedEmpty {
2153                                width: ctx.font_size * n,
2154                            });
2155                        }
2156                        no_break.push(PureHorzBox::OuterEmpty {
2157                            natural: Length::ZERO,
2158                            shrinkable: ctx.font_size * sh,
2159                            stretchable: ctx.font_size * st,
2160                        });
2161                    }
2162                    out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2163                        penalty: match kind {
2164                            BreakKind::Allowed => 0,
2165                            BreakKind::Mandatory => FORCED_BREAK_PENALTY,
2166                        },
2167                        pre_break: Vec::new(),
2168                        post_break: Vec::new(),
2169                        no_break,
2170                    }));
2171                }
2172                // The `PreventBreak` arm: `LBPure(glue)`, spelled as a
2173                // `Discretionary` whose every break slot is empty and whose
2174                // penalty is `NO_BREAK_PENALTY` (a bare `OuterEmpty` IS a
2175                // breakpoint in this box model, so a pure elastic box has no
2176                // other spelling).
2177                //
2178                // Only the ELASTIC half, deliberately — the one place this
2179                // port knowingly diverges from `discretionary_if_breakable`.
2180                // Landing the RIGID half too was written and MEASURED: it makes
2181                // the kern model ASYMMETRIC, since `cjk_pair_space`'s kern is a
2182                // property of the PAIR while upstream's is a property of the
2183                // CHARACTER, and the two agree only when BOTH neighbours are
2184                // CJK — `生成・変換` would get the nakaten's kern on both sides
2185                // while `(例:textbox` gets only its leading one (the trailing
2186                // side faces Latin, unreached by `cjk_pair_space`) — figbox's
2187                // largest intra-line divergence from upstream (mean |dx| on
2188                // that line 2.5pt -> 6.5pt).
2189                //
2190                // Completing it needs the per-character kerns at CJK<->Latin
2191                // boundaries and run edges too, which needs the source-
2192                // whitespace rewrite applied BEFORE `uax14_boundaries` rather
2193                // than during this loop (upstream's own order). Without that a
2194                // `。` before a deleted source newline gets its trailing kern
2195                // while the class space that pays it back is skipped (the
2196                // lookahead sees the newline, not the character after it), and
2197                // the layout-fidelity gate fails 12 ways. So the natural-width
2198                // bug the rigid half would fix — `」。`/`」、` a half-em too
2199                // wide, `末・雲` a quarter — stays exactly as open as before.
2200                None => {
2201                    if let Some((_, sh, st)) = pair {
2202                        if sh != 0.0 || st != 0.0 {
2203                            flush_word(&mut word, script, out)?;
2204                            word_script = None;
2205                            out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2206                                penalty: NO_BREAK_PENALTY,
2207                                pre_break: Vec::new(),
2208                                post_break: Vec::new(),
2209                                no_break: vec![PureHorzBox::OuterEmpty {
2210                                    natural: Length::ZERO,
2211                                    shrinkable: ctx.font_size * sh,
2212                                    stretchable: ctx.font_size * st,
2213                                }],
2214                            }));
2215                        }
2216                    }
2217                }
2218            }
2219        }
2220    }
2221    match word_script {
2222        Some(s) => flush_word(&mut word, s, out),
2223        None => Ok(()),
2224    }
2225}
2226
2227// ---- math conversion ----------------------
2228//
2229// Walks the already-elaborated `MathElem` tree straight into one
2230// `PureHorzBox::Math`, fixed-constant shift/scale (no MATH table).
2231
2232/// Superscript/subscript size ratio, used ONLY as `MathC`'s fallback when
2233/// the current math font has no OpenType MATH table (`script_percent_scale_down
2234/// / 100`). Not read anywhere outside `MathC` — every layout site goes
2235/// through `MathC::script_scale`/`sup_shift_clamped`/etc. so a MATH-table
2236/// font gets the real per-font ratio instead.
2237const SCRIPT_SCALE: f64 = 0.7;
2238/// Superscript raise, as a fraction of `ctx.font_size` — `MathC`'s
2239/// no-MATH-table fallback (`superscript_shift_up` clamped per
2240/// `math.ml:527`). Not read outside `MathC`.
2241const SUP_SHIFT: f64 = 0.5;
2242/// Cramped-style superscript raise fallback — the no-MATH-table fallback
2243/// `sup_shift_clamped` uses in place of `SuperscriptShiftUpCramped` when there
2244/// is no real MATH table to read. Deliberately set EQUAL to `SUP_SHIFT`: every
2245/// checked-in fixture font has no MATH table, so cramped and uncramped
2246/// superscripts get the identical fallback shift there. Only a real MATH
2247/// font (host-installed, test-guarded) makes cramped/uncramped diverge.
2248const SUP_SHIFT_CRAMPED: f64 = SUP_SHIFT;
2249/// Subscript drop, as a fraction of `ctx.font_size` — `MathC`'s
2250/// no-MATH-table fallback (`subscript_shift_down` per
2251/// `math.ml:545`). Not read outside `MathC`.
2252const SUB_SHIFT: f64 = 0.25;
2253/// `MathC::frac_numer_shift`'s no-MATH-table fallback: a flat,
2254/// content-independent numerator raise, as a fraction of the fraction's own
2255/// LOCAL size (mirrors `sup_shift_clamped`'s None-branch style, which
2256/// also ignores ink extent with no MATH table). Not read outside `MathC`.
2257const FRAC_NUMER_SHIFT_FALLBACK: f64 = 0.33;
2258/// `MathC::frac_denom_shift`'s no-MATH-table fallback (mirrors
2259/// `FRAC_NUMER_SHIFT_FALLBACK`; applied as a downward, i.e. negative, shift
2260/// by the caller). Not read outside `MathC`.
2261const FRAC_DENOM_SHIFT_FALLBACK: f64 = 0.33;
2262
2263/// MATH-table resolver: one query of `interp.metrics.math_constants(font)`
2264/// per laid-out math run, memoized here so every shift/scale/kern site in
2265/// that run reads the SAME `Option` instead of re-querying — and so a font
2266/// with no MATH table (every `Base14Metrics` call, and any TTF that lacks
2267/// one) transparently falls back to the flat pre-MATH-table constants
2268/// above. Fields are ratios of
2269/// the font size; callers multiply by whichever size is in scope
2270/// (`ctx.font_size` for the shift magnitudes — matching the pre-existing
2271/// "shift doesn't shrink with nesting" behavior these constants always had
2272/// — or the atom's own local `size` for glyph-relative queries like
2273/// `script_scale`/kerning).
2274struct MathC {
2275    c: Option<MathConstants>,
2276    /// `ctx.math_cramped` at the point this `MathC` was built — whether the
2277    /// current math sub-formula is laid out cramped. Consulted only by
2278    /// `sup_shift`/`sup_shift_clamped`, the sole positioning formula cramped
2279    /// changes in this port's feature set.
2280    cramped: bool,
2281}
2282
2283impl MathC {
2284    fn of(interp: &Interp, ctx: &Context) -> Self {
2285        Self {
2286            c: interp.metrics.math_constants(ctx.math_font),
2287            cramped: ctx.math_cramped,
2288        }
2289    }
2290
2291    /// Flat, unclamped superscript raise (`math.ml:527`'s `h_supstd` alone,
2292    /// no `math.ml:524-533` clamp) — the shape `layout_math_atom`'s callers
2293    /// need when no base/script ink extent is at hand yet.
2294    fn sup_shift(&self, s: Length) -> Length {
2295        match self.c {
2296            None => {
2297                s * if self.cramped {
2298                    SUP_SHIFT_CRAMPED
2299                } else {
2300                    SUP_SHIFT
2301                }
2302            }
2303            Some(c) => {
2304                s * if self.cramped {
2305                    c.superscript_shift_up_cramped
2306                } else {
2307                    c.superscript_shift_up
2308                }
2309            }
2310        }
2311    }
2312
2313    /// Flat, unclamped subscript drop (mirrors `sup_shift`).
2314    fn sub_shift(&self, s: Length) -> Length {
2315        self.c
2316            .map(|c| s * c.subscript_shift_down)
2317            .unwrap_or(s * SUB_SHIFT)
2318    }
2319
2320    /// `script_percent_scale_down / 100`, or the fixed `SCRIPT_SCALE`
2321    /// fallback. Nesting-level scale gap: upstream
2322    /// switches to `script_script_percent_scale_down` one level deeper;
2323    /// this port applies `script_scale_down` uniformly at every depth.
2324    fn script_scale(&self) -> f64 {
2325        self.c.map(|c| c.script_scale_down).unwrap_or(SCRIPT_SCALE)
2326    }
2327
2328    /// `math.ml`'s `h_bar` (axis height): the vertical center math content
2329    /// (fraction bars, `get-axis-height`) aligns to. Falls back to a fixed
2330    /// `0.25` ratio with no MATH table.
2331    fn axis(&self, s: Length) -> Length {
2332        self.c.map(|c| s * c.axis_height).unwrap_or(s * 0.25)
2333    }
2334
2335    /// `math.ml:524-533` `superscript_baseline_height`, clamped: the
2336    /// MAGNITUDE of the upward shift a superscript needs given the base's
2337    /// own ink height (`h_base`, a positive extent above ITS baseline) and
2338    /// the superscript's own ink depth (`d_sup`, a positive extent below
2339    /// ITS baseline — i.e. `MathGlyph.height`/`.depth`, not upstream's
2340    /// signed `Length.negate`d fields). Falls back to the flat `sup_shift`
2341    /// (ignoring `h_base`/`d_sup`) when there's no MATH table, so base-14
2342    /// output is untouched by this clamp.
2343    fn sup_shift_clamped(&self, s: Length, h_base: Length, d_sup: Length) -> Length {
2344        match self.c {
2345            None => self.sup_shift(s),
2346            Some(c) => {
2347                let shift_up = if self.cramped {
2348                    c.superscript_shift_up_cramped
2349                } else {
2350                    c.superscript_shift_up
2351                };
2352                let cand1 = s * shift_up;
2353                let cand2 = h_base - s * c.superscript_baseline_drop_max;
2354                let cand3 = s * c.superscript_bottom_min + d_sup;
2355                cand1.max(cand2).max(cand3)
2356            }
2357        }
2358    }
2359
2360    /// `math.ml:545-553` `subscript_baseline_depth`, clamped: the MAGNITUDE
2361    /// of the downward shift, given the base's own ink depth (`d_base`) and
2362    /// the subscript's own ink height (`h_sub`). Mirrors
2363    /// `sup_shift_clamped`'s fallback behavior.
2364    fn sub_shift_clamped(&self, s: Length, d_base: Length, h_sub: Length) -> Length {
2365        match self.c {
2366            None => self.sub_shift(s),
2367            Some(c) => {
2368                let cand1 = s * c.subscript_shift_down;
2369                let cand2 = d_base + s * c.subscript_baseline_drop_min;
2370                let cand3 = h_sub - s * c.subscript_top_max;
2371                cand1.max(cand2).max(cand3)
2372            }
2373        }
2374    }
2375
2376    /// `math.ml:562-573` `correct_script_baseline_heights`: when a base
2377    /// carries BOTH a subscript and a superscript, nudge the two
2378    /// already-clamped shift magnitudes apart so their ink keeps at least
2379    /// `sub_superscript_gap_min` clearance. `d_sup`/`h_sub` are the same ink
2380    /// extents `sup_shift_clamped`/`sub_shift_clamped` took; `sup`/`sub` are
2381    /// their (already clamped) outputs. A no-op when there's no MATH table
2382    /// — the flat fallback shifts are never additionally corrected, so
2383    /// base-14 output stays exactly `(sup, sub)`.
2384    fn correct_script_gap(
2385        &self,
2386        s: Length,
2387        d_sup: Length,
2388        h_sub: Length,
2389        sup: Length,
2390        sub: Length,
2391    ) -> (Length, Length) {
2392        let Some(c) = self.c else {
2393            return (sup, sub);
2394        };
2395        let gap_min = s * c.sub_superscript_gap_min;
2396        let gap = (sup - d_sup) - (h_sub - sub);
2397        if gap < gap_min {
2398            let corr = (gap_min - gap) * 0.5;
2399            (sup + corr, sub + corr)
2400        } else {
2401            (sup, sub)
2402        }
2403    }
2404
2405    /// `math.ml:596-602` `upper_limit_baseline_height`, clamped: the
2406    /// MAGNITUDE of the upward shift for an `\overset`-like upper limit,
2407    /// given the base's own ink height (`h_base`) and the limit content's
2408    /// own ink depth (`d_up`). Falls back to the flat `sup_shift` (same
2409    /// shape upstream's superscript raise uses) with no MATH table.
2410    fn upper_limit_shift(&self, s: Length, h_base: Length, d_up: Length) -> Length {
2411        match self.c {
2412            None => self.sup_shift(s),
2413            Some(c) => {
2414                let cand1 = h_base + s * c.upper_limit_baseline_rise_min;
2415                let cand2 = h_base + s * c.upper_limit_gap_min + d_up;
2416                cand1.max(cand2)
2417            }
2418        }
2419    }
2420
2421    /// `math.ml:605-611` `lower_limit_baseline_depth`, clamped: mirrors
2422    /// `upper_limit_shift` for a lower limit, given the base's own ink
2423    /// depth (`d_base`) and the limit content's own ink height (`h_low`).
2424    fn lower_limit_shift(&self, s: Length, d_base: Length, h_low: Length) -> Length {
2425        match self.c {
2426            None => self.sub_shift(s),
2427            Some(c) => {
2428                let cand1 = d_base + s * c.lower_limit_baseline_drop_min;
2429                let cand2 = d_base + s * c.lower_limit_gap_min + h_low;
2430                cand1.max(cand2)
2431            }
2432        }
2433    }
2434
2435    /// `math.ml:982-991` `horz_fraction_bar`'s rule thickness (also
2436    /// `radical_bar_metrics`'s `t_bar` — both are "the same generic rule
2437    /// ratio" in the pre-MATH-table fixed-constant world):
2438    /// `fraction_rule_thickness`, or the fixed `0.04` fallback. Multiplied
2439    /// by the ambient LOCAL nesting `size` (not `ctx.font_size` — a
2440    /// fraction/radical's own metrics DO shrink with nesting, matching
2441    /// upstream's `FontInfo.actual_math_font_size`, unlike the sup/sub shift
2442    /// constants' documented `ctx.font_size` simplification above).
2443    fn frac_rule(&self, s: Length) -> Length {
2444        self.c
2445            .map(|c| s * c.fraction_rule_thickness)
2446            .unwrap_or(s * 0.04)
2447    }
2448
2449    /// `math.ml:574-583` `numerator_baseline_height`, clamped: the
2450    /// MAGNITUDE of the upward shift a numerator needs given its own ink
2451    /// depth (`d_numer`, a positive extent below ITS baseline — this port's
2452    /// convention, see `sup_shift_clamped`'s doc comment; upstream's
2453    /// `Length.negate d_numer` becomes a plain ADD of `d_numer` here, not a
2454    /// subtract — getting this sign wrong would shrink the raise for a
2455    /// deeper numerator instead of growing it, overlapping the bar). Falls
2456    /// back to a flat, content-independent ratio with no MATH table
2457    /// (mirrors `sup_shift_clamped`'s None-branch style).
2458    fn frac_numer_shift(&self, s: Length, d_numer: Length) -> Length {
2459        match self.c {
2460            None => s * FRAC_NUMER_SHIFT_FALLBACK,
2461            Some(c) => {
2462                let std = s * c.fraction_numer_shift_up;
2463                let gap =
2464                    self.axis(s) + self.frac_rule(s) * 0.5 + s * c.fraction_numer_gap_min + d_numer;
2465                std.max(gap)
2466            }
2467        }
2468    }
2469
2470    /// `math.ml:585-594` `denominator_baseline_depth`, clamped: mirrors
2471    /// `frac_numer_shift`. Returns the SIGNED (already-negative) drop the
2472    /// caller applies straight to `dy` — unlike the sup/sub methods'
2473    /// positive-magnitude-then-caller-negates convention — because
2474    /// upstream's own `d_denombl` is signed too, so there's no sign flip to
2475    /// make here (and `h_denom`, a HEIGHT not a depth, is subtracted
2476    /// directly, matching upstream's un-negated use of it).
2477    fn frac_denom_shift(&self, s: Length, h_denom: Length) -> Length {
2478        match self.c {
2479            None => -(s * FRAC_DENOM_SHIFT_FALLBACK),
2480            Some(c) => {
2481                let std = -(s * c.fraction_denom_shift_down);
2482                let gap =
2483                    self.axis(s) - self.frac_rule(s) * 0.5 - s * c.fraction_denom_gap_min - h_denom;
2484                std.min(gap)
2485            }
2486        }
2487    }
2488
2489    /// `math.ml:620-626` `radical_bar_metrics`: `(h_bar, t_bar, l_extra)` —
2490    /// the bar's height above baseline (radicand height + gap, so the bar
2491    /// always clears the radicand with no separate raise needed), its rule
2492    /// thickness, and the extra ascender the WHOLE radical run reports
2493    /// above the bar. Fallback ratios (no MATH table):
2494    /// vertical_gap=0.06, rule=0.04 (same fixed ratio `frac_rule` falls back
2495    /// to), extra_ascender=0.06.
2496    fn radical_bar_metrics(&self, s: Length, h_cont: Length) -> (Length, Length, Length) {
2497        match self.c {
2498            Some(c) => (
2499                h_cont + s * c.radical_vertical_gap,
2500                s * c.radical_rule_thickness,
2501                s * c.radical_extra_ascender,
2502            ),
2503            None => (h_cont + s * 0.06, s * 0.04, s * 0.06),
2504        }
2505    }
2506}
2507
2508/// The ink height/depth of an already-laid-out run, as positive magnitudes
2509/// (`MathGlyph.dy` is signed, up-positive; `.height`/`.depth` are always
2510/// non-negative extents from EACH glyph's own local baseline) — the same
2511/// aggregate `read_math`/`layout_math_value` compute for a whole
2512/// `PureHorzBox::Math`, reused here per sub-run so `MathC`'s clamp formulas
2513/// have an `h_base`/`d_sup`/etc to clamp against. Empty input -> `(ZERO,
2514/// ZERO)` (an empty base/script contributes no clamp pressure).
2515fn glyphs_extent(glyphs: &[MathGlyph]) -> (Length, Length) {
2516    let mut height = Length::ZERO;
2517    let mut depth = Length::ZERO;
2518    for g in glyphs {
2519        height = height.max(g.dy + g.height);
2520        depth = depth.max(g.depth - g.dy);
2521    }
2522    (height, depth)
2523}
2524
2525/// `glyphs_extent` plus `rules`' own bounding boxes folded in — exactly the
2526/// aggregate `layout_math_value` computes for a whole `PureHorzBox::Math`
2527/// (see that function's doc comment on why a bare `Fill`, e.g. a fraction
2528/// bar/radical sign, needs its own bbox folded in rather than being silently
2529/// undercounted). Also reused to size a stretchy delimiter to its
2530/// enclosed run's REAL ink (glyphs + any drawn rules), not just its glyphs.
2531///
2532/// This — NOT bare `glyphs_extent` — is what every `layout_math_value` arm
2533/// must use for a sub-run's `h_base`/`d_base`/`d_sup`/`h_sub`/`d_numer`/…,
2534/// because it is upstream's `convert_to_low` return value: each arm's
2535/// `(_, h, d, _, _)` is the whole sub-run's `h_whole`/`d_whole`, and a
2536/// `MathParen`'s is `max(hC, hL, hR)` / `min(dC, dL, dR)` over the
2537/// DELIMITER boxes too (`math.ml:908-909`). A `math.satyh` delimiter is
2538/// `inline-graphics` ink, so it lands in `rules` and in nothing else: with
2539/// `glyphs_extent` a `\paren{…}` base reported only its CONTENT's height,
2540/// which is smaller than the delimiter it just sized, and
2541/// `sup_shift_clamped`'s `h_base - SuperscriptBaselineDropMax` candidate
2542/// therefore lost when upstream's wins. `${\paren{\frac{1}{1-v}}^{2}}` at
2543/// 12pt: `h_base` 16.116pt (content) vs upstream's 17.316pt (`hgtaxis +
2544/// halflen`, the paren's own declared box), i.e. a 1.2pt-too-low superscript
2545/// — `layout-tests/probes/math_box_extent.saty` row 4. The bbox of
2546/// `math.satyh`'s `paren-left`/`angle-left`/… path is exactly that declared
2547/// box (its extreme points ARE `ycenter ± halflen`), so folding the rule in
2548/// reproduces upstream's number rather than approximating it.
2549fn inner_ink_extent(glyphs: &[MathGlyph], rules: &[GraphicsElem]) -> (Length, Length) {
2550    let (mut height, mut depth) = glyphs_extent(glyphs);
2551    for r in rules {
2552        // `graphics_bbox` is now `Option` (`None` for an empty `Group`
2553        // — unreachable here under 0.0.6 math rules, but the fold is
2554        // version-blind and correct either way: a `None` rule contributes
2555        // nothing to the ink extent).
2556        if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
2557            height = height.max(max_y);
2558            depth = depth.max(-min_y);
2559        }
2560    }
2561    (height, depth)
2562}
2563
2564/// `math.ml:1040-1075`'s superscript kern tuck: the italic correction of
2565/// the base's TRAILING glyph plus the two corner kerns — the base's
2566/// top-right sampled at the height the raised superscript's ink starts
2567/// (`l_base = sup_shift - d_sup`, `superscript_correction_heights`'s first
2568/// component), and the superscript's own bottom-left sampled (at the
2569/// superscript's OWN size) at the height the base's ink ends (`l_sup =
2570/// h_base - sup_shift`, that function's second component) — the extra
2571/// horizontal gap upstream inserts between a base and a raised superscript
2572/// so slanted glyphs (an italic integral, say) don't collide with what's
2573/// stacked above them. `size`/`script_size` are the local sizes the base/
2574/// script glyphs were actually measured at (NOT `ctx.font_size`, unlike the
2575/// shift magnitude — these feed a design-units conversion that must match
2576/// each glyph's own em square). Every lookup misses to `Length::ZERO` (no
2577/// MATH table, no glyph, no kern data, ...), so base-14 output is
2578/// untouched: this returns exactly `Length::ZERO` whenever `ctx.math_font`
2579/// has no MATH table.
2580#[allow(clippy::too_many_arguments)]
2581fn superscript_kern(
2582    interp: &Interp,
2583    ctx: &Context,
2584    size: Length,
2585    script_size: Length,
2586    base_glyphs: &[MathGlyph],
2587    script_glyphs: &[MathGlyph],
2588    sup_shift: Length,
2589    h_base: Length,
2590    d_sup: Length,
2591) -> Length {
2592    let font = ctx.math_font;
2593    let last_base = base_glyphs.last().and_then(|g| g.text.chars().last());
2594    let first_script = script_glyphs.first().and_then(|g| g.text.chars().next());
2595    let l_italic = last_base
2596        .and_then(|c| interp.metrics.italic_correction(font, c, size))
2597        .unwrap_or(Length::ZERO);
2598    let l_base = sup_shift - d_sup;
2599    let l_sup = h_base - sup_shift;
2600    let l_kernbase = last_base
2601        .and_then(|c| {
2602            interp
2603                .metrics
2604                .math_kern(font, c, size, MathCorner::TopRight, l_base)
2605        })
2606        .unwrap_or(Length::ZERO);
2607    let l_kernsup = first_script
2608        .and_then(|c| {
2609            interp
2610                .metrics
2611                .math_kern(font, c, script_size, MathCorner::BottomLeft, l_sup)
2612        })
2613        .unwrap_or(Length::ZERO);
2614    l_italic + l_kernbase + l_kernsup
2615}
2616
2617/// A minimal stand-in for v0.0.6's per-codepoint math-class table
2618/// (`primitives.cppo.ml`) + `normalize_math_kind` (`math.ml:240`) — just
2619/// enough for `${a+b}` to get binary-operator spacing. Letters/digits/
2620/// everything else default to `Ord`.
2621fn ascii_math_kind(c: char) -> MathKind {
2622    match c {
2623        '+' | '-' | '*' | '/' => MathKind::Bin,
2624        '=' | '<' | '>' => MathKind::Rel,
2625        ',' | ';' | ':' | '.' => MathKind::Punct,
2626        _ => MathKind::Ord,
2627    }
2628}
2629
2630/// `normalize_math_kind` (`math.ml:238-277`): a BINARY atom whose neighbours
2631/// make it unary is really an ORDINARY one, and gets none of `Bin`'s spacing.
2632/// Upstream demotes on `mkprev in {Op, Bin, Rel, Open, Punct}` or `mknext in
2633/// {Rel, Close, Punct}`; `MathEnd` — the sentinel `math.ml:1270` passes for
2634/// both ends of a formula — is included here on the LEFT, which is what makes
2635/// `${-------}` set seven tight glyphs rather than a leading binary minus
2636/// followed by six ordinaries, and what keeps `${-N, -N + 1}`'s minus signs
2637/// tight against their operands the way the reference sets them. Every other
2638/// class passes through unchanged.
2639///
2640/// Reachable at all only since the math lexer stopped gluing a run of symbols
2641/// into one token: before that a `--` was a single `Ord` atom and there was no
2642/// adjacent pair to normalize.
2643fn normalize_math_kind(prev: MathKind, next: MathKind, raw: MathKind) -> MathKind {
2644    if raw != MathKind::Bin {
2645        return raw;
2646    }
2647    let unary_left = matches!(
2648        prev,
2649        MathKind::Op
2650            | MathKind::Bin
2651            | MathKind::Rel
2652            | MathKind::Open
2653            | MathKind::Punct
2654            | MathKind::End
2655    );
2656    let unary_right = matches!(next, MathKind::Rel | MathKind::Close | MathKind::Punct);
2657    if unary_left || unary_right {
2658        MathKind::Ord
2659    } else {
2660        MathKind::Bin
2661    }
2662}
2663
2664/// A deliberately tiny stand-in for `space_between_math_kinds`
2665/// (`math.ml:319-410`, a 40-pair table driven by context ratios + MATH-table
2666/// `space_after_script`): a thin space when either neighbor is
2667/// `Bin`, a thick space when either is `Rel`, none otherwise.
2668///
2669/// TWO IDENTICAL classes in a row are the one pair this stand-in gets right by
2670/// exception rather than by rule. Upstream's table lists `(Bin, Ord)`,
2671/// `(Ord, Bin)`, `(Rel, Ord)`, `(Ord, Rel)`, … but NOT `(Bin, Bin)` or
2672/// `(Rel, Rel)`, so those fall through to its `_` arm and get NO space — which
2673/// is why `${a:=b}` sets `:=` as one tight pair between two thick spaces. The
2674/// distinction only became reachable when the math lexer stopped gluing a run
2675/// of symbols into a single token; before that `:=` was one `Ord` atom and got
2676/// no spacing on EITHER side.
2677fn space_before(prev: MathKind, cur: MathKind, font_size: Length) -> Length {
2678    if prev == cur && matches!(prev, MathKind::Bin | MathKind::Rel) {
2679        Length::ZERO
2680    } else if prev == MathKind::Bin || cur == MathKind::Bin {
2681        font_size * 0.22
2682    } else if prev == MathKind::Rel || cur == MathKind::Rel {
2683        font_size * 0.28
2684    } else {
2685        Length::ZERO
2686    }
2687}
2688
2689/// FontKey a math glyph c@size should measure/emit in: dedicated ctx.math_font
2690/// when it can render c, else text ctx.font. The one place math diverges from
2691/// text font; the MATH-table slice keys lookups on the same returned FontKey.
2692fn math_glyph_font(interp: &Interp, ctx: &Context, c: char, size: Length) -> FontKey {
2693    if interp.metrics.advance(ctx.math_font, c, size).is_some() {
2694        ctx.math_font
2695    } else {
2696        ctx.font
2697    }
2698}
2699
2700/// gap-5 metrics-probe predicate, now math-font-aware.
2701fn math_char_available(interp: &Interp, ctx: &Context, c: char, size: Length) -> bool {
2702    interp.metrics.advance(ctx.math_font, c, size).is_some()
2703        || interp.metrics.advance(ctx.font, c, size).is_some()
2704}
2705
2706/// Measure one math character at `size` under `math_glyph_font(ctx, c)` and
2707/// push it as a `MathGlyph` at the running `*x` (`dy = 0`; callers shift
2708/// scripts afterward), advancing `*x` past it.
2709fn push_char_glyph(
2710    interp: &mut Interp,
2711    ctx: &Context,
2712    c: char,
2713    size: Length,
2714    out: &mut Vec<MathGlyph>,
2715    x: &mut Length,
2716) -> Result<(), EvalError> {
2717    let font = math_glyph_font(interp, ctx, c, size);
2718    // Graceful degradation for a math character neither the math font nor the
2719    // text font can render (e.g. `⋯` U+22EF under the bundled faces): fall back
2720    // to a half-em advance and let the glyph degrade to `.notdef` at render
2721    // time (`gid: None`, resolved by `cid::encode_glyph_run`), exactly as the
2722    // text path does in `measure_run` — a missing glyph must not abort the whole
2723    // document. This only ever changes behavior for a glyph that would otherwise
2724    // be a hard error, so covered-glyph documents stay byte-identical.
2725    let advance = interp.metrics.advance(font, c, size).unwrap_or(size * 0.5);
2726    let (height, depth) = math_glyph_vextent(interp, font, c, size);
2727    out.push(MathGlyph {
2728        info: HorzStringInfo {
2729            font,
2730            size,
2731            rising: Length::ZERO,
2732            color: ctx.text_color,
2733        },
2734        text: c.to_string(),
2735        gid: None,
2736        dx: *x,
2737        dy: Length::ZERO,
2738        width: advance,
2739        height,
2740        depth,
2741    });
2742    *x += advance;
2743    Ok(())
2744}
2745
2746/// One math glyph's vertical ink extent, the way upstream measures it:
2747/// `FontFormat.get_math_glyph_metrics` (`fontFormat.ml:2257-2264`) takes the
2748/// glyph's OWN bounding box and truncates each side towards the baseline —
2749/// `hgt = truncate_negative ymax` (so a wholly-subscripted glyph reports
2750/// height 0, never a negative height) and `dpt = truncate_positive ymin` (so a
2751/// glyph entirely above the baseline, `*` at ymin=+320, reports depth 0).
2752///
2753/// This is NOT the font-level ascender/descender:
2754/// `MathC::sub_shift_clamped`/`sup_shift_clamped` clamp against these extents
2755/// (`math.ml:527-552`), so feeding them latinmodern-math's hhea ascender
2756/// (806/1000 em) and descender (194/1000 em) instead of `m`'s real ink box
2757/// (ymax 442, ymin 0) made the `superscript_baseline_drop_max` /
2758/// `subscript_baseline_drop_min` candidate win every time. At 12pt that is
2759/// `9.672 - 3.0 = 6.672pt` of superscript rise where upstream's own clamp
2760/// picks `SuperscriptShiftUp = 4.356pt` (plus a gap correction, 4.525pt), and
2761/// `2.328 + 2.4 = 4.728pt` of subscript drop where upstream picks
2762/// `SubscriptShiftDown = 2.964pt` — measured by `layout-tests/probes/
2763/// math_script_drop.saty`.
2764///
2765/// Falls back to `ascender`/`descender` when the provider exposes no per-glyph
2766/// bbox (base-14 metrics, test stubs).
2767fn math_glyph_vextent(interp: &Interp, font: FontKey, c: char, size: Length) -> (Length, Length) {
2768    match interp.metrics.glyph_vextent(font, c, size) {
2769        Some((h, d)) => (h.max(Length::ZERO), d.max(Length::ZERO)),
2770        None => (
2771            interp.metrics.ascender(font, size),
2772            interp.metrics.descender(font, size),
2773        ),
2774    }
2775}
2776
2777/// `push_char_glyph`'s big-operator sibling: try the v0.0.6 `BigOp`
2778/// vertical variant (`fontInfo.ml:386-401` — the 2nd `MathVariants` record if
2779/// present, else the 1st) unconditionally. Upstream's own guard is
2780/// `is_in_display && is_big`, but `math.ml`'s `convert_math_char` hardcodes
2781/// `is_in_display = true`, so it reduces to just `is_big` — a big operator
2782/// grows even inline, even at script size, exactly like upstream; the port
2783/// tracks no display/inline distinction and needs none here. On any miss (no
2784/// MATH table, no vertical construction for `c`, or a variant/hmtx/bbox
2785/// lookup failure — every base-14 call, always) falls back to
2786/// `push_char_glyph`, byte-identical to the base output.
2787fn push_big_char_glyph(
2788    interp: &mut Interp,
2789    ctx: &Context,
2790    c: char,
2791    size: Length,
2792    out: &mut Vec<MathGlyph>,
2793    x: &mut Length,
2794) -> Result<(), EvalError> {
2795    let font = math_glyph_font(interp, ctx, c, size);
2796    match interp
2797        .metrics
2798        .math_vertical_variant(font, c, size, VertVariantPolicy::BigOp)
2799    {
2800        Some(v) => {
2801            out.push(MathGlyph {
2802                info: HorzStringInfo {
2803                    font,
2804                    size,
2805                    rising: Length::ZERO,
2806                    color: ctx.text_color,
2807                },
2808                text: c.to_string(),
2809                gid: Some(v.gid),
2810                dx: *x,
2811                dy: Length::ZERO,
2812                width: v.advance,
2813                height: v.height,
2814                depth: v.depth,
2815            });
2816            *x += v.advance;
2817            Ok(())
2818        }
2819        None => push_char_glyph(interp, ctx, c, size, out, x),
2820    }
2821}
2822
2823/// One stretchy-delimiter glyph: the smallest `MathVariants`
2824/// record whose `advance_measurement` covers `target` (else the largest
2825/// record — `VertVariantPolicy::AtLeast`), centered on the math axis
2826/// (`dy = axis - (h - d) / 2`; y-**up**, same sign convention as
2827/// `shift_and_append`'s `dy_shift` — see that function's doc comment on the
2828/// mirroring trap a flipped sign causes). Falls back to the baseline
2829/// base glyph (`push_char_glyph`) when there's no vertical construction.
2830fn push_delimiter_glyph(
2831    interp: &mut Interp,
2832    ctx: &Context,
2833    c: char,
2834    size: Length,
2835    target: Length,
2836    axis: Length,
2837    out: &mut Vec<MathGlyph>,
2838    x: &mut Length,
2839) -> Result<(), EvalError> {
2840    let font = math_glyph_font(interp, ctx, c, size);
2841    let variant =
2842        interp
2843            .metrics
2844            .math_vertical_variant(font, c, size, VertVariantPolicy::AtLeast(target));
2845    // `GlyphAssembly`: if even the largest discrete variant's own ink
2846    // extent (`height + depth`) still doesn't span `target` — a delimiter
2847    // taller than anything the font enumerates as a prepared variant — grow
2848    // it from the assembly parts instead (stack top + repeated extenders +
2849    // bottom). `None` (no MATH table / no assembly / base-14) leaves the
2850    // discrete/base path below byte-identical.
2851    let discrete_covers = variant
2852        .map(|v| (v.height + v.depth).0 >= target.0)
2853        .unwrap_or(false);
2854    if !discrete_covers {
2855        if let Some(parts) = interp.metrics.math_vertical_assembly(font, c, size, target) {
2856            if !parts.is_empty() {
2857                // Horizontal advance of the delimiter column: the largest
2858                // discrete variant's own hmtx advance when we have one (the
2859                // parts share the same nominal delimiter width), else the base
2860                // glyph's advance.
2861                let hadv = match variant {
2862                    Some(v) => v.advance,
2863                    None => interp
2864                        .metrics
2865                        .advance(font, c, size)
2866                        .unwrap_or(Length::ZERO),
2867                };
2868                // Total vertical extent of the stacked assembly (local, from
2869                // the bottom part's baseline at 0), then center it on the math
2870                // axis exactly like the discrete path centers a variant's ink.
2871                let total = parts
2872                    .last()
2873                    .map(|(_, dy, adv)| *dy + *adv)
2874                    .unwrap_or(Length::ZERO);
2875                let base_off = axis - total * 0.5;
2876                for (i, (gid, dy_local, adv)) in parts.iter().enumerate() {
2877                    out.push(MathGlyph {
2878                        info: HorzStringInfo {
2879                            font,
2880                            size,
2881                            rising: Length::ZERO,
2882                            color: ctx.text_color,
2883                        },
2884                        text: c.to_string(),
2885                        gid: Some(*gid),
2886                        dx: *x,
2887                        dy: base_off + *dy_local,
2888                        // Only the first part carries the column's horizontal
2889                        // width (all parts are stacked in the SAME x column);
2890                        // its baseline-relative extent is the part's vertical
2891                        // advance (up), so `glyphs_extent` folds the whole
2892                        // stacked column into the box's height/depth.
2893                        width: if i == 0 { hadv } else { Length::ZERO },
2894                        height: *adv,
2895                        depth: Length::ZERO,
2896                    });
2897                }
2898                *x += hadv;
2899                return Ok(());
2900            }
2901        }
2902    }
2903    match variant {
2904        Some(v) => {
2905            let dy = axis - (v.height - v.depth) * 0.5;
2906            out.push(MathGlyph {
2907                info: HorzStringInfo {
2908                    font,
2909                    size,
2910                    rising: Length::ZERO,
2911                    color: ctx.text_color,
2912                },
2913                text: c.to_string(),
2914                gid: Some(v.gid),
2915                dx: *x,
2916                dy,
2917                width: v.advance,
2918                height: v.height,
2919                depth: v.depth,
2920            });
2921            *x += v.advance;
2922            Ok(())
2923        }
2924        None => push_char_glyph(interp, ctx, c, size, out, x),
2925    }
2926}
2927
2928/// Lay out `elems` in isolation (its own local `x` starting at 0, its own
2929/// spacing state) at `size` — the shape a `Sup`/`Sub`/`Primes` script needs
2930/// before its glyphs get re-anchored onto the base's running `x` and
2931/// shifted by the caller. `size` is the caller's `MathC::script_scale`-
2932/// derived script size (real MATH-table ratio when available, `SCRIPT_SCALE`
2933/// otherwise). Returns the glyphs (still at local coordinates) and
2934/// the script's total width.
2935fn layout_script(
2936    interp: &mut Interp,
2937    ctx: &Context,
2938    elems: &[MathElem],
2939    size: Length,
2940) -> Result<(Vec<MathGlyph>, Length), EvalError> {
2941    let mut glyphs = Vec::new();
2942    let mut x = Length::ZERO;
2943    let mut last_kind: Option<MathKind> = None;
2944    for e in elems {
2945        layout_math_elem(interp, ctx, e, size, &mut glyphs, &mut x, &mut last_kind)?;
2946    }
2947    Ok((glyphs, x))
2948}
2949
2950/// Re-anchor an isolated script's glyphs (`layout_script`'s output) onto the
2951/// base's running `*x`, adding `dy_shift` to every glyph's vertical offset —
2952/// `dy_shift > 0` raises (superscript), `< 0` lowers (subscript). Advances
2953/// `*x` past the whole script.
2954fn place_script(
2955    out: &mut Vec<MathGlyph>,
2956    x: &mut Length,
2957    script_glyphs: Vec<MathGlyph>,
2958    script_width: Length,
2959    dy_shift: Length,
2960) {
2961    let base_x = *x;
2962    for mut g in script_glyphs {
2963        g.dx = base_x + g.dx;
2964        g.dy = g.dy + dy_shift;
2965        out.push(g);
2966    }
2967    *x = base_x + script_width;
2968}
2969
2970/// The recursive core of `read_math`: lays out one `MathElem` into `out`,
2971/// advancing `*x` and threading `*last_kind` (the trailing `MathKind` of
2972/// whatever was laid out immediately before, for `space_before`) through
2973/// siblings — the analog of `convert_to_low` + `horz_of_low_math`
2974/// (`math.ml:753`/`:1016`), fused and with fixed constants.
2975fn layout_math_elem(
2976    interp: &mut Interp,
2977    ctx: &Context,
2978    elem: &MathElem,
2979    size: Length,
2980    out: &mut Vec<MathGlyph>,
2981    x: &mut Length,
2982    last_kind: &mut Option<MathKind>,
2983) -> Result<(), EvalError> {
2984    match elem {
2985        MathElem::Chars(s) => {
2986            for c in s.chars() {
2987                let kind = ascii_math_kind(c);
2988                if let Some(prev) = *last_kind {
2989                    *x += space_before(prev, kind, ctx.font_size);
2990                }
2991                push_char_glyph(interp, ctx, c, size, out, x)?;
2992                *last_kind = Some(kind);
2993            }
2994            Ok(())
2995        }
2996        MathElem::Group(elems) => {
2997            for e in elems {
2998                layout_math_elem(interp, ctx, e, size, out, x, last_kind)?;
2999            }
3000            Ok(())
3001        }
3002        MathElem::Sup(base, script) => {
3003            let base_start = out.len();
3004            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3005            let mc = MathC::of(interp, ctx);
3006            let script_size = ctx.font_size * mc.script_scale();
3007            let (h_base, _) = glyphs_extent(&out[base_start..]);
3008            let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3009            let (_, d_sup) = glyphs_extent(&script_glyphs);
3010            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3011            let kern = superscript_kern(
3012                interp,
3013                ctx,
3014                size,
3015                script_size,
3016                &out[base_start..],
3017                &script_glyphs,
3018                sup_shift,
3019                h_base,
3020                d_sup,
3021            );
3022            *x += kern;
3023            place_script(out, x, script_glyphs, script_width, sup_shift);
3024            Ok(())
3025        }
3026        MathElem::Sub(base, script) => {
3027            let base_start = out.len();
3028            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3029            let mc = MathC::of(interp, ctx);
3030            let script_size = ctx.font_size * mc.script_scale();
3031            let (_, d_base) = glyphs_extent(&out[base_start..]);
3032            let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3033            let (h_sub, _) = glyphs_extent(&script_glyphs);
3034            let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
3035            place_script(out, x, script_glyphs, script_width, -sub_shift);
3036            Ok(())
3037        }
3038        MathElem::Primes(base, n) => {
3039            let base_start = out.len();
3040            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3041            let mc = MathC::of(interp, ctx);
3042            let script_size = ctx.font_size * mc.script_scale();
3043            let (h_base, _) = glyphs_extent(&out[base_start..]);
3044            // Upstream desugars primes to exactly this: a superscript of `n`
3045            // U+2032 `′` chars (`parser.mly:1082`).
3046            let primes = vec![MathElem::Chars("\u{2032}".repeat(*n))];
3047            let (script_glyphs, script_width) = layout_script(interp, ctx, &primes, script_size)?;
3048            let (_, d_sup) = glyphs_extent(&script_glyphs);
3049            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3050            let kern = superscript_kern(
3051                interp,
3052                ctx,
3053                size,
3054                script_size,
3055                &out[base_start..],
3056                &script_glyphs,
3057                sup_shift,
3058                h_base,
3059                d_sup,
3060            );
3061            *x += kern;
3062            place_script(out, x, script_glyphs, script_width, sup_shift);
3063            Ok(())
3064        }
3065        MathElem::Cmd { name, span, .. } => Err(EvalError {
3066            span: Some(*span),
3067            msg: format!("math command `{name}` needs the math package (phase 7 roadmap A)"),
3068        }),
3069        MathElem::Embed { span, .. } => Err(EvalError {
3070            span: Some(*span),
3071            msg: "embedding a program value in math needs the math package \
3072                  (phase 7 roadmap A)"
3073                .into(),
3074        }),
3075    }
3076}
3077
3078/// Walk an elaborated `${…}` tree (`read_inline`'s `EmbedMath` arm) into one
3079/// `PureHorzBox::Math`, measuring every glyph through `interp.metrics` at
3080/// `ctx.font`/`ctx.font_size` — the same `FontMetrics` seam `text_to_boxes`
3081/// uses. Box-model rationale: a math run carries its own pre-shifted
3082/// sub-glyphs, since the line model has no per-box vertical slot.
3083pub fn read_math(
3084    interp: &mut Interp,
3085    ctx: &Context,
3086    elems: &[MathElem],
3087) -> Result<PureHorzBox, EvalError> {
3088    let mut glyphs: Vec<MathGlyph> = Vec::new();
3089    let mut x = Length::ZERO;
3090    let mut last_kind: Option<MathKind> = None;
3091    for e in elems {
3092        layout_math_elem(
3093            interp,
3094            ctx,
3095            e,
3096            ctx.font_size,
3097            &mut glyphs,
3098            &mut x,
3099            &mut last_kind,
3100        )?;
3101    }
3102    let width = x;
3103    let mut height = Length::ZERO;
3104    let mut depth = Length::ZERO;
3105    for g in &glyphs {
3106        height = height.max(g.dy + g.height);
3107        depth = depth.max(g.depth - g.dy);
3108    }
3109    Ok(PureHorzBox::Math {
3110        width,
3111        height,
3112        depth,
3113        glyphs,
3114        rules: Vec::new(),
3115    })
3116}
3117
3118// ---- primitive bodies ----------------------------------------------------------
3119
3120fn prim_read_inline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3121    let it = args.pop().unwrap();
3122    let ctx = as_context(args.pop().unwrap())?;
3123    let (elems, env) = as_inline_text(it)?;
3124    Ok(Value::InlineBoxes(read_inline(interp, &ctx, &elems, &env)?))
3125}
3126
3127fn prim_read_block(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3128    let bt = args.pop().unwrap();
3129    let ctx = as_context(args.pop().unwrap())?;
3130    let (elems, env) = as_block_text(bt)?;
3131    Ok(Value::BlockBoxes(read_block(interp, &ctx, &elems, &env)?))
3132}
3133
3134/// `line-break : bool -> bool -> context -> inline-boxes -> block-boxes`
3135/// (vminst.ml `BackendLineBreaking`). The two leading bools tell the real
3136/// line breaker whether the paragraph's top/bottom edge may break across a
3137/// page; this port's `break_into_lines` does not yet model breakability
3138/// at all, so both are accepted (to keep the arity/signature faithful to
3139/// v0.0.6) and ignored for now.
3140///
3141/// This is upstream's `form_paragraph` seam — every stdlib caller
3142/// (`form-paragraph = line-break true true`, and every direct `line-break _
3143/// _ (ctx |> set-paragraph-margin …)` call for headings/itemize/footnotes)
3144/// relies on `line-break` itself to apply
3145/// `ctx.paragraph_top`/`paragraph_bottom` around the formed lines,
3146/// unconditionally of the two breakability bools (those only ever gate
3147/// page-break eligibility upstream, never whether the margin applies).
3148/// Prepending/appending `VertBox::Skip` here is a no-op in extent for a
3149/// caller that already zeroed the margin (e.g. `footnote-scheme.satyh`'s
3150/// `set-paragraph-margin 0pt 0pt`), and the leading skip specifically is
3151/// further discarded by `chop_page` when it lands at the very top of a
3152/// page/column (see that function's `pending_skip` handling) — mirroring
3153/// upstream's page-top glue suppression so a page's first paragraph does not
3154/// get a spurious gap above it.
3155fn prim_line_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3156    let ib = as_inline_boxes(args.pop().unwrap())?;
3157    let ctx = as_context(args.pop().unwrap())?;
3158    let _is_breakable_bottom = as_bool(args.pop().unwrap())?;
3159    let _is_breakable_top = as_bool(args.pop().unwrap())?;
3160    let lines = break_into_lines(&ctx, ib);
3161    // No lines were actually formed (empty inline content, `break_into_lines`'s
3162    // own `n == 0` early return) — don't manufacture a margin around nothing.
3163    let mut out = Vec::with_capacity(lines.len() + 2);
3164    if !lines.is_empty() {
3165        // `min_first_line_ascender` (9pt, `primitives.cppo.ml:516`) is folded
3166        // into the paragraph's OWN top margin, exactly as `lineBreak.ml:855-857`
3167        // does — `margin_top = paragraph_margin_top + max(0, 9pt - hgt)` over
3168        // the FIRST formed line's height. That padded value is what
3169        // `pageBreak.ml`'s `squash_margins` (`:596-601`) then max-collapses
3170        // against the previous block's bottom margin, so a larger predecessor
3171        // ABSORBS the pad instead of stacking it on top. Applying the floor
3172        // downstream to the line HEIGHT, after the collapse, is a different
3173        // function: it over-spaced every block whose predecessor had the larger
3174        // bottom margin — 5pt at every stdjabook section heading, whose 4pt
3175        // rule lines are shorter than the floor.
3176        //
3177        // The BOTTOM margin takes no pad: `min_last_descender` is assigned at
3178        // `lineBreak.ml:1144` and never read.
3179        let first_height = lines
3180            .iter()
3181            .find_map(|vb| match vb {
3182                VertBox::Line { height, .. } => Some(*height),
3183                _ => None,
3184            })
3185            .unwrap_or(Length::ZERO);
3186        let pad = (MIN_FIRST_ASCENDER - first_height).max(Length::ZERO);
3187        out.push(VertBox::ParagTop(ctx.paragraph_top + pad));
3188        out.extend(lines);
3189        out.push(VertBox::Skip(ctx.paragraph_bottom));
3190    }
3191    for vb in &mut out {
3192        if let VertBox::Line { contents, .. } = vb {
3193            resolve_outer_graphics_in_contents(interp, contents)?;
3194        }
3195    }
3196    Ok(Value::BlockBoxes(out))
3197}
3198
3199/// Look up `field` in a scheme record's fields, erroring with the
3200/// available-fields hint (mirrors `evalUtil.ml`'s `report_bug_value` arms
3201/// for a missing/mistyped scheme field) if it's absent.
3202fn record_field(
3203    fields: &BTreeMap<String, Value>,
3204    record_name: &str,
3205    field: &str,
3206) -> Result<Value, EvalError> {
3207    match fields.get(field) {
3208        Some(v) => Ok(v.clone()),
3209        None => eval_error(format!(
3210            "{record_name} record is missing field '{field}' (available fields: {})",
3211            available_fields(fields)
3212        )),
3213    }
3214}
3215
3216/// Extract `(text-origin, text-height)` from a `page-content-scheme`
3217/// record (`{| text-origin : point; text-height : length |}`) — the direct
3218/// port of `make_page_content_scheme_func`'s field pull (`evalUtil.ml:558-
3219/// 565`).
3220fn read_content_scheme(v: Value) -> Result<(Point, Length), EvalError> {
3221    let fields = match v {
3222        Value::Record(m) => m,
3223        other => {
3224            return eval_error(format!(
3225                "a page-content-scheme closure must return a record, got {}",
3226                other.type_name()
3227            ))
3228        }
3229    };
3230    let origin = as_point(record_field(&fields, "page-content-scheme", "text-origin")?)?;
3231    let height = as_length(record_field(&fields, "page-content-scheme", "text-height")?)?;
3232    Ok((origin, height))
3233}
3234
3235/// Extract `(header-origin, header-content, footer-origin, footer-content)`
3236/// from a `page-parts` record — the direct port of
3237/// `make_page_parts_scheme_func`'s field pull (`evalUtil.ml:576-595`).
3238fn read_parts_scheme(v: Value) -> Result<(Point, Vec<VertBox>, Point, Vec<VertBox>), EvalError> {
3239    let fields = match v {
3240        Value::Record(m) => m,
3241        other => {
3242            return eval_error(format!(
3243                "a page-parts closure must return a record, got {}",
3244                other.type_name()
3245            ))
3246        }
3247    };
3248    let header_origin = as_point(record_field(&fields, "page-parts", "header-origin")?)?;
3249    let header_content = as_block_boxes(record_field(&fields, "page-parts", "header-content")?)?;
3250    let footer_origin = as_point(record_field(&fields, "page-parts", "footer-origin")?)?;
3251    let footer_content = as_block_boxes(record_field(&fields, "page-parts", "footer-content")?)?;
3252    Ok((header_origin, header_content, footer_origin, footer_content))
3253}
3254
3255/// Upstream's `--page-number-limit` default (main.ml:1029). v0.0.6 guards
3256/// only the multicolumn loop (pageBreak.ml:765, `PageNumberLimitExceeded`);
3257/// the port guards the shared loop unconditionally — a hook-less run is
3258/// already bounded by the vbox count (`chop_page`'s progress guarantee), so
3259/// the guard can only fire when column hooks inject content, exactly the
3260/// case upstream added it for.
3261const PAGE_NUMBER_LIMIT: i64 = 10_000;
3262
3263/// The real 4-arg `page-break`, v0.0.6 arm — upstream `BCDocument(pagesize,
3264/// SingleColumn, (fun () -> []), (fun () -> []), …)` (vminst.ml:1039): one
3265/// zero-shift column, no hooks. Forked from the v0.1 arm below ONLY in its
3266/// first-argument extraction (`as_page` vs `as_page_v01`) — deliberately two
3267/// separate functions per tag rather than one branching on a `version`
3268/// parameter, so that a "shared" function is genuinely shared code.
3269/// `page_break_core` below IS that shared code.
3270fn prim_page_break_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3271    let bb = as_block_boxes(args.pop().unwrap())?;
3272    let pagepartsf = args.pop().unwrap();
3273    let pagecontf = args.pop().unwrap();
3274    let paper = as_page(args.pop().unwrap())?;
3275    page_break_core(
3276        interp,
3277        paper,
3278        vec![Length::ZERO],
3279        None,
3280        None,
3281        pagecontf,
3282        pagepartsf,
3283        bb,
3284    )
3285}
3286
3287/// v0.1 arm of `page-break`. Identical to `prim_page_break_v006` above
3288/// except `as_page_v01` in place of `as_page`; everything downstream
3289/// (`page_break_core`, `chop_page`, `place_block_at`, `DocumentValue`
3290/// assembly) is the SAME shared code both arms call, unedited by this fork.
3291fn prim_page_break_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3292    let bb = as_block_boxes(args.pop().unwrap())?;
3293    let pagepartsf = args.pop().unwrap();
3294    let pagecontf = args.pop().unwrap();
3295    let paper = as_page_v01(args.pop().unwrap())?;
3296    page_break_core(
3297        interp,
3298        paper,
3299        vec![Length::ZERO],
3300        None,
3301        None,
3302        pagecontf,
3303        pagepartsf,
3304        bb,
3305    )
3306}
3307
3308/// `page-break-two-column : page -> length -> (unit -> block-boxes) ->
3309/// (pbinfo -> page-content-scheme) -> (pbinfo -> page-parts) ->
3310/// block-boxes -> document` (vminst.ml:1041 `BackendPageBreakingTwoColumn`),
3311/// v0.0.6 arm — upstream builds `MultiColumn([origin_shift])` with the
3312/// user's column hook and a trivial column-end hook (vminst.ml:1062); the
3313/// `length` is the x-shift of the SECOND column's origin. See
3314/// `prim_page_break_v006`'s doc comment for the fork rationale.
3315fn prim_page_break_two_column_v006(
3316    interp: &mut Interp,
3317    mut args: Vec<Value>,
3318) -> Result<Value, EvalError> {
3319    let bb = as_block_boxes(args.pop().unwrap())?;
3320    let pagepartsf = args.pop().unwrap();
3321    let pagecontf = args.pop().unwrap();
3322    let columnhookf = args.pop().unwrap();
3323    let origin_shift = as_length(args.pop().unwrap())?;
3324    let paper = as_page(args.pop().unwrap())?;
3325    page_break_core(
3326        interp,
3327        paper,
3328        vec![Length::ZERO, origin_shift],
3329        Some(columnhookf),
3330        None,
3331        pagecontf,
3332        pagepartsf,
3333        bb,
3334    )
3335}
3336
3337/// v0.1 arm of `page-break-two-column`, using `as_page_v01` in place of
3338/// `as_page`.
3339fn prim_page_break_two_column_v01(
3340    interp: &mut Interp,
3341    mut args: Vec<Value>,
3342) -> Result<Value, EvalError> {
3343    let bb = as_block_boxes(args.pop().unwrap())?;
3344    let pagepartsf = args.pop().unwrap();
3345    let pagecontf = args.pop().unwrap();
3346    let columnhookf = args.pop().unwrap();
3347    let origin_shift = as_length(args.pop().unwrap())?;
3348    let paper = as_page_v01(args.pop().unwrap())?;
3349    page_break_core(
3350        interp,
3351        paper,
3352        vec![Length::ZERO, origin_shift],
3353        Some(columnhookf),
3354        None,
3355        pagecontf,
3356        pagepartsf,
3357        bb,
3358    )
3359}
3360
3361/// `page-break-multicolumn : page -> length list -> (unit -> block-boxes)
3362/// -> (unit -> block-boxes) -> (pbinfo -> page-content-scheme) -> (pbinfo
3363/// -> page-parts) -> block-boxes -> document` (vminst.ml:1065
3364/// `BackendPageBreakingMultiColumn`), v0.0.6 arm — FAITHFUL: the shift list
3365/// gives columns 2..N's x-origin shifts; upstream prepends `Length.zero` for
3366/// column 1 (pageBreak.ml:762), so `stdjareport.satyh:403`'s `[]` is a
3367/// one-column layout whose hooks still fire per column/page.
3368fn prim_page_break_multicolumn_v006(
3369    interp: &mut Interp,
3370    mut args: Vec<Value>,
3371) -> Result<Value, EvalError> {
3372    let bb = as_block_boxes(args.pop().unwrap())?;
3373    let pagepartsf = args.pop().unwrap();
3374    let pagecontf = args.pop().unwrap();
3375    let columnendhookf = args.pop().unwrap();
3376    let columnhookf = args.pop().unwrap();
3377    let mut origin_shifts = vec![Length::ZERO];
3378    for v in as_list(args.pop().unwrap())? {
3379        origin_shifts.push(as_length(v)?);
3380    }
3381    let paper = as_page(args.pop().unwrap())?;
3382    page_break_core(
3383        interp,
3384        paper,
3385        origin_shifts,
3386        Some(columnhookf),
3387        Some(columnendhookf),
3388        pagecontf,
3389        pagepartsf,
3390        bb,
3391    )
3392}
3393
3394/// v0.1 arm of `page-break-multicolumn`, using `as_page_v01` in place of
3395/// `as_page`.
3396fn prim_page_break_multicolumn_v01(
3397    interp: &mut Interp,
3398    mut args: Vec<Value>,
3399) -> Result<Value, EvalError> {
3400    let bb = as_block_boxes(args.pop().unwrap())?;
3401    let pagepartsf = args.pop().unwrap();
3402    let pagecontf = args.pop().unwrap();
3403    let columnendhookf = args.pop().unwrap();
3404    let columnhookf = args.pop().unwrap();
3405    let mut origin_shifts = vec![Length::ZERO];
3406    for v in as_list(args.pop().unwrap())? {
3407        origin_shifts.push(as_length(v)?);
3408    }
3409    let paper = as_page_v01(args.pop().unwrap())?;
3410    page_break_core(
3411        interp,
3412        paper,
3413        origin_shifts,
3414        Some(columnhookf),
3415        Some(columnendhookf),
3416        pagecontf,
3417        pagepartsf,
3418        bb,
3419    )
3420}
3421
3422/// Apply a `unit -> block-boxes` column hook and PREPEND its result to the
3423/// remaining content — the port of `chop_single_column_with_insertion`
3424/// (pageBreak.ml:699-702; the upstream `normalize` is a no-op here because
3425/// block-boxes are already solid `Vec<VertBox>`).
3426fn apply_column_hook(
3427    interp: &mut Interp,
3428    hook: &Value,
3429    remaining: &mut Vec<VertBox>,
3430) -> Result<(), EvalError> {
3431    let inserted = as_block_boxes(interp.apply(hook.clone(), Value::Unit)?)?;
3432    remaining.splice(0..0, inserted);
3433    Ok(())
3434}
3435
3436/// The shared per-page loop backing `page-break`, `page-break-two-column`,
3437/// and `page-break-multicolumn` — the port of `PageBreak.main` /
3438/// `main_multicolumn` (pageBreak.ml:705-781). Lang-side because it is the
3439/// one place that legally holds `&mut Interp` to apply the scheme/hook
3440/// closures (the `fire_hooks` seam). `origin_shifts` is the FULL column
3441/// list (leading zero included by the callers); `None` hooks are upstream's
3442/// `(fun () -> [])`.
3443///
3444/// Per page: apply `pagecontf` once; per column: fire `columnhookf`
3445/// (start of EVERY column, pageBreak.ml:700), chop one column at
3446/// `(x0 + shift, y0)` (footnotes bottom-place per column inside
3447/// `chop_page`), stop early when content runs out; then fire
3448/// `columnendhookf` exactly once (both upstream arms — exhausted
3449/// mid-columns `:751` and shifts-exhausted `:736` — reduce to "prepend its
3450/// output to the remainder"); then apply `pagepartsf` and place the parts.
3451#[allow(clippy::too_many_arguments)]
3452fn page_break_core(
3453    interp: &mut Interp,
3454    paper: PaperSize,
3455    origin_shifts: Vec<Length>,
3456    columnhookf: Option<Value>,
3457    columnendhookf: Option<Value>,
3458    pagecontf: Value,
3459    pagepartsf: Value,
3460    bb: Vec<VertBox>,
3461) -> Result<Value, EvalError> {
3462    let (paper_w, paper_h) = paper.dims();
3463
3464    // Capture the flat pre-page-break `Vec<VertBox>` BEFORE
3465    // `chop_page`/`apply_column_hook` below start draining/mutating
3466    // `remaining` — this clone is the document's natural linear flow exactly
3467    // as `bb` arrived here (no pages, no injected headers/footers, no
3468    // column-hook-inserted content). Unconditional (not gated on which
3469    // output format was requested — see `DocumentValue::reflow_source`'s doc
3470    // comment): PDF and the faithful HTML backend never read the field, so
3471    // this costs them only the clone itself, never a byte of their rendered
3472    // output.
3473    let reflow_source = bb.clone();
3474
3475    let mut remaining = bb;
3476    let mut pages: Vec<Page> = Vec::new();
3477    let mut pageno: i64 = 1;
3478    loop {
3479        if pageno > PAGE_NUMBER_LIMIT {
3480            return eval_error(format!(
3481                "page number limit exceeded ({PAGE_NUMBER_LIMIT}); a column hook keeps injecting content"
3482            ));
3483        }
3484        let mut pb_fields = BTreeMap::new();
3485        pb_fields.insert("page-number".to_string(), Value::Int(pageno));
3486        let pbinfo = Value::Record(pb_fields);
3487
3488        // ---- content scheme: this page's text area (applied ONCE per page, shared by all its columns — pageBreak.ml:769) ----
3489        let sch = interp.apply(pagecontf.clone(), pbinfo.clone())?;
3490        let (origin, height) = read_content_scheme(sch)?;
3491        let (x0, y0) = origin;
3492
3493        // ---- columns ----
3494        let mut lines = Vec::new();
3495        for shift in &origin_shifts {
3496            if let Some(hook) = &columnhookf {
3497                apply_column_hook(interp, hook, &mut remaining)?;
3498            }
3499            lines.extend(chop_page((x0 + *shift, y0), height, &mut remaining));
3500            if remaining.is_empty() {
3501                break; // content exhausted: remaining columns are skipped
3502            }
3503        }
3504        if let Some(hook) = &columnendhookf {
3505            apply_column_hook(interp, hook, &mut remaining)?;
3506        }
3507
3508        // A trailing pure-skip/glue (e.g. the last block's `paragraph_bottom`)
3509        // can roll past the previous page's bottom into a final `chop_page`
3510        // that places NO real line — `chop_page` discards it as a page-top
3511        // skip, leaving an empty body. SATySFi never emits such a trailing
3512        // blank page (glue at the end of the vertical list is dropped), so when
3513        // the body is empty AND content is now exhausted, stop before turning
3514        // that leftover into a spurious blank page (header/footer included).
3515        if remaining.is_empty() && !lines.iter().any(|l| placed_line_extent(l).is_some()) {
3516            break;
3517        }
3518
3519        // ---- parts scheme: this page's header + footer ----
3520        // Everything placed so far is body/column content; the header and
3521        // footer append AFTER it (see `Page::body_lines`).
3522        let body_lines = lines.len();
3523        let parts = interp.apply(pagepartsf.clone(), pbinfo)?;
3524        let (header_origin, header_content, footer_origin, footer_content) =
3525            read_parts_scheme(parts)?;
3526        lines.extend(place_block_at(header_origin, header_content));
3527        lines.extend(place_block_at(footer_origin, footer_content));
3528
3529        pages.push(Page { lines, body_lines });
3530        if remaining.is_empty() {
3531            break;
3532        }
3533        pageno += 1;
3534    }
3535
3536    // Every image `load-image` decoded while evaluating this document (see
3537    // `Interp::images`'s doc comment) rides along in the packaged
3538    // `DocumentValue` so the PDF writer can emit XObjects for the ones
3539    // actually placed on a page.
3540    let images = interp.images.clone();
3541    Ok(Value::Document(Rc::new(DocumentValue {
3542        geometry: PageGeometry::for_paper(paper_w, paper_h),
3543        pages,
3544        images,
3545        // Filled in by `compile_document_cst_with_trials` once `fire_hooks`
3546        // has walked the final trial's placed geometry (see
3547        // `DocumentValue::extras`'s doc comment) — hooks/decos haven't fired
3548        // yet at this point in `page-break`'s own evaluation.
3549        extras: DocExtras::default(),
3550        reflow_source: Some(reflow_source),
3551        // Filled in alongside `extras` once `fire_hooks` has run — see
3552        // `DocumentValue::reflow_links`'s doc comment.
3553        reflow_links: Vec::new(),
3554        reflow_dests: Vec::new(),
3555    })))
3556}
3557
3558// ---- int arithmetic -------------------------------------------------------
3559
3560// Wrapping arithmetic to match OCaml's native `int` (SATySFi's `int` is an
3561// OCaml int, which wraps on overflow) — and, decisively, so a debug build does
3562// not panic on the large intermediate products base's float bit-twiddling
3563// (`exp2i`, `ldexp`, `frexp`) computes.
3564binop_prim!(prim_int_add, as_int, Int, |a, b| a.wrapping_add(b));
3565binop_prim!(prim_int_sub, as_int, Int, |a, b| a.wrapping_sub(b));
3566binop_prim!(prim_int_mul, as_int, Int, |a, b| a.wrapping_mul(b));
3567
3568// OCaml catches `Division_by_zero` and reports `"division by zero"`; `mod`
3569// (see `Mod` in vminst.ml) shares that behavior.
3570binop_prim_try!(prim_int_div, as_int, |a, b| if b == 0 {
3571    eval_error("division by zero")
3572} else {
3573    Ok(Value::Int(a / b))
3574});
3575binop_prim_try!(prim_int_mod, as_int, |a, b| if b == 0 {
3576    eval_error("division by zero")
3577} else {
3578    Ok(Value::Int(a % b))
3579});
3580
3581// ---- int comparisons -------------------------------------------------------
3582
3583cmp_prim!(prim_int_eq, as_int, |a, b| a == b);
3584cmp_prim!(prim_int_ne, as_int, |a, b| a != b);
3585cmp_prim!(prim_int_lt, as_int, |a, b| a < b);
3586cmp_prim!(prim_int_gt, as_int, |a, b| a > b);
3587cmp_prim!(prim_int_le, as_int, |a, b| a <= b);
3588cmp_prim!(prim_int_ge, as_int, |a, b| a >= b);
3589
3590// ---- 0.1 bitwise ops -------------------------------------------------------
3591//
3592// `band`/`bor`/`bxor` mirror OCaml's `land`/`lor`/`lxor`; `bnot` mirrors
3593// `lnot` (bitwise complement). DOCUMENTED DEVIATION: this port's `int` is a
3594// 64-bit two's-complement `i64`, vs upstream's 63-bit boxed OCaml `int` — a
3595// value that actually uses bit 62 (the port's sign-adjacent bit upstream
3596// doesn't have) will complement/shift differently than upstream on that
3597// platform; upstream's own results are themselves platform-width-dependent
3598// there, and no bundled package relies on it.
3599binop_prim!(prim_band, as_int, Int, |a, b| a & b);
3600binop_prim!(prim_bor, as_int, Int, |a, b| a | b);
3601binop_prim!(prim_bxor, as_int, Int, |a, b| a ^ b);
3602unop_prim!(prim_bnot, as_int, Int, |a| !a);
3603
3604// `<<`/`>>` (dev-0-1-0 vminst.ml :2495/:2477): logical shifts (OCaml's
3605// `lsl`/`lsr`, NOT arithmetic — `>>` on a negative int does NOT sign-extend,
3606// see the `-16 >> 2` witness in the test suite), with upstream's exact
3607// dynamic-error message when the shift amount is out of `0..=63`.
3608binop_prim_try!(
3609    prim_bit_shift_left,
3610    as_int,
3611    |a, b| if !(0..=63).contains(&b) {
3612        eval_error("Bit offset out of bounds for '<<'")
3613    } else {
3614        Ok(Value::Int(((a as u64) << b) as i64))
3615    }
3616);
3617binop_prim_try!(
3618    prim_bit_shift_right,
3619    as_int,
3620    |a, b| if !(0..=63).contains(&b) {
3621        eval_error("Bit offset out of bounds for '>>'")
3622    } else {
3623        Ok(Value::Int(((a as u64) >> b) as i64))
3624    }
3625);
3626
3627// ---- bool -------------------------------------------------------------------
3628
3629// Strict (both arguments already evaluated by the caller before these natives
3630// run): real SATySFi source-level `&&`/`||` short-circuit via elaboration into
3631// `if`, which is out of scope here.
3632binop_prim!(prim_bool_and, as_bool, Bool, |a, b| a && b);
3633binop_prim!(prim_bool_or, as_bool, Bool, |a, b| a || b);
3634unop_prim!(prim_bool_not, as_bool, Bool, |a| !a);
3635
3636// ---- float --------------------------------------------------------------------
3637
3638binop_prim!(prim_float_add, as_float, Float, |a, b| a + b);
3639binop_prim!(prim_float_sub, as_float, Float, |a, b| a - b);
3640binop_prim!(prim_float_mul, as_float, Float, |a, b| a * b);
3641binop_prim!(prim_float_div, as_float, Float, |a, b| a / b);
3642unop_prim!(prim_float_of_int, as_int, Float, |n| n as f64);
3643
3644// `PrimitiveRound` in vminst.ml is, despite the name, `int_of_float`
3645// (truncation toward zero), not rounding to nearest.
3646unop_prim!(prim_round, as_float, Int, |x| x as i64);
3647
3648// ---- 0.1 float comparisons (saphe-split vminst.ml:2679-2740) ----
3649cmp_prim!(prim_float_gt, as_float, |a, b| a > b);
3650cmp_prim!(prim_float_lt, as_float, |a, b| a < b);
3651cmp_prim!(prim_float_ge, as_float, |a, b| a >= b);
3652cmp_prim!(prim_float_le, as_float, |a, b| a <= b);
3653
3654// ---- length ---------------------------------------------------------------------
3655
3656binop_prim!(prim_length_add, as_length, Length, |a, b| a + b);
3657binop_prim!(prim_length_sub, as_length, Length, |a, b| a - b);
3658binop_prim!(prim_length_scale, (as_length, as_float), Length, |a, b| a
3659    * b);
3660binop_prim!(prim_length_div, as_length, Float, |a, b| a / b);
3661cmp_prim!(prim_length_lt, as_length, |a, b| a < b);
3662
3663// `LengthGreaterThan` in vminst.ml is implemented as `len2 <% len1`, i.e.
3664// `a >' b` iff `b <' a` — the same ordering, just flipped operands.
3665cmp_prim!(prim_length_gt, as_length, |a, b| b < a);
3666
3667// ---- string -----------------------------------------------------------------------
3668
3669binop_prim!(prim_string_concat, as_str, Str, |a, b| a + &b);
3670unop_prim!(prim_arabic, as_int, Str, |n| n.to_string());
3671cmp_prim!(prim_string_same, as_str, |a, b| a == b);
3672
3673// ---- list -----------------------------------------------------------------
3674
3675/// `x :: xs` — prepend `x` onto the list `xs`.
3676fn prim_list_cons(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3677    let tail = args.pop().unwrap();
3678    let head = args.pop().unwrap();
3679    let mut list = match tail {
3680        Value::List(v) => v,
3681        other => return eval_error(format!("expected list, got {}", other.type_name())),
3682    };
3683    list.insert(0, head);
3684    Ok(Value::List(list))
3685}
3686
3687// ---- mutable-cell dereference ----------------------------------------------
3688
3689/// `!` — read the current contents of a mutable cell (see the `prims!`
3690/// registration above for how this differs structurally, not semantically,
3691/// from v0.0.6's `Dereference`/`Location` handling).
3692fn prim_deref(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3693    let v = args.pop().unwrap();
3694    match v {
3695        Value::Ref(cell) => Ok(cell.borrow().clone()),
3696        other => eval_error(format!(
3697            "expected a mutable cell for '!', got {}",
3698            other.type_name()
3699        )),
3700    }
3701}
3702
3703// ---- string, continued -----------------------------------------------------
3704
3705// `string-length : string -> int` (vminst.ml `PrimitiveStringLength`) —
3706// counts Unicode scalar values (`BatUTF8.length`), not UTF-8 bytes.
3707unop_prim!(prim_string_length, as_str, Int, |s| s.chars().count()
3708    as i64);
3709
3710/// `string-sub : string -> int -> int -> string` (vminst.ml
3711/// `PrimitiveStringSub`) — a substring addressed by Unicode-scalar-value
3712/// offset/width (`BatUTF8.sub`), not byte offset. Upstream raises a dynamic
3713/// error ("illegal index for string-sub") on an out-of-range index; we do
3714/// the same.
3715fn prim_string_sub(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3716    let wid = as_int(args.pop().unwrap())?;
3717    let pos = as_int(args.pop().unwrap())?;
3718    let s = as_str(args.pop().unwrap())?;
3719    if wid < 0 || pos < 0 {
3720        return eval_error("illegal index for string-sub");
3721    }
3722    let chars: Vec<char> = s.chars().collect();
3723    let pos = pos as usize;
3724    let wid = wid as usize;
3725    match pos.checked_add(wid) {
3726        Some(end) if end <= chars.len() => Ok(Value::Str(chars[pos..end].iter().collect())),
3727        _ => eval_error("illegal index for string-sub"),
3728    }
3729}
3730
3731// `string-explode : string -> int list` (vminst.ml `PrimitiveStringExplode`)
3732// — the string's Unicode scalar values (code points) in order, not its bytes.
3733unop_prim!(prim_string_explode, as_str, List, |s| s
3734    .chars()
3735    .map(|c| Value::Int(c as i64))
3736    .collect());
3737
3738fn prim_embed_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3739    let s = as_str(args.pop().unwrap())?;
3740    Ok(Value::InlineText {
3741        elems: Rc::new(vec![IText::Text(s)]),
3742        env: Env::root(),
3743    })
3744}
3745
3746// ---- context ops ------------------------------------------------------------
3747
3748/// `set-font-size : length -> context -> context` (vminst.ml
3749/// `PrimitiveSetFontSize`).
3750fn prim_set_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3751    let ctx = as_context(args.pop().unwrap())?;
3752    let size = as_length(args.pop().unwrap())?;
3753    Ok(Value::Context(Box::new(Context {
3754        font_size: size,
3755        ..ctx
3756    })))
3757}
3758
3759/// `get-font-size : context -> length` (vminst.ml `PrimitiveGetFontSize`).
3760fn prim_get_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3761    let ctx = as_context(args.pop().unwrap())?;
3762    Ok(Value::Length(ctx.font_size))
3763}
3764
3765/// `set-leading : length -> context -> context` (vminst.ml
3766/// `PrimitiveSetLeading`; see the `prims!` table comment for why this is
3767/// the baseline-distance setter and not `set-min-gap-of-lines`).
3768fn prim_set_leading(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3769    let ctx = as_context(args.pop().unwrap())?;
3770    let leading = as_length(args.pop().unwrap())?;
3771    Ok(Value::Context(Box::new(Context { leading, ..ctx })))
3772}
3773
3774/// `set-paragraph-margin : length -> length -> context -> context`
3775/// (vminst.ml `PrimitiveSetParagraphMargin`).
3776fn prim_set_paragraph_margin(
3777    _interp: &mut Interp,
3778    mut args: Vec<Value>,
3779) -> Result<Value, EvalError> {
3780    let ctx = as_context(args.pop().unwrap())?;
3781    let bottom = as_length(args.pop().unwrap())?;
3782    let top = as_length(args.pop().unwrap())?;
3783    Ok(Value::Context(Box::new(Context {
3784        paragraph_top: top,
3785        paragraph_bottom: bottom,
3786        ..ctx
3787    })))
3788}
3789
3790/// `get-text-width : context -> length` (vminst.ml `PrimitiveGetTextWidth`).
3791fn prim_get_text_width(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3792    let ctx = as_context(args.pop().unwrap())?;
3793    Ok(Value::Length(ctx.paragraph_width))
3794}
3795
3796/// `get-initial-context : length -> [math] inline-cmd -> context`
3797/// (vminst.ml `PrimitiveGetInitialContext`) — the second argument is the
3798/// default math command a bare `${…}` in inline text dispatches to (v0.0.6
3799/// `context_main.math_command`); interned via
3800/// `Interp::register_math_command`, carried as `Context::math_command`.
3801fn prim_get_initial_context(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3802    let cmd = args.pop().unwrap();
3803    let width = as_length(args.pop().unwrap())?;
3804    let mut ctx = Context::initial(width);
3805    ctx.math_command = Some(interp.register_math_command(cmd));
3806    // Overlay the configured `default-font.satysfi-hash` `scripts`
3807    // block, if any, so a bare document with a configured font root renders
3808    // CJK/etc. with zero `set-font` calls (`interp.metrics.
3809    // default_script_font` is `None` for every script on a provider with no
3810    // such config — `Base14Metrics` and a bare `TtfFontStore::load` both —
3811    // so this loop is a no-op there).
3812    for (idx, script) in [
3813        Script::HanIdeographic,
3814        Script::Kana,
3815        Script::Latin,
3816        Script::OtherScript,
3817    ]
3818    .into_iter()
3819    .enumerate()
3820    {
3821        if let Some((font, ratio, rising)) = interp.metrics.default_script_font(script) {
3822            ctx.font_scheme[idx] = ScriptFont {
3823                font,
3824                ratio,
3825                rising,
3826            };
3827            if script == Script::Latin {
3828                ctx.font = font;
3829            }
3830        }
3831    }
3832    // Overlay the configured `default-font.satysfi-hash` `"math"`
3833    // abbrev, if any, so a document with a bundled MATH-table font renders
3834    // real cramped/uncramped math metrics with zero `set-math-font` calls.
3835    // `interp.metrics.default_math_font` is `None` on a provider with no such
3836    // config, the same no-op-by-default shape as the `scripts` overlay above.
3837    if let Some(font) = interp.metrics.default_math_font() {
3838        ctx.math_font = font;
3839    }
3840    Ok(Value::Context(Box::new(ctx)))
3841}
3842
3843/// `set-font-key : int -> context -> context` — LOCAL, non-upstream
3844/// primitive; see the `prims!` table comment on `"set-font-key"` for why it
3845/// exists. Sets `Context::font` directly to `FontKey(n)`.
3846fn prim_set_font_key(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3847    let ctx = as_context(args.pop().unwrap())?;
3848    let key = as_int(args.pop().unwrap())?;
3849    if key < 0 || key > i64::from(u16::MAX) {
3850        return eval_error(format!("set-font-key: font key {key} is out of range"));
3851    }
3852    Ok(Value::Context(Box::new(Context {
3853        font: FontKey(key as u16),
3854        ..ctx
3855    })))
3856}
3857
3858// ---- box combinators ---------------------------------------------------------
3859
3860/// `++ : inline-boxes -> inline-boxes -> inline-boxes` (vminst.ml
3861/// `HorzConcat`).
3862fn prim_inline_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3863    let mut b = as_inline_boxes(args.pop().unwrap())?;
3864    let mut a = as_inline_boxes(args.pop().unwrap())?;
3865    a.append(&mut b);
3866    Ok(Value::InlineBoxes(a))
3867}
3868
3869/// `+++ : block-boxes -> block-boxes -> block-boxes` (vminst.ml
3870/// `VertConcat`).
3871fn prim_block_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3872    let mut b = as_block_boxes(args.pop().unwrap())?;
3873    let mut a = as_block_boxes(args.pop().unwrap())?;
3874    a.append(&mut b);
3875    Ok(Value::BlockBoxes(a))
3876}
3877
3878/// `inline-skip : length -> inline-boxes` (vminst.ml `BackendFixedEmpty`).
3879fn prim_inline_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3880    let width = as_length(args.pop().unwrap())?;
3881    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
3882        PureHorzBox::FixedEmpty { width },
3883    )]))
3884}
3885
3886/// `inline-glue : length -> length -> length -> inline-boxes` (vminst.ml
3887/// `BackendOuterEmpty`; params `(widnat, widshrink, widstretch)`, i.e.
3888/// natural, then shrink, then stretch — the same order `OuterEmpty`'s
3889/// fields are already declared in).
3890fn prim_inline_glue(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3891    let stretchable = as_length(args.pop().unwrap())?;
3892    let shrinkable = as_length(args.pop().unwrap())?;
3893    let natural = as_length(args.pop().unwrap())?;
3894    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
3895        PureHorzBox::OuterEmpty {
3896            natural,
3897            shrinkable,
3898            stretchable,
3899        },
3900    )]))
3901}
3902
3903/// `block-skip : length -> block-boxes` (vminst.ml `BackendVertSkip`).
3904fn prim_block_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3905    let len = as_length(args.pop().unwrap())?;
3906    Ok(Value::BlockBoxes(vec![VertBox::Skip(len)]))
3907}
3908
3909/// `list-mark : int -> block-boxes` — the block-level reflow marker
3910/// constructor `itemize.satyh`'s `listing`/`listing-item`/`listing-item-
3911/// breakable`/`enumerate`/`enumerate-item` call to fence list/item
3912/// boundaries. Returns a single-element `block-boxes` carrying an INERT
3913/// `VertBox::ListMark` — zero height/depth, stripped with zero contribution
3914/// by `chop_page`/`place_block_at`/`measure_block` before it can ever reach a
3915/// `PlacedLine`, so PDF and faithful HTML render identically whether or not
3916/// a document's stdlib calls this. Only `page_break_core`'s `reflow_source`
3917/// clone (taken BEFORE `chop_page` drains its input) retains it, for the
3918/// `html-support` branch's reflow HTML walker to read back.
3919///
3920/// `tag` encoding (the only "int tag" scheme any caller needs to know,
3921/// since this primitive is never reflected through the type system beyond
3922/// `int -> block-boxes`):
3923/// - `0` = `ListStart { ordered: false }` (opens a `<ul>`)
3924/// - `1` = `ListStart { ordered: true }` (opens an `<ol>`)
3925/// - `2` = `ListEnd`
3926/// - `3` = `ItemStart`
3927/// - `4` = `ItemEnd`
3928fn prim_list_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3929    let tag = as_int(args.pop().unwrap())?;
3930    let kind = match tag {
3931        0 => ListMarkKind::ListStart { ordered: false },
3932        1 => ListMarkKind::ListStart { ordered: true },
3933        2 => ListMarkKind::ListEnd,
3934        3 => ListMarkKind::ItemStart,
3935        4 => ListMarkKind::ItemEnd,
3936        other => return eval_error(format!("list-mark: unknown tag {other}")),
3937    };
3938    Ok(Value::BlockBoxes(vec![VertBox::ListMark(kind)]))
3939}
3940
3941/// `inline-mark : int -> inline-boxes` — the inline-level reflow marker
3942/// constructor: `itemize.satyh`'s `make-bullet`/`enumerate-item` fence the
3943/// drawn bullet/number glyph run with `BulletStart`/`BulletEnd`, and the
3944/// repo-controlled `\emph`/`\bold` definitions (an opt-in, per-command wrap)
3945/// fence their body with `EmphStart`/`EmphEnd`. Same INERT-marker contract as
3946/// `list-mark` above — a zero-size `PureHorzBox::InlineMark`, ignored by
3947/// `measure`/`natural_metrics`/`justify_line`
3948/// (rustyfi-backend's `linebreak.rs`),
3949/// `math_glyphs_of_inline_boxes`/`math_boxes_of_inline_boxes` below, and both
3950/// the PDF and faithful HTML writers; read only by the `html-support`
3951/// branch's reflow HTML walker.
3952///
3953/// `tag` encoding:
3954/// - `0` = `EmphStart { strong: false }` (opens `<em>`)
3955/// - `1` = `EmphStart { strong: true }` (opens `<strong>`)
3956/// - `2` = `EmphEnd`
3957/// - `3` = `BulletStart`
3958/// - `4` = `BulletEnd`
3959fn prim_inline_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3960    let tag = as_int(args.pop().unwrap())?;
3961    let kind = match tag {
3962        0 => InlineMarkKind::EmphStart { strong: false },
3963        1 => InlineMarkKind::EmphStart { strong: true },
3964        2 => InlineMarkKind::EmphEnd,
3965        3 => InlineMarkKind::BulletStart,
3966        4 => InlineMarkKind::BulletEnd,
3967        other => return eval_error(format!("inline-mark: unknown tag {other}")),
3968    };
3969    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
3970        PureHorzBox::InlineMark(kind),
3971    )]))
3972}
3973
3974// ---- pure float primitives --------------------------------------------------
3975//
3976// All bodies below are `float -> float` (or `float -> float -> float`)
3977// straight wraps of the matching `f64` method — vminst.ml's OCaml bodies
3978// (`make_float (sin flt1)`, etc.) are themselves direct wraps of the same
3979// IEEE-754 libm functions, so there is no behavioral daylight here.
3980
3981binop_prim!(prim_atan2, as_float, Float, |a, b| a.atan2(b));
3982unop_prim!(prim_sin, as_float, Float, |x| x.sin());
3983unop_prim!(prim_asin, as_float, Float, |x| x.asin());
3984unop_prim!(prim_cos, as_float, Float, |x| x.cos());
3985unop_prim!(prim_acos, as_float, Float, |x| x.acos());
3986unop_prim!(prim_tan, as_float, Float, |x| x.tan());
3987unop_prim!(prim_atan, as_float, Float, |x| x.atan());
3988// vminst.ml:2834 `FloatLogarithm`: OCaml's `log` is the NATURAL logarithm
3989// (`ln`), not `log10`.
3990unop_prim!(prim_log, as_float, Float, |x| x.ln());
3991unop_prim!(prim_exp, as_float, Float, |x| x.exp());
3992// `ceil`/`floor` return `float`, unlike `round` (above), which returns
3993// `int` — see this file's `prims!` table comment on `"ceil"`/`"floor"`.
3994unop_prim!(prim_ceil, as_float, Float, |x| x.ceil());
3995unop_prim!(prim_floor, as_float, Float, |x| x.floor());
3996
3997// `show-float : float -> string` (vminst.ml:2319 `PrimitiveShowFloat`) —
3998// OCaml's `string_of_float`. See `ocaml_show_float`'s doc comment (below)
3999// for the emulation and its known fidelity limits.
4000unop_prim!(prim_show_float, as_float, Str, |x| ocaml_show_float(x));
4001
4002/// A from-scratch emulation of OCaml's `Stdlib.string_of_float`: format via
4003/// a C `%.12g` equivalent (12 significant digits; fixed-point when the
4004/// decimal exponent falls in `-4..12`, scientific otherwise; trailing
4005/// fractional zeros trimmed), then apply `valid_float_lexem`'s post-pass —
4006/// append a trailing `.` when the result would otherwise print as a bare
4007/// integer (`"1."`, never `"1"`, so a `float`'s printed form always reads
4008/// as a float, not an `int`). Known limitation: this is a Rust
4009/// reimplementation of the same specification (OCaml itself defers to the
4010/// platform C library's `%.12g`), so it may disagree with OCaml in obscure
4011/// corner cases, though it agrees on ordinary values (verified by hand
4012/// against real OCaml output for `0.`, `-0.`, `1.`, `100.`, `0.0025`,
4013/// `1e+20`, `1e-05`).
4014fn ocaml_show_float(x: f64) -> String {
4015    if x.is_nan() {
4016        return "nan".to_string();
4017    }
4018    if x.is_infinite() {
4019        return if x < 0.0 { "-infinity" } else { "infinity" }.to_string();
4020    }
4021    const PREC: i32 = 12;
4022    // Style-E rendering at precision PREC-1 recovers the correctly-rounded
4023    // decimal exponent (a naive `log10().floor()` can be off by one right
4024    // at a power of ten, because of binary/decimal rounding).
4025    let sci = format!("{:.*e}", (PREC - 1) as usize, x);
4026    let epos = sci
4027        .find('e')
4028        .expect("scientific formatting always emits 'e'");
4029    let exp: i32 = sci[epos + 1..].parse().expect("well-formed exponent");
4030    let body = if exp < -4 || exp >= PREC {
4031        let mantissa = trim_trailing_fractional_zeros(&sci[..epos]);
4032        format!(
4033            "{mantissa}e{}{:02}",
4034            if exp < 0 { "-" } else { "+" },
4035            exp.abs()
4036        )
4037    } else {
4038        let decimals = (PREC - 1 - exp).max(0) as usize;
4039        trim_trailing_fractional_zeros(&format!("{:.*}", decimals, x)).to_string()
4040    };
4041    if body.bytes().all(|b| b.is_ascii_digit() || b == b'-') {
4042        format!("{body}.")
4043    } else {
4044        body
4045    }
4046}
4047
4048/// Strip trailing zeros after a decimal point, then the point itself if
4049/// nothing remains after it (`"3.140" -> "3.14"`, `"5.000" -> "5"`);
4050/// already-integer-shaped strings (no `.`) pass through unchanged.
4051fn trim_trailing_fractional_zeros(s: &str) -> &str {
4052    if !s.contains('.') {
4053        return s;
4054    }
4055    s.trim_end_matches('0').trim_end_matches('.')
4056}
4057
4058// `string-byte-length : string -> int` (vminst.ml:2159
4059// `PrimitiveStringByteLength`) — UTF-8 BYTE count (`String.length` in
4060// OCaml, whose native strings are raw byte sequences), unlike
4061// `string-length`'s Unicode-scalar-value count.
4062unop_prim!(prim_string_byte_length, as_str, Int, |s| s.len() as i64);
4063
4064/// `string-sub-bytes : string -> int -> int -> string` (vminst.ml:2123
4065/// `PrimitiveStringSubBytes`) — byte-indexed substring (OCaml's
4066/// `String.sub`), unlike `string-sub`'s Unicode-scalar-value indexing.
4067/// Guards an out-of-range span exactly like `prim_string_sub`'s "illegal
4068/// index" dynamic error, AND a split landing inside a multi-byte UTF-8
4069/// sequence — impossible for OCaml's byte-oriented strings, but a
4070/// `Value::Str` here is a Rust `String`, which must stay valid UTF-8.
4071fn prim_string_sub_bytes(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4072    let wid = as_int(args.pop().unwrap())?;
4073    let pos = as_int(args.pop().unwrap())?;
4074    let s = as_str(args.pop().unwrap())?;
4075    if wid < 0 || pos < 0 {
4076        return eval_error("illegal index for string-sub-bytes");
4077    }
4078    let (pos, wid) = (pos as usize, wid as usize);
4079    match pos.checked_add(wid) {
4080        Some(end) if end <= s.len() && s.is_char_boundary(pos) && s.is_char_boundary(end) => {
4081            Ok(Value::Str(s[pos..end].to_string()))
4082        }
4083        _ => eval_error("illegal index for string-sub-bytes"),
4084    }
4085}
4086
4087/// `string-unexplode : int list -> string` (vminst.ml:2196
4088/// `PrimitiveStringUnexplode`) — the inverse of `string-explode` (above):
4089/// each int is a Unicode scalar value (code point), concatenated into one
4090/// UTF-8 string. Upstream's `Uchar.of_int` raises on an int that isn't a
4091/// valid Unicode scalar value (a surrogate, or out of range); reported here
4092/// as the same kind of dynamic error rather than panicking.
4093fn prim_string_unexplode(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4094    let items = as_list(args.pop().unwrap())?;
4095    let mut s = String::new();
4096    for v in items {
4097        let n = as_int(v)?;
4098        match u32::try_from(n).ok().and_then(char::from_u32) {
4099            Some(c) => s.push(c),
4100            None => {
4101                return eval_error(format!(
4102                    "string-unexplode: {n} is not a valid Unicode scalar value"
4103                ))
4104            }
4105        }
4106    }
4107    Ok(Value::Str(s))
4108}
4109
4110/// `normalize-string-to-nfc : string -> string` (dev-0-1-0 vminst.ml:2050
4111/// `NormalizeStringToNFC`) — REAL: UAX #15 Normalization Form C, via the
4112/// `unicode-normalization` crate (`UnicodeNormalization::nfc`), a pure-Rust
4113/// stand-in for upstream's uunf-backed `NormalizeString.of_utf8_nfc`.
4114/// DOCUMENTED NON-RISK: this crate's embedded Unicode table version may lag
4115/// or lead upstream's uunf pin — both track recent Unicode, and no bundled
4116/// package/test relies on a normalization pair that changed between
4117/// versions.
4118fn prim_normalize_string_to_nfc(
4119    _interp: &mut Interp,
4120    mut args: Vec<Value>,
4121) -> Result<Value, EvalError> {
4122    let s = as_str(args.pop().unwrap())?;
4123    Ok(Value::Str(s.nfc().collect()))
4124}
4125
4126/// `normalize-string-to-nfd : string -> string` (dev-0-1-0 vminst.ml:2066
4127/// `NormalizeStringToNFD`) — REAL: UAX #15 Normalization Form D, same
4128/// crate/caveats as [`prim_normalize_string_to_nfc`] above.
4129fn prim_normalize_string_to_nfd(
4130    _interp: &mut Interp,
4131    mut args: Vec<Value>,
4132) -> Result<Value, EvalError> {
4133    let s = as_str(args.pop().unwrap())?;
4134    Ok(Value::Str(s.nfd().collect()))
4135}
4136
4137/// `split-grapheme-cluster : string -> list string` (dev-0-1-0 vminst.ml:
4138/// 2082 `SplitOnGraphemeCluster` / `GraphemeCluster.split_utf8`) — REAL: UAX
4139/// #29 EXTENDED grapheme clusters, via the `unicode-segmentation` crate's
4140/// `graphemes(s, true)` (`true` selects the extended, not legacy, cluster
4141/// rules — what upstream's uuseg default segmenter produces).
4142fn prim_split_grapheme_cluster(
4143    _interp: &mut Interp,
4144    mut args: Vec<Value>,
4145) -> Result<Value, EvalError> {
4146    let s = as_str(args.pop().unwrap())?;
4147    let clusters: Vec<Value> = s
4148        .graphemes(true)
4149        .map(|g| Value::Str(g.to_string()))
4150        .collect();
4151    Ok(Value::List(clusters))
4152}
4153
4154/// `display-message : string -> unit` (vminst.ml:2056
4155/// `PrimitiveDisplayMessage`) — upstream prints via `print_endline`
4156/// (STDOUT); this port deliberately prints to STDERR instead (`eprintln!`),
4157/// keeping stdout reserved for actual document output. This matches the
4158/// existing house convention: the CLI's own "output written" status line
4159/// (`rustyfi`'s `main.rs`) is likewise stderr-only, never stdout — a
4160/// documented deviation, not an oversight.
4161fn prim_display_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4162    let msg = as_str(args.pop().unwrap())?;
4163    eprintln!("{msg}");
4164    Ok(Value::Unit)
4165}
4166
4167/// `abort-with-message : string -> 'a` (vminst.ml:3133 `AbortWithMessage`)
4168/// — raises a dynamic error carrying `msg` verbatim. The polymorphic result
4169/// type (`prim_types.rs`'s `poly1`) is vacuously satisfiable: this always
4170/// evaluates to `Err`, never actually producing a value of whatever type
4171/// the call site expected.
4172fn prim_abort_with_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4173    let msg = as_str(args.pop().unwrap())?;
4174    eval_error(msg)
4175}
4176
4177// ---- images (raster images) -----------
4178
4179/// `load-image : string -> image` (v0.0.6 vminstdef.yaml:540). Resolves
4180/// `path` against the process's current working directory — this port
4181/// has no "job directory" threaded through `Interp` yet, so this is a
4182/// deliberately simple stand-in for v0.0.6's real job-directory-relative
4183/// resolution, good enough for a CLI invoked from the document's own
4184/// directory and for this crate's fixture-driven tests (which pass an
4185/// absolute path).
4186///
4187/// Decoding is eager (via the `image` crate, to 8-bit `DeviceRGB` — see
4188/// `ImageResource`'s doc comment for the alpha-dropping/format caveats),
4189/// matching v0.0.6's `ImageInfo.add_image` (imageInfo.ml): a missing or
4190/// undecodable file is a clean `EvalError` here, not deferred to the PDF
4191/// writer.
4192///
4193/// JPEG DCTDecode passthrough: in addition to the eager RGB8 decode above
4194/// (still needed for `use-image-by-width`'s aspect ratio and the HTML
4195/// backend's `<img>` data URI), this re-reads the same path's raw bytes and
4196/// sniffs them for a baseline JPEG (`ImageResource::sniff_baseline_jpeg_dct`)
4197/// so the PDF writer can embed the ORIGINAL DCT-encoded bytes instead of
4198/// re-encoding the flattened samples. The second read is best-effort: a
4199/// failure just leaves `jpeg_dct` as `None` and falls back to flat-RGB8
4200/// embedding, since the file already decoded fine above.
4201fn prim_load_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4202    let path = as_str(args.pop().unwrap())?;
4203    let decoded = image::open(&path).map_err(|e| EvalError {
4204        span: None,
4205        msg: format!("load-image: cannot decode '{path}': {e}"),
4206    })?;
4207    let rgb = decoded.to_rgb8();
4208    let (px_w, px_h) = rgb.dimensions();
4209    let jpeg_dct = std::fs::read(&path)
4210        .ok()
4211        .and_then(ImageResource::sniff_baseline_jpeg_dct);
4212    let id = ImageId(interp.images.len());
4213    interp.images.push(ImageResource {
4214        samples: rgb.into_raw(),
4215        px_w,
4216        px_h,
4217        jpeg_dct,
4218        pdf: None,
4219    });
4220    Ok(Value::Image(id))
4221}
4222
4223/// `use-image-by-width : image -> length -> inline-boxes` (v0.0.6
4224/// vminstdef.yaml:554). Computes the on-page height from the source
4225/// image's own pixel aspect ratio (v0.0.6
4226/// `ImageInfo.get_height_from_width`, imageInfo.ml:44): `height = width *
4227/// px_h / px_w`.
4228fn prim_use_image_by_width(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4229    let width = as_length(args.pop().unwrap())?;
4230    let image = as_image(args.pop().unwrap())?;
4231    let resource = interp.images.get(image.0).ok_or_else(|| EvalError {
4232        span: None,
4233        msg: format!("internal error: image id {} out of range", image.0),
4234    })?;
4235    let (iw, ih) = resource.intrinsic_dims_pt();
4236    if iw == 0.0 {
4237        return eval_error("use-image-by-width: image has zero width, cannot scale");
4238    }
4239    let height = width * (ih / iw);
4240    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4241        PureHorzBox::Image {
4242            width,
4243            height,
4244            image,
4245        },
4246    )]))
4247}
4248
4249/// `load-pdf-image : string -> int -> image` (v0.0.6 vminstdef.yaml:525-538
4250/// `BackendRegisterPdfImage`; dev-0-1-0 renames it `PrimitiveLoadPdfImage`
4251/// with the identical type/body). Loads page `pageno` (1-based) of the PDF
4252/// at `path`, parsed eagerly with `lopdf`, and stores a `PdfPageResource` —
4253/// the page's `/MediaBox` (for `use-image-by-width`'s aspect ratio), its
4254/// content stream(s) (already inflated/concatenated by
4255/// `lopdf::Document::get_page_content`), and its imported `/Resources`
4256/// object subtree (for the PDF writer's Form XObject).
4257///
4258/// Path resolution is cwd-relative, the same documented deviation as
4259/// `prim_load_image`/`prim_read_file` (no job-directory threaded through
4260/// `Interp` yet).
4261///
4262/// Errors (all clean `EvalError`, no panics):
4263/// - file missing/unreadable → "cannot open '<path>': <e>";
4264/// - malformed/unparseable PDF → "cannot parse PDF '<path>': <e>";
4265/// - `pageno < 1` → "page number must be >= 1 (got <n>)";
4266/// - `pageno` beyond the page count → "'<path>' has no page <n>";
4267/// - `/Encrypt` present in the trailer → "'<path>' is encrypted; not
4268///   supported" (decryption is never attempted);
4269/// - no usable `/MediaBox` (missing at every level of the inherited page
4270///   tree, wrong array length, or non-numeric entries) → "page <n> of
4271///   '<path>' has no usable MediaBox".
4272fn prim_load_pdf_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4273    let pageno = as_int(args.pop().unwrap())?;
4274    let path = as_str(args.pop().unwrap())?;
4275    if pageno < 1 {
4276        return eval_error(format!(
4277            "load-pdf-image: page number must be >= 1 (got {pageno})"
4278        ));
4279    }
4280    let doc = lopdf::Document::load(&path).map_err(|e| {
4281        let msg = match &e {
4282            lopdf::Error::IO(io_e) => format!("load-pdf-image: cannot open '{path}': {io_e}"),
4283            other => format!("load-pdf-image: cannot parse PDF '{path}': {other}"),
4284        };
4285        EvalError { span: None, msg }
4286    })?;
4287    if doc.is_encrypted() {
4288        return eval_error(format!(
4289            "load-pdf-image: '{path}' is encrypted; not supported"
4290        ));
4291    }
4292    let pages = doc.get_pages();
4293    let page_id = *pages.get(&(pageno as u32)).ok_or_else(|| EvalError {
4294        span: None,
4295        msg: format!("load-pdf-image: '{path}' has no page {pageno}"),
4296    })?;
4297    let page_dict = doc.get_dictionary(page_id).map_err(|e| EvalError {
4298        span: None,
4299        msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4300    })?;
4301    let media_box = resolve_pdf_media_box(&doc, page_dict).ok_or_else(|| EvalError {
4302        span: None,
4303        msg: format!("load-pdf-image: page {pageno} of '{path}' has no usable MediaBox"),
4304    })?;
4305    let content = doc.get_page_content(page_id).map_err(|e| EvalError {
4306        span: None,
4307        msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4308    })?;
4309    let resources = import_pdf_resources(&doc, page_dict);
4310    let id = ImageId(interp.images.len());
4311    interp.images.push(ImageResource {
4312        samples: Vec::new(),
4313        px_w: 0,
4314        px_h: 0,
4315        jpeg_dct: None,
4316        pdf: Some(PdfPageResource {
4317            media_box,
4318            content,
4319            resources,
4320        }),
4321    });
4322    Ok(Value::Image(id))
4323}
4324
4325/// `/MediaBox` lookup with page-tree inheritance (`lopdf` does not resolve
4326/// this automatically, unlike upstream camlpdf's `Pdfpage` helpers): walk
4327/// `page_dict`, then its `/Parent` chain, returning the first `/MediaBox`
4328/// found as `(x0, y0, x1, y1)` in raw PDF points. `None`
4329/// if no ancestor carries a well-formed 4-element numeric array, or if a
4330/// `/Parent` cycle is detected.
4331fn resolve_pdf_media_box(
4332    doc: &lopdf::Document,
4333    page_dict: &lopdf::Dictionary,
4334) -> Option<(f64, f64, f64, f64)> {
4335    let mut cur = page_dict;
4336    let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4337    loop {
4338        if let Ok(obj) = cur.get(b"MediaBox") {
4339            if let Ok(arr) = obj.as_array() {
4340                if arr.len() == 4 {
4341                    let mut v = [0f64; 4];
4342                    let mut ok = true;
4343                    for (slot, item) in v.iter_mut().zip(arr.iter()) {
4344                        match item.as_float() {
4345                            Ok(f) => *slot = f as f64,
4346                            Err(_) => {
4347                                ok = false;
4348                                break;
4349                            }
4350                        }
4351                    }
4352                    if ok {
4353                        return Some((v[0], v[1], v[2], v[3]));
4354                    }
4355                }
4356            }
4357        }
4358        match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4359            Ok(parent_id) => {
4360                if !seen.insert(parent_id) {
4361                    return None; // cycle
4362                }
4363                cur = doc.get_dictionary(parent_id).ok()?;
4364            }
4365            Err(_) => return None,
4366        }
4367    }
4368}
4369
4370/// Import the page's `/Resources` subtree (walking page-tree inheritance
4371/// like `resolve_pdf_media_box`) into a neutral `ImportedObjects` table for
4372/// the PDF writer. Local id `0` always holds the
4373/// (possibly inline) `/Resources` dictionary itself; every other entry is a
4374/// real source PDF object number, keyed by `convert_pdf_object`'s
4375/// transitive walk of every `Reference` reachable from it.
4376fn import_pdf_resources(doc: &lopdf::Document, page_dict: &lopdf::Dictionary) -> ImportedObjects {
4377    let mut out: Vec<(u32, ObjRepr)> = Vec::new();
4378    let mut seen: BTreeSet<u32> = BTreeSet::new();
4379    let root_repr = match resolve_pdf_resources_object(doc, page_dict) {
4380        Some(obj) => convert_pdf_object(doc, obj, &mut out, &mut seen),
4381        None => ObjRepr::Dict(Vec::new()),
4382    };
4383    out.insert(0, (0, root_repr));
4384    ImportedObjects(out)
4385}
4386
4387/// `/Resources` lookup with page-tree inheritance, mirroring
4388/// `resolve_pdf_media_box` but returning the raw (possibly-inline)
4389/// `&lopdf::Object` rather than a decoded value, since `/Resources` may
4390/// legally be either a direct dictionary or an indirect reference.
4391fn resolve_pdf_resources_object<'a>(
4392    doc: &'a lopdf::Document,
4393    page_dict: &'a lopdf::Dictionary,
4394) -> Option<&'a lopdf::Object> {
4395    let mut cur = page_dict;
4396    let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4397    loop {
4398        if let Ok(obj) = cur.get(b"Resources") {
4399            return Some(obj);
4400        }
4401        match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4402            Ok(parent_id) => {
4403                if !seen.insert(parent_id) {
4404                    return None;
4405                }
4406                cur = doc.get_dictionary(parent_id).ok()?;
4407            }
4408            Err(_) => return None,
4409        }
4410    }
4411}
4412
4413/// Recursively convert one `lopdf::Object` into the neutral `ObjRepr`
4414/// grammar, following every `Reference` transitively and
4415/// appending newly-visited indirect objects to `out` keyed by their source
4416/// object number (`seen` guards against re-visiting/cycles — a shared
4417/// object referenced from multiple places is emitted once and pointed at by
4418/// `ObjRepr::Ref` from every occurrence). Stream objects are copied
4419/// **verbatim** (still-filtered bytes, `/Filter`/`/DecodeParms` kept as-is;
4420/// only `/Length` is dropped since the writer derives it) — unlike the
4421/// page's own content stream (`Document::get_page_content`, inflated
4422/// separately in `prim_load_pdf_image`), a resource stream (font program,
4423/// embedded image XObject, ICC profile, ...) is re-emitted byte-for-byte,
4424/// so no decode/re-encode risk is taken on data this importer doesn't need
4425/// to understand.
4426fn convert_pdf_object(
4427    doc: &lopdf::Document,
4428    obj: &lopdf::Object,
4429    out: &mut Vec<(u32, ObjRepr)>,
4430    seen: &mut BTreeSet<u32>,
4431) -> ObjRepr {
4432    use lopdf::Object as LObj;
4433    match obj {
4434        LObj::Null => ObjRepr::Null,
4435        LObj::Boolean(b) => ObjRepr::Bool(*b),
4436        LObj::Integer(n) => ObjRepr::Int(*n),
4437        LObj::Real(r) => ObjRepr::Real(*r as f64),
4438        LObj::Name(n) => ObjRepr::Name(n.clone()),
4439        LObj::String(s, _) => ObjRepr::String(s.clone()),
4440        LObj::Array(items) => ObjRepr::Array(
4441            items
4442                .iter()
4443                .map(|it| convert_pdf_object(doc, it, out, seen))
4444                .collect(),
4445        ),
4446        LObj::Dictionary(d) => ObjRepr::Dict(convert_pdf_dict(doc, d, out, seen)),
4447        LObj::Stream(s) => {
4448            let dict_entries = convert_pdf_dict(doc, &s.dict, out, seen)
4449                .into_iter()
4450                .filter(|(k, _)| k.as_slice() != b"Length")
4451                .collect();
4452            ObjRepr::Stream(dict_entries, s.content.clone())
4453        }
4454        LObj::Reference((obj_num, gen)) => {
4455            let (obj_num, gen) = (*obj_num, *gen);
4456            if obj_num != 0 && seen.insert(obj_num) {
4457                if let Ok(target) = doc.get_object((obj_num, gen)) {
4458                    let repr = convert_pdf_object(doc, target, out, seen);
4459                    out.push((obj_num, repr));
4460                }
4461            }
4462            ObjRepr::Ref(obj_num)
4463        }
4464    }
4465}
4466
4467fn convert_pdf_dict(
4468    doc: &lopdf::Document,
4469    dict: &lopdf::Dictionary,
4470    out: &mut Vec<(u32, ObjRepr)>,
4471    seen: &mut BTreeSet<u32>,
4472) -> Vec<(Vec<u8>, ObjRepr)> {
4473    dict.iter()
4474        .map(|(k, v)| (k.clone(), convert_pdf_object(doc, v, out, seen)))
4475        .collect()
4476}
4477
4478/// `read-file : string -> list string` (dev-0-1-0 vminst.ml:3073
4479/// `PrimitiveReadFile`) — REAL, with two documented
4480/// deviations:
4481///
4482/// 1. **Path resolution**: resolves `path` against the process's current
4483///    working directory, the same `load-image` precedent
4484///    (`prim_load_image`'s doc comment) — this port has no job-directory
4485///    notion threaded through `Interp` yet. Upstream resolves against
4486///    `OptionState.job_directory ()` (the input document's own directory).
4487/// 2. **Containment tightening**: upstream rejects any `..` path component
4488///    (`"cannot access files by using '..'"`, vminst.ml:3084-3090) but
4489///    otherwise resolves `Filename.concat jobdir path` literally — an
4490///    absolute `path` silently escapes the job directory upstream. This
4491///    port ALSO rejects absolute paths (same error class), making the
4492///    containment upstream's own message implies actually real.
4493///
4494/// Line splitting is faithful to OCaml's `input_line` loop: split on `'\n'`,
4495/// drop a trailing empty piece (file ends with `\n`), keep `'\r'` (do NOT
4496/// use `BufRead::lines`, which strips `\r\n`) — an empty file yields `[]`.
4497/// Non-UTF-8 content is a clean `EvalError` (upstream's OCaml strings are
4498/// byte-transparent; this port's `Value::Str` must stay valid UTF-8 —
4499/// documented deviation).
4500fn prim_read_file(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4501    let path_str = as_str(args.pop().unwrap())?;
4502    let path = std::path::Path::new(&path_str);
4503    if path.is_absolute() {
4504        return eval_error(
4505            "read-file: cannot access files by using an absolute path (job-directory containment)",
4506        );
4507    }
4508    if path
4509        .components()
4510        .any(|c| matches!(c, std::path::Component::ParentDir))
4511    {
4512        return eval_error("cannot access files by using '..'");
4513    }
4514    let bytes = std::fs::read(path).map_err(|e| EvalError {
4515        span: None,
4516        msg: format!("read-file: cannot open '{path_str}': {e}"),
4517    })?;
4518    let text = String::from_utf8(bytes).map_err(|_| EvalError {
4519        span: None,
4520        msg: format!("read-file '{path_str}': not valid UTF-8"),
4521    })?;
4522    let mut lines: Vec<Value> = text
4523        .split('\n')
4524        .map(|s| Value::Str(s.to_string()))
4525        .collect();
4526    if matches!(lines.last(), Some(Value::Str(s)) if s.is_empty()) {
4527        lines.pop();
4528    }
4529    Ok(Value::List(lines))
4530}
4531
4532/// `(string) option` — `register-document-information`'s `title`/`subject`/
4533/// `author` fields, parsed the same way [`as_border_option`] reads a
4534/// `Value::Ctor`.
4535fn as_option_string(v: Value) -> Result<Option<String>, EvalError> {
4536    match v {
4537        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
4538            ("None", None) => Ok(None),
4539            ("Some", Some(Value::Str(s))) => Ok(Some(s)),
4540            (other, _) => eval_error(format!(
4541                "expected a string option (None / Some(string)), got variant '{other}'"
4542            )),
4543        },
4544        other => eval_error(format!("expected an option, got {}", other.type_name())),
4545    }
4546}
4547
4548/// `register-document-information : document-information-dictionary ->
4549/// unit` (dev-0-1-0 vminst.ml:2978 `PrimitiveRegisterDocumentInformation`)
4550/// — REAL: extracts `title`/`subject`/`author`
4551/// (`option string`) and `keywords` (`list string`) from the record
4552/// argument (`t_doc_info_dictionary()`'s shape, `prim_types.rs`) and stores
4553/// them onto `Interp::doc_info` — LAST WRITE WINS (upstream's `register`,
4554/// `documentInformationDictionary.ml`), matching the `outline`/
4555/// `annotations`/`destinations` accumulator policy (`eval.rs`): reset per
4556/// trial (fresh `Interp`), the final trial's value drained into
4557/// `DocExtras::doc_info` (`lib.rs`) and emitted as the PDF `/Info`
4558/// dictionary by both writers (`rustyfi-pdf`'s `lib.rs`/`cid.rs`).
4559fn prim_register_document_information(
4560    interp: &mut Interp,
4561    mut args: Vec<Value>,
4562) -> Result<Value, EvalError> {
4563    let fields = match args.pop().unwrap() {
4564        Value::Record(m) => m,
4565        other => {
4566            return eval_error(format!(
4567                "register-document-information: expected a document-information-dictionary \
4568                 record, got {}",
4569                other.type_name()
4570            ))
4571        }
4572    };
4573    let record_name = "document-information-dictionary";
4574    let title = as_option_string(record_field(&fields, record_name, "title")?)?;
4575    let subject = as_option_string(record_field(&fields, record_name, "subject")?)?;
4576    let author = as_option_string(record_field(&fields, record_name, "author")?)?;
4577    let keywords = as_list(record_field(&fields, record_name, "keywords")?)?
4578        .into_iter()
4579        .map(as_str)
4580        .collect::<Result<Vec<_>, _>>()?;
4581    interp.doc_info = Some(DocInfo {
4582        title,
4583        subject,
4584        author,
4585        keywords,
4586    });
4587    Ok(Value::Unit)
4588}
4589
4590// ============================================================================
4591// ---- graphics primitives ------
4592// `start-path`/`line-to`/`terminate-path`/`close-with-line`/`fill`/`stroke`/
4593// `inline-graphics`. Argument order matches `tools/gencode/vminst.ml`
4594// (point-first for `line-to`, width-first for `stroke`).
4595// ============================================================================
4596
4597/// `start-path : point -> pre-path` (vminst.ml:713).
4598fn prim_start_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4599    let start = as_point(args.pop().unwrap())?;
4600    Ok(Value::PrePath(PrePath {
4601        start,
4602        segs: Vec::new(),
4603    }))
4604}
4605
4606/// `line-to : point -> pre-path -> pre-path` (vminst.ml:727) — appends a
4607/// straight segment to the pre-path's forward-accumulated `segs`.
4608fn prim_line_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4609    let mut pp = as_prepath(args.pop().unwrap())?;
4610    let pt = as_point(args.pop().unwrap())?;
4611    pp.segs.push(PathSeg::Line(pt));
4612    Ok(Value::PrePath(pp))
4613}
4614
4615/// `terminate-path : pre-path -> path` (vminst.ml:759) — finishes an OPEN
4616/// subpath (no closing segment).
4617fn prim_terminate_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4618    let pp = as_prepath(args.pop().unwrap())?;
4619    Ok(Value::Path(Path {
4620        subpaths: vec![Subpath {
4621            start: pp.start,
4622            segs: pp.segs,
4623            closing: Closing::Open,
4624        }],
4625    }))
4626}
4627
4628/// `close-with-line : pre-path -> path` (vminst.ml:773) — closes the subpath
4629/// with a straight segment back to its start (PDF `h`).
4630fn prim_close_with_line(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4631    let pp = as_prepath(args.pop().unwrap())?;
4632    Ok(Value::Path(Path {
4633        subpaths: vec![Subpath {
4634            start: pp.start,
4635            segs: pp.segs,
4636            closing: Closing::Line,
4637        }],
4638    }))
4639}
4640
4641/// `fill : color -> path -> graphics` (vminst.ml:2398) — a filled region;
4642/// the PDF writer (`place_graphics`, rustyfi-pdf) paints it with the
4643/// even-odd rule, matching upstream's `op_f'`.
4644fn prim_fill(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4645    let path = as_path(args.pop().unwrap())?;
4646    let color = as_color(args.pop().unwrap())?;
4647    Ok(Value::Graphics(GraphicsElem::Fill(color, path)))
4648}
4649
4650/// `stroke : length -> color -> path -> graphics` (vminst.ml:2381) — width
4651/// first, then color, then path.
4652fn prim_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4653    let path = as_path(args.pop().unwrap())?;
4654    let color = as_color(args.pop().unwrap())?;
4655    let wid = as_length(args.pop().unwrap())?;
4656    Ok(Value::Graphics(GraphicsElem::Stroke(wid, color, path)))
4657}
4658
4659/// `inline-graphics : length -> length -> length -> (point -> graphics
4660/// list) -> inline-boxes` (vminst.ml:1872 `BackendInlineGraphics`) — a box
4661/// of size `(w, h, d)` carrying the callback's resolved graphics elements,
4662/// the minimal on-page sink for a `graphics` value.
4663///
4664/// **Eager-callback shortcut.** Upstream defers the callback until
4665/// the box's *placed* point is known on the page, then calls
4666/// `gfun(placed_point)`. A lang closure cannot live inside a backend box
4667/// (`PureHorzBox::Graphics` only holds resolved `GraphicsElem`s), and the
4668/// placed point isn't known until page-break/render time — so instead this
4669/// calls `gfun` immediately at `(0pt, 0pt)`, and the PDF writer
4670/// (`place_graphics`, rustyfi-pdf) translates the *whole* box to its placed
4671/// position via a single `cm` at render time. This equals upstream's
4672/// behavior if and only if `gfun` uses its point argument purely additively
4673/// (shift-covariant) — true of every real `Gr`/`deco` generator, but not
4674/// enforced by this signature. Deferring it faithfully would need the same
4675/// deferred-firing architecture the `deco` family already uses
4676/// (`interp.decos`/`fire_hooks`).
4677fn prim_inline_graphics(
4678    interp: &mut Interp,
4679    version: RustyfiVersion,
4680    mut args: Vec<Value>,
4681) -> Result<Value, EvalError> {
4682    let gfun = args.pop().unwrap();
4683    let d = as_length(args.pop().unwrap())?;
4684    let h = as_length(args.pop().unwrap())?;
4685    let w = as_length(args.pop().unwrap())?;
4686    let origin = make_point_value((Length::ZERO, Length::ZERO));
4687    let list_v = interp.apply(gfun.clone(), origin)?;
4688    // The callback's result type is `list graphics` under v0.0.6, one
4689    // `graphics` collection under v0.1 — see `coerce_graphics_result`'s doc
4690    // comment.
4691    let elems = coerce_graphics_result_for(version, list_v)?;
4692    // Detect a PAGE-ABSOLUTE callback: run it again at a far-off probe point
4693    // and compare. If the output is byte-identical the callback ignored its
4694    // placed-point argument (`fun _ -> …`, e.g. slydifi's frame background /
4695    // figbox's `draw-text pt`), so its coordinates are already page-absolute
4696    // and the PDF writer must NOT translate them by the box's placed position
4697    // (which is often a negative text-origin, shifting the decoration off the
4698    // page). A position-relative callback yields different output here, so
4699    // `origin_independent` stays false and the per-box `cm` applies as before.
4700    // Upstream (`handlePdf.ml`) always calls the callback with the true placed
4701    // point and never post-translates; this recovers that for the constant
4702    // case without a post-layout deferral. (The extra evaluation must be free
4703    // of observable side effects — true of every `Gr`/`draw-text` generator.)
4704    let origin_independent = {
4705        let probe = make_point_value((Length::pt(4096.0), Length::pt(2731.0)));
4706        match interp.apply(gfun, probe) {
4707            Ok(v) => coerce_graphics_result_for(version, v)
4708                .map(|e2| e2 == elems)
4709                .unwrap_or(false),
4710            Err(_) => false,
4711        }
4712    };
4713    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4714        PureHorzBox::Graphics {
4715            width: w,
4716            height: h,
4717            depth: d,
4718            elems,
4719            origin_independent,
4720        },
4721    )]))
4722}
4723
4724/// `inline-graphics-outer : length -> length -> (length -> point -> graphics
4725/// list) -> inline-boxes` (vminst.ml:1891 `BackendInlineGraphicsOuter`) — a
4726/// graphics box whose width stretches like `inline-fil` (upstream widinfo
4727/// `Fils(1)`). The callback needs the RESOLVED width, unknown until line
4728/// layout, so it is deferred through `Interp::outer_graphics` (the `HookId`
4729/// pattern) and fired by `resolve_outer_graphics_in_contents` (called from
4730/// `line-break`/`tabular`/`draw-text`) with the width `justify_line` wrote
4731/// into the box and the point `(0pt, 0pt)` — the same shift-covariance
4732/// shortcut as `inline-graphics` above (the writer's `cm` supplies the
4733/// placed point); the width argument is faithful.
4734fn prim_inline_graphics_outer(
4735    interp: &mut Interp,
4736    version: RustyfiVersion,
4737    mut args: Vec<Value>,
4738) -> Result<Value, EvalError> {
4739    let gfun = args.pop().unwrap();
4740    let d = as_length(args.pop().unwrap())?;
4741    let h = as_length(args.pop().unwrap())?;
4742    interp.outer_graphics.push((gfun, version));
4743    let fn_id = GraphicsFnId(interp.outer_graphics.len() - 1);
4744    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4745        PureHorzBox::GraphicsOuter {
4746            height: h,
4747            depth: d,
4748            width: Length::ZERO,
4749            fn_id,
4750        },
4751    )]))
4752}
4753
4754/// Fire every deferred `inline-graphics-outer` callback in an already-
4755/// justified run, replacing its `GraphicsOuter` marker with a resolved
4756/// `Graphics` box (see `prim_inline_graphics_outer`). Idempotent (a resolved
4757/// box no longer matches) and cheap when nothing matches (one pass, no
4758/// allocation).
4759fn resolve_outer_graphics_in_contents(
4760    interp: &mut Interp,
4761    contents: &mut [(Length, PureHorzBox)],
4762) -> Result<(), EvalError> {
4763    for (_, bx) in contents.iter_mut() {
4764        if let PureHorzBox::GraphicsOuter {
4765            height,
4766            depth,
4767            width,
4768            fn_id,
4769        } = bx
4770        {
4771            let (w, h, d) = (*width, *height, *depth);
4772            let (gfun, gver) = match interp.outer_graphics.get(fn_id.0) {
4773                Some((f, v)) => (f.clone(), *v),
4774                None => {
4775                    return eval_error(format!(
4776                        "inline-graphics-outer: dangling callback index {}",
4777                        fn_id.0
4778                    ))
4779                }
4780            };
4781            let partial = interp.apply(gfun, Value::Length(w))?;
4782            let listv = interp.apply(partial, make_point_value((Length::ZERO, Length::ZERO)))?;
4783            // Same per-version coercion as `prim_inline_graphics`
4784            // above, shared with `tabular`'s per-cell use. The generation is
4785            // the one the callback was REGISTERED under, carried alongside it in
4786            // `Interp::outer_graphics`: this pass is a DEFERRED one
4787            // (`line-break`/`tabular`/`draw-text`), so `interp.version` here
4788            // is the entry document's, not the callback author's.
4789            let elems = coerce_graphics_result_for(gver, listv)?;
4790            *bx = PureHorzBox::Graphics {
4791                width: w,
4792                height: h,
4793                depth: d,
4794                elems,
4795                origin_independent: false,
4796            };
4797        }
4798    }
4799    Ok(())
4800}
4801
4802/// `tabular : (cell list) list -> (length list -> length list -> graphics
4803/// list) -> inline-boxes` (vminst.ml:539) — solve the grid (backend
4804/// `rustyfi_backend::tabular::main`) and eagerly drive the rule callback
4805/// with the solved box-local grid-line coordinates.
4806///
4807/// **Why eager is faithful here, unlike `inline-graphics`.** The callback's
4808/// arguments are the grid-line coordinates, fully determined by cell
4809/// content alone (`main` computes them before any placement) — so calling
4810/// it once at construction time with the true box-local `xs`/`ys` is exactly
4811/// what upstream's later, placement-time call produces once the PDF
4812/// writer's per-box `cm` translate (shared with `place_graphics`, see
4813/// `rustyfi-pdf`) shifts the resulting rule paths into position. No
4814/// shift-covariance caveat (contrast `prim_inline_graphics` above).
4815fn prim_tabular(
4816    interp: &mut Interp,
4817    version: RustyfiVersion,
4818    mut args: Vec<Value>,
4819) -> Result<Value, EvalError> {
4820    let rulesf = args.pop().unwrap();
4821    let rows = as_cell_grid(args.pop().unwrap())?;
4822    let mut solved = rustyfi_backend::tabular::main(rows);
4823    for cell in &mut solved.cells {
4824        resolve_outer_graphics_in_contents(interp, &mut cell.contents)?;
4825    }
4826
4827    let xs = make_length_list(&solved.xs);
4828    let ys = make_length_list(&solved.ys);
4829    let partial = interp.apply(rulesf, xs)?;
4830    let gval = interp.apply(partial, ys)?;
4831    // The rules callback returns `list graphics` under v0.0.6, one
4832    // `graphics` collection under v0.1 — per the CALLER's generation
4833    // (`version`), which is the one whose `tabular` type this call was
4834    // checked against.
4835    let rules = coerce_graphics_result_for(version, gval)?;
4836
4837    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4838        PureHorzBox::Tabular(TabularBox {
4839            width: solved.width,
4840            height: solved.height,
4841            depth: Length::ZERO,
4842            cells: solved.cells,
4843            rules,
4844        }),
4845    )]))
4846}
4847
4848// ============================================================================
4849// ---- gr.satyh graphics primitives -------------------------------------------
4850// ============================================================================
4851
4852/// `bezier-to : point -> point -> point -> pre-path -> pre-path`
4853/// (vminst.ml:742) — appends a cubic Bézier segment (`ptS`/`ptT` control
4854/// points, `pt1` destination) to the pre-path's forward-accumulated `segs`.
4855fn prim_bezier_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4856    let mut pp = as_prepath(args.pop().unwrap())?;
4857    let pt1 = as_point(args.pop().unwrap())?;
4858    let pt_t = as_point(args.pop().unwrap())?;
4859    let pt_s = as_point(args.pop().unwrap())?;
4860    pp.segs.push(PathSeg::Bezier(pt_s, pt_t, pt1));
4861    Ok(Value::PrePath(pp))
4862}
4863
4864/// `close-with-bezier : point -> point -> pre-path -> path` (vminst.ml:787)
4865/// — closes the subpath with a cubic Bézier back to its start (`ptS`/`ptT`
4866/// control points; the destination is always the subpath's own `start`, per
4867/// `Closing::Bezier`'s doc comment).
4868fn prim_close_with_bezier(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4869    let pp = as_prepath(args.pop().unwrap())?;
4870    let pt_t = as_point(args.pop().unwrap())?;
4871    let pt_s = as_point(args.pop().unwrap())?;
4872    Ok(Value::Path(Path {
4873        subpaths: vec![Subpath {
4874            start: pp.start,
4875            segs: pp.segs,
4876            closing: Closing::Bezier(pt_s, pt_t),
4877        }],
4878    }))
4879}
4880
4881/// `shift-path : point -> path -> path` (vminst.ml:663) — translate every
4882/// point of the path by the given vector (`rustyfi_backend::shift_path`).
4883fn prim_shift_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4884    let path = as_path(args.pop().unwrap())?;
4885    let v = as_point(args.pop().unwrap())?;
4886    Ok(Value::Path(shift_path(v, &path)))
4887}
4888
4889/// `linear-transform-path : float -> float -> float -> float -> path ->
4890/// path` (vminst.ml:678) — apply the 2x2 matrix `(a, b, c, d)` to every
4891/// point of the path (`rustyfi_backend::linear_transform_path`).
4892fn prim_linear_transform_path(
4893    _interp: &mut Interp,
4894    mut args: Vec<Value>,
4895) -> Result<Value, EvalError> {
4896    let path = as_path(args.pop().unwrap())?;
4897    let d = as_float(args.pop().unwrap())?;
4898    let c = as_float(args.pop().unwrap())?;
4899    let b = as_float(args.pop().unwrap())?;
4900    let a = as_float(args.pop().unwrap())?;
4901    Ok(Value::Path(linear_transform_path((a, b, c, d), &path)))
4902}
4903
4904/// `shift-graphics : point -> graphics -> graphics` (vminst.ml:2451) —
4905/// translate every point of the graphics element by the given vector.
4906fn prim_shift_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4907    let g = as_graphics(args.pop().unwrap())?;
4908    let v = as_point(args.pop().unwrap())?;
4909    Ok(Value::Graphics(shift_graphics(v, &g)))
4910}
4911
4912/// `linear-transform-graphics : float -> float -> float -> float -> graphics
4913/// -> graphics` (vminst.ml:2432). **Eager, unlike upstream**:
4914/// `graphicD.ml`'s `make_linear_trans` lazily wraps the element in a
4915/// `LinearTrans` node, deferring the matrix to a PDF `cm` operator at render
4916/// time — which also scales any wrapped `Stroke`/`DashedStroke`'s effective
4917/// line width (width is specified in the pre-transform coordinate space).
4918/// This port instead rewrites every point up front (a pure coordinate map,
4919/// no PDF change needed) and leaves `width` untouched, so
4920/// a non-uniform `scale-graphics` (`gr.satyh`) will NOT scale a stroke's
4921/// line width the way upstream does — invisible for pure rotation
4922/// (`rotate-graphics`, orthonormal, preserves lengths) and for `Fill`, which
4923/// is the only `GraphicsElem` shape any bundled package actually
4924/// strokes-then-scales.
4925fn prim_linear_transform_graphics(
4926    _interp: &mut Interp,
4927    mut args: Vec<Value>,
4928) -> Result<Value, EvalError> {
4929    let g = as_graphics(args.pop().unwrap())?;
4930    let d = as_float(args.pop().unwrap())?;
4931    let c = as_float(args.pop().unwrap())?;
4932    let b = as_float(args.pop().unwrap())?;
4933    let a = as_float(args.pop().unwrap())?;
4934    Ok(Value::Graphics(linear_transform_graphics((a, b, c, d), &g)))
4935}
4936
4937/// `get-graphics-bbox : graphics -> point * point` (v0.0.6 vminst.ml:2466)
4938/// — the v006 fork side. `.unwrap_or(…)` is UNREACHABLE under 0.0.6 (no
4939/// 0.0.6-visible constructor produces `Group`/`Clip`, so `graphics_bbox`
4940/// never returns `None` here); documented rather than `.expect`ed so a
4941/// future faithful `Group`/`Clip` leak (a bug) fails soft instead of
4942/// panicking.
4943fn prim_get_graphics_bbox_v006(
4944    _interp: &mut Interp,
4945    mut args: Vec<Value>,
4946) -> Result<Value, EvalError> {
4947    let g = as_graphics(args.pop().unwrap())?;
4948    let (pmin, pmax) =
4949        graphics_bbox(&g).unwrap_or(((Length::ZERO, Length::ZERO), (Length::ZERO, Length::ZERO)));
4950    Ok(Value::Tuple(vec![
4951        make_point_value(pmin),
4952        make_point_value(pmax),
4953    ]))
4954}
4955
4956/// `get-graphics-bbox : graphics -> option (point * point)` (dev-0-1-0
4957/// vminst.ml:2301) — the v01 fork side: `graphics` is a collection,
4958/// so an empty `unite-graphics []` (or an empty `Clip`'s contents-blind
4959/// bbox is still `Some`, but an empty `Group` folds to nothing)
4960/// legitimately has no bbox — surfaced as the SATySFi `option` variant,
4961/// the `probe-cross-reference` building pattern.
4962fn prim_get_graphics_bbox_v01(
4963    _interp: &mut Interp,
4964    mut args: Vec<Value>,
4965) -> Result<Value, EvalError> {
4966    let g = as_graphics(args.pop().unwrap())?;
4967    Ok(match graphics_bbox(&g) {
4968        Some((pmin, pmax)) => Value::Ctor(
4969            "Some".to_string(),
4970            Some(Box::new(Value::Tuple(vec![
4971                make_point_value(pmin),
4972                make_point_value(pmax),
4973            ]))),
4974        ),
4975        None => Value::Ctor("None".to_string(), None),
4976    })
4977}
4978
4979/// `unite-graphics : list graphics -> graphics` (dev-0-1-0 vminst.ml:3119)
4980/// — `GraphicD.concat` = `List.concat`, ported as the `Group` container.
4981/// `unite-graphics []` is legal and yields the
4982/// empty collection (the `None`-bbox witness `get-graphics-bbox` exercises
4983/// above).
4984fn prim_unite_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4985    let items = as_list(args.pop().unwrap())?;
4986    let mut elems = Vec::with_capacity(items.len());
4987    for it in items {
4988        elems.push(as_graphics(it)?);
4989    }
4990    Ok(Value::Graphics(GraphicsElem::Group(elems)))
4991}
4992
4993/// `clip-graphics-by-path : path -> graphics -> graphics` (dev-0-1-0
4994/// vminst.ml:3105) — `GraphicD.make_clip gr pathlst` = `Clip(paths, gr)`;
4995/// the port's single-element `g` (possibly itself a `Group`) IS the
4996/// collection upstream's `gr` argument names.
4997fn prim_clip_graphics_by_path(
4998    _interp: &mut Interp,
4999    mut args: Vec<Value>,
5000) -> Result<Value, EvalError> {
5001    let g = as_graphics(args.pop().unwrap())?;
5002    let path = as_path(args.pop().unwrap())?;
5003    Ok(Value::Graphics(GraphicsElem::Clip(path, vec![g])))
5004}
5005
5006/// `get-path-bbox : path -> point * point` (vminst.ml:696
5007/// `PathGetBoundingBox`) — `rustyfi_backend::path_bbox` (see that function's
5008/// doc comment for the exact cubic-extrema policy shared with
5009/// `get-graphics-bbox`).
5010fn prim_get_path_bbox(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5011    let path = as_path(args.pop().unwrap())?;
5012    let (pmin, pmax) = path_bbox(&path);
5013    Ok(Value::Tuple(vec![
5014        make_point_value(pmin),
5015        make_point_value(pmax),
5016    ]))
5017}
5018
5019/// `dashed-stroke : length -> (length*length*length) -> color -> path ->
5020/// graphics` (vminst.ml:2414) — width first, then the dash pattern, then
5021/// color, then path (mirrors `stroke`'s argument order with one extra
5022/// dash-pattern argument inserted).
5023fn prim_dashed_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5024    let path = as_path(args.pop().unwrap())?;
5025    let color = as_color(args.pop().unwrap())?;
5026    let dash = as_dash(args.pop().unwrap())?;
5027    let wid = as_length(args.pop().unwrap())?;
5028    Ok(Value::Graphics(GraphicsElem::DashedStroke(
5029        wid, dash, color, path,
5030    )))
5031}
5032
5033/// `draw-text : point -> inline-boxes -> graphics` (vminst.ml:2363
5034/// `PrimitiveDrawText`) — FAITHFUL: lays the run out at natural width
5035/// (upstream `LineBreak.natural`; here `natural_metrics` + `fit_cell` at that
5036/// width, so slack is 0 and every box keeps its natural advance) and stores
5037/// the placed run in `GraphicsElem::Text`. Also resolves any
5038/// `inline-graphics-outer` marker the run carries (`resolve_outer_graphics_
5039/// in_contents` — width 0 there, since slack is 0 at natural width, upstream
5040/// identical: `widperfil = 0`).
5041fn prim_draw_text(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5042    let ib = as_inline_boxes(args.pop().unwrap())?;
5043    let pt = as_point(args.pop().unwrap())?;
5044    let (width, height, depth) = natural_metrics(&ib);
5045    let (mut contents, _, _) = fit_cell(ib, width);
5046    resolve_outer_graphics_in_contents(interp, &mut contents)?;
5047    Ok(Value::Graphics(GraphicsElem::Text {
5048        pt,
5049        contents,
5050        width,
5051        height,
5052        depth,
5053        transform: None,
5054    }))
5055}
5056
5057// ============================================================================
5058// ---- pervasives.satyh prims -------------------
5059// ============================================================================
5060
5061/// `get-natural-metrics : inline-boxes -> length * length * length`
5062/// (vminst.ml:2020 `PrimitiveGetNaturalMetrics`) — FAITHFUL: delegates to
5063/// `rustyfi_backend::natural_metrics` (see that function's doc comment for
5064/// why no depth sign-flip is needed here, unlike upstream).
5065fn prim_get_natural_metrics(
5066    _interp: &mut Interp,
5067    mut args: Vec<Value>,
5068) -> Result<Value, EvalError> {
5069    let ib = as_inline_boxes(args.pop().unwrap())?;
5070    let (width, height, depth) = natural_metrics(&ib);
5071    Ok(Value::Tuple(vec![
5072        Value::Length(width),
5073        Value::Length(height),
5074        Value::Length(depth),
5075    ]))
5076}
5077
5078/// Build the atomic `PureHorzBox::Frame` for `inline-frame-outer`/`-inner`
5079/// (upstream keeps both atomic too; `-breakable` is transparent instead, see
5080/// [`prim_inline_frame_breakable`]): fit `inner` at its natural width
5081/// (`fit_cell` — the same
5082/// no-Context fit tabular cells use), pad the fitted run by `pads`, intern
5083/// `deco` into `interp.decos`. `deco` is fired lang-side, after
5084/// placement, by `fire_hooks`/`fire_inline_frame` — this constructor never
5085/// calls it.
5086fn make_inline_frame(
5087    interp: &mut Interp,
5088    version: RustyfiVersion,
5089    (pad_l, pad_r, pad_t, pad_b): (Length, Length, Length, Length),
5090    deco: Value,
5091    inner: Vec<HorzBox>,
5092) -> Value {
5093    let (w, _, _) = natural_metrics(&inner);
5094    let (contents, height, depth) = fit_cell(inner, w);
5095    let contents = contents.into_iter().map(|(x, b)| (x + pad_l, b)).collect();
5096    let id = DecoId(interp.decos.len());
5097    // `version` is the CALLING code's generation, threaded in by the
5098    // per-version prim rows below — fire time is a post-page-break pass with
5099    // no version context of its own, so this is the only moment the answer
5100    // is available. See `DecoEntry`'s doc comment.
5101    interp.decos.push(DecoEntry::Inline { deco, version });
5102    Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::Frame {
5103        width: pad_l + w + pad_r,
5104        height: height + pad_t,
5105        depth: depth + pad_b,
5106        deco: id,
5107        contents,
5108    })])
5109}
5110
5111/// `inline-frame-outer : paddings -> deco -> inline-boxes -> inline-boxes`
5112/// (vminst.ml:1787 `BackendOuterFrame`) — FAITHFUL: builds the atomic
5113/// `PureHorzBox::Frame`; see [`make_inline_frame`]. Upstream's
5114/// outer/inner distinction is glue participation in the enclosing line
5115/// (`PHGOuterFrame` vs `PHGInnerFrame`), which this atomic box model
5116/// collapses — both this and [`prim_inline_frame_inner`] build the exact
5117/// same box.
5118fn prim_inline_frame_outer(
5119    interp: &mut Interp,
5120    version: RustyfiVersion,
5121    mut args: Vec<Value>,
5122) -> Result<Value, EvalError> {
5123    let inner = as_inline_boxes(args.pop().unwrap())?;
5124    let deco = args.pop().unwrap();
5125    let pads = as_paddings(args.pop().unwrap())?;
5126    Ok(make_inline_frame(interp, version, pads, deco, inner))
5127}
5128
5129/// `inline-frame-inner : paddings -> deco -> inline-boxes -> inline-boxes`
5130/// (vminst.ml:1807 `BackendInnerFrame`) — same construction as
5131/// [`prim_inline_frame_outer`]; see that function's doc comment for the
5132/// outer/inner distinction this atomic model collapses.
5133fn prim_inline_frame_inner(
5134    interp: &mut Interp,
5135    version: RustyfiVersion,
5136    mut args: Vec<Value>,
5137) -> Result<Value, EvalError> {
5138    let inner = as_inline_boxes(args.pop().unwrap())?;
5139    let deco = args.pop().unwrap();
5140    let pads = as_paddings(args.pop().unwrap())?;
5141    Ok(make_inline_frame(interp, version, pads, deco, inner))
5142}
5143
5144/// `set-manual-rising : length -> context -> context` (vminst.ml:1661
5145/// `PrimitiveSetManualRising`) — FAITHFUL store into
5146/// `Context::manual_rising`, the same shape as `set-font-size`/
5147/// `set-leading` above. Read by `text_to_boxes`'s `flush_word`, which adds
5148/// it to the script font's own baseline raise; the default is
5149/// `Length::ZERO`, so a document that never calls this is unaffected.
5150fn prim_set_manual_rising(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5151    let ctx = as_context(args.pop().unwrap())?;
5152    let rising = as_length(args.pop().unwrap())?;
5153    Ok(Value::Context(Box::new(Context {
5154        manual_rising: rising,
5155        ..ctx
5156    })))
5157}
5158
5159/// `script-guard : script -> inline-boxes -> inline-boxes` (vminst.ml:1908
5160/// `BackendScriptGuard`).
5161///
5162/// STAND-IN: upstream wraps `hblst` in a `HorzScriptGuard` that tells the
5163/// line breaker which script to assume at each edge, for inter-script
5164/// spacing rules (`lineBreak.ml`'s script-boundary handling). This port's
5165/// line breaker has no script-aware spacing at all yet, so this is the
5166/// identity function: the `script` argument is accepted (so callers like
5167/// pervasives.satyh's `\SATySFi`/`\LaTeX`/`\TeX` type-check and run) and
5168/// discarded.
5169fn prim_script_guard(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5170    let ib = as_inline_boxes(args.pop().unwrap())?;
5171    let _script = args.pop().unwrap();
5172    Ok(Value::InlineBoxes(ib))
5173}
5174
5175/// Unwrap `inline-boxes`' `Vec<HorzBox>` down to the bare `Vec<PureHorzBox>`
5176/// a `PureHorzBox::Discretionary` slot stores (mirrors `prim_line_break`'s
5177/// identical unwrap, linebreak.rs's only other consumer of this shape).
5178fn into_pure(boxes: Vec<HorzBox>) -> Vec<PureHorzBox> {
5179    boxes.into_iter().map(|HorzBox::Pure(p)| p).collect()
5180}
5181
5182/// `discretionary : int -> inline-boxes -> inline-boxes -> inline-boxes ->
5183/// inline-boxes` (vminst.ml:1969 `BackendDiscretionary`), params `(pb,
5184/// hblst0, hblst1, hblst2)` — FAITHFUL: builds the same
5185/// `PureHorzBox::Discretionary` the UAX#14 line breaker already produces
5186/// internally. `hblst0` (`no_break`) renders when this point is NOT chosen
5187/// as a line break; `hblst1`/`hblst2` (`pre_break`/`post_break`) render at
5188/// the end/start of the two lines a break here would produce.
5189fn prim_discretionary(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5190    let post_break = as_inline_boxes(args.pop().unwrap())?;
5191    let pre_break = as_inline_boxes(args.pop().unwrap())?;
5192    let no_break = as_inline_boxes(args.pop().unwrap())?;
5193    let penalty = as_int(args.pop().unwrap())?;
5194    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5195        PureHorzBox::Discretionary {
5196            penalty: penalty.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
5197            pre_break: into_pure(pre_break),
5198            post_break: into_pure(post_break),
5199            no_break: into_pure(no_break),
5200        },
5201    )]))
5202}
5203
5204/// `get-axis-height : context -> length` (vminst.ml:1739
5205/// `PrimitiveGetAxisHeight`), needed by `picture.satyh`'s `Picture.node`
5206/// (Tier-2 decoration/graphics wave) — centers text vertically around the
5207/// math axis.
5208///
5209/// FAITHFUL: reads `axis_height` from `ctx.math_font`'s OpenType MATH
5210/// table via `MathC` (`FontInfo.get_axis_height mfabbrev fontsize`), falling
5211/// back to a fixed `0.25` ratio of `ctx.font_size` (`pervasives.satyh`'s
5212/// `\SATySFi`/`\LaTeX` manual-rising ratio) whenever the font has no MATH
5213/// table — so base-14/non-math output is unchanged.
5214fn prim_get_axis_height(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5215    let ctx = as_context(args.pop().unwrap())?;
5216    let mc = MathC::of(interp, &ctx);
5217    Ok(Value::Length(mc.axis(ctx.font_size)))
5218}
5219
5220// ============================================================================
5221// ---- page-break hooks + cross-references -----------------------------------
5222// ============================================================================
5223
5224/// `hook-page-break : (page-break-info -> point -> unit) -> inline-boxes`
5225/// (vminstdef.yaml:576). Pushes the closure argument onto `interp.hooks`
5226/// (the lang-side table `fire_hooks` reads back after placement) and
5227/// returns an inline box carrying only the opaque `HookId` — exactly
5228/// `prim_load_image`'s shape (`ImageId`/`interp.images`), applied to a
5229/// deferred *computation* instead of a resource. The backend places this
5230/// box like any other zero-width content and never sees the closure.
5231fn prim_hook_page_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5232    let closure = args.pop().unwrap();
5233    let id = HookId(interp.hooks.len());
5234    interp.hooks.push(closure);
5235    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5236        PureHorzBox::HookPageBreak { id },
5237    )]))
5238}
5239
5240/// `hook-page-break-block : (page-break-info -> point -> unit) ->
5241/// block-boxes` (vminst.ml:632 `BackendHookPageBreakBlock`) — the
5242/// block-level analog of `prim_hook_page_break` above, FAITHFUL: same
5243/// `interp.hooks` push, same opaque `HookId`, but wrapped in a
5244/// `VertBox::HookPageBreak` marker instead of an inline box. `chop_page`/
5245/// `place_block_at` (rustyfi-backend) place it as a zero-height
5246/// `PlacedLine` carrying the SAME `PureHorzBox::HookPageBreak` wrapper the
5247/// inline primitive uses, so `fire_hooks` (lib.rs) fires it through the
5248/// exact same scan with no changes of its own.
5249fn prim_hook_page_break_block(
5250    interp: &mut Interp,
5251    mut args: Vec<Value>,
5252) -> Result<Value, EvalError> {
5253    let closure = args.pop().unwrap();
5254    let id = HookId(interp.hooks.len());
5255    interp.hooks.push(closure);
5256    Ok(Value::BlockBoxes(vec![VertBox::HookPageBreak(id)]))
5257}
5258
5259/// `register-cross-reference : string -> string -> unit` (vminstdef.yaml:1793).
5260/// Callable anywhere (not just from a hook) — ordinary strict primitive
5261/// over the shared `crossrefs` table.
5262fn prim_register_cross_reference(
5263    interp: &mut Interp,
5264    mut args: Vec<Value>,
5265) -> Result<Value, EvalError> {
5266    let value = as_str(args.pop().unwrap())?;
5267    let key = as_str(args.pop().unwrap())?;
5268    interp.crossrefs.borrow_mut().register(key, value);
5269    Ok(Value::Unit)
5270}
5271
5272/// `get-cross-reference : string -> string option` (vminstdef.yaml:1808).
5273/// A miss is recorded (`CrossRefs::get`) so an unresolved forward reference
5274/// forces another fixpoint trial; the result surfaces as the SATySFi
5275/// `option` variant (`None` / `Some(string)`).
5276fn prim_get_cross_reference(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5277    let key = as_str(args.pop().unwrap())?;
5278    Ok(match interp.crossrefs.borrow_mut().get(&key) {
5279        Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5280        None => Value::Ctor("None".to_string(), None),
5281    })
5282}
5283
5284/// `probe-cross-reference : string -> string option` (vminst.ml:3043
5285/// `BackendProbeCrossReference`) — FAITHFUL: `get-cross-reference` minus the
5286/// miss bookkeeping (`CrossRefs::probe`, crossRef.ml:112), so a `None` here
5287/// never forces another fixpoint trial.
5288fn prim_probe_cross_reference(
5289    interp: &mut Interp,
5290    mut args: Vec<Value>,
5291) -> Result<Value, EvalError> {
5292    let key = as_str(args.pop().unwrap())?;
5293    Ok(match interp.crossrefs.borrow().probe(&key) {
5294        Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5295        None => Value::Ctor("None".to_string(), None),
5296    })
5297}
5298
5299// ============================================================================
5300// ---- annot.satyh's prim surface (link annotations + the frame/script
5301// stand-ins it needs) --------------------------------------------------------
5302// ============================================================================
5303
5304/// `get-leftmost-script`/`get-rightmost-script : inline-boxes -> script
5305/// option` (vminstdef.yaml:1754/1767 `BackendGetLeftmostScript`/
5306/// `BackendGetRightmostScript`) — STAND-IN: upstream inspects the actual
5307/// Unicode script of the first/last character in `hblst`
5308/// (`LineBreak.get_leftmost_script`/`get_rightmost_script`), which
5309/// `annot.satyh`'s `\href` uses to `script-guard` the link's edges so
5310/// inter-script spacing isn't inserted right at the boundary. This port's
5311/// `PureHorzBox::InnerString` carries no per-character script tag (no
5312/// script-aware line breaking at all yet — `script-guard` above is already
5313/// an identity stand-in for the same reason), so both primitives
5314/// unconditionally return `None`: `\href` then takes its `None` arm
5315/// (`inline-nil`, no guard inserted) — a safe, honest default rather than
5316/// fabricating a script this port cannot actually see.
5317fn prim_get_leftmost_script(
5318    _interp: &mut Interp,
5319    mut args: Vec<Value>,
5320) -> Result<Value, EvalError> {
5321    let _ib = as_inline_boxes(args.pop().unwrap())?;
5322    Ok(Value::Ctor("None".to_string(), None))
5323}
5324
5325/// See [`prim_get_leftmost_script`] — the rightmost-edge twin, identical
5326/// stand-in reasoning.
5327fn prim_get_rightmost_script(
5328    _interp: &mut Interp,
5329    mut args: Vec<Value>,
5330) -> Result<Value, EvalError> {
5331    let _ib = as_inline_boxes(args.pop().unwrap())?;
5332    Ok(Value::Ctor("None".to_string(), None))
5333}
5334
5335/// `inline-frame-breakable : paddings -> deco-set -> inline-boxes ->
5336/// inline-boxes` (vminstdef.yaml:1672 `BackendOuterFrameBreakable`) —
5337/// FAITHFUL: upstream's `HorzFrameBreakable` is *transparent* to the
5338/// paragraph breaker (`lineBreak.ml:1094` threads the enclosing width map
5339/// straight through the frame's contents), so the frame's own glue and
5340/// discretionaries are break candidates of the enclosing paragraph, and
5341/// `cut` (`:824`) re-frames the chosen fragments one line at a time —
5342/// `decoS` for a frame that came out unbroken, `decoH`/`decoM`/`decoT` per
5343/// fragment for one that split.
5344///
5345/// This port's breaker is a flat index DP rather than upstream's recursive
5346/// one, so transparency is spelled by SPLICING: the contents go straight into
5347/// the returned box list, bracketed by a zero-width
5348/// [`PureHorzBox::InlineFrameMarker`] pair that `fire_hooks` walks to
5349/// reassemble the fragments and fire the right closure for each. The
5350/// horizontal paddings become `FixedEmpty` boxes inside the bracket, exactly
5351/// upstream's `append_horz_padding` (`lineBreak.ml:79`); the vertical ones
5352/// ride on the markers (which is how they still reach the line's height and
5353/// depth) and are re-applied per fragment at fire time.
5354///
5355/// The atomic `PureHorzBox::Frame` is NOT usable here — it fires only
5356/// `decoS` and, being width-rigid, can neither break nor let an interior
5357/// `inline-fil` stretch. It is reserved for `inline-frame-outer`/`-inner`,
5358/// which upstream really does keep atomic.
5359fn prim_inline_frame_breakable(
5360    interp: &mut Interp,
5361    version: RustyfiVersion,
5362    mut args: Vec<Value>,
5363) -> Result<Value, EvalError> {
5364    let inner = as_inline_boxes(args.pop().unwrap())?;
5365    let decoset = as_decoset(args.pop().unwrap())?;
5366    let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
5367    let (_, height, depth) = natural_metrics(&inner);
5368    let id = DecoId(interp.decos.len());
5369    // `version` is the CALLING code's generation — see `make_inline_frame`'s
5370    // identical comment and `DecoEntry`'s doc comment.
5371    interp.decos.push(DecoEntry::InlineBreakable {
5372        pads: Paddings {
5373            l: pad_l,
5374            r: pad_r,
5375            t: pad_t,
5376            b: pad_b,
5377        },
5378        decoset,
5379        version,
5380    });
5381    let marker = |end| {
5382        HorzBox::Pure(PureHorzBox::InlineFrameMarker {
5383            id,
5384            end,
5385            height: height + pad_t,
5386            depth: depth + pad_b,
5387        })
5388    };
5389    let mut out = Vec::with_capacity(inner.len() + 4);
5390    out.push(marker(false));
5391    // Upstream emits both padding boxes unconditionally; a zero-width
5392    // `FixedEmpty` is inert everywhere in this port too, but skipping it keeps
5393    // the box stream (and every placed-line snapshot) unchanged for the
5394    // zero-padding callers, which is every bundled one.
5395    if pad_l != Length::ZERO {
5396        out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_l }));
5397    }
5398    out.extend(inner);
5399    if pad_r != Length::ZERO {
5400        out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_r }));
5401    }
5402    out.push(marker(true));
5403    Ok(Value::InlineBoxes(out))
5404}
5405
5406/// `deco-set` = `Value::Tuple` of 4 closures (`(decoS, decoH, decoM,
5407/// decoT)`, evalUtil.ml:169 `get_decoset`) — no type check on the elements
5408/// themselves (they're closures, applied later by `apply_deco`).
5409fn as_decoset(v: Value) -> Result<[Value; 4], EvalError> {
5410    match v {
5411        Value::Tuple(vs) if vs.len() == 4 => {
5412            let mut it = vs.into_iter();
5413            let a = it.next().unwrap();
5414            let b = it.next().unwrap();
5415            let c = it.next().unwrap();
5416            let d = it.next().unwrap();
5417            Ok([a, b, c, d])
5418        }
5419        other => eval_error(format!(
5420            "expected a deco-set (4-tuple of decorations), got {}",
5421            other.type_name()
5422        )),
5423    }
5424}
5425
5426/// 0.0.6 graphics-producing callbacks return `list graphics` (`tL tGR`);
5427/// 0.1's return one `graphics` collection (`tGR` — dev-0-1-0
5428/// `primitives.cppo.ml:75-85`). STRICT per
5429/// version: a 0.1 program returning a list here is a bug the type checker
5430/// already rejected; don't mask it with tolerant decoding. Shared by every
5431/// coercion site (`prim_inline_graphics`, `inline-graphics-outer`/
5432/// `tabular`'s `resolve_outer_graphics_in_contents`, `tabular`'s own rules
5433/// callback, and `apply_deco` below).
5434///
5435/// `version` is EXPLICIT rather than read off `interp.version`, and that is
5436/// the whole point. `interp.version` is one whole-program field, set once by
5437/// `lib.rs`'s `eval_document_trials`; in a cross-version program it names
5438/// the ENTRY document's generation, while the callback being decoded here
5439/// may have been written by a spliced 0.0.6 dependency. Every caller gets
5440/// the right answer from a place that genuinely knows it: the six carrier
5441/// prim bodies are registered per version
5442/// (`version_forked_prims!`, folded at compile time by
5443/// `compile.rs`'s `Ast::VersionScope` arm), and the two DEFERRED consumers
5444/// read the generation captured when the closure was interned
5445/// (`DecoEntry::version`, `Interp::outer_graphics`'s second component).
5446fn coerce_graphics_result_for(
5447    version: RustyfiVersion,
5448    v: Value,
5449) -> Result<Vec<GraphicsElem>, EvalError> {
5450    if version.graphics_is_collection() {
5451        Ok(vec![as_graphics(v)?])
5452    } else {
5453        as_list(v)?.into_iter().map(as_graphics).collect()
5454    }
5455}
5456
5457/// `make_frame_deco` (evalUtil.ml:604): apply a curried
5458/// `point -> length -> length -> length -> graphics list` deco and coerce
5459/// the result. Depths here are already user-sign (nonnegative), so no
5460/// negate (upstream negates because ITS internal depths are nonpositive).
5461/// The deco closure's result is `list graphics` under v0.0.6, one `graphics`
5462/// collection under v0.1 — see `coerce_graphics_result`'s doc comment.
5463///
5464/// `version` is the generation the closure was CAPTURED under
5465/// (`DecoEntry::version`), not `interp.version`: this runs from `lib.rs`'s
5466/// post-page-break firing pass, which is outside every `VersionScope`
5467/// window, so `interp.version` there is the entry document's generation. In
5468/// a single-version program the two are the same value.
5469pub(crate) fn apply_deco(
5470    interp: &mut Interp,
5471    version: RustyfiVersion,
5472    deco: Value,
5473    pt: Point,
5474    w: Length,
5475    h: Length,
5476    d: Length,
5477) -> Result<Vec<GraphicsElem>, EvalError> {
5478    let v = interp.apply(deco, make_point_value(pt))?;
5479    let v = interp.apply(v, Value::Length(w))?;
5480    let v = interp.apply(v, Value::Length(h))?;
5481    let v = interp.apply(v, Value::Length(d))?;
5482    coerce_graphics_result_for(version, v)
5483}
5484
5485/// `(length * color) option` — `register-link-to-uri`/`-to-location`'s
5486/// trailing border argument (vminstdef.yaml:2755/2775's `vborderopt`),
5487/// parsed the same way [`as_color`]/[`as_page`] read a `Value::Ctor`.
5488fn as_border_option(v: Value) -> Result<Option<(Length, Color)>, EvalError> {
5489    match v {
5490        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
5491            ("None", None) => Ok(None),
5492            ("Some", Some(Value::Tuple(vs))) if vs.len() == 2 => {
5493                let mut it = vs.into_iter();
5494                let w = as_length(it.next().unwrap())?;
5495                let c = as_color(it.next().unwrap())?;
5496                Ok(Some((w, c)))
5497            }
5498            (other, _) => eval_error(format!(
5499                "expected a border option (None / Some(length * color)), got variant '{other}'"
5500            )),
5501        },
5502        other => eval_error(format!("expected an option, got {}", other.type_name())),
5503    }
5504}
5505
5506/// `register-destination : string -> point -> unit` (vminstdef.yaml:2738) —
5507/// FAITHFUL: upstream `NamedDest.register` + `notify_pagebreak` collapsed
5508/// into one step, since our firing window (`fire_hooks`) already knows the
5509/// page. Errors outside that window (`annotation.ml:15`'s
5510/// `State.during_page_break` gate).
5511fn prim_register_destination(
5512    interp: &mut Interp,
5513    mut args: Vec<Value>,
5514) -> Result<Value, EvalError> {
5515    let (x, y) = as_point(args.pop().unwrap())?;
5516    let key = as_str(args.pop().unwrap())?;
5517    let Some(page) = interp.current_page else {
5518        return eval_error(
5519            "register-destination can only be called during page breaking \
5520             (from a page-break hook or a decoration)",
5521        );
5522    };
5523    let name = interp.dest_name(&key);
5524    // See `prim_register_link_to_uri`'s identical comment —
5525    // `register-location-frame`'s `decoR` fires this from inside a firing
5526    // block-frame deco.
5527    if let Some(deco_id) = interp.current_deco_id {
5528        interp.dest_decos.push((deco_id, name.clone()));
5529    }
5530    interp.destinations.push(NamedDest { page, name, x, y });
5531    Ok(Value::Unit)
5532}
5533
5534/// Shared body of `register-link-to-uri` / `register-link-to-location`
5535/// (vminstdef.yaml:2753/2773): pops the common `point/w/h/d/border` suffix,
5536/// builds `annotation.ml:22`'s rect `(x, y - d, x + w, y + h)` (PDF y-up
5537/// points; our depths are already nonnegative), and pushes the `Annot`.
5538fn register_link(
5539    interp: &mut Interp,
5540    mut args: Vec<Value>,
5541    prim_name: &str,
5542    make_action: impl FnOnce(&mut Interp, String) -> AnnotAction,
5543) -> Result<Value, EvalError> {
5544    let border = as_border_option(args.pop().unwrap())?;
5545    let dpt = as_length(args.pop().unwrap())?;
5546    let hgt = as_length(args.pop().unwrap())?;
5547    let wid = as_length(args.pop().unwrap())?;
5548    let (x, y) = as_point(args.pop().unwrap())?;
5549    let target = as_str(args.pop().unwrap())?;
5550    let Some(page) = interp.current_page else {
5551        return eval_error(format!(
5552            "{prim_name} can only be called during page breaking \
5553             (from a page-break hook or a decoration)"
5554        ));
5555    };
5556    let action = make_action(interp, target);
5557    // Tag this link with the DecoId of whatever deco closure is currently
5558    // firing (set by `fire_hooks`'s two `apply_deco` call sites, `lib.rs`) —
5559    // `annot.satyh`'s `\href` always calls this from inside one, so
5560    // `current_deco_id` is `Some` for every real `\href`; a hand-built test
5561    // calling this prim directly (not through a firing deco) legitimately
5562    // leaves it `None`, and the reflow backend just won't find a Frame to
5563    // wrap for that link.
5564    if let Some(deco_id) = interp.current_deco_id {
5565        interp.link_decos.push((deco_id, action.clone()));
5566    }
5567    interp.annotations.push(Annot {
5568        page,
5569        rect: (x, y - dpt, x + wid, y + hgt),
5570        action,
5571        border,
5572    });
5573    Ok(Value::Unit)
5574}
5575
5576/// `register-link-to-uri : string -> point -> length -> length -> length ->
5577/// (length * color) option -> unit` (vminstdef.yaml:2753
5578/// `BackendRegisterLinkToUri`) — FAITHFUL: see [`register_link`].
5579fn prim_register_link_to_uri(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
5580    register_link(interp, args, "register-link-to-uri", |_, uri| {
5581        AnnotAction::Uri(uri)
5582    })
5583}
5584
5585/// `register-link-to-location : string -> point -> length -> length ->
5586/// length -> (length * color) option -> unit` (vminstdef.yaml:2773
5587/// `BackendRegisterLinkToLocation`) — FAITHFUL: same shape as
5588/// [`prim_register_link_to_uri`], but upstream's action is
5589/// `GotoName(NamedDest.get name)` — the key goes through the SAME name table
5590/// as [`prim_register_destination`], so a link to a not-(yet-)registered
5591/// destination still mints a stable name (a viewer no-ops on it), exactly
5592/// like upstream.
5593fn prim_register_link_to_location(
5594    interp: &mut Interp,
5595    args: Vec<Value>,
5596) -> Result<Value, EvalError> {
5597    register_link(interp, args, "register-link-to-location", |interp, key| {
5598        AnnotAction::GotoName(interp.dest_name(&key))
5599    })
5600}
5601
5602// ============================================================================
5603// The faithful `Value::Math` primitive layer `math.satyh` is built
5604// out of. Every `math-*` primitive here builds or consumes a
5605// `Value::Math(Rc<Vec<Math>>)` (`value.rs`'s `Math`); a `math`-typed
5606// argument may equally arrive as a `Value::MathText` (a `${…}` literal —
5607// `as_math` accepts either, reflecting a `MathText`'s `MathElem` tree into
5608// `Math` nodes on the fly, see below).
5609// ============================================================================
5610
5611use crate::value::{Math, MathElement, MathVariantStyle};
5612
5613/// `math-class` = `Value::Ctor("MathOrd"|"MathBin"|…, None)` — mirrors
5614/// `as_color`/`as_page`'s shape exactly.
5615fn as_math_kind(v: Value) -> Result<MathKind, EvalError> {
5616    match v {
5617        Value::Ctor(name, None) => match name.as_str() {
5618            "MathOrd" => Ok(MathKind::Ord),
5619            "MathBin" => Ok(MathKind::Bin),
5620            "MathRel" => Ok(MathKind::Rel),
5621            "MathOp" => Ok(MathKind::Op),
5622            "MathPunct" => Ok(MathKind::Punct),
5623            "MathOpen" => Ok(MathKind::Open),
5624            "MathClose" => Ok(MathKind::Close),
5625            "MathPrefix" => Ok(MathKind::Prefix),
5626            "MathInner" => Ok(MathKind::Inner),
5627            other => eval_error(format!("expected a math-class constructor, got '{other}'")),
5628        },
5629        other => eval_error(format!("expected a math-class, got {}", other.type_name())),
5630    }
5631}
5632
5633/// `math-char-class` = `Value::Ctor("MathItalic"|…, None)`, resolved to the
5634/// backend's [`MathCharClass`] (see `value.rs`'s
5635/// `Math::ChangeCharClass` doc comment).
5636fn as_math_char_class(v: Value) -> Result<MathCharClass, EvalError> {
5637    match v {
5638        Value::Ctor(name, None) => match name.as_str() {
5639            "MathItalic" => Ok(MathCharClass::Italic),
5640            "MathBoldItalic" => Ok(MathCharClass::BoldItalic),
5641            "MathRoman" => Ok(MathCharClass::Roman),
5642            "MathBoldRoman" => Ok(MathCharClass::BoldRoman),
5643            "MathScript" => Ok(MathCharClass::Script),
5644            "MathBoldScript" => Ok(MathCharClass::BoldScript),
5645            "MathFraktur" => Ok(MathCharClass::Fraktur),
5646            "MathBoldFraktur" => Ok(MathCharClass::BoldFraktur),
5647            "MathDoubleStruck" => Ok(MathCharClass::DoubleStruck),
5648            // V0_1-only registration — these 5
5649            // ctor names are only ever declared by `builtin_variants` under
5650            // V0_1, so under V0_0 this arm is simply never reached: the
5651            // ctor name itself is rejected earlier, at typecheck, as
5652            // unknown.
5653            "MathSansSerif" => Ok(MathCharClass::SansSerif),
5654            "MathBoldSansSerif" => Ok(MathCharClass::BoldSansSerif),
5655            "MathItalicSansSerif" => Ok(MathCharClass::ItalicSansSerif),
5656            "MathBoldItalicSansSerif" => Ok(MathCharClass::BoldItalicSansSerif),
5657            "MathTypewriter" => Ok(MathCharClass::Typewriter),
5658            other => eval_error(format!(
5659                "expected a math-char-class constructor, got '{other}'"
5660            )),
5661        },
5662        other => eval_error(format!(
5663            "expected a math-char-class, got {}",
5664            other.type_name()
5665        )),
5666    }
5667}
5668
5669/// `math-variant-char`'s 9-field style record (`value.rs`'s
5670/// `MathVariantStyle`; `prim_types::t_math_variant_style`'s runtime
5671/// counterpart).
5672fn as_math_variant_style(v: Value) -> Result<MathVariantStyle, EvalError> {
5673    match v {
5674        Value::Record(mut fields) => {
5675            let mut take = |label: &str| -> Result<String, EvalError> {
5676                match fields.remove(label) {
5677                    Some(v) => as_str(v),
5678                    None => eval_error(format!(
5679                        "math-variant-char style record missing field '{label}'"
5680                    )),
5681                }
5682            };
5683            Ok(MathVariantStyle {
5684                italic: take("italic")?,
5685                bold_italic: take("bold-italic")?,
5686                roman: take("roman")?,
5687                bold_roman: take("bold-roman")?,
5688                script: take("script")?,
5689                bold_script: take("bold-script")?,
5690                fraktur: take("fraktur")?,
5691                bold_fraktur: take("bold-fraktur")?,
5692                double_struck: take("double-struck")?,
5693            })
5694        }
5695        other => eval_error(format!(
5696            "expected a math-variant-char style record, got {}",
5697            other.type_name()
5698        )),
5699    }
5700}
5701
5702/// A `math` argument: either an already-faithful `Value::Math` (built by
5703/// another `math-*` primitive), or a `${…}` literal `Value::MathText`,
5704/// reflected into `Math` nodes on the fly via [`reflect_math_elem`] — see
5705/// `value.rs`'s `Value::Math` doc comment for why both are interchangeable.
5706fn as_math(interp: &mut Interp, v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
5707    match v {
5708        Value::Math(m) => Ok(m),
5709        Value::MathText { elems, env } => {
5710            let mut out = Vec::new();
5711            for e in elems.iter() {
5712                reflect_math_elem(interp, e, &env, &mut out)?;
5713            }
5714            Ok(Rc::new(out))
5715        }
5716        other => eval_error(format!("expected math, got {}", other.type_name())),
5717    }
5718}
5719
5720/// Reflect one elaborated `${…}` literal `MathElem` (a fused,
5721/// math-class-free form) into zero-or-more faithful `Math` atoms, pushed
5722/// onto `out` — the "less churn" resolution:
5723/// `MathElem` stays the fast path for a bare `${x^2}` in prose
5724/// (`read_inline`'s `EmbedMath` arm, untouched), and only gets reflected
5725/// into `Value::Math` at a command/primitive boundary (here — whenever a
5726/// `${…}` literal is passed where a faithful `math` value is expected).
5727/// `Cmd`/`Embed` are resolved by actually evaluating them against `env` (the
5728/// literal's own captured environment) and recursively reflecting/flattening
5729/// the result — the "Embed of a `#…` program value that itself
5730/// evaluates to math" case.
5731fn reflect_math_elem(
5732    interp: &mut Interp,
5733    elem: &MathElem,
5734    env: &Env,
5735    out: &mut Vec<Math>,
5736) -> Result<(), EvalError> {
5737    match elem {
5738        MathElem::Chars(s) => {
5739            // One atom per MATHCHAR token ("one atom per run" —
5740            // the lexer already grouped a symbol run or a single latin
5741            // digit/letter into `s`); class + codepoint remap are both
5742            // deferred to `layout_math_atom`'s `VariantCharPending` arm,
5743            // where `Context::math_class_map`/`math_variant_char_map` and
5744            // the current font are available.
5745            out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
5746            Ok(())
5747        }
5748        MathElem::Group(elems) => {
5749            for e in elems {
5750                reflect_math_elem(interp, e, env, out)?;
5751            }
5752            Ok(())
5753        }
5754        MathElem::Sub(base, script) => {
5755            let mut base_v = Vec::new();
5756            reflect_math_elem(interp, base, env, &mut base_v)?;
5757            let mut script_v = Vec::new();
5758            for e in script {
5759                reflect_math_elem(interp, e, env, &mut script_v)?;
5760            }
5761            out.push(Math::Sub(base_v, script_v));
5762            Ok(())
5763        }
5764        MathElem::Sup(base, script) => {
5765            let mut base_v = Vec::new();
5766            reflect_math_elem(interp, base, env, &mut base_v)?;
5767            let mut script_v = Vec::new();
5768            for e in script {
5769                reflect_math_elem(interp, e, env, &mut script_v)?;
5770            }
5771            out.push(Math::Sup(base_v, script_v));
5772            Ok(())
5773        }
5774        MathElem::Primes(base, n) => {
5775            let mut base_v = Vec::new();
5776            reflect_math_elem(interp, base, env, &mut base_v)?;
5777            let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
5778            out.push(Math::Sup(
5779                base_v,
5780                vec![Math::Pure(MathElement::Char {
5781                    class: MathKind::Ord,
5782                    big: false,
5783                    chars: primes,
5784                })],
5785            ));
5786            Ok(())
5787        }
5788        MathElem::Cmd { cmd, args, .. } => {
5789            let mut v = cmd.run(env, interp)?;
5790            for arg in args {
5791                // `arg.opts` is always empty here — the math-mode application
5792                // grammar has no `?(l=e)` bundle form (see `MathElem::Cmd`'s
5793                // doc comment, `ast.rs`) — but fold through `apply_with_opts`
5794                // uniformly with `read_inline`/`read_block` regardless.
5795                let mut opt_vals = Vec::with_capacity(arg.opts.len());
5796                for (label, e) in &arg.opts {
5797                    opt_vals.push((label.clone(), e.run(env, interp)?));
5798                }
5799                let arg_v = arg.arg.run(env, interp)?;
5800                v = interp.apply_with_opts(v, opt_vals, arg_v)?;
5801            }
5802            let m = as_math(interp, v)?;
5803            out.extend(m.iter().cloned());
5804            Ok(())
5805        }
5806        MathElem::Embed { expr, span: _ } => {
5807            let v = expr.run(env, interp)?;
5808            let m = as_math(interp, v)?;
5809            out.extend(m.iter().cloned());
5810            Ok(())
5811        }
5812    }
5813}
5814
5815fn single_math(m: Math) -> Value {
5816    Value::Math(Rc::new(vec![m]))
5817}
5818
5819// ============================================================================
5820// V0_1's `math-text`/`math-boxes` split + `read-math`.
5821// Everything below is additive and V0_1-only — no 0.0.6 path calls any of
5822// this (`as_math`/`reflect_math_elem`/`single_math` above stay byte-
5823// identical and untouched).
5824// ============================================================================
5825
5826fn single_math_boxes(m: Math) -> Value {
5827    Value::MathBoxes(Rc::new(vec![m]))
5828}
5829
5830/// V0_1 strict `math-boxes` extractor: accepts only `Value::MathBoxes` — a
5831/// `math-text` literal reaching a V0_1 `math-*` primitive is a genuine 0.1
5832/// type error (well-typed programs never hit this; it's the runtime
5833/// fallback for a call built by hand, e.g. from a unit test).
5834fn as_math_boxes(v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
5835    match v {
5836        Value::MathBoxes(m) => Ok(m),
5837        other => eval_error(format!(
5838            "expected math-boxes, got {} (V0_1: math-text and math-boxes \
5839             are distinct types — bridge with `read-math`)",
5840            other.type_name()
5841        )),
5842    }
5843}
5844
5845/// V0_1 strict `math-text` extractor: accepts only `Value::MathText`,
5846/// returning its elements together with the environment they were captured
5847/// under (needed to evaluate any `#x` embed / math-command lookup inside).
5848fn as_math_text(v: Value) -> Result<(Rc<Vec<MathElem>>, Env), EvalError> {
5849    match v {
5850        Value::MathText { elems, env } => Ok((elems, env)),
5851        other => eval_error(format!("expected math-text, got {}", other.type_name())),
5852    }
5853}
5854
5855/// `option math-text` extractor (`None`/`Some math-text`) — `%math-attach-
5856/// scripts`' sub/sup arguments.
5857fn as_option_math_text(v: Value) -> Result<Option<(Rc<Vec<MathElem>>, Env)>, EvalError> {
5858    match v {
5859        Value::Ctor(name, None) if name == "None" => Ok(None),
5860        Value::Ctor(name, Some(payload)) if name == "Some" => {
5861            let (elems, env) = as_math_text(*payload)?;
5862            Ok(Some((elems, env)))
5863        }
5864        other => eval_error(format!(
5865            "expected an option (None/Some), got {}",
5866            other.type_name()
5867        )),
5868    }
5869}
5870
5871/// Wrap a raw (ambient-`env`-sharing) script `MathElem` slice as an `option
5872/// math-text` VALUE — `Cmd`'s uniform V0_1 calling convention always
5873/// passes its command's sub/sup arguments this way, never pre-reflected.
5874fn option_math_text_value(opt: Option<&[MathElem]>, env: &Env) -> Value {
5875    match opt {
5876        None => Value::Ctor("None".to_string(), None),
5877        Some(elems) => Value::Ctor(
5878            "Some".to_string(),
5879            Some(Box::new(Value::MathText {
5880                elems: Rc::new(elems.to_vec()),
5881                env: env.clone(),
5882            })),
5883        ),
5884    }
5885}
5886
5887/// `math-char-class` ctor-name mapper — the inverse of `as_math_char_class`
5888/// (above), used by `get-math-char-class` and by `set-math-variant-char`'s
5889/// V0_1 body (which must build a `math-char-class` VALUE to feed the
5890/// caller's selector closure).
5891fn math_char_class_ctor_name(c: MathCharClass) -> &'static str {
5892    match c {
5893        MathCharClass::Italic => "MathItalic",
5894        MathCharClass::BoldItalic => "MathBoldItalic",
5895        MathCharClass::Roman => "MathRoman",
5896        MathCharClass::BoldRoman => "MathBoldRoman",
5897        MathCharClass::Script => "MathScript",
5898        MathCharClass::BoldScript => "MathBoldScript",
5899        MathCharClass::Fraktur => "MathFraktur",
5900        MathCharClass::BoldFraktur => "MathBoldFraktur",
5901        MathCharClass::DoubleStruck => "MathDoubleStruck",
5902        MathCharClass::SansSerif => "MathSansSerif",
5903        MathCharClass::BoldSansSerif => "MathBoldSansSerif",
5904        MathCharClass::ItalicSansSerif => "MathItalicSansSerif",
5905        MathCharClass::BoldItalicSansSerif => "MathBoldItalicSansSerif",
5906        MathCharClass::Typewriter => "MathTypewriter",
5907    }
5908}
5909
5910fn math_char_class_value(c: MathCharClass) -> Value {
5911    Value::Ctor(math_char_class_ctor_name(c).to_string(), None)
5912}
5913
5914/// Port of `dev-0-1-0 src/frontend/context.ml:52-68`: bump `ctx`'s
5915/// `math_script_level` and scale `font_size`
5916/// accordingly. `Base -> Script`: scale by the font's MATH-table
5917/// `script_scale_down` (fallback `0.7`, consistent with the engine's other
5918/// fixed-fraction fallbacks). `Script -> ScriptScript`: scale by
5919/// `script_script_scale_down / script_scale_down` (fallback `5.0/7.0`).
5920/// `ScriptScript`: no-op — saturates at the deepest level, matching
5921/// upstream (no `ScriptScriptScript`).
5922fn enter_script(interp: &Interp, ctx: &Context) -> Context {
5923    let mc = MathC::of(interp, ctx);
5924    let (scale, next_level) = match ctx.math_script_level {
5925        MathScriptLevel::Base => (
5926            mc.c.map(|c| c.script_scale_down).unwrap_or(0.7),
5927            MathScriptLevel::Script,
5928        ),
5929        MathScriptLevel::Script => (
5930            mc.c.map(|c| c.script_script_scale_down / c.script_scale_down)
5931                .unwrap_or(5.0 / 7.0),
5932            MathScriptLevel::ScriptScript,
5933        ),
5934        MathScriptLevel::ScriptScript => return ctx.clone(),
5935    };
5936    Context {
5937        font_size: ctx.font_size * scale,
5938        math_script_level: next_level,
5939        ..ctx.clone()
5940    }
5941}
5942
5943/// Flatten a `Sub`/`Sup` `MathElem`'s (at most two-deep) nesting into `(base,
5944/// sub_opt, sup_opt)` — `elaborate.rs::fold_math_scripts` always builds a
5945/// both-scripts element as `Sup(Box::new(Sub(base, sub)), sup)` regardless
5946/// of source order (`x_a^b` and `x^b_a` both fold this way), so a bare
5947/// `Sub`/`Sup` and the fused two-level shape are the only cases to handle.
5948/// `elem` MUST be `MathElem::Sub` or `MathElem::Sup` — every caller already
5949/// matched on that.
5950fn flatten_math_scripts(elem: &MathElem) -> (&MathElem, Option<&[MathElem]>, Option<&[MathElem]>) {
5951    match elem {
5952        MathElem::Sup(base, sup) => match base.as_ref() {
5953            MathElem::Sub(inner, sub) => {
5954                (inner.as_ref(), Some(sub.as_slice()), Some(sup.as_slice()))
5955            }
5956            _ => (base.as_ref(), None, Some(sup.as_slice())),
5957        },
5958        MathElem::Sub(base, sub) => (base.as_ref(), Some(sub.as_slice()), None),
5959        _ => unreachable!("flatten_math_scripts called on a non-Sub/Sup MathElem"),
5960    }
5961}
5962
5963/// `attach_scripts` — mirrors upstream's
5964/// `append_sub_and_super_scripts` + its `enter_script` iteration
5965/// (`evaluator.cppo.ml:901-904`): reflects `sub_opt`/`sup_opt` (each an
5966/// already-extracted math-text payload — an ambient-env script slice for
5967/// the `reflect_scripted_v01` caller, or a genuine runtime `Value::MathText`
5968/// for the `%math-attach-scripts` primitive caller, both the SAME shape)
5969/// under `enter_script(interp, ctx)` — so commands *inside* a script observe
5970/// script-level context — then wraps `Math::Sub`/`Math::Sup` around `base`.
5971/// Both scripts present wraps as `Sup(Sub(base, sub), sup)`, matching the
5972/// shape `layout_math_atom`'s `check_subscript` already knows how to merge.
5973fn attach_scripts(
5974    interp: &mut Interp,
5975    ctx: &Context,
5976    base: Vec<Math>,
5977    sub_opt: Option<(Rc<Vec<MathElem>>, Env)>,
5978    sup_opt: Option<(Rc<Vec<MathElem>>, Env)>,
5979) -> Result<Vec<Math>, EvalError> {
5980    if sub_opt.is_none() && sup_opt.is_none() {
5981        return Ok(base);
5982    }
5983    let script_ctx = enter_script(interp, ctx);
5984    let mut cur = base;
5985    if let Some((elems, senv)) = sub_opt {
5986        let mut sub_v = Vec::new();
5987        for e in elems.iter() {
5988            reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sub_v)?;
5989        }
5990        cur = vec![Math::Sub(cur, sub_v)];
5991    }
5992    if let Some((elems, senv)) = sup_opt {
5993        let mut sup_v = Vec::new();
5994        for e in elems.iter() {
5995            reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sup_v)?;
5996        }
5997        cur = vec![Math::Sup(cur, sup_v)];
5998    }
5999    Ok(cur)
6000}
6001
6002/// One base `MathElem` (already stripped of any wrapping `Sub`/`Sup`) plus
6003/// its (possibly absent) `sub`/`sup` script slices — the shared tail of
6004/// `reflect_math_elem_v01`'s `Sub`/`Sup` arm (after flattening) AND its bare
6005/// `Cmd` arm (`sub = sup = None`). `base` a `Cmd`: route ctx+sub+sup into
6006/// the application per the uniform V0_1 calling convention — a
6007/// SEPARATE math-command value shape does not exist in this port, so every
6008/// V0_1 math command, scripted or not, is applied exactly this way. `base`
6009/// anything else: reflect it plainly, then `attach_scripts`.
6010fn reflect_scripted_v01(
6011    interp: &mut Interp,
6012    ctx: &Context,
6013    base: &MathElem,
6014    sub: Option<&[MathElem]>,
6015    sup: Option<&[MathElem]>,
6016    env: &Env,
6017    out: &mut Vec<Math>,
6018) -> Result<(), EvalError> {
6019    if let MathElem::Cmd { cmd, args, .. } = base {
6020        let mut v = cmd.run(env, interp)?;
6021        for arg in args {
6022            // `arg.opts` is always empty here too (see the bare-`Cmd` arm
6023            // above, `reflect_math_elem`) — folded through `apply_with_opts`
6024            // uniformly regardless.
6025            let mut opt_vals = Vec::with_capacity(arg.opts.len());
6026            for (label, e) in &arg.opts {
6027                opt_vals.push((label.clone(), e.run(env, interp)?));
6028            }
6029            let arg_v = arg.arg.run(env, interp)?;
6030            v = interp.apply_with_opts(v, opt_vals, arg_v)?;
6031        }
6032        // A 0.0.6-authored math command reached from a 0.1 document: the two
6033        // generations invoke a command differently, though `math` relabels
6034        // to `math-text` and both type-check. 0.0.6 gets `\cmd a1..an ->
6035        // math` with scripts attached STRUCTURALLY afterward
6036        // (`reflect_math_elem`'s `Sub`/`Sup` arms); 0.1 applies three extra
6037        // arguments (`ctx sub sup -> math-boxes`, `sub`/`sup : math-text
6038        // option`) so a command can typeset its own scripts. Applying those
6039        // three to a 0.0.6 command used to die with `cannot apply a value of
6040        // type math as a function`.
6041        //
6042        // Discrimination here is DYNAMIC, and total: after its declared
6043        // arguments a 0.1 command is by construction still a function (ends
6044        // `.. -> context -> ..`), so a math VALUE at this point can only be
6045        // a 0.0.6 command's result — a static check can't recover the
6046        // authoring generation, since `Ast::VersionScope` governs which
6047        // `PrimDef` the body folds to and nothing on the resulting closure
6048        // records where it came from. `as_math` runs 0.0.6's own reflection
6049        // (so nested commands in a returned `${..}` literal stay 0.0.6
6050        // commands), and its `Rc<Vec<Math>>` payload is byte-for-byte what
6051        // `Value::MathBoxes` carries, so crossing needs no conversion;
6052        // untaken scripts then attach via `attach_scripts`, the same
6053        // structural `Math::Sub`/`Math::Sup` shape 0.0.6's own reflector
6054        // would have built. A 0.0.6 command still can't RESTYLE its own
6055        // scripts — it never could, in 0.0.6 either.
6056        if matches!(v, Value::Math(_) | Value::MathText { .. }) {
6057            let base_v = as_math(interp, v)?.as_ref().clone();
6058            let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6059            let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6060            let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6061            out.extend(attached);
6062            return Ok(());
6063        }
6064        v = interp.apply(v, Value::Context(Box::new(ctx.clone())))?;
6065        v = interp.apply(v, option_math_text_value(sub, env))?;
6066        v = interp.apply(v, option_math_text_value(sup, env))?;
6067        let m = as_math_boxes(v)?;
6068        out.extend(m.iter().cloned());
6069        return Ok(());
6070    }
6071    let mut base_v = Vec::new();
6072    reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6073    let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6074    let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6075    let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6076    out.extend(attached);
6077    Ok(())
6078}
6079
6080/// V0_1 twin of `reflect_math_elem` (differs only where upstream's
6081/// `read_pdf_mode_math_text` (`evaluator.cppo.ml:887-930`) differs from
6082/// 0.0.6 reflection): `Chars`/`Group`/`Primes` are
6083/// identical to the v006 arms (class/variant resolution stays deferred to
6084/// layout, where `ctx`'s maps live); `Sub`/`Sup` flatten and route through
6085/// [`reflect_scripted_v01`]; a bare `Cmd` also routes through it (with
6086/// `sub = sup = None`) so the uniform ctx+sub+sup calling convention
6087/// applies uniformly, scripted or not; `Embed` (`#x`) requires the embedded
6088/// value to be `math-text` (it typechecked as `math-text`) and
6089/// recurses — upstream `MathTextValueGroup` (`evaluator.cppo.ml:944-949`);
6090/// scripts on an embed attach via [`reflect_scripted_v01`]'s generic
6091/// (non-`Cmd`) path, same as any other non-command base.
6092fn reflect_math_elem_v01(
6093    interp: &mut Interp,
6094    ctx: &Context,
6095    elem: &MathElem,
6096    env: &Env,
6097    out: &mut Vec<Math>,
6098) -> Result<(), EvalError> {
6099    match elem {
6100        MathElem::Chars(s) => {
6101            out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
6102            Ok(())
6103        }
6104        MathElem::Group(elems) => {
6105            for e in elems {
6106                reflect_math_elem_v01(interp, ctx, e, env, out)?;
6107            }
6108            Ok(())
6109        }
6110        MathElem::Primes(base, n) => {
6111            let mut base_v = Vec::new();
6112            reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6113            let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
6114            out.push(Math::Sup(
6115                base_v,
6116                vec![Math::Pure(MathElement::Char {
6117                    class: MathKind::Ord,
6118                    big: false,
6119                    chars: primes,
6120                })],
6121            ));
6122            Ok(())
6123        }
6124        MathElem::Sub(_, _) | MathElem::Sup(_, _) => {
6125            let (base, sub, sup) = flatten_math_scripts(elem);
6126            reflect_scripted_v01(interp, ctx, base, sub, sup, env, out)
6127        }
6128        MathElem::Cmd { .. } => reflect_scripted_v01(interp, ctx, elem, None, None, env, out),
6129        MathElem::Embed { expr, span: _ } => {
6130            let v = expr.run(env, interp)?;
6131            let (elems2, env2) = as_math_text(v)?;
6132            for e in elems2.iter() {
6133                reflect_math_elem_v01(interp, ctx, e, &env2, out)?;
6134            }
6135            Ok(())
6136        }
6137    }
6138}
6139
6140/// `read-math : context -> math-text -> math-boxes` (dev-0-1-0
6141/// vminst.ml:790-793). Reflects every element of
6142/// `mt` under `ctx` via [`reflect_math_elem_v01`], then wraps the whole run
6143/// in a single `Math::WithContext` node so `ctx` (including any color/font/
6144/// size override the caller composed onto it) reaches the layout engine —
6145/// see [`layout_math_list`]'s `Math::WithContext` arm.
6146fn prim_read_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6147    let mt = args.pop().unwrap();
6148    let ctx = as_context(args.pop().unwrap())?;
6149    let (elems, env) = as_math_text(mt)?;
6150    let mut out = Vec::new();
6151    for e in elems.iter() {
6152        reflect_math_elem_v01(interp, &ctx, e, &env, &mut out)?;
6153    }
6154    Ok(Value::MathBoxes(Rc::new(vec![Math::WithContext(
6155        Box::new(ctx),
6156        out,
6157    )])))
6158}
6159
6160/// `stringify-math : text-info -> math-text -> string` (vminst.ml:858) —
6161/// STAND-IN: the text-mode backend is out of scope for this PDF port (same
6162/// scoping note as `prim_convert_string_for_math`'s doc comment); registered
6163/// so 0.1 packages that reference it still typecheck.
6164fn prim_stringify_math(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6165    let _mt = args.pop().unwrap();
6166    let _tctx = args.pop().unwrap();
6167    eval_error(
6168        "stringify-math: the text-mode backend is out of scope for this PDF port \
6169         (see primitives.rs's prim_convert_string_for_math doc comment)"
6170            .to_string(),
6171    )
6172}
6173
6174/// `set-math-char : int -> int -> math-class -> context -> context`
6175/// (vminst.ml:59) — REAL: inserts `(char(cp_from)) -> (char(cp_to), kind)`
6176/// into `Context::math_class_map` (single-char string key, matching the
6177/// map's existing token-keying convention — see `prim_convert_string_for_
6178/// math`'s doc comment on how that map is consulted).
6179fn prim_set_math_char(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6180    let mut ctx = as_context(args.pop().unwrap())?;
6181    let kind = as_math_kind(args.pop().unwrap())?;
6182    let cpto = as_int(args.pop().unwrap())?;
6183    let cpfrom = as_int(args.pop().unwrap())?;
6184    let from = u32::try_from(cpfrom)
6185        .ok()
6186        .and_then(char::from_u32)
6187        .ok_or_else(|| EvalError {
6188            span: None,
6189            msg: format!("set-math-char: {cpfrom} is not a valid Unicode codepoint"),
6190        })?;
6191    let to = u32::try_from(cpto)
6192        .ok()
6193        .and_then(char::from_u32)
6194        .ok_or_else(|| EvalError {
6195            span: None,
6196            msg: format!("set-math-char: {cpto} is not a valid Unicode codepoint"),
6197        })?;
6198    Arc::make_mut(&mut ctx.math_class_map).insert(from.to_string(), (to.to_string(), kind));
6199    Ok(Value::Context(Box::new(ctx)))
6200}
6201
6202/// `set-math-char-class : math-char-class -> context -> context`
6203/// (vminst.ml:445) — REAL: sets `Context::math_char_class`.
6204fn prim_set_math_char_class(
6205    _interp: &mut Interp,
6206    mut args: Vec<Value>,
6207) -> Result<Value, EvalError> {
6208    let ctx = as_context(args.pop().unwrap())?;
6209    let cls = as_math_char_class(args.pop().unwrap())?;
6210    Ok(Value::Context(Box::new(Context {
6211        math_char_class: cls,
6212        ..ctx
6213    })))
6214}
6215
6216/// `get-math-char-class : context -> math-char-class` (vminst.ml:459) —
6217/// REAL: inverse of `as_math_char_class`.
6218fn prim_get_math_char_class(
6219    _interp: &mut Interp,
6220    mut args: Vec<Value>,
6221) -> Result<Value, EvalError> {
6222    let ctx = as_context(args.pop().unwrap())?;
6223    Ok(math_char_class_value(ctx.math_char_class))
6224}
6225
6226/// `embed-inline-to-math : math-class -> inline-boxes -> math-boxes`
6227/// (vminst.ml:432) — REAL data, stand-in render (`MathElement::
6228/// EmbeddedBoxes`'s doc comment).
6229fn prim_embed_inline_to_math(
6230    _interp: &mut Interp,
6231    mut args: Vec<Value>,
6232) -> Result<Value, EvalError> {
6233    let ib = as_inline_boxes(args.pop().unwrap())?;
6234    let class = as_math_kind(args.pop().unwrap())?;
6235    Ok(single_math_boxes(Math::Pure(MathElement::EmbeddedBoxes {
6236        class,
6237        boxes: ib,
6238    })))
6239}
6240
6241/// `get-math-axis-height-ratio : context -> float` (vminst.ml:1305) — REAL:
6242/// the axis-height ratio `MathC` already scales font sizes by
6243/// (`MathC::axis`).
6244fn prim_get_math_axis_height_ratio(
6245    interp: &mut Interp,
6246    mut args: Vec<Value>,
6247) -> Result<Value, EvalError> {
6248    let ctx = as_context(args.pop().unwrap())?;
6249    let ratio = MathC::of(interp, &ctx)
6250        .c
6251        .map(|c| c.axis_height)
6252        .unwrap_or(0.25);
6253    Ok(Value::Float(ratio))
6254}
6255
6256/// `%math-attach-scripts : context -> math-boxes -> option math-text ->
6257/// option math-text -> math-boxes` — hidden:
6258/// the synthesized script-attacher `val math` commands WITHOUT `with sub
6259/// sup` lower to. Body = [`attach_scripts`] directly — the same function
6260/// `reflect_scripted_v01`'s non-`Cmd` path calls.
6261fn prim_math_attach_scripts(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6262    let sup_v = args.pop().unwrap();
6263    let sub_v = args.pop().unwrap();
6264    let base_v = args.pop().unwrap();
6265    let ctx = as_context(args.pop().unwrap())?;
6266    let base = as_math_boxes(base_v)?;
6267    let sub_opt = as_option_math_text(sub_v)?;
6268    let sup_opt = as_option_math_text(sup_v)?;
6269    let out = attach_scripts(interp, &ctx, (*base).clone(), sub_opt, sup_opt)?;
6270    Ok(Value::MathBoxes(Rc::new(out)))
6271}
6272
6273/// `load-hyphenation-dictionary : string -> hyphenation` (`vminst.ml`'s
6274/// `LoadHyphenationDictionary`: upstream calls `LoadHyph.main abspath` to
6275/// build a `BCHyphenation` constant). REAL: unlike upstream, which
6276/// loads a dictionary from an on-disk `.rustyfi-hyph` path, this port has no
6277/// filesystem-loaded pattern data — the argument is instead treated as a
6278/// dictionary NAME (`"english"`/`"en-US"`, matching the `hyph-english.satyh`
6279/// stdlib package's usage) and mapped to the compiled-in `HyphenLang` tag.
6280/// An unrecognized name is a hard error rather than a silent no-op, since a
6281/// document that asks for a dictionary and gets none would silently render
6282/// without hyphenation. The heavy `hyphenation::Standard` dictionary itself
6283/// is not loaded here — only the lightweight tag is; the actual load is
6284/// deferred (load-once, cached) to `crate::hyphenation::hyphenate_word`'s
6285/// first call for that tag.
6286fn prim_load_hyphenation_dictionary(
6287    _interp: &mut Interp,
6288    mut args: Vec<Value>,
6289) -> Result<Value, EvalError> {
6290    let arg = as_str(args.pop().unwrap())?;
6291    // Accept either a bare dictionary NAME ("english"/"en-US") or an
6292    // upstream-style PATH ending `.../<name>.rustyfi-hyph` — this is what
6293    // the real, vendored `hyph-english.satyh` stand-in package actually
6294    // passes (`here ^ "/../hyph/english.rustyfi-hyph"`, mirroring
6295    // upstream's `LoadHyph.main abspath` convention). This port has no
6296    // on-disk pattern-file loader (the dictionary is compiled in via
6297    // `embed_en-us`), so the path's file stem doubles as the dictionary
6298    // name.
6299    let stem = std::path::Path::new(&arg)
6300        .file_stem()
6301        .and_then(|s| s.to_str())
6302        .unwrap_or(arg.as_str())
6303        .to_ascii_lowercase();
6304    let tag = match stem.as_str() {
6305        "english" | "en-us" => HyphenLang::EnglishUS,
6306        // en-GB (en-GB option): "british"/"en-GB"/"british-english",
6307        // mirroring the "english"/ "en-US" naming pair above.
6308        "british" | "en-gb" | "british-english" => HyphenLang::EnglishGB,
6309        _ => {
6310            return eval_error(format!(
6311                "load-hyphenation-dictionary: unknown dictionary {arg:?} \
6312                 (supported: \"english\"/\"en-US\", \"british\"/\"en-GB\"/\"british-english\", \
6313                 bare or as a `.../<name>.rustyfi-hyph`-style path)"
6314            ))
6315        }
6316    };
6317    Ok(Value::Hyphenation(tag))
6318}
6319
6320/// `load-unicode-char-database : string -> string -> string ->
6321/// unicode-char-database` (`vminst.ml`'s `LoadUnicodeCharDatabase`:
6322/// upstream builds `(ScriptDataMap, LineBreakDataMap)` from the three
6323/// Unicode data file paths into a `BCUnidata` constant). STAND-IN: no-op,
6324/// same rationale as `prim_load_hyphenation_dictionary` above — all three
6325/// paths are popped and dropped.
6326fn prim_load_unicode_char_database(
6327    _interp: &mut Interp,
6328    mut args: Vec<Value>,
6329) -> Result<Value, EvalError> {
6330    args.truncate(0);
6331    Ok(Value::Unit)
6332}
6333
6334/// `set-hyphenation-dictionary : hyphenation -> context -> context`
6335/// (`vminst.ml`'s setter: upstream stores `{ ctx with hyphen_dictionary }`).
6336/// REAL: writes `Context::hyphen_dictionary = Some(tag)`. This is the
6337/// ONLY way a `Context` acquires a dictionary — `Context::initial` seeds
6338/// `None`, so a document that never calls this gets no hyphenation at all.
6339fn prim_set_hyphenation_dictionary(
6340    _interp: &mut Interp,
6341    mut args: Vec<Value>,
6342) -> Result<Value, EvalError> {
6343    let ctx = as_context(args.pop().unwrap())?;
6344    let tag = as_hyphenation(args.pop().unwrap())?;
6345    Ok(Value::Context(Box::new(Context {
6346        hyphen_dictionary: Some(tag),
6347        ..ctx
6348    })))
6349}
6350
6351/// `set-unicode-char-database : unicode-char-database -> context ->
6352/// context` (`vminst.ml`'s setter: upstream stores `{ ctx with script_map;
6353/// line_break_map }`). STAND-IN no-op, same shape as
6354/// `prim_set_hyphenation_dictionary` above.
6355fn prim_set_unicode_char_database(
6356    _interp: &mut Interp,
6357    mut args: Vec<Value>,
6358) -> Result<Value, EvalError> {
6359    let ctx = args.pop().unwrap();
6360    let _db = args.pop().unwrap();
6361    Ok(ctx)
6362}
6363
6364fn prim_math_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6365    let s = as_str(args.pop().unwrap())?;
6366    let class = as_math_kind(args.pop().unwrap())?;
6367    let _ = interp;
6368    Ok(single_math(Math::Pure(MathElement::Char {
6369        class,
6370        big: false,
6371        chars: s,
6372    })))
6373}
6374
6375/// `math-char : context -> math-class -> string -> math-boxes` (dev-0-1-0
6376/// vminst.ml:358) — ctx ACCEPTED, not stored on the atom (coarse,
6377/// `read-math`-granularity context capture only).
6378fn prim_math_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6379    let s = as_str(args.pop().unwrap())?;
6380    let class = as_math_kind(args.pop().unwrap())?;
6381    let _ctx = as_context(args.pop().unwrap())?;
6382    let _ = interp;
6383    Ok(single_math_boxes(Math::Pure(MathElement::Char {
6384        class,
6385        big: false,
6386        chars: s,
6387    })))
6388}
6389
6390fn prim_math_big_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6391    let s = as_str(args.pop().unwrap())?;
6392    let class = as_math_kind(args.pop().unwrap())?;
6393    let _ = interp;
6394    Ok(single_math(Math::Pure(MathElement::Char {
6395        class,
6396        big: true,
6397        chars: s,
6398    })))
6399}
6400
6401/// `math-big-char : context -> math-class -> string -> math-boxes`
6402/// (vminst.ml:374) — same fork as `math-char`.
6403fn prim_math_big_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6404    let s = as_str(args.pop().unwrap())?;
6405    let class = as_math_kind(args.pop().unwrap())?;
6406    let _ctx = as_context(args.pop().unwrap())?;
6407    let _ = interp;
6408    Ok(single_math_boxes(Math::Pure(MathElement::Char {
6409        class,
6410        big: true,
6411        chars: s,
6412    })))
6413}
6414
6415fn prim_math_char_with_kern_v006(
6416    interp: &mut Interp,
6417    mut args: Vec<Value>,
6418) -> Result<Value, EvalError> {
6419    let kern_r = args.pop().unwrap();
6420    let kern_l = args.pop().unwrap();
6421    let s = as_str(args.pop().unwrap())?;
6422    let class = as_math_kind(args.pop().unwrap())?;
6423    let _ = interp;
6424    Ok(single_math(Math::Pure(MathElement::CharWithKern {
6425        class,
6426        big: false,
6427        chars: s,
6428        kern_l: Box::new(kern_l),
6429        kern_r: Box::new(kern_r),
6430    })))
6431}
6432
6433/// `math-char-with-kern : context -> math-class -> string -> kernf -> kernf
6434/// -> math-boxes` (vminst.ml:390).
6435fn prim_math_char_with_kern_v01(
6436    interp: &mut Interp,
6437    mut args: Vec<Value>,
6438) -> Result<Value, EvalError> {
6439    let kern_r = args.pop().unwrap();
6440    let kern_l = args.pop().unwrap();
6441    let s = as_str(args.pop().unwrap())?;
6442    let class = as_math_kind(args.pop().unwrap())?;
6443    let _ctx = as_context(args.pop().unwrap())?;
6444    let _ = interp;
6445    Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
6446        class,
6447        big: false,
6448        chars: s,
6449        kern_l: Box::new(kern_l),
6450        kern_r: Box::new(kern_r),
6451    })))
6452}
6453
6454fn prim_math_big_char_with_kern_v006(
6455    interp: &mut Interp,
6456    mut args: Vec<Value>,
6457) -> Result<Value, EvalError> {
6458    let kern_r = args.pop().unwrap();
6459    let kern_l = args.pop().unwrap();
6460    let s = as_str(args.pop().unwrap())?;
6461    let class = as_math_kind(args.pop().unwrap())?;
6462    let _ = interp;
6463    Ok(single_math(Math::Pure(MathElement::CharWithKern {
6464        class,
6465        big: true,
6466        chars: s,
6467        kern_l: Box::new(kern_l),
6468        kern_r: Box::new(kern_r),
6469    })))
6470}
6471
6472/// `math-big-char-with-kern : context -> math-class -> string -> kernf ->
6473/// kernf -> math-boxes` (vminst.ml:411) — same fork as
6474/// `math-char-with-kern`.
6475fn prim_math_big_char_with_kern_v01(
6476    interp: &mut Interp,
6477    mut args: Vec<Value>,
6478) -> Result<Value, EvalError> {
6479    let kern_r = args.pop().unwrap();
6480    let kern_l = args.pop().unwrap();
6481    let s = as_str(args.pop().unwrap())?;
6482    let class = as_math_kind(args.pop().unwrap())?;
6483    let _ctx = as_context(args.pop().unwrap())?;
6484    let _ = interp;
6485    Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
6486        class,
6487        big: true,
6488        chars: s,
6489        kern_l: Box::new(kern_l),
6490        kern_r: Box::new(kern_r),
6491    })))
6492}
6493
6494/// `math-concat : math -> math -> math` (vminst.ml:193) — FAITHFUL: a plain
6495/// list append (`math` is always a flat sequence of atoms; see `value.rs`'s
6496/// `Value::Math` doc comment).
6497fn prim_math_concat_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6498    let m2 = args.pop().unwrap();
6499    let m1 = args.pop().unwrap();
6500    let m1 = as_math(interp, m1)?;
6501    let m2 = as_math(interp, m2)?;
6502    let mut out = (*m1).clone();
6503    out.extend((*m2).iter().cloned());
6504    Ok(Value::Math(Rc::new(out)))
6505}
6506
6507/// `math-concat : math-boxes -> math-boxes -> math-boxes` (vminst.ml:181).
6508fn prim_math_concat_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6509    let m2 = as_math_boxes(args.pop().unwrap())?;
6510    let m1 = as_math_boxes(args.pop().unwrap())?;
6511    let mut out = (*m1).clone();
6512    out.extend((*m2).iter().cloned());
6513    Ok(Value::MathBoxes(Rc::new(out)))
6514}
6515
6516fn prim_math_group_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6517    let m = args.pop().unwrap();
6518    let cls2 = as_math_kind(args.pop().unwrap())?;
6519    let cls1 = as_math_kind(args.pop().unwrap())?;
6520    let inner = as_math(interp, m)?;
6521    Ok(single_math(Math::Group(cls1, cls2, (*inner).clone())))
6522}
6523
6524/// `math-group : math-class -> math-class -> math-boxes -> math-boxes`
6525/// (vminst.ml:194).
6526fn prim_math_group_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6527    let m = as_math_boxes(args.pop().unwrap())?;
6528    let cls2 = as_math_kind(args.pop().unwrap())?;
6529    let cls1 = as_math_kind(args.pop().unwrap())?;
6530    Ok(single_math_boxes(Math::Group(cls1, cls2, (*m).clone())))
6531}
6532
6533fn prim_math_sup_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6534    let m2 = args.pop().unwrap();
6535    let m1 = args.pop().unwrap();
6536    let base = as_math(interp, m1)?;
6537    let script = as_math(interp, m2)?;
6538    Ok(single_math(Math::Sup((*base).clone(), (*script).clone())))
6539}
6540
6541/// `math-sup : context -> math-boxes -> (context -> math-boxes) ->
6542/// math-boxes` (vminst.ml:208) — the script argument is a context-taking
6543/// callback, run under `enter_script`.
6544fn prim_math_sup_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6545    let f = args.pop().unwrap();
6546    let base_v = args.pop().unwrap();
6547    let ctx = as_context(args.pop().unwrap())?;
6548    let base = as_math_boxes(base_v)?;
6549    let script_ctx = enter_script(interp, &ctx);
6550    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6551    let script = as_math_boxes(script_v)?;
6552    Ok(single_math_boxes(Math::Sup(
6553        (*base).clone(),
6554        (*script).clone(),
6555    )))
6556}
6557
6558fn prim_math_sub_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6559    let m2 = args.pop().unwrap();
6560    let m1 = args.pop().unwrap();
6561    let base = as_math(interp, m1)?;
6562    let script = as_math(interp, m2)?;
6563    Ok(single_math(Math::Sub((*base).clone(), (*script).clone())))
6564}
6565
6566/// `math-sub : context -> math-boxes -> (context -> math-boxes) ->
6567/// math-boxes` (vminst.ml:228) — same shape as `math-sup`.
6568fn prim_math_sub_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6569    let f = args.pop().unwrap();
6570    let base_v = args.pop().unwrap();
6571    let ctx = as_context(args.pop().unwrap())?;
6572    let base = as_math_boxes(base_v)?;
6573    let script_ctx = enter_script(interp, &ctx);
6574    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6575    let script = as_math_boxes(script_v)?;
6576    Ok(single_math_boxes(Math::Sub(
6577        (*base).clone(),
6578        (*script).clone(),
6579    )))
6580}
6581
6582fn prim_math_frac_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6583    let m2 = args.pop().unwrap();
6584    let m1 = args.pop().unwrap();
6585    let num = as_math(interp, m1)?;
6586    let den = as_math(interp, m2)?;
6587    Ok(single_math(Math::Fraction((*num).clone(), (*den).clone())))
6588}
6589
6590/// `math-frac : context -> math-boxes -> math-boxes -> math-boxes`
6591/// (vminst.ml:248).
6592fn prim_math_frac_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6593    let m2 = as_math_boxes(args.pop().unwrap())?;
6594    let m1 = as_math_boxes(args.pop().unwrap())?;
6595    let _ctx = as_context(args.pop().unwrap())?;
6596    Ok(single_math_boxes(Math::Fraction(
6597        (*m1).clone(),
6598        (*m2).clone(),
6599    )))
6600}
6601
6602/// `math-radical : math option -> math -> math` (vminst.ml:274) — `None`
6603/// degree is `\sqrt`; upstream's `MathRadicalWithDegree` (`\sqrt[n]`) is
6604/// unimplemented too (`math.ml:886`), carried faithfully but not rendered
6605/// specially, matching upstream by parity (see `value.rs`'s `Math::Radical`).
6606fn prim_math_radical_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6607    let m2 = args.pop().unwrap();
6608    let opt = args.pop().unwrap();
6609    let radicand = as_math(interp, m2)?;
6610    let degree = match opt {
6611        Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
6612        Value::Ctor(name, Some(payload)) if name == "Some" => {
6613            Some((*as_math(interp, *payload)?).clone())
6614        }
6615        other => {
6616            return eval_error(format!(
6617                "expected a math option (None/Some), got {}",
6618                other.type_name()
6619            ))
6620        }
6621    };
6622    Ok(single_math(Math::Radical(degree, (*radicand).clone())))
6623}
6624
6625/// `math-radical : context -> option math-boxes -> math-boxes ->
6626/// math-boxes` (vminst.ml:262).
6627fn prim_math_radical_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6628    let m2 = args.pop().unwrap();
6629    let opt = args.pop().unwrap();
6630    let _ctx = as_context(args.pop().unwrap())?;
6631    let radicand = as_math_boxes(m2)?;
6632    let degree = match opt {
6633        Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
6634        Value::Ctor(name, Some(payload)) if name == "Some" => {
6635            Some((*as_math_boxes(*payload)?).clone())
6636        }
6637        other => {
6638            return eval_error(format!(
6639                "expected a math-boxes option (None/Some), got {}",
6640                other.type_name()
6641            ))
6642        }
6643    };
6644    Ok(single_math_boxes(Math::Radical(
6645        degree,
6646        (*radicand).clone(),
6647    )))
6648}
6649
6650fn prim_math_lower_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6651    let m2 = args.pop().unwrap();
6652    let m1 = args.pop().unwrap();
6653    let base = as_math(interp, m1)?;
6654    let lower = as_math(interp, m2)?;
6655    Ok(single_math(Math::LowerLimit(
6656        (*base).clone(),
6657        (*lower).clone(),
6658    )))
6659}
6660
6661/// `math-lower : context -> math-boxes -> (context -> math-boxes) ->
6662/// math-boxes` (vminst.ml:338) — same script-callback shape as `math-sup`.
6663fn prim_math_lower_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6664    let f = args.pop().unwrap();
6665    let base_v = args.pop().unwrap();
6666    let ctx = as_context(args.pop().unwrap())?;
6667    let base = as_math_boxes(base_v)?;
6668    let script_ctx = enter_script(interp, &ctx);
6669    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6670    let lower = as_math_boxes(script_v)?;
6671    Ok(single_math_boxes(Math::LowerLimit(
6672        (*base).clone(),
6673        (*lower).clone(),
6674    )))
6675}
6676
6677fn prim_math_upper_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6678    let m2 = args.pop().unwrap();
6679    let m1 = args.pop().unwrap();
6680    let base = as_math(interp, m1)?;
6681    let upper = as_math(interp, m2)?;
6682    Ok(single_math(Math::UpperLimit(
6683        (*base).clone(),
6684        (*upper).clone(),
6685    )))
6686}
6687
6688/// `math-upper : context -> math-boxes -> (context -> math-boxes) ->
6689/// math-boxes` (vminst.ml:318) — same script-callback shape as `math-sup`.
6690fn prim_math_upper_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6691    let f = args.pop().unwrap();
6692    let base_v = args.pop().unwrap();
6693    let ctx = as_context(args.pop().unwrap())?;
6694    let base = as_math_boxes(base_v)?;
6695    let script_ctx = enter_script(interp, &ctx);
6696    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6697    let upper = as_math_boxes(script_v)?;
6698    Ok(single_math_boxes(Math::UpperLimit(
6699        (*base).clone(),
6700        (*upper).clone(),
6701    )))
6702}
6703
6704/// `math-pull-in-scripts : math-class -> math-class -> (math option -> math
6705/// option -> math) -> math` (vminst.ml:368) — FAITHFUL construction: the
6706/// resolver closure is stored opaquely here, only ever invoked by
6707/// `layout_pull_in_scripts` — with
6708/// the subscript/superscript actually pulled in off an enclosing `Sub`/`Sup`
6709/// (`{scripts} m^{sup}`-style), or with `(None, None)` for the common
6710/// unscripted case (a bare `\sum`/`\int` with nothing pulled in).
6711fn prim_math_pull_in_scripts(
6712    interp: &mut Interp,
6713    mut args: Vec<Value>,
6714) -> Result<Value, EvalError> {
6715    let resolver = args.pop().unwrap();
6716    let cls2 = as_math_kind(args.pop().unwrap())?;
6717    let cls1 = as_math_kind(args.pop().unwrap())?;
6718    let _ = interp;
6719    Ok(single_math(Math::PullInScripts(
6720        cls1,
6721        cls2,
6722        Box::new(resolver),
6723    )))
6724}
6725
6726fn prim_math_color(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6727    let m = args.pop().unwrap();
6728    let color = as_color(args.pop().unwrap())?;
6729    let inner = as_math(interp, m)?;
6730    Ok(single_math(Math::ChangeColor(color, (*inner).clone())))
6731}
6732
6733fn prim_math_char_class(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6734    let m = args.pop().unwrap();
6735    let cls = as_math_char_class(args.pop().unwrap())?;
6736    let inner = as_math(interp, m)?;
6737    Ok(single_math(Math::ChangeCharClass(cls, (*inner).clone())))
6738}
6739
6740fn prim_math_variant_char(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6741    let style = as_math_variant_style(args.pop().unwrap())?;
6742    let class = as_math_kind(args.pop().unwrap())?;
6743    let _ = interp;
6744    Ok(single_math(Math::Pure(MathElement::VariantChar {
6745        class,
6746        big: false,
6747        style: Box::new(style),
6748    })))
6749}
6750
6751fn prim_math_paren_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6752    let m = args.pop().unwrap();
6753    let paren_r = args.pop().unwrap();
6754    let paren_l = args.pop().unwrap();
6755    let inner = as_math(interp, m)?;
6756    Ok(single_math(Math::Paren(
6757        Box::new(paren_l),
6758        Box::new(paren_r),
6759        (*inner).clone(),
6760    )))
6761}
6762
6763/// `math-paren : context -> paren -> paren -> math-boxes -> math-boxes`
6764/// (vminst.ml:279).
6765fn prim_math_paren_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6766    let m = args.pop().unwrap();
6767    let paren_r = args.pop().unwrap();
6768    let paren_l = args.pop().unwrap();
6769    let _ctx = as_context(args.pop().unwrap())?;
6770    let inner = as_math_boxes(m)?;
6771    Ok(single_math_boxes(Math::Paren(
6772        Box::new(paren_l),
6773        Box::new(paren_r),
6774        (*inner).clone(),
6775    )))
6776}
6777
6778fn prim_math_paren_with_middle_v006(
6779    interp: &mut Interp,
6780    mut args: Vec<Value>,
6781) -> Result<Value, EvalError> {
6782    let mlst = args.pop().unwrap();
6783    let middle = args.pop().unwrap();
6784    let paren_r = args.pop().unwrap();
6785    let paren_l = args.pop().unwrap();
6786    let items = as_list(mlst)?;
6787    let mut mlstlst = Vec::with_capacity(items.len());
6788    for it in items {
6789        mlstlst.push((*as_math(interp, it)?).clone());
6790    }
6791    Ok(single_math(Math::ParenWithMiddle(
6792        Box::new(paren_l),
6793        Box::new(paren_r),
6794        Box::new(middle),
6795        mlstlst,
6796    )))
6797}
6798
6799/// `math-paren-with-middle : context -> paren -> paren -> paren -> list
6800/// math-boxes -> math-boxes` (vminst.ml:297).
6801fn prim_math_paren_with_middle_v01(
6802    _interp: &mut Interp,
6803    mut args: Vec<Value>,
6804) -> Result<Value, EvalError> {
6805    let mlst = args.pop().unwrap();
6806    let middle = args.pop().unwrap();
6807    let paren_r = args.pop().unwrap();
6808    let paren_l = args.pop().unwrap();
6809    let _ctx = as_context(args.pop().unwrap())?;
6810    let items = as_list(mlst)?;
6811    let mut mlstlst = Vec::with_capacity(items.len());
6812    for it in items {
6813        mlstlst.push((*as_math_boxes(it)?).clone());
6814    }
6815    Ok(single_math_boxes(Math::ParenWithMiddle(
6816        Box::new(paren_l),
6817        Box::new(paren_r),
6818        Box::new(middle),
6819        mlstlst,
6820    )))
6821}
6822
6823fn prim_text_in_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6824    let body = args.pop().unwrap();
6825    let class = as_math_kind(args.pop().unwrap())?;
6826    let _ = interp;
6827    Ok(single_math(Math::Pure(MathElement::EmbeddedText {
6828        class,
6829        body: Box::new(body),
6830    })))
6831}
6832
6833/// `convert-string-for-math : context -> math-char-class -> string ->
6834/// string` (`vminstdef.yaml` `PrimitiveConvertStringForMath`). Faithful to
6835/// upstream: it overrides the context's `math_char_class` with the passed
6836/// `mccls`, then runs `MathContext.convert_math_variant_char`
6837/// (`types.cppo.ml:1602`) over the whole string —
6838///  1. if the WHOLE string is a key of the (token-level) `math_class_map`
6839///     (`default_math_class_map`, e.g. `"-"` → `"−"` U+2212), return its
6840///     replacement codepoints; else
6841///  2. remap each char via the runtime `math_variant_char_map`
6842///     (`set-math-variant-char` overrides, keyed by `(char, mccls)`) first,
6843///     then the built-in `default_math_variant_char` table (the
6844///     Mathematical-Alphanumeric-Symbols remap), keeping any char with no
6845///     mapping.
6846/// Unlike the *rendering*-path `resolve_variant_char`, this string primitive
6847/// does NOT gate on font glyph availability (upstream's
6848/// `convert_math_variant_char` never does — it returns codepoints, not
6849/// glyphs), so `abc` under `MathItalic` yields U+1D44E/44F/450 regardless of
6850/// the active font.
6851fn prim_convert_string_for_math(
6852    _interp: &mut Interp,
6853    mut args: Vec<Value>,
6854) -> Result<Value, EvalError> {
6855    let s = as_str(args.pop().unwrap())?;
6856    let class = as_math_char_class(args.pop().unwrap())?;
6857    let ctx = as_context(args.pop().unwrap())?;
6858    // (1) whole-token class-map hit -> its replacement codepoints verbatim.
6859    if let Some((target, _mk)) = ctx.math_class_map.get(&s) {
6860        return Ok(Value::Str(target.clone()));
6861    }
6862    // (2) per-char variant remap under the PASSED class (which upstream
6863    // installs as the effective `math_char_class` before converting).
6864    let mut out = String::with_capacity(s.len());
6865    for ch in s.chars() {
6866        let mapped = ctx
6867            .math_variant_char_map
6868            .get(&(ch, class))
6869            .copied()
6870            .or_else(|| default_math_variant_char(class, ch))
6871            .unwrap_or(ch);
6872        out.push(mapped);
6873    }
6874    Ok(Value::Str(out))
6875}
6876
6877/// `set-math-variant-char : math-char-class -> int -> int -> context ->
6878/// context` — FAITHFUL: installs a per-`(source char, style)`
6879/// override into `Context::math_variant_char_map`, consulted by
6880/// `resolve_variant_char` BEFORE the built-in `default_math_variant_char`
6881/// table. `Arc::make_mut` copy-on-writes the map so contexts that never
6882/// call this keep sharing one `Arc`-refcounted empty table.
6883fn prim_set_math_variant_char_v006(
6884    _interp: &mut Interp,
6885    mut args: Vec<Value>,
6886) -> Result<Value, EvalError> {
6887    let mut ctx = as_context(args.pop().unwrap())?;
6888    let cpto = as_int(args.pop().unwrap())?;
6889    let cpfrom = as_int(args.pop().unwrap())?;
6890    let cls = as_math_char_class(args.pop().unwrap())?;
6891    let from = u32::try_from(cpfrom)
6892        .ok()
6893        .and_then(char::from_u32)
6894        .ok_or_else(|| EvalError {
6895            span: None,
6896            msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
6897        })?;
6898    let to = u32::try_from(cpto)
6899        .ok()
6900        .and_then(char::from_u32)
6901        .ok_or_else(|| EvalError {
6902            span: None,
6903            msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
6904        })?;
6905    Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
6906    Ok(Value::Context(Box::new(ctx)))
6907}
6908
6909/// `set-math-variant-char : int -> (math-char-class -> int) -> context ->
6910/// context` (vminst.ml:36) — the v01 body applies the selector once per
6911/// each of the 9 `MathCharClass` values and inserts into `math_variant_
6912/// char_map` (an eager materialization of upstream's stored selector
6913/// closure; the observable map is the same either way).
6914fn prim_set_math_variant_char_v01(
6915    interp: &mut Interp,
6916    mut args: Vec<Value>,
6917) -> Result<Value, EvalError> {
6918    let mut ctx = as_context(args.pop().unwrap())?;
6919    let selector = args.pop().unwrap();
6920    let cpfrom = as_int(args.pop().unwrap())?;
6921    let from = u32::try_from(cpfrom)
6922        .ok()
6923        .and_then(char::from_u32)
6924        .ok_or_else(|| EvalError {
6925            span: None,
6926            msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
6927        })?;
6928    const CLASSES: [MathCharClass; 9] = [
6929        MathCharClass::Italic,
6930        MathCharClass::BoldItalic,
6931        MathCharClass::Roman,
6932        MathCharClass::BoldRoman,
6933        MathCharClass::Script,
6934        MathCharClass::BoldScript,
6935        MathCharClass::Fraktur,
6936        MathCharClass::BoldFraktur,
6937        MathCharClass::DoubleStruck,
6938    ];
6939    for cls in CLASSES {
6940        let cpto_v = interp.apply(selector.clone(), math_char_class_value(cls))?;
6941        let cpto = as_int(cpto_v)?;
6942        let to = u32::try_from(cpto)
6943            .ok()
6944            .and_then(char::from_u32)
6945            .ok_or_else(|| EvalError {
6946                span: None,
6947                msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
6948            })?;
6949        Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
6950    }
6951    Ok(Value::Context(Box::new(ctx)))
6952}
6953
6954/// The `MathKind` one `MathElement` atom presents as its own boundary class
6955/// — `Char`/`CharWithKern`/`EmbeddedText`/`VariantChar` carry an explicit
6956/// `class` field; `VariantCharPending` (not yet resolved to a class
6957/// at this point in the tree) consults `ctx.math_class_map` the same way
6958/// `layout_math_atom`'s own arm does, defaulting to `Ord` when the token
6959/// isn't a whole-token class-map entry (mirrors `layout_math_atom`'s
6960/// fallback path, whose per-char variant remap never changes the class).
6961fn math_element_kind(ctx: &Context, me: &MathElement) -> MathKind {
6962    match me {
6963        MathElement::Char { class, .. }
6964        | MathElement::CharWithKern { class, .. }
6965        | MathElement::EmbeddedText { class, .. }
6966        | MathElement::VariantChar { class, .. }
6967        | MathElement::EmbeddedBoxes { class, .. } => *class,
6968        MathElement::VariantCharPending(s) => ctx
6969            .math_class_map
6970            .get(s.as_str())
6971            .map(|(_, kind)| *kind)
6972            .unwrap_or(MathKind::Ord),
6973    }
6974}
6975
6976/// Upstream `get_left_math_kind`/`get_right_math_kind` (math.ml:481-524),
6977/// fused into one direction-parameterized walk over a `&[Math]` list's
6978/// FIRST (`left = true`) or LAST (`left = false`) element: `Pure` atoms
6979/// report their own class (`math_element_kind`); `Group`/`PullInScripts`
6980/// present an explicit, possibly-asymmetric left/right pair; `Sup`/`Sub`/
6981/// `UpperLimit`/`LowerLimit` recurse into their `base`; `Fraction`/
6982/// `Radical` are always `Inner`; `Paren`/`ParenWithMiddle` are always
6983/// `Open`/`Close`; `ChangeColor`/`ChangeCharClass` recurse into `inner`; an
6984/// empty list is the synthetic `End` boundary sentinel (`MathKind::End`,
6985/// `horzBox.ml:134`) — `make_math_class_option_value` maps that to `None`,
6986/// same as upstream's own list-boundary handling.
6987fn boundary_math_kind(ctx: &Context, ms: &[Math], left: bool) -> MathKind {
6988    let m = if left { ms.first() } else { ms.last() };
6989    let Some(m) = m else {
6990        return MathKind::End;
6991    };
6992    match m {
6993        Math::Pure(me) => math_element_kind(ctx, me),
6994        Math::Group(cls1, cls2, _) => {
6995            if left {
6996                *cls1
6997            } else {
6998                *cls2
6999            }
7000        }
7001        Math::PullInScripts(cls1, cls2, _) => {
7002            if left {
7003                *cls1
7004            } else {
7005                *cls2
7006            }
7007        }
7008        Math::Sup(base, _)
7009        | Math::Sub(base, _)
7010        | Math::UpperLimit(base, _)
7011        | Math::LowerLimit(base, _) => boundary_math_kind(ctx, base, left),
7012        Math::Fraction(..) | Math::Radical(..) => MathKind::Inner,
7013        Math::Paren(..) | Math::ParenWithMiddle(..) => {
7014            if left {
7015                MathKind::Open
7016            } else {
7017                MathKind::Close
7018            }
7019        }
7020        Math::ChangeColor(_, inner) | Math::ChangeCharClass(_, inner) => {
7021            boundary_math_kind(ctx, inner, left)
7022        }
7023        // V0_1 only (`read-math`): the boundary class is a property of the
7024        // wrapped content, not of which context laid it out under, so
7025        // recurse into `inner` with the SAME probing `ctx` (mirrors the
7026        // `ChangeColor`/`ChangeCharClass` arms above, which also recurse
7027        // with the ambient `ctx` rather than switching to their own stored
7028        // state).
7029        Math::WithContext(_, inner) => boundary_math_kind(ctx, inner, left),
7030    }
7031}
7032
7033fn left_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7034    boundary_math_kind(ctx, ms, true)
7035}
7036
7037fn right_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7038    boundary_math_kind(ctx, ms, false)
7039}
7040
7041/// `math-class option` — `MathKind::End` (the empty-list sentinel) becomes
7042/// `None`; every real class becomes `Some(<ctor>)`, round-tripping exactly
7043/// with `as_math_kind`'s ctor names.
7044fn make_math_class_option_value(mk: MathKind) -> Value {
7045    let name = match mk {
7046        MathKind::Ord => "MathOrd",
7047        MathKind::Bin => "MathBin",
7048        MathKind::Rel => "MathRel",
7049        MathKind::Op => "MathOp",
7050        MathKind::Punct => "MathPunct",
7051        MathKind::Open => "MathOpen",
7052        MathKind::Close => "MathClose",
7053        MathKind::Prefix => "MathPrefix",
7054        MathKind::Inner => "MathInner",
7055        MathKind::End => return Value::Ctor("None".to_string(), None),
7056    };
7057    Value::Ctor(
7058        "Some".to_string(),
7059        Some(Box::new(Value::Ctor(name.to_string(), None))),
7060    )
7061}
7062
7063/// `get-left-math-class : context -> math -> math-class option`.
7064fn prim_get_left_math_class_v006(
7065    interp: &mut Interp,
7066    mut args: Vec<Value>,
7067) -> Result<Value, EvalError> {
7068    let m = as_math(interp, args.pop().unwrap())?;
7069    let ctx = as_context(args.pop().unwrap())?;
7070    Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7071}
7072
7073/// `get-left-math-class : math-boxes -> math-class option` (vminst.ml:128)
7074/// — ctx DROPPED (matches upstream, which takes no context at all here).
7075/// The boundary-class probe still needs SOME `Context` to resolve an
7076/// unresolved `VariantCharPending` token's whole-token class map
7077/// (`math_element_kind`) — this port's own deferred-resolution design, not
7078/// upstream's, since upstream's `math` atoms already carry a resolved
7079/// class — so a bare default context stands in.
7080fn prim_get_left_math_class_v01(
7081    _interp: &mut Interp,
7082    mut args: Vec<Value>,
7083) -> Result<Value, EvalError> {
7084    let m = as_math_boxes(args.pop().unwrap())?;
7085    let ctx = Context::initial(Length::ZERO);
7086    Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7087}
7088
7089/// `get-right-math-class : context -> math -> math-class option`.
7090fn prim_get_right_math_class_v006(
7091    interp: &mut Interp,
7092    mut args: Vec<Value>,
7093) -> Result<Value, EvalError> {
7094    let m = as_math(interp, args.pop().unwrap())?;
7095    let ctx = as_context(args.pop().unwrap())?;
7096    Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7097}
7098
7099/// `get-right-math-class : math-boxes -> math-class option` (vminst.ml:146)
7100/// — same fork as `get-left-math-class`.
7101fn prim_get_right_math_class_v01(
7102    _interp: &mut Interp,
7103    mut args: Vec<Value>,
7104) -> Result<Value, EvalError> {
7105    let m = as_math_boxes(args.pop().unwrap())?;
7106    let ctx = Context::initial(Length::ZERO);
7107    Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7108}
7109
7110/// `set-math-command : [math] inline-cmd -> context -> context`
7111/// FAITHFUL: installs the command `read_inline`'s `EmbedMath` arm applies
7112/// to bare `${…}`.
7113fn prim_set_math_command(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7114    let mut ctx = as_context(args.pop().unwrap())?;
7115    let cmd = args.pop().unwrap();
7116    ctx.math_command = Some(interp.register_math_command(cmd));
7117    Ok(Value::Context(Box::new(ctx)))
7118}
7119
7120/// Resolve a font abbrev to one of the 3 base faces by name heuristic — the
7121/// only font-name resolution this port has. Shared by set-font/set-math-font.
7122fn resolve_font_abbrev(abbrev: &str) -> FontKey {
7123    let lower = abbrev.to_ascii_lowercase();
7124    if lower.contains("bold") {
7125        FONT_BOLD
7126    } else if lower.contains("it") || lower.contains("obl") || lower.contains("slant") {
7127        FONT_OBLIQUE
7128    } else {
7129        FONT_REGULAR
7130    }
7131}
7132
7133/// `set-math-font : string -> context -> context` (0.0.6
7134/// `vminstdef.yaml:1364`) — `abbrev` resolves through the font metrics
7135/// provider's registry first (the same upgrade as `set-font`), falling back
7136/// to the 3-face name heuristic, so a math OTF configured under
7137/// any abbrev (not just the CLI regular face) can be selected.
7138fn prim_set_math_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7139    let ctx = as_context(args.pop().unwrap())?;
7140    let abbrev = as_str(args.pop().unwrap())?;
7141    let math_font = interp
7142        .metrics
7143        .resolve_font_abbrev(&abbrev)
7144        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7145    Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7146}
7147
7148/// `set-math-font : font -> context -> context` (saphe-split
7149/// `tools/gencode/vminst.ml:1462`, whose body is
7150/// `ctx with math_font_key = Some(mathkey)`) — the 0.1 arm takes the opaque
7151/// handle, so there is no abbrev left to resolve. The bundled 0.1 corpus
7152/// already calls it that way (`std-ja.satyh`'s `set-math-font
7153/// FontLatinModernMath.main`).
7154fn prim_set_math_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7155    let ctx = as_context(args.pop().unwrap())?;
7156    let math_font = as_font_key(args.pop().unwrap())?;
7157    Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7158}
7159
7160/// `load-single-font : string -> font` — LOCAL, non-upstream, V0_1-only.
7161///
7162/// Upstream has no surface name for this: `envelopeChecker.ml`'s
7163/// `check_font_envelope` synthesizes one binding per `files[]` row of a font
7164/// ENVELOPE, typed `BaseType(FontType)`, whose right-hand side is the
7165/// internal `LoadSingleFont{ path; used_as_math_font }` node — and
7166/// `evaluator.cppo.ml:427-434` evaluates that to `BaseConstant(BCFontKey
7167/// (FontInfo.add_single path))`. This port's bundled 0.1 font envelopes are
7168/// ordinary `.satyh` stand-ins (`dist-v01/packages/font-*.satyh`) rather
7169/// than envelopes the loader synthesizes bindings from, so they need a
7170/// spelling for the same step; this is it. Same LOCAL-primitive precedent as
7171/// `set-font-key`.
7172///
7173/// The argument stands in for upstream's font-file PATH: it is the port's
7174/// font-store key, resolved here through exactly the ladder `set-font` used
7175/// to run per call — the metrics provider's registry
7176/// (`FontMetrics::resolve_font_abbrev`, a real `TtfFontStore` built from
7177/// `fonts.satysfi-hash`), falling back to the 3-face name heuristic. Doing
7178/// it HERE rather than at `set-font` time is what makes the resulting `font`
7179/// a genuine handle: resolution is a pure function of the abbrev and the
7180/// provider (`&self`, no interior mutation), so moving it earlier is
7181/// observationally identical.
7182fn prim_load_single_font(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7183    let abbrev = as_str(args.pop().unwrap())?;
7184    let key = interp
7185        .metrics
7186        .resolve_font_abbrev(&abbrev)
7187        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7188    Ok(Value::Font(key))
7189}
7190
7191/// `space-between-maths : context -> math -> math -> inline-boxes option`
7192/// (vminst.ml:173) — STAND-IN: the real inter-atom glue is the full
7193/// `space_between_math_kinds` table (`math.ml:319-410`); always returns
7194/// `None` (no extra glue), used by `math.satyh`'s
7195/// `+align` — never invoked eagerly (that binding is a `let-block` closure).
7196fn prim_space_between_maths_v006(
7197    _interp: &mut Interp,
7198    mut args: Vec<Value>,
7199) -> Result<Value, EvalError> {
7200    let _m2 = args.pop().unwrap();
7201    let _m1 = args.pop().unwrap();
7202    let _ctx = as_context(args.pop().unwrap())?;
7203    Ok(Value::Ctor("None".to_string(), None))
7204}
7205
7206/// `space-between-maths : context -> math-boxes -> math-boxes -> inline-
7207/// boxes option` (vminst.ml:164) — shared STAND-IN body, only the extractor
7208/// forks (`as_math_boxes` vs `as_math`).
7209fn prim_space_between_maths_v01(
7210    _interp: &mut Interp,
7211    mut args: Vec<Value>,
7212) -> Result<Value, EvalError> {
7213    let _m2 = as_math_boxes(args.pop().unwrap())?;
7214    let _m1 = as_math_boxes(args.pop().unwrap())?;
7215    let _ctx = as_context(args.pop().unwrap())?;
7216    Ok(Value::Ctor("None".to_string(), None))
7217}
7218
7219/// `raise-inline : length -> inline-boxes -> inline-boxes` — STAND-IN: the
7220/// line model has no per-box vertical-offset wrapper outside
7221/// `PureHorzBox::Math`'s own per-glyph `dy` ("structural difference"
7222/// note); returns the boxes unshifted (used by `math.satyh`'s `\cases`,
7223/// never invoked eagerly).
7224fn prim_raise_inline(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7225    let ib = as_inline_boxes(args.pop().unwrap())?;
7226    let _len = as_length(args.pop().unwrap())?;
7227    Ok(Value::InlineBoxes(ib))
7228}
7229
7230/// `embed-block-breakable : context -> block-boxes -> inline-boxes`
7231/// (vminst.ml:973; upstream `HorzEmbeddedVertBreakable`) — a MANDATORY
7232/// break on both sides: upstream's `LBEmbeddedVertBreakable` resets the
7233/// width map to this breakpoint alone (`lineBreak.ml:1076-1087`), flushes
7234/// the accumulated line, emits the block as its own vertical item, then
7235/// starts a fresh line (`lineBreak.ml:809-818`).
7236///
7237/// Modelled here as a forced `Discretionary` either side of the block —
7238/// without them the block was just an inline box, so latexcmds'
7239/// `\linebreak` (`inline-fil ++ embed-block-breakable ctx (block-skip
7240/// gap)`, `latexcmds.satyh:150`) never broke: the `inline-fil` swallowed
7241/// the line's whole slack and shoved everything after it off the page
7242/// edge, silently losing it (`このように`/`使い`/`すぎると`/`読みにくく`
7243/// all vanished from the render).
7244fn prim_embed_block_breakable(
7245    _interp: &mut Interp,
7246    mut args: Vec<Value>,
7247) -> Result<Value, EvalError> {
7248    let bb = as_block_boxes(args.pop().unwrap())?;
7249    let ctx = as_context(args.pop().unwrap())?;
7250    // Embed the block inline, top-anchored (the block's FIRST line sits on the
7251    // surrounding text baseline — same as `embed-block-top`).
7252    // `make_embedded_block` splits the box's height/depth around the first line
7253    // so the pager accounts for the embedded figure's extent.
7254    let block = match make_embedded_block(ctx.paragraph_width, bb, false, true) {
7255        Value::InlineBoxes(boxes) => boxes,
7256        other => return Ok(other),
7257    };
7258    let forced = || {
7259        HorzBox::Pure(PureHorzBox::Discretionary {
7260            penalty: FORCED_BREAK_PENALTY,
7261            pre_break: Vec::new(),
7262            post_break: Vec::new(),
7263            no_break: Vec::new(),
7264        })
7265    };
7266    let mut out = vec![forced()];
7267    out.extend(block);
7268    out.push(forced());
7269    Ok(Value::InlineBoxes(out))
7270}
7271
7272/// `unite-path : path -> path -> path` — FAITHFUL: `path` is upstream's
7273/// `path list` (a list of independently-closed subpaths — see
7274/// `graphics.rs`'s `Path` doc comment), so uniting two is a plain
7275/// subpath-list append. Used by `math.satyh`'s `\norm` (two parallel bars).
7276fn prim_unite_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7277    let p2 = as_path(args.pop().unwrap())?;
7278    let p1 = as_path(args.pop().unwrap())?;
7279    let mut subpaths = p1.subpaths;
7280    subpaths.extend(p2.subpaths);
7281    Ok(Value::Path(Path { subpaths }))
7282}
7283
7284/// `set-min-gap-of-lines : length -> context -> context` (vminst.ml:1291) —
7285/// STAND-IN: no separate `min_gap_of_lines` field on `Context` yet (see
7286/// `set-leading`'s own comment on why IT, not this, is the baseline-distance
7287/// setter); accepted and dropped. Used by `math.satyh`'s `+math-list`, never
7288/// invoked eagerly.
7289fn prim_set_min_gap_of_lines(
7290    _interp: &mut Interp,
7291    mut args: Vec<Value>,
7292) -> Result<Value, EvalError> {
7293    let ctx = as_context(args.pop().unwrap())?;
7294    let _len = as_length(args.pop().unwrap())?;
7295    Ok(Value::Context(Box::new(ctx)))
7296}
7297
7298/// `embed-math : context -> math -> inline-boxes` (vminst.ml:520) — the
7299/// bridge to the page: the faithful, primitive-driven analog of `read_math`,
7300/// operating on a `Value::Math` tree instead. FAITHFUL for the atoms
7301/// `read_math` already draws (plain/kerned/variant chars, groups, sup/sub);
7302/// the structural forms (fraction/radical/paren/limits/pull-in-scripts/
7303/// embedded-text) get a deliberately cheap, documented stand-in rendering
7304/// rather than an error, so `${…}`-shaped math built through these
7305/// primitives is never *unusable*, just not yet typographically faithful.
7306fn prim_embed_math_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7307    let m = args.pop().unwrap();
7308    let ctx = as_context(args.pop().unwrap())?;
7309    let elems = as_math(interp, m)?;
7310    let boxed = layout_math_value(interp, &ctx, &elems)?;
7311    Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7312}
7313
7314/// `embed-math : context -> math-boxes -> inline-boxes` (vminst.ml:472) —
7315/// `as_math_boxes` then the SAME `layout_math_value` (:5165 below) — the
7316/// whole MATH-engine reuse in one primitive.
7317fn prim_embed_math_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7318    let m = args.pop().unwrap();
7319    let ctx = as_context(args.pop().unwrap())?;
7320    let elems = as_math_boxes(m)?;
7321    let boxed = layout_math_value(interp, &ctx, &elems)?;
7322    Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7323}
7324
7325/// Lay out a faithful `&[Math]` run into one `PureHorzBox::Math`, mirroring
7326/// `read_math`'s glyph-emission shape (fixed-constant super/subscript
7327/// shift/scale, the same minimal `Bin`/`Rel` spacer) but keyed on each
7328/// atom's own EXPLICIT class (from `math-char`/`math-group`/…) rather than
7329/// `ascii_math_kind`'s inference.
7330fn layout_math_value(
7331    interp: &mut Interp,
7332    ctx: &Context,
7333    elems: &[Math],
7334) -> Result<PureHorzBox, EvalError> {
7335    let (glyphs, rules, width, _left, _right) =
7336        layout_math_list(interp, ctx, elems, ctx.font_size)?;
7337    let mut height = Length::ZERO;
7338    let mut depth = Length::ZERO;
7339    for g in &glyphs {
7340        height = height.max(g.dy + g.height);
7341        depth = depth.max(g.depth - g.dy);
7342    }
7343    // A fraction bar/radical sign is a `Fill` with no `MathGlyph` backing it
7344    // at all, so the glyph-only aggregation above would silently undercount
7345    // a run whose bar/sign extends above every glyph's own ink (e.g.
7346    // `${\sqrt{2}}`'s `l_extra` ascender). Fold every rule's own (y-up,
7347    // box-local — same frame as `MathGlyph::dy`) bounding box in too.
7348    for r in &rules {
7349        // `graphics_bbox` -> `Option`; a `None` rule (unreachable here
7350        // under 0.0.6 math rules) contributes nothing.
7351        if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
7352            height = height.max(max_y);
7353            depth = depth.max(-min_y);
7354        }
7355    }
7356    Ok(PureHorzBox::Math {
7357        width,
7358        height,
7359        depth,
7360        glyphs,
7361        rules,
7362    })
7363}
7364
7365/// Lay out a flat `&[Math]` list at `size`, threading inter-atom spacing
7366/// (`space_before`, a minimal spacer) and returning the glyphs (at
7367/// LOCAL coordinates starting at `x = 0`), any graphics `rules` an atom
7368/// pushed (shifted horizontally by the same running `x` a glyph gets
7369/// — `layout_math_list` never shifts an atom vertically, only
7370/// `shift_and_append`'s callers do), the total width, and the boundary
7371/// classes on either end (needed by a `Group` ancestor, which can present
7372/// different left/right classes — see `Math::Group`'s doc comment).
7373fn layout_math_list(
7374    interp: &mut Interp,
7375    ctx: &Context,
7376    elems: &[Math],
7377    size: Length,
7378) -> Result<
7379    (
7380        Vec<MathGlyph>,
7381        Vec<GraphicsElem>,
7382        Length,
7383        MathKind,
7384        MathKind,
7385    ),
7386    EvalError,
7387> {
7388    // Lay every atom out FIRST, because `normalize_math_kind` below needs each
7389    // one's NEIGHBOURS' raw classes — upstream's `convert_to_low` passes
7390    // `mkprev`/`mknext` into `convert_to_low_single` for exactly this
7391    // (`math.ml:753-765`, via `get_right_math_kind`/`get_left_math_kind`).
7392    // The layout of an atom does not depend on its class, only the SPACING
7393    // between atoms does, so splitting the walk in two moves no glyph.
7394    let mut laid: Vec<(
7395        Vec<MathGlyph>,
7396        Vec<GraphicsElem>,
7397        Length,
7398        MathKind,
7399        MathKind,
7400    )> = Vec::with_capacity(elems.len());
7401    for atom in elems {
7402        laid.push(layout_math_atom(interp, ctx, atom, size)?);
7403    }
7404
7405    let mut glyphs = Vec::new();
7406    let mut rules = Vec::new();
7407    let mut x = Length::ZERO;
7408    let mut last_kind: Option<MathKind> = None;
7409    let mut first_kind: Option<MathKind> = None;
7410    for (i, (atom_glyphs, atom_rules, atom_width, left_raw, right_raw)) in
7411        laid.iter().cloned().enumerate()
7412    {
7413        // `mkprev`/`mknext` are the neighbours' RAW classes (upstream never
7414        // feeds a normalized class back in), and the ends of the list are
7415        // `MathEnd` — `math.ml:1270`'s `convert_to_low mathctx MathEnd MathEnd`.
7416        let prev_raw = if i == 0 { MathKind::End } else { laid[i - 1].4 };
7417        let next_raw = laid.get(i + 1).map_or(MathKind::End, |a| a.3);
7418        let left = normalize_math_kind(prev_raw, next_raw, left_raw);
7419        let right = normalize_math_kind(prev_raw, next_raw, right_raw);
7420        if let Some(prev) = last_kind {
7421            x += space_before(prev, left, ctx.font_size);
7422        }
7423        first_kind.get_or_insert(left);
7424        let base_x = x;
7425        for mut g in atom_glyphs {
7426            g.dx = base_x + g.dx;
7427            glyphs.push(g);
7428        }
7429        for r in &atom_rules {
7430            rules.push(shift_graphics((base_x, Length::ZERO), r));
7431        }
7432        x = base_x + atom_width;
7433        last_kind = Some(right);
7434    }
7435    let left = first_kind.unwrap_or(MathKind::Ord);
7436    let right = last_kind.unwrap_or(MathKind::Ord);
7437    Ok((glyphs, rules, x, left, right))
7438}
7439
7440/// Upstream `check_subscript` (math.ml:682-699): if a superscript base's
7441/// LAST element is itself a `Sub`, strip it — returning `(subscript script,
7442/// new base)` where the new base is the preceding elements followed by the
7443/// inner `Sub`'s own base, so `{x_1}^2` becomes one base carrying both a
7444/// sub and a sup. Recurses through `ChangeColor`/`ChangeCharClass`.
7445fn check_subscript(base: &[Math]) -> Option<(Vec<Math>, Vec<Math>)> {
7446    let (last, head) = base.split_last()?;
7447    match last {
7448        Math::Sub(inner_base, sub_script) => {
7449            let mut new_base = head.to_vec();
7450            new_base.extend(inner_base.iter().cloned());
7451            Some((sub_script.clone(), new_base))
7452        }
7453        Math::ChangeColor(color, inner) => {
7454            let (sub_script, inner_new) = check_subscript(inner)?;
7455            let mut new_base = head.to_vec();
7456            new_base.push(Math::ChangeColor(color.clone(), inner_new));
7457            Some((vec![Math::ChangeColor(color.clone(), sub_script)], new_base))
7458        }
7459        Math::ChangeCharClass(cls, inner) => {
7460            let (sub_script, inner_new) = check_subscript(inner)?;
7461            let mut new_base = head.to_vec();
7462            new_base.push(Math::ChangeCharClass(cls.clone(), inner_new));
7463            Some((
7464                vec![Math::ChangeCharClass(cls.clone(), sub_script)],
7465                new_base,
7466            ))
7467        }
7468        _ => None,
7469    }
7470}
7471
7472/// Upstream `invoke_pull_in_scripts` (math.ml:957-966): call a
7473/// `math-pull-in-scripts` resolver with the actual pulled-in scripts —
7474/// `resolver : math option -> math option -> math`, SUBSCRIPT option first,
7475/// SUPERSCRIPT second — then splice the returned math after the remaining
7476/// base as ONE `Group(cls1, cls2, …)` atom and lay the whole list out.
7477#[allow(clippy::too_many_arguments)]
7478fn layout_pull_in_scripts(
7479    interp: &mut Interp,
7480    ctx: &Context,
7481    head: &[Math],
7482    cls1: MathKind,
7483    cls2: MathKind,
7484    resolver: &Value,
7485    sub: Option<&[Math]>,
7486    sup: Option<&[Math]>,
7487    size: Length,
7488) -> Result<
7489    (
7490        Vec<MathGlyph>,
7491        Vec<GraphicsElem>,
7492        Length,
7493        MathKind,
7494        MathKind,
7495    ),
7496    EvalError,
7497> {
7498    let opt_math = |o: Option<&[Math]>| match o {
7499        Some(m) => Value::Ctor(
7500            "Some".to_string(),
7501            Some(Box::new(Value::Math(Rc::new(m.to_vec())))),
7502        ),
7503        None => Value::Ctor("None".to_string(), None),
7504    };
7505    let partial = interp.apply(resolver.clone(), opt_math(sub))?;
7506    let result = interp.apply(partial, opt_math(sup))?;
7507    let resolved = as_math(interp, result)?;
7508    let mut items: Vec<Math> = head.to_vec();
7509    items.push(Math::Group(cls1, cls2, (*resolved).clone()));
7510    layout_math_list(interp, ctx, &items, size)
7511}
7512
7513/// The metrics-probe fallback policy: resolve `c` under `ctx`'s current
7514/// `math_char_class` (checking the runtime override map first, then the
7515/// built-in `default_math_variant_char` table), but only actually EMIT the
7516/// remapped codepoint if the current font can render it
7517/// (`interp.metrics.advance` returns `Some`) — otherwise fall back to the
7518/// source char `c` (its class, from `Context::math_class_map`/
7519/// `ascii_math_kind`-style inference, is kept regardless). This is what
7520/// keeps base-14/WinAnsi documents byte-identical (`Base14Metrics` returns
7521/// `None` outside ASCII 32-126) while a math-capable TTF, or a permissive
7522/// test stub, gets the real Mathematical-Alphanumeric glyph automatically.
7523fn resolve_variant_char(interp: &Interp, ctx: &Context, c: char, size: Length) -> char {
7524    let mapped = ctx
7525        .math_variant_char_map
7526        .get(&(c, ctx.math_char_class))
7527        .copied()
7528        .or_else(|| default_math_variant_char(ctx.math_char_class, c));
7529    match mapped {
7530        Some(m) if math_char_available(interp, ctx, m, size) => m,
7531        _ => c,
7532    }
7533}
7534
7535/// Invoke ONE `paren` closure (`math.satyh`'s `paren-left`/
7536/// `paren-right`/`abs-left`/`brace-left`/…) exactly the way upstream's
7537/// `make_paren` does (`math.ml:644-649`): 5 CURRIED args in order — inner
7538/// height `h_in` (≥0), inner depth SIGNED (≤0, hence `-d_in` — this port
7539/// carries depths as non-negative magnitudes, see this function's `d_in`
7540/// param doc below), the axis height at the local size, the local
7541/// (script-scaled) size, and the current text color — then unpack the
7542/// returned `(inline-boxes, length -> length)` 2-tuple and harvest the
7543/// boxes' glyphs/rules/width via `math_boxes_of_inline_boxes` (the
7544/// graphics-harvesting sibling of `math_glyphs_of_inline_boxes`, since a
7545/// closure's delimiter is drawn `Fill`/`Stroke` ink via `inline-graphics`,
7546/// not a font glyph). The kernf itself is returned un-invoked (callers
7547/// re-derive/discard it as `math.ml:923` does for `ParenWithMiddle`'s own
7548/// middle).
7549///
7550/// `d_in`: this port's non-negative ink-depth MAGNITUDE (`inner_ink_extent`'s
7551/// second component). Upstream's own box depths are non-positive internally
7552/// (`convert_to_low`'s `dC` folds via `Length.min`, always ≤ `Length.zero`),
7553/// and `half-length` (`math.satyh:1023-1026`) computes the below-axis need
7554/// as `hgtaxis +' dpt` on that SIGNED value — so passing the magnitude
7555/// directly would OVERSIZE every delimiter below the axis (double-counts
7556/// the depth on the wrong side). Negating here is what keeps the closure's
7557/// own arithmetic faithful without changing this port's magnitude
7558/// convention everywhere else.
7559fn make_paren_run(
7560    interp: &mut Interp,
7561    ctx: &Context,
7562    paren: &Value,
7563    h_in: Length,
7564    d_in: Length,
7565    axis: Length,
7566    size: Length,
7567) -> Result<(Vec<MathGlyph>, Vec<GraphicsElem>, Length, Value), EvalError> {
7568    let mut v = paren.clone();
7569    if interp.version.math_is_split() {
7570        // 0.1 protocol (math.ml:640-642): `paren h d ictx` — (height, SIGNED
7571        // depth, context). The closure extracts fontsize / axis-ratio (via
7572        // `get-math-axis-height-ratio`) / color FROM the context instead of
7573        // receiving them as separate explicit arguments (the 0.0.6→0.1
7574        // delta, `t_paren`'s doc comment). Upstream's `ictx` is already
7575        // scaled to the local (script-level) size at this call site; this
7576        // port threads `size` as a separate parameter, so clone-and-set —
7577        // BIGGEST RISK: forgetting this silently
7578        // oversizes script-level delimiters (the closure would read the
7579        // OUTER context's font_size instead of the local scaled one).
7580        let mut c2 = ctx.clone();
7581        c2.font_size = size;
7582        let args = [
7583            Value::Length(h_in),
7584            Value::Length(-d_in),
7585            Value::Context(Box::new(c2)),
7586        ];
7587        for a in args {
7588            v = interp.apply(v, a)?;
7589        }
7590    } else {
7591        // 0.0.6 protocol.
7592        let args = [
7593            Value::Length(h_in),
7594            Value::Length(-d_in),
7595            Value::Length(axis),
7596            Value::Length(size),
7597            make_color_value(ctx.text_color),
7598        ];
7599        for a in args {
7600            v = interp.apply(v, a)?;
7601        }
7602    }
7603    let (boxes_v, kernf) = match v {
7604        Value::Tuple(mut items) if items.len() == 2 => {
7605            let kernf = items.pop().unwrap();
7606            (items.pop().unwrap(), kernf)
7607        }
7608        other => {
7609            return eval_error(format!(
7610                "math-paren: a paren closure must return (inline-boxes, length -> length), got {}",
7611                other.type_name()
7612            ))
7613        }
7614    };
7615    let boxes = as_inline_boxes(boxes_v)?;
7616    let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
7617    Ok((glyphs, rules, width, kernf))
7618}
7619
7620/// The original MATH-native stretchy-delimiter body, extracted verbatim as
7621/// the fallback `Math::Paren`/`Math::ParenWithMiddle` now take when the
7622/// closure route (`make_paren_run`, primary — upstream-faithful) errors:
7623/// every delimiter renders as a correctly-SIZED `(`/`)`/`|` regardless of
7624/// the requested paren kind (identity-wrong, but usable, for any closure
7625/// that can't be run — a synthetic/ill-shaped test closure, or a real error
7626/// from a malformed user-supplied one).
7627fn paren_variant_fallback(
7628    interp: &mut Interp,
7629    ctx: &Context,
7630    parts: Vec<(Vec<MathGlyph>, Vec<GraphicsElem>, Length)>,
7631    h_in: Length,
7632    d_in: Length,
7633    axis: Length,
7634    size: Length,
7635) -> Result<
7636    (
7637        Vec<MathGlyph>,
7638        Vec<GraphicsElem>,
7639        Length,
7640        MathKind,
7641        MathKind,
7642    ),
7643    EvalError,
7644> {
7645    let target = (h_in - axis).max(axis + d_in) * 2.0;
7646    let mut glyphs = Vec::new();
7647    let mut rules = Vec::new();
7648    let mut x = Length::ZERO;
7649    push_delimiter_glyph(interp, ctx, '(', size, target, axis, &mut glyphs, &mut x)?;
7650    for (i, (pg, pr, pw)) in parts.into_iter().enumerate() {
7651        if i > 0 {
7652            push_delimiter_glyph(interp, ctx, '|', size, target, axis, &mut glyphs, &mut x)?;
7653        }
7654        append_at(&mut glyphs, &mut rules, &mut x, pg, pr, pw);
7655    }
7656    push_delimiter_glyph(interp, ctx, ')', size, target, axis, &mut glyphs, &mut x)?;
7657    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
7658}
7659
7660/// Re-derive a paren base's TRAILING (right) delimiter's dense math
7661/// kern function by re-invoking its closure at the script-attachment site
7662/// (`superscript_kern`'s glyph-corner sampling doesn't apply to a paren
7663/// base — it has no single "last glyph" to sample italic-correction/corner
7664/// kerns off; the closure itself IS the source of truth for how much a
7665/// script should tuck into it, exactly upstream's `lp_math_kern_scheme`,
7666/// `math.ml:906`/`922`). Closures are pure (`math.satyh`'s bundled ones
7667/// have no side effects), so re-invoking with the SAME `(h_in, d_in, axis,
7668/// size)` the original `Math::Paren`/`ParenWithMiddle` layout used yields
7669/// the identical `kernf` value. Returns `None` when `base`'s last atom
7670/// isn't a paren, or when re-running its closure(s) errors (the delimiter
7671/// fallback path carries no math-kern scheme at all — `dense_kern`'s
7672/// caller then falls back to zero, matching that stand-in's own
7673/// `kerninfo _ = 0pt` shape).
7674fn paren_trailing_kernf(
7675    interp: &mut Interp,
7676    ctx: &Context,
7677    base: &[Math],
7678    size: Length,
7679) -> Option<Value> {
7680    let (r, h_in, d_in) = match base.last()? {
7681        Math::Paren(_, r, inner) => {
7682            let (g, ru, ..) = layout_math_list(interp, ctx, inner, size).ok()?;
7683            let (h, d) = inner_ink_extent(&g, &ru);
7684            (r, h, d)
7685        }
7686        Math::ParenWithMiddle(_, r, _, parts) => {
7687            let mut h = Length::ZERO;
7688            let mut d = Length::ZERO;
7689            for p in parts {
7690                let (g, ru, ..) = layout_math_list(interp, ctx, p, size).ok()?;
7691                let (ph, pd) = inner_ink_extent(&g, &ru);
7692                h = h.max(ph);
7693                d = d.max(pd);
7694            }
7695            (r, h, d)
7696        }
7697        _ => return None,
7698    };
7699    let mc = MathC::of(interp, ctx);
7700    let axis = mc.axis(size);
7701    let (_, _, _, kernf) = make_paren_run(interp, ctx, r, h_in, d_in, axis, size).ok()?;
7702    Some(kernf)
7703}
7704
7705/// `fontInfo.ml:361`'s `DenseMathKern` branch: `Length.negate (kernf
7706/// corrhgt)` — the closure returns a POSITIVE tuck amount (how far to slide
7707/// the script INTO the delimiter's hollow), and the engine negates it into
7708/// a kern (negative = closer to the previous glyph, `get_math_kern`'s own
7709/// doc comment). Any failure (wrong-shaped return, closure error) collapses
7710/// to `Length::ZERO` — no kern, not a layout error; matches
7711/// `paren_trailing_kernf`'s own `None`-on-error contract.
7712fn dense_kern(interp: &mut Interp, kernf: &Value, corrhgt: Length) -> Length {
7713    match interp.apply(kernf.clone(), Value::Length(corrhgt)) {
7714        Ok(Value::Length(l)) => -l,
7715        _ => Length::ZERO,
7716    }
7717}
7718
7719/// Lay out one `Math` atom at `size` (LOCAL coordinates, `x` starting at
7720/// 0), returning its glyphs, any graphics `rules` it pushed (only the
7721/// `Fraction`/`Radical` arms produce any; every other arm forwards its
7722/// children's), width, and left/right boundary class.
7723fn layout_math_atom(
7724    interp: &mut Interp,
7725    ctx: &Context,
7726    atom: &Math,
7727    size: Length,
7728) -> Result<
7729    (
7730        Vec<MathGlyph>,
7731        Vec<GraphicsElem>,
7732        Length,
7733        MathKind,
7734        MathKind,
7735    ),
7736    EvalError,
7737> {
7738    match atom {
7739        Math::Pure(MathElement::Char { class, big, chars })
7740        | Math::Pure(MathElement::CharWithKern {
7741            class, big, chars, ..
7742        }) => {
7743            let mut glyphs = Vec::new();
7744            let mut x = Length::ZERO;
7745            for c in chars.chars() {
7746                if *big {
7747                    push_big_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
7748                } else {
7749                    push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
7750                }
7751            }
7752            Ok((glyphs, Vec::new(), x, *class, *class))
7753        }
7754        Math::Pure(MathElement::VariantChar { class, style, .. }) => {
7755            // Select the target codepoints by the CURRENT restyling
7756            // (`Context::math_char_class`, set by `ChangeCharClass`'s
7757            // layout arm below) rather than always `style.italic` — these
7758            // are explicit per-style codepoints the caller built
7759            // (`math-variant-char`), so no metrics-probe fallback (unlike
7760            // `resolve_variant_char`): `push_char_glyph` errors like any
7761            // other explicit-codepoint atom if the font can't render it.
7762            let text = match ctx.math_char_class {
7763                MathCharClass::Italic => &style.italic,
7764                MathCharClass::BoldItalic => &style.bold_italic,
7765                MathCharClass::Roman => &style.roman,
7766                MathCharClass::BoldRoman => &style.bold_roman,
7767                MathCharClass::Script => &style.script,
7768                MathCharClass::BoldScript => &style.bold_script,
7769                MathCharClass::Fraktur => &style.fraktur,
7770                MathCharClass::BoldFraktur => &style.bold_fraktur,
7771                MathCharClass::DoubleStruck => &style.double_struck,
7772                // `MathVariantStyle` (this
7773                // 9-field record) is deliberately NOT widened to 14 fields
7774                // — it models the 0.0.6 `math-variant-char` prim's record
7775                // shape, which upstream itself never grew sans-
7776                // serif/typewriter fields for either (only `math-char-class`
7777                // itself widened, `horzBox.ml:98-113`). This arm is
7778                // unreachable in practice: `math-variant-char`/
7779                // `MathElement::VariantChar` is a V0_0-only prim
7780                // (registered `v006` only, `primitives.rs`'s prim table),
7781                // and the 5 new `MathCharClass` ctors are V0_1-only
7782                // (`prim_types.rs::math_char_class_decl`) — the two can
7783                // never co-occur. Closest-analog fallback, purely to keep
7784                // the match exhaustive.
7785                MathCharClass::SansSerif | MathCharClass::Typewriter => &style.roman,
7786                MathCharClass::ItalicSansSerif => &style.italic,
7787                MathCharClass::BoldSansSerif => &style.bold_roman,
7788                MathCharClass::BoldItalicSansSerif => &style.bold_italic,
7789            };
7790            let mut glyphs = Vec::new();
7791            let mut x = Length::ZERO;
7792            for c in text.chars() {
7793                push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
7794            }
7795            Ok((glyphs, Vec::new(), x, *class, *class))
7796        }
7797        Math::Pure(MathElement::VariantCharPending(s)) => {
7798            // One MATHCHAR token, resolved now that `ctx` (font +
7799            // math_char_class + both override maps) is available: first
7800            // try the whole-TOKEN class map (`=`, `-`, `,`, … ->
7801            // (replacement, MathKind)); if the token isn't there, fall back
7802            // to a per-char variant remap (the metrics-probe policy)
7803            // with `MathKind::Ord`.
7804            let mut glyphs = Vec::new();
7805            let mut x = Length::ZERO;
7806            if let Some((target, kind)) = ctx.math_class_map.get(s.as_str()) {
7807                let kind = *kind;
7808                let all_renderable = target
7809                    .chars()
7810                    .all(|c| math_char_available(interp, ctx, c, size));
7811                let chosen = if all_renderable {
7812                    target.clone()
7813                } else {
7814                    s.clone()
7815                };
7816                for c in chosen.chars() {
7817                    push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
7818                }
7819                return Ok((glyphs, Vec::new(), x, kind, kind));
7820            }
7821            for c in s.chars() {
7822                let chosen = resolve_variant_char(interp, ctx, c, size);
7823                push_char_glyph(interp, ctx, chosen, size, &mut glyphs, &mut x)?;
7824            }
7825            Ok((glyphs, Vec::new(), x, MathKind::Ord, MathKind::Ord))
7826        }
7827        Math::Pure(MathElement::EmbeddedText { class, body }) => {
7828            let v = interp.apply((**body).clone(), Value::Context(Box::new(ctx.clone())))?;
7829            let boxes = as_inline_boxes(v)?;
7830            // `math_boxes_of_inline_boxes`, not the glyphs-only walk: embedded
7831            // inline content can carry its ink as GRAPHICS rather than glyphs.
7832            // latexcmds' `\underset`/`\overset` are exactly that — they reduce
7833            // to `text-in-math (… \normal-underset …)`, which draws through
7834            // `inline-graphics` + `draw-text`. Harvesting glyphs alone kept the
7835            // box's WIDTH and threw the drawing away, so the Schrödinger-equation
7836            // example rendered as `[−     + V(x)]Ψ`: a correctly-sized hole where
7837            // the fraction and its under-text should be.
7838            let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
7839            Ok((glyphs, rules, width, *class, *class))
7840        }
7841        Math::Pure(MathElement::EmbeddedBoxes { class, boxes }) => {
7842            // V0_1 `embed-inline-to-math`: eager, already-materialized
7843            // boxes, so no closure application (contrast `EmbeddedText`
7844            // above) — but the same graphics-bearing content is possible.
7845            let (glyphs, rules, width) = math_boxes_of_inline_boxes(boxes);
7846            Ok((glyphs, rules, width, *class, *class))
7847        }
7848        Math::Group(cls1, cls2, inner) => {
7849            let (glyphs, rules, width, _, _) = layout_math_list(interp, ctx, inner, size)?;
7850            Ok((glyphs, rules, width, *cls1, *cls2))
7851        }
7852        Math::Sup(base, script) => {
7853            // Upstream MathSuperscript: (1) check_subscript merges a
7854            // base-tail `Sub` into one base + (sub, sup) pair;
7855            // (2) check_pull_in hands the script(s) to a base-tail
7856            // `PullInScripts` resolver.
7857            if let Some((sub_script, new_base)) = check_subscript(base) {
7858                if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) =
7859                    new_base.split_last()
7860                {
7861                    return layout_pull_in_scripts(
7862                        interp,
7863                        ctx,
7864                        head,
7865                        *cls1,
7866                        *cls2,
7867                        resolver,
7868                        Some(&sub_script),
7869                        Some(script),
7870                        size,
7871                    );
7872                }
7873                // No pull-in (`{x_1}^2`): one sub+sup pair on the same base.
7874                let (mut glyphs, mut rules, base_width, left, _) =
7875                    layout_math_list(interp, ctx, &new_base, size)?;
7876                let mc = MathC::of(interp, ctx);
7877                let script_size = size * mc.script_scale();
7878                // Re-derive ONCE (a paren base's dense math
7879                // kern function, if `new_base`'s trailing atom is a paren —
7880                // `paren_trailing_kernf`'s doc comment) and reuse it for
7881                // BOTH the sup and sub kerns below, mirroring
7882                // `lp_math_kern_scheme`'s single scheme feeding both corner
7883                // attachments upstream.
7884                let paren_kernf = paren_trailing_kernf(interp, ctx, &new_base, size);
7885                // Subscripts are always cramped; the superscript inherits
7886                // the ambient cramped state unchanged (do NOT flip/reset it
7887                // here).
7888                let sub_ctx = Context {
7889                    math_cramped: true,
7890                    ..ctx.clone()
7891                };
7892                let (sub_glyphs, sub_rules, sub_width, _, _) =
7893                    layout_math_list(interp, &sub_ctx, &sub_script, script_size)?;
7894                let (sup_glyphs, sup_rules, sup_width, _, _) =
7895                    layout_math_list(interp, ctx, script, script_size)?;
7896                let (h_base, d_base) = inner_ink_extent(&glyphs, &rules);
7897                let (_, d_sup) = inner_ink_extent(&sup_glyphs, &sup_rules);
7898                let (h_sub, _) = inner_ink_extent(&sub_glyphs, &sub_rules);
7899                let sup_shift_raw = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
7900                let sub_shift_raw = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
7901                let (sup_shift, sub_shift) = mc.correct_script_gap(
7902                    ctx.font_size,
7903                    d_sup,
7904                    h_sub,
7905                    sup_shift_raw,
7906                    sub_shift_raw,
7907                );
7908                let kern = match &paren_kernf {
7909                    Some(kf) => dense_kern(interp, kf, sup_shift - d_sup),
7910                    None => superscript_kern(
7911                        interp,
7912                        ctx,
7913                        size,
7914                        script_size,
7915                        &glyphs,
7916                        &sup_glyphs,
7917                        sup_shift,
7918                        h_base,
7919                        d_sup,
7920                    ),
7921                };
7922                let sub_kern = paren_kernf
7923                    .as_ref()
7924                    .map(|kf| dense_kern(interp, kf, h_sub - d_base))
7925                    .unwrap_or(Length::ZERO);
7926                shift_and_append(
7927                    &mut glyphs,
7928                    &mut rules,
7929                    sub_glyphs,
7930                    sub_rules,
7931                    base_width + sub_kern,
7932                    -sub_shift,
7933                );
7934                shift_and_append(
7935                    &mut glyphs,
7936                    &mut rules,
7937                    sup_glyphs,
7938                    sup_rules,
7939                    base_width + kern,
7940                    sup_shift,
7941                );
7942                return Ok((
7943                    glyphs,
7944                    rules,
7945                    base_width + (sub_kern + sub_width).max(kern + sup_width),
7946                    left,
7947                    MathKind::Ord,
7948                ));
7949            }
7950            if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
7951                return layout_pull_in_scripts(
7952                    interp,
7953                    ctx,
7954                    head,
7955                    *cls1,
7956                    *cls2,
7957                    resolver,
7958                    None,
7959                    Some(script),
7960                    size,
7961                );
7962            }
7963            let (mut glyphs, mut rules, base_width, left, _) =
7964                layout_math_list(interp, ctx, base, size)?;
7965            let mc = MathC::of(interp, ctx);
7966            let script_size = size * mc.script_scale();
7967            let (script_glyphs, script_rules, script_width, _, _) =
7968                layout_math_list(interp, ctx, script, script_size)?;
7969            let (h_base, _) = inner_ink_extent(&glyphs, &rules);
7970            let (_, d_sup) = inner_ink_extent(&script_glyphs, &script_rules);
7971            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
7972            // A paren base has no italic correction / glyph
7973            // corner kern to sample (`superscript_kern`'s own last-glyph
7974            // sampling would hit the INNER run's last glyph, not the
7975            // delimiter) — its closure's dense kern REPLACES
7976            // `superscript_kern` outright rather than adding to it.
7977            let kern = match paren_trailing_kernf(interp, ctx, base, size) {
7978                Some(kf) => dense_kern(interp, &kf, sup_shift - d_sup),
7979                None => superscript_kern(
7980                    interp,
7981                    ctx,
7982                    size,
7983                    script_size,
7984                    &glyphs,
7985                    &script_glyphs,
7986                    sup_shift,
7987                    h_base,
7988                    d_sup,
7989                ),
7990            };
7991            shift_and_append(
7992                &mut glyphs,
7993                &mut rules,
7994                script_glyphs,
7995                script_rules,
7996                base_width + kern,
7997                sup_shift,
7998            );
7999            Ok((
8000                glyphs,
8001                rules,
8002                base_width + kern + script_width,
8003                left,
8004                MathKind::Ord,
8005            ))
8006        }
8007        Math::Sub(base, script) => {
8008            // Upstream MathSubscript: a `PullInScripts` at the base list's
8009            // TAIL receives the subscript itself instead of a corner script.
8010            if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
8011                return layout_pull_in_scripts(
8012                    interp,
8013                    ctx,
8014                    head,
8015                    *cls1,
8016                    *cls2,
8017                    resolver,
8018                    Some(script),
8019                    None,
8020                    size,
8021                );
8022            }
8023            let (mut glyphs, mut rules, base_width, left, _) =
8024                layout_math_list(interp, ctx, base, size)?;
8025            let mc = MathC::of(interp, ctx);
8026            let script_size = size * mc.script_scale();
8027            // Subscripts are always cramped.
8028            let sub_ctx = Context {
8029                math_cramped: true,
8030                ..ctx.clone()
8031            };
8032            let (script_glyphs, script_rules, script_width, _, _) =
8033                layout_math_list(interp, &sub_ctx, script, script_size)?;
8034            let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8035            let (h_sub, _) = inner_ink_extent(&script_glyphs, &script_rules);
8036            let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
8037            // Non-paren subscripts carry no kern (`kern = Length::ZERO`); a
8038            // paren base's closure supplies one via `paren_trailing_kernf`'s
8039            // `Some` arm.
8040            let kern = match paren_trailing_kernf(interp, ctx, base, size) {
8041                Some(kf) => dense_kern(interp, &kf, h_sub - d_base),
8042                None => Length::ZERO,
8043            };
8044            shift_and_append(
8045                &mut glyphs,
8046                &mut rules,
8047                script_glyphs,
8048                script_rules,
8049                base_width + kern,
8050                -sub_shift,
8051            );
8052            Ok((
8053                glyphs,
8054                rules,
8055                base_width + kern + script_width,
8056                left,
8057                MathKind::Ord,
8058            ))
8059        }
8060        Math::ChangeColor(_, inner) => {
8061            // STAND-IN: color restyling doesn't affect glyph rendering yet
8062            // — just render the content.
8063            let (glyphs, rules, width, left, right) = layout_math_list(interp, ctx, inner, size)?;
8064            Ok((glyphs, rules, width, left, right))
8065        }
8066        Math::ChangeCharClass(cls, inner) => {
8067            // Lay `inner` out under a
8068            // context with `math_char_class` set to `cls`, which is what
8069            // `VariantCharPending`/`VariantChar`'s arms above consult.
8070            let ctx2 = Context {
8071                math_char_class: *cls,
8072                ..ctx.clone()
8073            };
8074            let (glyphs, rules, width, left, right) = layout_math_list(interp, &ctx2, inner, size)?;
8075            Ok((glyphs, rules, width, left, right))
8076        }
8077        Math::Fraction(num, den) => {
8078            // Real numerator/denominator placement (`math.ml:574-594`
8079            // `numerator_baseline_height`/ `denominator_baseline_depth`)
8080            // plus a bar `Fill` — replaces the ASCII "num / den" stand-in.
8081            // `num`/`den` are laid out at the SAME `size` as this atom (no
8082            // script-scale reduction — a fraction's own
8083            // numerator/denominator aren't scripts, matching upstream's
8084            // `convert_to_low` call with the ambient `mathctx` unchanged).
8085            let (num_glyphs, num_rules, num_w, ..) = layout_math_list(interp, ctx, num, size)?;
8086            // The denominator is always cramped; the numerator inherits the
8087            // ambient cramped state unchanged.
8088            let den_ctx = Context {
8089                math_cramped: true,
8090                ..ctx.clone()
8091            };
8092            let (den_glyphs, den_rules, den_w, ..) = layout_math_list(interp, &den_ctx, den, size)?;
8093            let w = num_w.max(den_w);
8094            // Center the narrower of the two over/under the wider
8095            // (`math.ml:1140-1155`'s symmetric padding).
8096            let num_dx = (w - num_w) * 0.5;
8097            let den_dx = (w - den_w) * 0.5;
8098            let (_, d_numer) = inner_ink_extent(&num_glyphs, &num_rules);
8099            let (h_denom, _) = inner_ink_extent(&den_glyphs, &den_rules);
8100            let mc = MathC::of(interp, ctx);
8101            let numer_shift = mc.frac_numer_shift(size, d_numer);
8102            let denom_shift = mc.frac_denom_shift(size, h_denom);
8103            let axis = mc.axis(size);
8104            let rule = mc.frac_rule(size);
8105            let mut glyphs = Vec::new();
8106            let mut rules = Vec::new();
8107            // `num dy>0` (raised above the axis), `den dy<0` (`frac_denom_
8108            // shift` is already signed negative — see that method's doc
8109            // comment) — both applied via the SAME up-positive `dy_shift`
8110            // `shift_and_append` uses for Sup/Sub.
8111            shift_and_append(
8112                &mut glyphs,
8113                &mut rules,
8114                num_glyphs,
8115                num_rules,
8116                num_dx,
8117                numer_shift,
8118            );
8119            shift_and_append(
8120                &mut glyphs,
8121                &mut rules,
8122                den_glyphs,
8123                den_rules,
8124                den_dx,
8125                denom_shift,
8126            );
8127            // The bar itself: `rect x∈[0,w], y∈[axis·s, axis·s+rule·s]`
8128            // (a deliberate simplification of
8129            // upstream's own `Rectangle((xpos, ypos+h_bar+t_bar/2), (wid,
8130            // t_bar))`, which centers the rule on its OWN half-thickness
8131            // rather than sitting flush on the axis; this port picks the
8132            // simpler flush-on-axis placement instead).
8133            rules.push(GraphicsElem::Fill(
8134                ctx.text_color,
8135                rect_path((Length::ZERO, axis), (w, rule)),
8136            ));
8137            Ok((glyphs, rules, w, MathKind::Inner, MathKind::Inner))
8138        }
8139        Math::Radical(_degree, inner) => {
8140            // Real bar metrics (`math.ml:620-626` `radical_bar_
8141            // metrics`) plus a ported `default_radical` checkmark `Fill`
8142            // (`primitives.cppo.ml:311-355`) and an overbar rect `Fill` —
8143            // replaces the U+221A stand-in. `RadicalWithDegree` (`_degree =
8144            // Some(..)`, `\sqrt[n]{..}`) stays unimplemented — the degree is
8145            // carried faithfully in the
8146            // `Math` value but silently NOT drawn, matching upstream's own
8147            // parity note (`math.ml:886-899`'s `failwith "unsupported"` is
8148            // upstream's harder failure mode; this port's own stand-in
8149            // policy already chose "render the radicand
8150            // without the degree" over erroring, unchanged since).
8151            // The radicand is always cramped.
8152            let radicand_ctx = Context {
8153                math_cramped: true,
8154                ..ctx.clone()
8155            };
8156            let (inner_glyphs, inner_rules, inner_w, ..) =
8157                layout_math_list(interp, &radicand_ctx, inner, size)?;
8158            let (h_cont, d_cont) = inner_ink_extent(&inner_glyphs, &inner_rules);
8159            let mc = MathC::of(interp, ctx);
8160            let (h_bar, t_bar, l_extra) = mc.radical_bar_metrics(size, h_cont);
8161            // `_nonnegdpt` (the sign's own, slightly deeper, ink extent —
8162            // `default_radical`'s downward checkmark stroke pads `d_cont` by
8163            // `size*0.1`, upstream's own `nonnegdpt`) isn't threaded into
8164            // this atom's reported `depth` directly: unlike upstream's own
8165            // `d_whole = d_cont` (`math.ml:884`, a "temporary" simplification
8166            // per its own comment there), this port's `layout_math_value`
8167            // folds every rule's `graphics_bbox` into the OUTER box's
8168            // height/depth (a correctness fix, `PureHorzBox::Math`'s doc
8169            // comment), so the sign's real ink depth reaches the top-level
8170            // box automatically THROUGH the drawn `Fill` — no separate
8171            // manual accounting needed here.
8172            let (sign_path, sign_w, _nonnegdpt) = radical_sign_geometry(size, h_bar, t_bar, d_cont);
8173            let mut rules = vec![GraphicsElem::Fill(ctx.text_color, sign_path)];
8174            // Overbar + radicand share the same x-range right after the
8175            // sign (`math.ml:1163-1176`'s `hbbar`/`hbback`/`hblstC`); the
8176            // radicand itself stays at `dy = 0` (its own baseline), exactly
8177            // upstream — `h_bar` already clears it via the vertical-gap add
8178            // in `radical_bar_metrics`, so no raise is needed here.
8179            rules.push(GraphicsElem::Fill(
8180                ctx.text_color,
8181                rect_path((sign_w, h_bar), (inner_w, t_bar)),
8182            ));
8183            // `l_extra`: the extra ascender ABOVE the bar this run reports
8184            // to its container (upstream `h_whole = h_rad +% l_extra`,
8185            // `math.ml:882`) — no ink of its own, just headroom, so there's
8186            // no glyph/fill shape to naturally carry it. A single-point
8187            // "extent marker" `Fill` (a subpath with a `move_to` and no
8188            // further segments paints nothing — PDF's `f` on a degenerate
8189            // zero-length path is a no-op) reports it through the SAME
8190            // `graphics_bbox` fold `layout_math_value` already does for
8191            // every rule, without adding a new return channel just for this
8192            // one field.
8193            rules.push(GraphicsElem::Fill(
8194                ctx.text_color,
8195                Path {
8196                    subpaths: vec![Subpath {
8197                        start: (Length::ZERO, h_bar + t_bar + l_extra),
8198                        segs: Vec::new(),
8199                        closing: Closing::Open,
8200                    }],
8201                },
8202            ));
8203            let mut glyphs = Vec::new();
8204            for mut g in inner_glyphs {
8205                g.dx = sign_w + g.dx;
8206                glyphs.push(g);
8207            }
8208            for r in &inner_rules {
8209                rules.push(shift_graphics((sign_w, Length::ZERO), r));
8210            }
8211            Ok((
8212                glyphs,
8213                rules,
8214                sign_w + inner_w,
8215                MathKind::Inner,
8216                MathKind::Inner,
8217            ))
8218        }
8219        Math::Paren(l, r, inner) => {
8220            // PRIMARY route is upstream's own `make_paren` closure
8221            // invocation (`math.ml:644-649`, `make_paren_run` above) —
8222            // identity (a `\paren` drawing round parens vs. an `\abs`
8223            // drawing vertical bars, etc.) lives ENTIRELY in the `l`/`r`
8224            // closures (`math.satyh`'s `paren-left`/`abs-left`/…), so
8225            // running them is what makes different delimiter kinds actually
8226            // look different. Falls back to the MATH-native
8227            // stretchy-variant stand-in (`paren_variant_fallback`) only if
8228            // either closure errors (synthetic/ill-shaped test closures, or
8229            // a real user error) — that fallback's own delimiter kind is
8230            // always `(`/`)` regardless of what was requested. Inner is laid
8231            // out OUTSIDE the closure
8232            // route so an inner layout error still propagates normally
8233            // (only closure-route errors trigger the fallback); splice
8234            // order `lg ++ inner ++ rg` matches upstream's own
8235            // `LowMathParen(lpL, lpR, lmC)` (`math.ml:909`).
8236            let (inner_glyphs, inner_rules, inner_w, ..) =
8237                layout_math_list(interp, ctx, inner, size)?;
8238            let (h_in, d_in) = inner_ink_extent(&inner_glyphs, &inner_rules);
8239            let mc = MathC::of(interp, ctx);
8240            let axis = mc.axis(size);
8241            let closure_route =
8242                make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8243                    let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8244                    Ok((left, right))
8245                });
8246            match closure_route {
8247                Ok(((lg, lr, lw, _), (rg, rr, rw, _))) => {
8248                    let mut glyphs = Vec::new();
8249                    let mut rules = Vec::new();
8250                    let mut x = Length::ZERO;
8251                    append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8252                    append_at(
8253                        &mut glyphs,
8254                        &mut rules,
8255                        &mut x,
8256                        inner_glyphs,
8257                        inner_rules,
8258                        inner_w,
8259                    );
8260                    append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8261                    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8262                }
8263                Err(_) => paren_variant_fallback(
8264                    interp,
8265                    ctx,
8266                    vec![(inner_glyphs, inner_rules, inner_w)],
8267                    h_in,
8268                    d_in,
8269                    axis,
8270                    size,
8271                ),
8272            }
8273        }
8274        Math::ParenWithMiddle(l, r, m, mlstlst) => {
8275            // Same closure-primary/fallback policy as `Math::Paren`,
8276            // but ONE shared `(h_in, d_in)` over every part (the tallest
8277            // part's ink drives the size of every delimiter, including the
8278            // middle separator(s)) — mirrors upstream's own
8279            // `MathParenWithMiddle` fold (`math.ml:912-916`). The middle
8280            // closure's own kernf is DISCARDED (`math.ml:923`: `let
8281            // (hblstmiddle, _) = make_paren mathctx middle hC dC in ...`) —
8282            // a separator never tucks a script into itself.
8283            let mut parts = Vec::with_capacity(mlstlst.len());
8284            let mut h_in = Length::ZERO;
8285            let mut d_in = Length::ZERO;
8286            for part in mlstlst {
8287                let (part_glyphs, part_rules, part_w, ..) =
8288                    layout_math_list(interp, ctx, part, size)?;
8289                let (h, d) = inner_ink_extent(&part_glyphs, &part_rules);
8290                h_in = h_in.max(h);
8291                d_in = d_in.max(d);
8292                parts.push((part_glyphs, part_rules, part_w));
8293            }
8294            let mc = MathC::of(interp, ctx);
8295            let axis = mc.axis(size);
8296            let closure_route =
8297                make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8298                    let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8299                    let middle = make_paren_run(interp, ctx, m, h_in, d_in, axis, size)?;
8300                    Ok((left, right, middle))
8301                });
8302            match closure_route {
8303                Ok(((lg, lr, lw, _), (rg, rr, rw, _), (mg, mr, mw, _))) => {
8304                    let mut glyphs = Vec::new();
8305                    let mut rules = Vec::new();
8306                    let mut x = Length::ZERO;
8307                    append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8308                    for (i, (part_glyphs, part_rules, part_w)) in parts.into_iter().enumerate() {
8309                        if i > 0 {
8310                            append_at(&mut glyphs, &mut rules, &mut x, mg.clone(), mr.clone(), mw);
8311                        }
8312                        append_at(
8313                            &mut glyphs,
8314                            &mut rules,
8315                            &mut x,
8316                            part_glyphs,
8317                            part_rules,
8318                            part_w,
8319                        );
8320                    }
8321                    append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8322                    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8323                }
8324                Err(_) => paren_variant_fallback(interp, ctx, parts, h_in, d_in, axis, size),
8325            }
8326        }
8327        Math::UpperLimit(base, upper) => {
8328            let (mut glyphs, mut rules, base_width, left, right) =
8329                layout_math_list(interp, ctx, base, size)?;
8330            let mc = MathC::of(interp, ctx);
8331            let script_size = size * mc.script_scale();
8332            let (script_glyphs, script_rules, script_width, _, _) =
8333                layout_math_list(interp, ctx, upper, script_size)?;
8334            let (h_base, _) = inner_ink_extent(&glyphs, &rules);
8335            let (_, d_up) = inner_ink_extent(&script_glyphs, &script_rules);
8336            let up_shift = mc.upper_limit_shift(ctx.font_size, h_base, d_up);
8337            // A LIMIT is CENTERED over its base, not set beside it
8338            // (`math.ml:1219-1231`: upstream pads the narrower of the two with
8339            // half the difference on each side, so the whole is
8340            // `max(w_base, w_up)` wide). Placing it at `base_width` — i.e. to
8341            // the right, widening the box to the SUM — set `\sum_a^b`'s limits
8342            // off the operator's shoulder instead of above and below it.
8343            let (base_dx, script_dx) = center_offsets(base_width, script_width);
8344            shift_existing(&mut glyphs, &mut rules, base_dx);
8345            shift_and_append(
8346                &mut glyphs,
8347                &mut rules,
8348                script_glyphs,
8349                script_rules,
8350                script_dx,
8351                up_shift,
8352            );
8353            Ok((glyphs, rules, base_width.max(script_width), left, right))
8354        }
8355        Math::LowerLimit(base, lower) => {
8356            let (mut glyphs, mut rules, base_width, left, right) =
8357                layout_math_list(interp, ctx, base, size)?;
8358            let mc = MathC::of(interp, ctx);
8359            let script_size = size * mc.script_scale();
8360            let (script_glyphs, script_rules, script_width, _, _) =
8361                layout_math_list(interp, ctx, lower, script_size)?;
8362            let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8363            let (h_low, _) = inner_ink_extent(&script_glyphs, &script_rules);
8364            let low_shift = mc.lower_limit_shift(ctx.font_size, d_base, h_low);
8365            // Centered under the base — see the `UpperLimit` arm above.
8366            let (base_dx, script_dx) = center_offsets(base_width, script_width);
8367            shift_existing(&mut glyphs, &mut rules, base_dx);
8368            shift_and_append(
8369                &mut glyphs,
8370                &mut rules,
8371                script_glyphs,
8372                script_rules,
8373                script_dx,
8374                -low_shift,
8375            );
8376            Ok((glyphs, rules, base_width.max(script_width), left, right))
8377        }
8378        Math::PullInScripts(cls1, cls2, resolver) => {
8379            // Not consumed by an enclosing Sub/Sup (bare `\sum` with no
8380            // scripts): resolver gets (None, None).
8381            layout_pull_in_scripts(interp, ctx, &[], *cls1, *cls2, resolver, None, None, size)
8382        }
8383        // V0_1 only (`read-math`): lay `inner` out
8384        // with ambient context = the STORED context, and size = the
8385        // stored context's OWN `font_size` — an ABSOLUTE override, not a
8386        // further multiply of the caller's `size`. This is deliberate: a
8387        // `WithContext` built under an `enter_script`-shrunk context
8388        // already carries the script-shrunk `font_size` in `stored`, so
8389        // laying it out at `stored.font_size` (rather than at this call's
8390        // `size`) means the engine's own Sup/Sub shrink is never applied a
8391        // second time on top of it.
8392        Math::WithContext(stored, inner) => {
8393            layout_math_list(interp, stored, inner, stored.font_size)
8394        }
8395    }
8396}
8397
8398/// Append `glyphs`/`rules` (already at LOCAL coordinates relative to their
8399/// own run) onto `out_glyphs`/`out_rules` at the running `*x`, advancing `*x`
8400/// past them — the no-spacing-adjustment sibling of `layout_math_list`'s
8401/// per-atom loop, used by the structural stand-ins above (paren) that
8402/// concatenate sub-runs directly rather than through the spacing table.
8403/// `rules` shifts horizontally only (`shift_graphics` with a zero `dy` —
8404/// `append_at`'s callers never raise/lower a sub-run, only `dx`-place it;
8405/// contrast `shift_and_append` below, which does both).
8406fn append_at(
8407    out_glyphs: &mut Vec<MathGlyph>,
8408    out_rules: &mut Vec<GraphicsElem>,
8409    x: &mut Length,
8410    glyphs: Vec<MathGlyph>,
8411    rules: Vec<GraphicsElem>,
8412    width: Length,
8413) {
8414    let base_x = *x;
8415    for mut g in glyphs {
8416        g.dx = base_x + g.dx;
8417        out_glyphs.push(g);
8418    }
8419    for r in &rules {
8420        out_rules.push(shift_graphics((base_x, Length::ZERO), r));
8421    }
8422    *x = base_x + width;
8423}
8424
8425/// Horizontal offsets that CENTER a limit against its base: half the width
8426/// difference goes to whichever of the two is narrower, so the pair occupies
8427/// `max(base, script)` (upstream `math.ml:1219-1231`).
8428fn center_offsets(base_width: Length, script_width: Length) -> (Length, Length) {
8429    if base_width < script_width {
8430        ((script_width - base_width) * 0.5, Length::ZERO)
8431    } else {
8432        (Length::ZERO, (base_width - script_width) * 0.5)
8433    }
8434}
8435
8436/// Slide already-emitted glyphs/rules right by `dx` — used when a limit is
8437/// WIDER than its base, so the base itself has to move to stay centered.
8438fn shift_existing(glyphs: &mut [MathGlyph], rules: &mut [GraphicsElem], dx: Length) {
8439    if dx == Length::ZERO {
8440        return;
8441    }
8442    for g in glyphs.iter_mut() {
8443        g.dx = g.dx + dx;
8444    }
8445    for r in rules.iter_mut() {
8446        *r = shift_graphics((dx, Length::ZERO), r);
8447    }
8448}
8449
8450/// Append `glyphs`/`rules` (LOCAL coordinates, from an isolated
8451/// `layout_math_list` call) onto `out_glyphs`/`out_rules`, shifting every
8452/// glyph/rule right by `dx_shift` (its base's own width — placing the
8453/// script/numerator/denominator/radicand right after the preceding content)
8454/// and up/down by `dy_shift` (`> 0` raises, `< 0` lowers) — the `Math`-atom
8455/// analog of `place_script`, which instead threads a single
8456/// running `x` across a flat `MathElem` list. `rules` go through the SAME
8457/// `shift_graphics` a standalone `inline-graphics` box's `shift-graphics`
8458/// primitive uses — box-local, y-**up** coordinates, exactly
8459/// `MathGlyph::dy`'s sign convention (a critical correctness note: get
8460/// this sign wrong and a fraction bar/ radical mirrors instead of landing
8461/// at the axis).
8462fn shift_and_append(
8463    out_glyphs: &mut Vec<MathGlyph>,
8464    out_rules: &mut Vec<GraphicsElem>,
8465    glyphs: Vec<MathGlyph>,
8466    rules: Vec<GraphicsElem>,
8467    dx_shift: Length,
8468    dy_shift: Length,
8469) {
8470    for mut g in glyphs {
8471        g.dx = dx_shift + g.dx;
8472        g.dy = g.dy + dy_shift;
8473        out_glyphs.push(g);
8474    }
8475    for r in &rules {
8476        out_rules.push(shift_graphics((dx_shift, dy_shift), r));
8477    }
8478}
8479
8480/// An axis-aligned rectangle `Fill` path, box-local (y-**up**): bottom-left
8481/// corner `origin`, extending `size.0` right and `size.1` up. Shared by the
8482/// fraction bar and the radical overbar — both are exactly this
8483/// shape, just at different `y`/width.
8484fn rect_path(origin: Point, size: (Length, Length)) -> Path {
8485    let (x, y) = origin;
8486    let (w, h) = size;
8487    Path {
8488        subpaths: vec![Subpath {
8489            start: (x, y),
8490            segs: vec![
8491                PathSeg::Line((x + w, y)),
8492                PathSeg::Line((x + w, y + h)),
8493                PathSeg::Line((x, y + h)),
8494            ],
8495            closing: Closing::Line,
8496        }],
8497    }
8498}
8499
8500/// Port of `default_radical` (`primitives.cppo.ml:311-355`): the radical
8501/// checkmark's `GeneralPath`, plus its own natural advance (`wid`, upstream's
8502/// `PHGFixedGraphics`'s declared width) and `nonnegdpt` (its own depth
8503/// extent, upstream's declared `depth` — returned for completeness though
8504/// The overall `Math::Radical` depth uses `d_cont` directly, matching
8505/// upstream's own "temporary" simplification, see that arm's call site).
8506/// `size` is the ambient LOCAL nesting size (upstream `fontsize`); `hgt_bar`/
8507/// `t_bar` come from `MathC::radical_bar_metrics`; `dpt` is the radicand's
8508/// own depth (a NON-NEGATIVE magnitude, this port's convention — see
8509/// `sup_shift_clamped`'s doc comment; upstream's signed `Length.negate dpt`
8510/// becomes a plain ADD of `dpt` here).
8511///
8512/// Box-local origin `(0, 0)` = this atom's own baseline-left corner (where
8513/// upstream's `graphics (xpos, ypos)` closure is finally called with the
8514/// box's placed anchor — every point below is relative to that same origin,
8515/// matching `PathSeg`/`Subpath`'s y-**up** convention).
8516fn radical_sign_geometry(
8517    size: Length,
8518    hgt_bar: Length,
8519    t_bar: Length,
8520    dpt: Length,
8521) -> (Path, Length, Length) {
8522    let w_m = size * 0.02;
8523    let w1 = size * 0.1;
8524    let w2 = size * 0.15;
8525    let w3 = size * 0.4;
8526    let w_a = size * 0.18;
8527    let h1 = size * 0.3;
8528    let h2 = size * 0.375;
8529
8530    let nonnegdpt = dpt + size * 0.1;
8531    let l_r = hgt_bar + nonnegdpt;
8532
8533    let wid = w_m + w1 + w2 + w3;
8534    let a1 = (h2 - h1) / w1;
8535    let a2 = h2 / w2;
8536    let a3 = l_r / w3;
8537    let t1 = t_bar * (1.0 + a1 * a1).sqrt();
8538    let t3 = t_bar * (((1.0 + a3 * a3).sqrt() - 1.0) / a3);
8539    let h_a = h1 + t1 + w_a * a1;
8540    let w_b = (l_r + t_bar - h_a - (w1 + w2 + w3 - t3 - w_a) * a3) * (-1.0 / (a2 + a3));
8541    let h_b = h_a - w_b * a2;
8542
8543    let path = Path {
8544        subpaths: vec![Subpath {
8545            start: (wid, hgt_bar),
8546            segs: vec![
8547                PathSeg::Line((w_m + w1 + w2, -nonnegdpt)),
8548                PathSeg::Line((w_m + w1, -nonnegdpt + h2)),
8549                PathSeg::Line((w_m, -nonnegdpt + h1)),
8550                PathSeg::Line((w_m, -nonnegdpt + h1 + t1)),
8551                PathSeg::Line((w_m + w_a, -nonnegdpt + h_a)),
8552                PathSeg::Line((w_m + w_a + w_b, -nonnegdpt + h_b)),
8553                PathSeg::Line((wid - t3, hgt_bar + t_bar)),
8554                PathSeg::Line((wid, hgt_bar + t_bar)),
8555            ],
8556            closing: Closing::Line,
8557        }],
8558    };
8559    (path, wid, nonnegdpt)
8560}
8561
8562/// Flatten `text-in-math`'s embedded `inline-boxes` (already laid out
8563/// by `read_inline` against the math atom's own context) into `MathGlyph`s
8564/// nestable in a math run — the box-in-math bridge `layout_math_atom`'s
8565/// `EmbeddedText` arm needs. Mirrors `linebreak.rs`'s `natural_metrics`
8566/// exhaustive `PureHorzBox` walk EXACTLY (same variant list, same "what
8567/// advances `x`" choice per variant) so an added/renamed `PureHorzBox`
8568/// variant can't silently drop content here without also breaking that
8569/// walk. Caveats (faithful to what's actually renderable here): only
8570/// `InnerString`/nested `Math` boxes contribute real glyphs (hence height/
8571/// depth, computed by the caller from the returned glyphs); every other
8572/// box kind (`Image`/`Graphics`/`Tabular`/`EmbeddedBlock`/…) keeps its
8573/// horizontal space but contributes no ink; text run at full (non-script)
8574/// size regardless of the math run's own `size` (upstream-faithful — a
8575/// `text-in-math` body is laid out once, by `read_inline`, before this
8576/// function ever sees it).
8577// UNWIRED. `layout_script` builds the same `(Vec<MathGlyph>, Length)` on the
8578// live path, so nothing calls this. Kept rather than deleted because
8579// `math_boxes_of_inline_boxes` below is documented as its sibling, and
8580// because it is the upstream-faithful flattening a `text-in-math` body needs
8581// if that path is ever wired back up.
8582#[allow(dead_code)]
8583fn math_glyphs_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Length) {
8584    fn go(pure: &PureHorzBox, out: &mut Vec<MathGlyph>, x: &mut Length) {
8585        match pure {
8586            PureHorzBox::InnerString {
8587                info,
8588                text,
8589                width,
8590                height,
8591                depth,
8592            } => {
8593                out.push(MathGlyph {
8594                    info: info.clone(),
8595                    text: text.clone(),
8596                    gid: None,
8597                    dx: *x,
8598                    dy: Length::ZERO,
8599                    width: *width,
8600                    height: *height,
8601                    depth: *depth,
8602                });
8603                *x += *width;
8604            }
8605            PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
8606            PureHorzBox::OuterFil => {}
8607            PureHorzBox::FixedEmpty { width } => *x += *width,
8608            PureHorzBox::Image { width, .. } => *x += *width,
8609            PureHorzBox::Discretionary { no_break, .. } => {
8610                for p in no_break {
8611                    go(p, out, x);
8612                }
8613            }
8614            PureHorzBox::Graphics { width, .. } => *x += *width,
8615            // An unresolved `inline-graphics-outer` marker has zero width
8616            // (fil semantics, see the variant's doc comment) and no glyph
8617            // representation this walk can extract — advance past it like
8618            // `Image`/`Tabular` (a resolved one is an ordinary `Graphics`,
8619            // handled by the arm above).
8620            PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
8621            PureHorzBox::Math { width, glyphs, .. } => {
8622                for g in glyphs {
8623                    let mut g = g.clone();
8624                    g.dx = *x + g.dx;
8625                    out.push(g);
8626                }
8627                *x += *width;
8628            }
8629            PureHorzBox::HookPageBreak { .. } => {}
8630            PureHorzBox::Tabular(tab) => *x += tab.width,
8631            PureHorzBox::EmbeddedBlock { width, .. } => *x += *width,
8632            // A frame in a math context has no glyph representation this
8633            // walk can extract — advance past it like `Image`/`Tabular`.
8634            PureHorzBox::Frame { width, .. } => *x += *width,
8635            PureHorzBox::FrameMarker { .. } => {}
8636            // Zero-width bracket; its contents are spliced siblings, already
8637            // walked by this same loop.
8638            PureHorzBox::InlineFrameMarker { .. } => {}
8639            // Zero-width marker; no glyph representation. Same treatment
8640            // as `HookPageBreak`.
8641            PureHorzBox::Footnote { .. } => {}
8642            // inert reflow marker, no glyph representation — same
8643            // treatment as `HookPageBreak`/`FrameMarker`/`Footnote`
8644            // above.
8645            PureHorzBox::InlineMark(_) => {}
8646        }
8647    }
8648    let mut glyphs = Vec::new();
8649    let mut x = Length::ZERO;
8650    for HorzBox::Pure(p) in boxes {
8651        go(p, &mut glyphs, &mut x);
8652    }
8653    (glyphs, x)
8654}
8655
8656/// `math_glyphs_of_inline_boxes`'s graphics-harvesting sibling — the
8657/// shape a `make_paren` closure's result needs, since a delimiter drawn via
8658/// `inline-graphics` (`math.satyh`'s `paren-left`/`abs-left`/…, `fill`/
8659/// `stroke` a path) carries its ink as a `PureHorzBox::Graphics` box, not a
8660/// `MathGlyph`. Same exhaustive walk as `math_glyphs_of_inline_boxes` (do
8661/// NOT modify that function — every OTHER caller still wants glyphs-only,
8662/// e.g. `EmbeddedText`), but additionally harvests `Graphics::elems` (`dx`-
8663/// shifted via `shift_graphics`, the box's own local-origin convention —
8664/// see `PureHorzBox::Graphics`'s doc comment) and forwards BOTH the glyphs
8665/// AND `rules` out of any nested `PureHorzBox::Math` box (a paren closure
8666/// could, in principle, embed one via `text-in-math`/`embed-math`).
8667fn math_boxes_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Vec<GraphicsElem>, Length) {
8668    fn go(
8669        pure: &PureHorzBox,
8670        out: &mut Vec<MathGlyph>,
8671        rules: &mut Vec<GraphicsElem>,
8672        x: &mut Length,
8673    ) {
8674        match pure {
8675            PureHorzBox::InnerString {
8676                info,
8677                text,
8678                width,
8679                height,
8680                depth,
8681            } => {
8682                out.push(MathGlyph {
8683                    info: info.clone(),
8684                    text: text.clone(),
8685                    gid: None,
8686                    dx: *x,
8687                    dy: Length::ZERO,
8688                    width: *width,
8689                    height: *height,
8690                    depth: *depth,
8691                });
8692                *x += *width;
8693            }
8694            PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
8695            PureHorzBox::OuterFil => {}
8696            PureHorzBox::FixedEmpty { width } => *x += *width,
8697            PureHorzBox::Image { width, .. } => *x += *width,
8698            PureHorzBox::Discretionary { no_break, .. } => {
8699                for p in no_break {
8700                    go(p, out, rules, x);
8701                }
8702            }
8703            PureHorzBox::Graphics { width, elems, .. } => {
8704                for e in elems {
8705                    rules.push(shift_graphics((*x, Length::ZERO), e));
8706                }
8707                *x += *width;
8708            }
8709            // See `math_glyphs_of_inline_boxes`'s matching arm.
8710            PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
8711            PureHorzBox::Math {
8712                width,
8713                glyphs,
8714                rules: inner_rules,
8715                ..
8716            } => {
8717                for g in glyphs {
8718                    let mut g = g.clone();
8719                    g.dx = *x + g.dx;
8720                    out.push(g);
8721                }
8722                for r in inner_rules {
8723                    rules.push(shift_graphics((*x, Length::ZERO), r));
8724                }
8725                *x += *width;
8726            }
8727            PureHorzBox::HookPageBreak { .. } => {}
8728            PureHorzBox::Tabular(tab) => *x += tab.width,
8729            PureHorzBox::EmbeddedBlock { width, .. } => *x += *width,
8730            // See `math_glyphs_of_inline_boxes`'s matching arm.
8731            PureHorzBox::Frame { width, .. } => *x += *width,
8732            PureHorzBox::FrameMarker { .. } => {}
8733            // See `math_glyphs_of_inline_boxes`'s matching arm.
8734            PureHorzBox::InlineFrameMarker { .. } => {}
8735            // See `math_glyphs_of_inline_boxes`'s matching arm.
8736            PureHorzBox::Footnote { .. } => {}
8737            // See `math_glyphs_of_inline_boxes`'s matching arm.
8738            PureHorzBox::InlineMark(_) => {}
8739        }
8740    }
8741    let mut glyphs = Vec::new();
8742    let mut rules = Vec::new();
8743    let mut x = Length::ZERO;
8744    for HorzBox::Pure(p) in boxes {
8745        go(p, &mut glyphs, &mut rules, &mut x);
8746    }
8747    (glyphs, rules, x)
8748}
8749
8750// ============================================================================
8751// ---- context-setter + box-combinator prims `code.satyh`/`itemize.satyh`
8752// need. ------------------------------------------------------------------
8753// ============================================================================
8754
8755/// The inverse of `as_color` (mirrors `evalUtil.ml:124`'s `get_color` the
8756/// other way) — `get-text-color`'s result, which `itemize.satyh` feeds
8757/// straight into `fill`, so the tag/payload shape must match `as_color`
8758/// exactly (see that primitive's doc comment).
8759fn make_color_value(c: Color) -> Value {
8760    match c {
8761        Color::Gray(g) => Value::Ctor("Gray".to_string(), Some(Box::new(Value::Float(g)))),
8762        Color::Rgb(r, g, b) => Value::Ctor(
8763            "RGB".to_string(),
8764            Some(Box::new(Value::Tuple(vec![
8765                Value::Float(r),
8766                Value::Float(g),
8767                Value::Float(b),
8768            ]))),
8769        ),
8770        Color::Cmyk(c, m, y, k) => Value::Ctor(
8771            "CMYK".to_string(),
8772            Some(Box::new(Value::Tuple(vec![
8773                Value::Float(c),
8774                Value::Float(m),
8775                Value::Float(y),
8776                Value::Float(k),
8777            ]))),
8778        ),
8779    }
8780}
8781
8782/// `font` = `Value::Tuple([string, float, float])` in `(abbrev, size_ratio,
8783/// rising_ratio)` order (vminst.ml's `tFONT`) — `set-font`'s second argument.
8784fn as_font(v: Value) -> Result<(String, f64, f64), EvalError> {
8785    match v {
8786        Value::Tuple(vs) if vs.len() == 3 => {
8787            let mut it = vs.into_iter();
8788            let abbrev = as_str(it.next().unwrap())?;
8789            let size_ratio = as_float(it.next().unwrap())?;
8790            let rising_ratio = as_float(it.next().unwrap())?;
8791            Ok((abbrev, size_ratio, rising_ratio))
8792        }
8793        other => eval_error(format!(
8794            "expected a font (string * float * float), got {}",
8795            other.type_name()
8796        )),
8797    }
8798}
8799
8800/// [`as_font`]'s V0_1 twin — saphe-split's `tFONTWR = font * float * float`,
8801/// whose head is the opaque handle rather than an abbrev.
8802fn as_font_with_ratio(v: Value) -> Result<(FontKey, f64, f64), EvalError> {
8803    match v {
8804        Value::Tuple(vs) if vs.len() == 3 => {
8805            let mut it = vs.into_iter();
8806            let key = as_font_key(it.next().unwrap())?;
8807            let size_ratio = as_float(it.next().unwrap())?;
8808            let rising_ratio = as_float(it.next().unwrap())?;
8809            Ok((key, size_ratio, rising_ratio))
8810        }
8811        other => eval_error(format!(
8812            "expected a font (font * float * float), got {}",
8813            other.type_name()
8814        )),
8815    }
8816}
8817
8818/// The opaque V0_1 `font` handle (upstream's `BCFontKey of FontKey.t`).
8819fn as_font_key(v: Value) -> Result<FontKey, EvalError> {
8820    match v {
8821        Value::Font(key) => Ok(key),
8822        other => eval_error(format!("expected a font, got {}", other.type_name())),
8823    }
8824}
8825
8826/// `set-text-color : color -> context -> context` (vminst.ml:1603) —
8827/// FAITHFUL store (`Context::text_color`, the `set-font-size` shape); it
8828/// rides on every `HorzStringInfo` and both PDF writers emit `rg`/`g`
8829/// before `Tj` for a non-black run.
8830fn prim_set_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
8831    let ctx = as_context(args.pop().unwrap())?;
8832    let color = as_color(args.pop().unwrap())?;
8833    Ok(Value::Context(Box::new(Context {
8834        text_color: color,
8835        ..ctx
8836    })))
8837}
8838
8839/// `get-text-color : context -> color` (vminst.ml:1618) — FAITHFUL and
8840/// load-bearing: `itemize.satyh`'s `make-bullet` feeds this straight into
8841/// `fill color (Gr.circle …)`, so it must round-trip exactly what
8842/// `set-text-color` stored (see `make_color_value`'s doc comment).
8843fn prim_get_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
8844    let ctx = as_context(args.pop().unwrap())?;
8845    Ok(make_color_value(ctx.text_color))
8846}
8847
8848/// `set-hyphen-penalty : int -> context -> context` (vminst.ml:1692) —
8849/// FAITHFUL store (`Context::hyphen_badness`), now a real consumer:
8850/// `text_to_boxes`'s `flush_word` uses this as each injected
8851/// `Discretionary`'s `penalty`, but only when a dictionary is installed via
8852/// `set-hyphenation-dictionary` — with no dictionary installed (the
8853/// default), this is stored but has no layout effect, same as before.
8854/// `code.satyh`'s `set-hyphen-penalty 100000` still works as "disable
8855/// hyphenation" (huge positive penalty, DP avoids it).
8856fn prim_set_hyphen_penalty(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
8857    let ctx = as_context(args.pop().unwrap())?;
8858    let n = as_int(args.pop().unwrap())?;
8859    Ok(Value::Context(Box::new(Context {
8860        hyphen_badness: n,
8861        ..ctx
8862    })))
8863}
8864
8865/// `set-hyphen-min : int -> int -> context -> context` (upstream
8866/// `vminstdef.yaml:1163-1177`) — writes
8867/// `Context::left_hyphen_min`/`right_hyphen_min`, each clamped to `>= 0`
8868/// (mirrors `set-space-ratio`'s `.max(0.0)` clamping style; a negative
8869/// minimum would be meaningless to the min-fragment filter in
8870/// `crate::hyphenation::hyphenate_word`).
8871fn prim_set_hyphen_min(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
8872    let ctx = as_context(args.pop().unwrap())?;
8873    let right = as_int(args.pop().unwrap())?.max(0);
8874    let left = as_int(args.pop().unwrap())?.max(0);
8875    Ok(Value::Context(Box::new(Context {
8876        left_hyphen_min: left,
8877        right_hyphen_min: right,
8878        ..ctx
8879    })))
8880}
8881
8882/// `set-space-ratio : float -> float -> float -> context -> context`
8883/// (vminst.ml:1309), params `(natural, shrink, stretch)` — FAITHFUL store
8884/// (`Context::space_natural`/`space_shrink`/`space_stretch`, clamped to
8885/// `>= 0.0` like upstream), read by `text_to_boxes`'s interword-glue
8886/// computation.
8887fn prim_set_space_ratio(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
8888    let ctx = as_context(args.pop().unwrap())?;
8889    let stretch = as_float(args.pop().unwrap())?.max(0.0);
8890    let shrink = as_float(args.pop().unwrap())?.max(0.0);
8891    let natural = as_float(args.pop().unwrap())?.max(0.0);
8892    Ok(Value::Context(Box::new(Context {
8893        space_natural: natural,
8894        space_shrink: shrink,
8895        space_stretch: stretch,
8896        ..ctx
8897    })))
8898}
8899
8900/// `set-space-ratio-between-scripts : float -> float -> float -> script ->
8901/// script -> context -> context` (slydifi's arctic theme). STAND-IN: this port
8902/// has no script-aware line breaking and inserts no inter-script glue at all
8903/// (see [`prim_get_leftmost_script`]), so there is no per-script-pair spacing
8904/// to store — the primitive validates its arguments (three ratios and two
8905/// scripts) and returns the context unchanged. slydifi only ever calls it with
8906/// `0. 0. 0.` to SUPPRESS inter-script spacing, which the port already does, so
8907/// the observable layout matches upstream. Tuning a non-zero ratio would need
8908/// real script-boundary glue first (follow-on).
8909fn prim_set_space_ratio_between_scripts(
8910    _interp: &mut Interp,
8911    mut args: Vec<Value>,
8912) -> Result<Value, EvalError> {
8913    let ctx = as_context(args.pop().unwrap())?;
8914    let _script2 = as_script(args.pop().unwrap())?;
8915    let _script1 = as_script(args.pop().unwrap())?;
8916    let _stretch = as_float(args.pop().unwrap())?;
8917    let _shrink = as_float(args.pop().unwrap())?;
8918    let _natural = as_float(args.pop().unwrap())?;
8919    Ok(Value::Context(Box::new(ctx)))
8920}
8921
8922/// `split-into-lines : string -> (int * string) list` (vminst.ml:2269) —
8923/// FAITHFUL: splits on `'\n'` and, per line, counts the leading ASCII spaces
8924/// `i` and returns `(i, rest_after_indent)` — exactly `evalUtil.ml:36`'s
8925/// `chop_space_indent`. Pure string op: no context, no box, no new type.
8926fn prim_split_into_lines(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
8927    let s = as_str(args.pop().unwrap())?;
8928    let mut out = Vec::new();
8929    for line in s.split('\n') {
8930        let indent = line.chars().take_while(|c| *c == ' ').count();
8931        let rest: String = line.chars().skip(indent).collect();
8932        out.push(Value::Tuple(vec![
8933            Value::Int(indent as i64),
8934            Value::Str(rest),
8935        ]));
8936    }
8937    Ok(Value::List(out))
8938}
8939
8940/// Shift every content box in `block`'s `Line`s right by `pad_l`
8941/// (`block-frame-breakable`'s left-indent, point 4) — the simplest of the
8942/// two options that section names: adjusting each box's own `x` offset
8943/// directly rather than prepending an extra `FixedEmpty` box (`Skip`s carry
8944/// no `x` offsets to shift, so they pass through unchanged).
8945fn indent_left(block: Vec<VertBox>, pad_l: Length) -> Vec<VertBox> {
8946    block
8947        .into_iter()
8948        .map(|vb| match vb {
8949            VertBox::Line {
8950                height,
8951                depth,
8952                leading,
8953                contents,
8954            } => VertBox::Line {
8955                height,
8956                depth,
8957                leading,
8958                contents: contents
8959                    .into_iter()
8960                    .map(|(x, bx)| (x + pad_l, bx))
8961                    .collect(),
8962            },
8963            // `Skip`/`ClearPage`/`HookPageBreak` carry no `x` offsets to shift.
8964            other => other,
8965        })
8966        .collect()
8967}
8968
8969/// `block-frame-breakable : context -> paddings -> deco-set -> (context ->
8970/// block-boxes) -> block-boxes` (vminst.ml:1090) — the `inline-frame-outer`
8971/// playbook, one dimension up:
8972/// `paddingL`/`paddingR` shrink the inner `reducef` closure's context width,
8973/// and the result is indented and top/bottom-padded with plain `Skip`s,
8974/// bracketed by a `FrameStart(id)`/`FrameEnd(id)` marker pair — the frame's
8975/// pads/width/deco-set are interned into `interp.decos` under `id`
8976/// (`DecoEntry::Block`), and `fire_hooks`'s block-fragment pass fires
8977/// `decoS` once the frame's whole single-page fragment is placed (the first
8978/// cut: multi-page fragments/`decoH`/`decoM`/`decoT` are a documented
8979/// follow-up, see `fire_hooks`'s doc comment).
8980/// Drop the margin boxes at either END of a `block-frame-breakable`'s body —
8981/// the first inner block's top margin and the last inner block's bottom
8982/// margin, which upstream's `normalize` never produces for a frame's contents
8983/// (`pageBreak.ml:664` and `:582-585`; see the call site). Margins in the
8984/// MIDDLE of the body are untouched: those are real inter-block gaps, squashed
8985/// there exactly as they are outside a frame.
8986fn strip_outer_margins(body: &mut Vec<VertBox>) {
8987    let is_margin = |vb: &VertBox| matches!(vb, VertBox::Skip(_) | VertBox::ParagTop(_));
8988    if body.first().is_some_and(is_margin) {
8989        body.remove(0);
8990    }
8991    if body.last().is_some_and(is_margin) {
8992        body.pop();
8993    }
8994}
8995
8996fn prim_block_frame_breakable(
8997    interp: &mut Interp,
8998    version: RustyfiVersion,
8999    mut args: Vec<Value>,
9000) -> Result<Value, EvalError> {
9001    let k = args.pop().unwrap();
9002    let decoset = as_decoset(args.pop().unwrap())?;
9003    let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
9004    let ctx = as_context(args.pop().unwrap())?;
9005    let id = DecoId(interp.decos.len());
9006    interp.decos.push(DecoEntry::Block {
9007        pads: Paddings {
9008            l: pad_l,
9009            r: pad_r,
9010            t: pad_t,
9011            b: pad_b,
9012        },
9013        width: ctx.paragraph_width,
9014        decoset,
9015        // See `make_inline_frame`'s identical capture.
9016        version,
9017    });
9018    let inner_ctx = Context {
9019        paragraph_width: ctx.paragraph_width - pad_l - pad_r,
9020        ..ctx
9021    };
9022    let inner = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9023    let mut indented = indent_left(inner, pad_l);
9024    // THE FRAME CARRIES THE MARGINS, ITS BODY DOES NOT. Upstream normalizes a
9025    // frame's contents STANDALONE — `aux None TopMarginProhibited Alist.empty
9026    // vblstsub` (`pageBreak.ml:664`) — so the first inner block's `margin_top`
9027    // is never appended, and the last inner block's `margin_bottom` goes
9028    // through `squash_margins _ []`, whose empty-list arm (`:582-585`) emits
9029    // no skip at all. What surrounds the frame instead is the frame's OWN
9030    // `margins`, taken from the OUTER context (`vminstdef.yaml`'s
9031    // `BackendVertFrame`: `margin_top = ctx.paragraph_top`, `margin_bottom =
9032    // ctx.paragraph_bottom`), which `squash_margins` max-collapses against the
9033    // neighbouring blocks' margins exactly as `chop_page` collapses adjacent
9034    // `Skip`s.
9035    //
9036    // The distinction is not cosmetic: the body's `ParagTop` carries
9037    // `min_first_line_ascender` folded in (`prim_line_break`,
9038    // `lineBreak.ml:855-857`), and the frame's margin does NOT. Keeping the
9039    // body's made the advance INTO a frame a constant — `max(0, 9pt - hgt)`
9040    // cancels the first line's own height — where upstream's tracks the ink:
9041    // measured on `layout-tests/probes/code_line_height.saty` against real
9042    // SATySFi 0.0.11, `+code(`ooo`)` / `lll` / `ggg` advance 29.114 / 31.166 /
9043    // 29.138pt upstream and a flat 32.835pt here.
9044    strip_outer_margins(&mut indented);
9045    let mut out = Vec::with_capacity(indented.len() + 6);
9046    out.push(VertBox::Skip(ctx.paragraph_top));
9047    out.push(VertBox::FrameStart(id));
9048    out.push(VertBox::FramePad(pad_t));
9049    out.extend(indented);
9050    out.push(VertBox::FramePad(pad_b));
9051    out.push(VertBox::FrameEnd(id));
9052    out.push(VertBox::Skip(ctx.paragraph_bottom));
9053    Ok(Value::BlockBoxes(out))
9054}
9055
9056/// Build the `PureHorzBox::EmbeddedBlock` shared by `embed-block-top`
9057/// (vminst.ml:1145) and `embed-block-bottom` (vminst.ml:1185). FAITHFUL:
9058/// `anchor_last` selects which of `block`'s lines lands on the surrounding
9059/// text baseline — the FIRST for top (upstream's `adjust_to_first_line`) or
9060/// the LAST for bottom (`adjust_to_last_line`) — computed by placing the
9061/// block once (`place_block_at`) to find where each line's baseline falls,
9062/// then splitting the box's total vertical extent around the anchored line:
9063/// around the first line for TOP, so the box hangs DOWN from the baseline;
9064/// around the last line for BOTTOM, so it hangs UP. A degenerate line-less
9065/// block (only skips, no baseline to anchor) falls back to
9066/// `measure_block`'s skip-as-height sum for both.
9067fn make_embedded_block(
9068    width: Length,
9069    block: Vec<VertBox>,
9070    anchor_last: bool,
9071    breakable: bool,
9072) -> Value {
9073    let first_line_height = block.iter().find_map(|vb| match vb {
9074        VertBox::Line { height, .. } => Some(*height),
9075        _ => None,
9076    });
9077    let last_line_depth = block.iter().rev().find_map(|vb| match vb {
9078        VertBox::Line { depth, .. } => Some(*depth),
9079        _ => None,
9080    });
9081    let (height, depth) = match (first_line_height, last_line_depth) {
9082        // Place once to learn each line's baseline, then split the box's
9083        // total vertical extent around the anchored line: `place_block_at`
9084        // seats the first baseline at `first_h` (origin 0), so the block
9085        // spans `[0, last_baseline + last_d]`.
9086        (Some(first_h), Some(last_d)) => {
9087            let placed = place_block_at((Length::ZERO, Length::ZERO), block.clone());
9088            let last_baseline = placed.last().map(|l| l.baseline_y).unwrap_or(first_h);
9089            let bottom_edge = last_baseline + last_d;
9090            if anchor_last {
9091                (last_baseline, last_d)
9092            } else {
9093                (first_h, bottom_edge - first_h)
9094            }
9095        }
9096        // A degenerate line-less block (only skips — no baseline to anchor):
9097        // keep `measure_block` (its skip-as-height fallback is right there).
9098        _ => measure_block(&block),
9099    };
9100    Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::EmbeddedBlock {
9101        width,
9102        height,
9103        depth,
9104        block,
9105        anchor_last,
9106        breakable,
9107    })])
9108}
9109
9110/// `embed-block-top : context -> length -> (context -> block-boxes) ->
9111/// inline-boxes` (vminst.ml:1145) — see [`make_embedded_block`].
9112fn prim_embed_block_top(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9113    let k = args.pop().unwrap();
9114    let wid = as_length(args.pop().unwrap())?;
9115    let ctx = as_context(args.pop().unwrap())?;
9116    let inner_ctx = Context {
9117        paragraph_width: wid,
9118        ..ctx
9119    };
9120    let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9121    Ok(make_embedded_block(wid, block, false, false))
9122}
9123
9124/// `embed-block-bottom : context -> length -> (context -> block-boxes) ->
9125/// inline-boxes` (vminst.ml:1185) — see [`make_embedded_block`]; anchors the
9126/// LAST line, used by latexcmds' `\parbox?:(Bottom)`.
9127fn prim_embed_block_bottom(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9128    let k = args.pop().unwrap();
9129    let wid = as_length(args.pop().unwrap())?;
9130    let ctx = as_context(args.pop().unwrap())?;
9131    let inner_ctx = Context {
9132        paragraph_width: wid,
9133        ..ctx
9134    };
9135    let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9136    Ok(make_embedded_block(wid, block, true, false))
9137}
9138
9139/// `line-stack-bottom : inline-boxes list -> inline-boxes` (vminst.ml:1229,
9140/// `evalUtil.ml`'s `make_line_stack`) — FAITHFUL: each `inline-boxes` in the
9141/// list becomes exactly one line, fit (not broken) to the widest line's
9142/// natural width via `fit_cell` (this port's `LineBreak.fit`, already used
9143/// by the tabular grid solver — same "no `Context`, `natural_metrics`
9144/// height/depth" fallback upstream's `make_line_stack` needs since it too
9145/// has no context to lean on). Lines are stacked with zero extra margin
9146/// (upstream's `VertParagraph`s all have `margin_top`/`margin_bottom =
9147/// None`): each line's `leading` is set to the previous line's depth plus
9148/// this line's height, so consecutive baselines sit exactly
9149/// `prev_depth + this_height` apart (see `pagebreak.rs`'s
9150/// `leading.max(height)` placement formula — this choice makes that `max`
9151/// always resolve to our computed `leading`). See `line_stack` for the
9152/// shared body and [`prim_line_stack_top`] for the other half.
9153fn prim_line_stack_bottom(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9154    line_stack(args.pop().unwrap(), true)
9155}
9156
9157/// `line-stack-top : inline-boxes list -> inline-boxes`
9158/// (vminstdef.yaml:1109 `BackendLineStackTop`) — FAITHFUL: the same
9159/// `make_line_stack` construction as [`prim_line_stack_bottom`], differing
9160/// only in which stacked line's baseline becomes the result's — one shared
9161/// body and one flag rather than two copies that could drift.
9162///
9163/// `ruby` calls this to sit its annotation above the base run.
9164fn prim_line_stack_top(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9165    line_stack(args.pop().unwrap(), false)
9166}
9167
9168/// `evalUtil.ml`'s `make_line_stack` — shared body of the two `line-stack-*`
9169/// prims; `anchor_last` picks upstream's `adjust_to_last_line` (`true`) or
9170/// `adjust_to_first_line` (`false`).
9171fn line_stack(arg: Value, anchor_last: bool) -> Result<Value, EvalError> {
9172    let hblstlst = as_list(arg)?
9173        .into_iter()
9174        .map(as_inline_boxes)
9175        .collect::<Result<Vec<_>, _>>()?;
9176    let wid = hblstlst
9177        .iter()
9178        .map(|hbs| natural_metrics(hbs).0)
9179        .fold(Length::ZERO, |acc, w| if w > acc { w } else { acc });
9180    let mut block = Vec::with_capacity(hblstlst.len());
9181    let mut prev_depth = Length::ZERO;
9182    for (idx, hbs) in hblstlst.into_iter().enumerate() {
9183        let (contents, height, depth) = fit_cell(hbs, wid);
9184        let leading = if idx == 0 {
9185            height + depth
9186        } else {
9187            prev_depth + height
9188        };
9189        block.push(VertBox::Line {
9190            height,
9191            depth,
9192            leading,
9193            contents,
9194        });
9195        prev_depth = depth;
9196    }
9197    // `anchor_last` IS upstream's `adjust_to_last_line`/`adjust_to_first_line`
9198    // choice: `line-stack-bottom` is BOTTOM-anchored (SATySFi vminst.ml:1229 —
9199    // the result baseline is the LAST stacked line's baseline), so the box's
9200    // height spans everything above that last line and its depth is the last
9201    // line's depth. Top-anchoring it instead put the baseline at the FIRST
9202    // line, which dropped the whole stack below the baseline — e.g. figbox's
9203    // `margin`/`hvmargin` (a `line-stack-bottom` of [top-mgn; content;
9204    // bot-mgn]) had its content rendered below its frame (the E=mc² bug). For
9205    // `line-stack-top` the first line IS the right anchor, which is the whole
9206    // difference between the two prims.
9207    Ok(make_embedded_block(wid, block, anchor_last, false))
9208}
9209
9210/// `add-footnote : block-boxes -> inline-boxes` (vminst.ml:1130
9211/// `BackendAddFootnote`) — FAITHFUL: wraps the block in a zero-metric
9212/// `PureHorzBox::Footnote` marker (upstream `PHGFootnote`,
9213/// vminstdef.yaml:1034-1044; the upstream body's `PageBreak.solidify` is a
9214/// no-op here because this port's block-boxes are already solid
9215/// `Vec<VertBox>`). `chop_page` (rustyfi-backend) extracts the marker when
9216/// its line is committed to a page, reserves the stack's height at the
9217/// column bottom, and places the block bottom-aligned there — see that
9218/// function's doc comment. The cross-trial `changed`-flag protocol
9219/// `footnote-scheme.satyh` layers on top rides the crossref fixpoint.
9220fn prim_add_footnote(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9221    let block = as_block_boxes(args.pop().unwrap())?;
9222    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
9223        PureHorzBox::Footnote { block },
9224    )]))
9225}
9226
9227/// `set-font : script -> string * float * float -> context -> context`
9228/// (0.0.6 `vminstdef.yaml:1335`, `tFONT` head) — real per-script wiring.
9229/// `abbrev` resolves through the font metrics
9230/// provider's registry first (`FontMetrics::resolve_font_abbrev` — a real
9231/// `TtfFontStore` built from `fonts.satysfi-hash`), falling back to
9232/// the 3-face name heuristic (`resolve_font_abbrev` free fn)
9233/// when the provider has no registry entry for it (an abbrev the config
9234/// doesn't name) — never an error, matching this
9235/// port's existing accept-and-degrade stance on unresolvable font names.
9236///
9237/// **Resolution rule (back-compat critical).** `Latin`-script text keeps
9238/// reading `Context::font` directly rather than `font_scheme[Latin]` (see
9239/// that field's doc comment) — `set-font Latin f` therefore writes BOTH so
9240/// the two stay in sync, but `set-font` on any OTHER script only touches
9241/// `font_scheme`, leaving `ctx.font` (and hence `set-font-key`/`\bold`/
9242/// `\emph`, which only ever read `ctx.font`) untouched.
9243fn prim_set_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9244    let mut ctx = as_context(args.pop().unwrap())?;
9245    let (abbrev, size_ratio, rising_ratio) = as_font(args.pop().unwrap())?;
9246    let script = as_script(args.pop().unwrap())?;
9247    let font = interp
9248        .metrics
9249        .resolve_font_abbrev(&abbrev)
9250        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
9251    install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9252    Ok(Value::Context(Box::new(ctx)))
9253}
9254
9255/// `set-font : script -> font * float * float -> context -> context`
9256/// (saphe-split `tools/gencode/vminst.ml:1433`, `tFONTWR` head). Identical
9257/// to [`prim_set_font_v006`] except that the triple's head is ALREADY a
9258/// resolved handle — 0.1 has no abbrev at this point and nothing to resolve;
9259/// `load-single-font` did that when the font envelope's member was minted.
9260fn prim_set_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9261    let mut ctx = as_context(args.pop().unwrap())?;
9262    let (font, size_ratio, rising_ratio) = as_font_with_ratio(args.pop().unwrap())?;
9263    let script = as_script(args.pop().unwrap())?;
9264    install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9265    Ok(Value::Context(Box::new(ctx)))
9266}
9267
9268/// `get-font : script -> context -> string * float * float`
9269/// (vminstdef.yaml:1350 `PrimitiveGetFont`) — FAITHFUL: `script_font` IS
9270/// upstream's `get_font_with_ratio` (normalize the script, then read the
9271/// scheme slot), and the triple is `evalUtil.ml:196`'s `make_font_value`.
9272///
9273/// The head is a font ABBREV. Upstream's `font_scheme` stores abbrevs and
9274/// resolves them to files only at render time; this port resolves eagerly in
9275/// [`prim_set_font_v006`] and stores a `FontKey`, so the name comes back from
9276/// the store that minted it (`FontMetrics::font_abbrev`) and is `""` when the
9277/// key was never named by a registry — see that method for exactly when, and
9278/// why the corpus does not care (every caller in it, and upstream's own
9279/// `convertText.ml:78`, writes `let (_, ratio, _) =` and uses the RATIO,
9280/// which is exact).
9281///
9282/// This is what `ruby` and `quotation` need: the CJK face's size ratio, so a
9283/// ruby annotation or a two-em Japanese indent scales with the face rather
9284/// than with the Latin `get-font-size`.
9285fn prim_get_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9286    let ctx = as_context(args.pop().unwrap())?;
9287    let script = as_script(args.pop().unwrap())?;
9288    let sf = script_font(&ctx, script);
9289    let abbrev = interp.metrics.font_abbrev(sf.font).unwrap_or_default();
9290    Ok(Value::Tuple(vec![
9291        Value::Str(abbrev),
9292        Value::Float(sf.ratio),
9293        Value::Float(sf.rising),
9294    ]))
9295}
9296
9297/// `get-font : script -> context -> font * float * float` — the 0.1 arm,
9298/// mirroring [`prim_set_font_v006`]/[`prim_set_font_v01`]'s split. 0.1's
9299/// `font` IS the opaque handle this port already stores, so unlike the 0.0.6
9300/// arm there is nothing to recover: the value round-trips exactly.
9301fn prim_get_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9302    let ctx = as_context(args.pop().unwrap())?;
9303    let script = as_script(args.pop().unwrap())?;
9304    let sf = script_font(&ctx, script);
9305    Ok(Value::Tuple(vec![
9306        Value::Font(sf.font),
9307        Value::Float(sf.ratio),
9308        Value::Float(sf.rising),
9309    ]))
9310}
9311
9312/// The half of `set-font` that is NOT version-forked — the `Context` write
9313/// both arms end at, kept in one place so the 0.0.6 behaviour cannot drift
9314/// when the 0.1 one changes. See `prim_set_font_v006`'s "Resolution rule".
9315fn install_script_font(ctx: &mut Context, script: Script, font: FontKey, ratio: f64, rising: f64) {
9316    ctx.font_scheme[script as usize] = ScriptFont {
9317        font,
9318        ratio,
9319        rising,
9320    };
9321    if script == Script::Latin {
9322        ctx.font = font;
9323    }
9324}
9325
9326/// `set-code-text-command : [string] inline-cmd -> context -> context`
9327/// (`stdja:116`; no vminst.ml entry to cite). STAND-IN, same
9328/// shape as `set-math-command`/`set-math-font` above: `(command \cmd)`
9329/// means a real program CAN build a `[string]
9330/// inline-cmd` value to pass here — but `Context` (`rustyfi-backend`) still
9331/// cannot hold an arbitrary lang-side `Value` without a reverse crate
9332/// dependency, and the one seam this codebase uses for that indirection
9333/// (`Interp::hooks`'s ID-table, `eval.rs`) sits outside this file's
9334/// boundary — so the command argument is accepted (to keep the
9335/// arity/signature faithful) and dropped.
9336fn prim_set_code_text_command(
9337    interp: &mut Interp,
9338    mut args: Vec<Value>,
9339) -> Result<Value, EvalError> {
9340    let mut ctx = as_context(args.pop().unwrap())?;
9341    let cmd = args.pop().unwrap();
9342    ctx.code_text_command = Some(interp.register_math_command(cmd));
9343    Ok(Value::Context(Box::new(ctx)))
9344}
9345
9346/// `get-natural-length : block-boxes -> length` (vminst.ml:2040) —
9347/// FAITHFUL: `get-natural-width`'s block sibling (`get-natural-width` itself
9348/// is a `pervasives.satyh` wrapper over `get-natural-metrics`, not a
9349/// primitive). A block's own "natural length" is its total vertical extent
9350/// — `measure_block`'s two components (height above the nominal top, depth
9351/// of the last line) summed into one length.
9352fn prim_get_natural_length(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9353    let bb = as_block_boxes(args.pop().unwrap())?;
9354    let (height, depth) = measure_block(&bb);
9355    Ok(Value::Length(height + depth))
9356}
9357
9358/// `set-dominant-wide-script : script -> context -> context`
9359/// (vminst.ml:1511 `PrimitiveSetDominantWideScript`) — FAITHFUL store,
9360/// consumed by `get-dominant-wide-script` now, by CJK script normalization
9361/// later.
9362fn prim_set_dominant_wide_script(
9363    _interp: &mut Interp,
9364    mut args: Vec<Value>,
9365) -> Result<Value, EvalError> {
9366    let ctx = as_context(args.pop().unwrap())?;
9367    let dominant_wide_script = as_script(args.pop().unwrap())?;
9368    Ok(Value::Context(Box::new(Context {
9369        dominant_wide_script,
9370        ..ctx
9371    })))
9372}
9373
9374/// `set-dominant-narrow-script : script -> context -> context`
9375/// (vminst.ml:1539) — FAITHFUL store, mirror of the wide setter.
9376fn prim_set_dominant_narrow_script(
9377    _interp: &mut Interp,
9378    mut args: Vec<Value>,
9379) -> Result<Value, EvalError> {
9380    let ctx = as_context(args.pop().unwrap())?;
9381    let dominant_narrow_script = as_script(args.pop().unwrap())?;
9382    Ok(Value::Context(Box::new(Context {
9383        dominant_narrow_script,
9384        ..ctx
9385    })))
9386}
9387
9388/// `set-language : script -> language -> context -> context`
9389/// (vminst.ml:1568 `PrimitiveSetLangSys`) — FAITHFUL per-script map insert
9390/// (`langsys_scheme |> ScriptSchemeMap.add script langsys` upstream; a
9391/// 4-slot array write here).
9392fn prim_set_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9393    let ctx = as_context(args.pop().unwrap())?;
9394    let langsys = as_language(args.pop().unwrap())?;
9395    let script = as_script(args.pop().unwrap())?;
9396    let mut langsys_scheme = ctx.langsys_scheme;
9397    langsys_scheme[script as usize] = langsys;
9398    Ok(Value::Context(Box::new(Context {
9399        langsys_scheme,
9400        ..ctx
9401    })))
9402}
9403
9404/// `get-dominant-wide-script : context -> script` (vminst.ml:1526) — FAITHFUL.
9405fn prim_get_dominant_wide_script(
9406    _interp: &mut Interp,
9407    mut args: Vec<Value>,
9408) -> Result<Value, EvalError> {
9409    let ctx = as_context(args.pop().unwrap())?;
9410    Ok(make_script_value(ctx.dominant_wide_script))
9411}
9412
9413/// `get-dominant-narrow-script : context -> script` (vminst.ml:1555) — FAITHFUL.
9414fn prim_get_dominant_narrow_script(
9415    _interp: &mut Interp,
9416    mut args: Vec<Value>,
9417) -> Result<Value, EvalError> {
9418    let ctx = as_context(args.pop().unwrap())?;
9419    Ok(make_script_value(ctx.dominant_narrow_script))
9420}
9421
9422/// `get-language : script -> context -> language` (vminst.ml:1587
9423/// `PrimitiveGetLangSys`) — FAITHFUL. Upstream routes through
9424/// `get_language_system`, whose `normalize_script` step is the identity on
9425/// every script a VALUE can carry (only the char-decoder-internal
9426/// CommonNarrow/CommonWide/Inherited normalize, horzBox.ml:470-479), so
9427/// this is a plain indexed read; absent-entry default `NoLanguageSystem`
9428/// is baked into the array's initial value.
9429fn prim_get_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9430    let ctx = as_context(args.pop().unwrap())?;
9431    let script = as_script(args.pop().unwrap())?;
9432    Ok(make_language_value(ctx.langsys_scheme[script as usize]))
9433}
9434
9435/// `set-every-word-break : inline-boxes -> inline-boxes -> context -> context`
9436/// (vminst.ml:3007 `PrimitiveSetEveryWordBreak`) — sets the inline-boxes
9437/// inserted before/after every inter-word break (mdja.satyh uses it for a
9438/// CJK word-break strut). STAND-IN: accepted and dropped (no per-context
9439/// every-word-break state yet), same pattern as `prim_set_language` above.
9440fn prim_set_every_word_break(
9441    _interp: &mut Interp,
9442    mut args: Vec<Value>,
9443) -> Result<Value, EvalError> {
9444    let ctx = as_context(args.pop().unwrap())?;
9445    let _after = args.pop().unwrap();
9446    let _before = args.pop().unwrap();
9447    Ok(Value::Context(Box::new(ctx)))
9448}
9449
9450/// `register-outline : (int * string * string * bool) list -> unit`
9451/// (vminstdef.yaml:2794 `BackendRegisterOutline`) — FAITHFUL: upstream
9452/// REPLACES the whole registered list (`outline.ml`: `registered_outline :=
9453/// ol`), it does not append; and it is callable anywhere (no
9454/// during-page-break gate — upstream's `Outline.register` has no `State`
9455/// check). Keys resolve through [`Interp::dest_name`] (upstream
9456/// `make_entry`'s `NamedDest.get key`).
9457fn prim_register_outline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9458    let entries = as_list(args.pop().unwrap())?;
9459    let mut out = Vec::with_capacity(entries.len());
9460    for e in entries {
9461        let Value::Tuple(vs) = e else {
9462            return eval_error("register-outline expects a list of (int * string * string * bool)");
9463        };
9464        if vs.len() != 4 {
9465            return eval_error("register-outline expects 4-tuples (level, text, key, is-open)");
9466        }
9467        let mut it = vs.into_iter();
9468        let level = as_int(it.next().unwrap())?;
9469        let text = as_str(it.next().unwrap())?;
9470        let key = as_str(it.next().unwrap())?;
9471        let is_open = as_bool(it.next().unwrap())?;
9472        let dest_name = interp.dest_name(&key);
9473        out.push(OutlineEntry {
9474            level,
9475            text,
9476            dest_name,
9477            is_open,
9478        });
9479    }
9480    interp.outline = out; // replace, not extend
9481    Ok(Value::Unit)
9482}
9483
9484/// Recursive `extract_one` helper for [`prim_extract_string`] — mirrors
9485/// `horzBox.ml`'s `extract_string`'s `extract_one`: an `InnerString`
9486/// contributes its own text, a `Discretionary` recurses into `no_break`
9487/// (the "not yet broken" reading), every other box contributes nothing.
9488/// This port's box vocabulary has no separate Rising/Frame/ScriptGuard
9489/// wrapper (`inline-frame-breakable` et al. already flatten their padding
9490/// into the same flat `Vec<HorzBox>` — see `prim_inline_frame_breakable`),
9491/// so there is nothing else to recurse into.
9492fn extract_string_pure_one(phb: &PureHorzBox) -> String {
9493    match phb {
9494        PureHorzBox::InnerString { text, .. } => text.clone(),
9495        PureHorzBox::Discretionary { no_break, .. } => {
9496            no_break.iter().map(extract_string_pure_one).collect()
9497        }
9498        // Upstream `extract_string` recurses into frames.
9499        PureHorzBox::Frame { contents, .. } => contents
9500            .iter()
9501            .map(|(_, b)| extract_string_pure_one(b))
9502            .collect(),
9503        _ => String::new(),
9504    }
9505}
9506
9507fn extract_string_one(hb: &HorzBox) -> String {
9508    match hb {
9509        HorzBox::Pure(phb) => extract_string_pure_one(phb),
9510    }
9511}
9512
9513/// `extract-string : inline-boxes -> string` (vminstdef.yaml:1565
9514/// `PrimitiveExtract`) — FAITHFUL (see [`extract_string_one`]).
9515fn prim_extract_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9516    let boxes = as_inline_boxes(args.pop().unwrap())?;
9517    let s: String = boxes.iter().map(extract_string_one).collect();
9518    Ok(Value::Str(s))
9519}
9520
9521/// `get-initial-text-info : unit -> text-info` (v0.0.6 vminst.ml:953
9522/// `TextGetInitialTextModeContext`) — FAITHFUL:
9523/// `TextBackend.get_initial_text_mode_context` is `{ indent = 0;
9524/// escape_list = [] }` (textBackend.ml:9-12); escape_list is omitted from
9525/// the port's `TextInfo` (see its doc comment). The v0.0.6 fork side.
9526fn prim_get_initial_text_info_v006(
9527    _interp: &mut Interp,
9528    mut args: Vec<Value>,
9529) -> Result<Value, EvalError> {
9530    let _unit = args.pop().unwrap();
9531    Ok(Value::TextInfo(TextInfo { indent: 0 }))
9532}
9533
9534/// `get-initial-text-info : inline [math-text] -> (string -> option string
9535/// -> option string -> string) -> text-info` (dev-0-1-0 vminst.ml:904-925)
9536/// — the v0.1 fork side. STAND-IN: pops and
9537/// drops both new arguments (the text-mode default math command and the
9538/// math-scripts stringifier) — this port's `TextInfo` carries no text-mode
9539/// command state, same degenerate policy as `stringify-math`. Returns the
9540/// same `TextInfo{indent: 0}` as the v0.0.6 side.
9541fn prim_get_initial_text_info_v01(
9542    _interp: &mut Interp,
9543    mut args: Vec<Value>,
9544) -> Result<Value, EvalError> {
9545    let _stringifier = args.pop().unwrap();
9546    let _default_math_cmd = args.pop().unwrap();
9547    Ok(Value::TextInfo(TextInfo { indent: 0 }))
9548}
9549
9550/// `deepen-indent : int -> text-info -> text-info` (vminst.ml:921
9551/// `TextDeepenIndent`) — FAITHFUL: `indent + max i 0`
9552/// (`TextBackend.deepen_indent`, textBackend.ml:15-16 — the INCREMENT is
9553/// clamped, not the total).
9554fn prim_deepen_indent(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9555    let tinfo = as_text_info(args.pop().unwrap())?;
9556    let i = as_int(args.pop().unwrap())?;
9557    Ok(Value::TextInfo(TextInfo {
9558        indent: tinfo.indent + i.max(0),
9559    }))
9560}
9561
9562/// `break : text-info -> string` (vminst.ml:935 `TextBreak`) — FAITHFUL:
9563/// `"\n" ^ String.make indent ' '` (`TextBackend.get_indent`).
9564fn prim_break(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9565    let tinfo = as_text_info(args.pop().unwrap())?;
9566    let mut s = String::with_capacity(1 + tinfo.indent as usize);
9567    s.push('\n');
9568    for _ in 0..tinfo.indent {
9569        s.push(' ');
9570    }
9571    Ok(Value::Str(s))
9572}
9573
9574// ============================================================================
9575// unit tests: `as_page` (every paper-size ctor),
9576// `read_content_scheme`/`read_parts_scheme` (field extraction +
9577// missing-field errors). These extractors are private, so the tests live
9578// in-module rather than in `tests/`, same pattern as `crossref.rs`'s own
9579// `#[cfg(test)] mod tests`.
9580// ============================================================================
9581#[cfg(test)]
9582mod page_model_tests {
9583    use super::*;
9584
9585    #[test]
9586    fn as_page_maps_every_nullary_ctor_to_the_right_paper_size() {
9587        let cases: &[(&str, PaperSize)] = &[
9588            ("A0Paper", PaperSize::A0),
9589            ("A1Paper", PaperSize::A1),
9590            ("A2Paper", PaperSize::A2),
9591            ("A3Paper", PaperSize::A3),
9592            ("A4Paper", PaperSize::A4),
9593            ("A5Paper", PaperSize::A5),
9594            ("USLetter", PaperSize::USLetter),
9595            ("USLegal", PaperSize::USLegal),
9596        ];
9597        for (name, expected) in cases {
9598            let v = Value::Ctor((*name).to_string(), None);
9599            assert_eq!(as_page(v).unwrap(), *expected, "ctor {name}");
9600        }
9601    }
9602
9603    #[test]
9604    fn as_page_unwraps_user_defined_papers_tuple_payload() {
9605        let v = Value::Ctor(
9606            "UserDefinedPaper".to_string(),
9607            Some(Box::new(Value::Tuple(vec![
9608                Value::Length(Length::pt(100.0)),
9609                Value::Length(Length::pt(200.0)),
9610            ]))),
9611        );
9612        assert_eq!(
9613            as_page(v).unwrap(),
9614            PaperSize::UserDefined(Length::pt(100.0), Length::pt(200.0))
9615        );
9616    }
9617
9618    #[test]
9619    fn a4_paper_dims_are_595_by_842_points() {
9620        let (w, h) = PaperSize::A4.dims();
9621        assert!((w.0 - 595.0).abs() < 1.0, "width: {}", w.0);
9622        assert!((h.0 - 842.0).abs() < 1.0, "height: {}", h.0);
9623    }
9624
9625    #[test]
9626    fn read_content_scheme_extracts_origin_and_height() {
9627        let mut fields = BTreeMap::new();
9628        fields.insert(
9629            "text-origin".to_string(),
9630            Value::Tuple(vec![
9631                Value::Length(Length::pt(10.0)),
9632                Value::Length(Length::pt(20.0)),
9633            ]),
9634        );
9635        fields.insert("text-height".to_string(), Value::Length(Length::pt(300.0)));
9636        let (origin, height) = read_content_scheme(Value::Record(fields)).unwrap();
9637        assert_eq!(origin, (Length::pt(10.0), Length::pt(20.0)));
9638        assert_eq!(height, Length::pt(300.0));
9639    }
9640
9641    #[test]
9642    fn read_content_scheme_errors_on_a_missing_field() {
9643        let mut fields = BTreeMap::new();
9644        fields.insert(
9645            "text-origin".to_string(),
9646            Value::Tuple(vec![
9647                Value::Length(Length::ZERO),
9648                Value::Length(Length::ZERO),
9649            ]),
9650        );
9651        let err = read_content_scheme(Value::Record(fields)).unwrap_err();
9652        assert!(
9653            err.msg.contains("text-height"),
9654            "error should name the missing field: {}",
9655            err.msg
9656        );
9657    }
9658
9659    #[test]
9660    fn read_parts_scheme_extracts_all_four_fields() {
9661        let mut fields = BTreeMap::new();
9662        fields.insert(
9663            "header-origin".to_string(),
9664            Value::Tuple(vec![
9665                Value::Length(Length::ZERO),
9666                Value::Length(Length::ZERO),
9667            ]),
9668        );
9669        fields.insert("header-content".to_string(), Value::BlockBoxes(Vec::new()));
9670        fields.insert(
9671            "footer-origin".to_string(),
9672            Value::Tuple(vec![
9673                Value::Length(Length::pt(1.0)),
9674                Value::Length(Length::pt(2.0)),
9675            ]),
9676        );
9677        fields.insert("footer-content".to_string(), Value::BlockBoxes(Vec::new()));
9678        let (horg, hbb, forg, fbb) = read_parts_scheme(Value::Record(fields)).unwrap();
9679        assert_eq!(horg, (Length::ZERO, Length::ZERO));
9680        assert!(hbb.is_empty());
9681        assert_eq!(forg, (Length::pt(1.0), Length::pt(2.0)));
9682        assert!(fbb.is_empty());
9683    }
9684
9685    #[test]
9686    fn read_parts_scheme_errors_on_a_missing_field() {
9687        let err = read_parts_scheme(Value::Record(BTreeMap::new())).unwrap_err();
9688        assert!(
9689            err.msg.contains("header-origin"),
9690            "error should name the missing field: {}",
9691            err.msg
9692        );
9693    }
9694}
9695
9696/// `enter_script_scales_and_saturates`:
9697/// `enter_script` is crate-private, so this lives here rather than in the
9698/// external `tests/v01_math.rs` integration suite, which can only reach
9699/// `pub` items.
9700#[cfg(test)]
9701mod math_split_tests {
9702    use super::*;
9703    use rustyfi_backend::FontMetrics;
9704
9705    /// A `FontMetrics` stub with NO MATH table (`math_constants` defaults
9706    /// to `None`) — exercises `enter_script`'s documented fallback
9707    /// constants (`0.7`, `5.0/7.0`), the shape every other base-14 fixture
9708    /// in this crate already relies on.
9709    struct NoMath;
9710    impl FontMetrics for NoMath {
9711        fn advance(&self, _f: FontKey, c: char, size: Length) -> Option<Length> {
9712            if c.is_ascii() {
9713                Some(size * 0.5)
9714            } else {
9715                None
9716            }
9717        }
9718        fn ascender(&self, _f: FontKey, size: Length) -> Length {
9719            size * 0.75
9720        }
9721        fn descender(&self, _f: FontKey, size: Length) -> Length {
9722            size * 0.25
9723        }
9724    }
9725
9726    #[test]
9727    fn enter_script_scales_and_saturates() {
9728        let metrics = NoMath;
9729        let interp = Interp::new(&metrics);
9730        let ctx = Context::initial(Length::pt(400.0));
9731        assert_eq!(ctx.math_script_level, MathScriptLevel::Base);
9732        assert_eq!(ctx.font_size, Length::pt(12.0));
9733
9734        // Base -> Script: font_size * script_scale_down (fallback 0.7).
9735        let s1 = enter_script(&interp, &ctx);
9736        assert_eq!(s1.math_script_level, MathScriptLevel::Script);
9737        assert!(
9738            (s1.font_size.0 - ctx.font_size.0 * 0.7).abs() < 1e-9,
9739            "expected {} * 0.7, got {}",
9740            ctx.font_size.0,
9741            s1.font_size.0
9742        );
9743
9744        // Script -> ScriptScript: font_size * (script_script_scale_down /
9745        // script_scale_down) (fallback 5.0/7.0).
9746        let s2 = enter_script(&interp, &s1);
9747        assert_eq!(s2.math_script_level, MathScriptLevel::ScriptScript);
9748        assert!(
9749            (s2.font_size.0 - s1.font_size.0 * (5.0 / 7.0)).abs() < 1e-9,
9750            "expected {} * 5/7, got {}",
9751            s1.font_size.0,
9752            s2.font_size.0
9753        );
9754
9755        // ScriptScript saturates: no further shrink, level stays put.
9756        let s3 = enter_script(&interp, &s2);
9757        assert_eq!(s3.math_script_level, MathScriptLevel::ScriptScript);
9758        assert_eq!(s3.font_size, s2.font_size);
9759    }
9760}