Skip to main content

rustyfi_lang/
primitives.rs

1//! The primitive registry. Shaped so the ~300 vminst instructions can be
2//! ported one `prims!` line at a time; primitives are registered under their
3//! real v0.0.6 names so later stdlib loading finds them.
4//!
5//! `document`, `+p` and `\emph` are not natives: they live in the
6//! `stdja-mini` stdlib package
7//! (`lib-rustyfi/dist/packages/stdja-mini.satyh`), loaded through
8//! `rustyfi-loader` and typechecked/evaluated like any other `.satyh`
9//! library. See that file's header comment for the primitives it is built
10//! from.
11
12use crate::eval::{available_fields, eval_error, DecoEntry, EvalError, Interp};
13use crate::quoted::{BText, IText, MathElem};
14use crate::value::{BaseEnv, DocumentValue, Env, TextInfo, Value};
15use rustyfi_backend::char_script;
16use rustyfi_backend::{
17    break_into_lines, break_opportunities, chop_page, default_math_variant_char, fit_cell,
18    graphics_bbox, linear_transform_graphics, linear_transform_path, measure_block,
19    natural_metrics, path_bbox, place_block_at, placed_line_extent, shift_graphics, shift_path,
20    Annot, AnnotAction, BreakKind, Cell, Closing, Color, Context, Dash, DecoId, DocExtras, DocInfo,
21    FontKey, GraphicsElem, GraphicsFnId, HookId, HorzBox, HorzStringInfo, HyphenLang, ImageId,
22    ImageResource, InlineMarkKind, Language, Length, ListMarkKind, MathCharClass, MathConstants,
23    MathCorner, MathGlyph, MathKind, MathScriptLevel, NamedDest, OutlineEntry, Paddings, Page,
24    PageGeometry, PaperSize, Path, PathSeg, Point, PrePath, PureHorzBox, Script, ScriptFont,
25    Subpath, TabularBox, VertBox, VertVariantPolicy, FORCED_BREAK_PENALTY, MIN_FIRST_ASCENDER,
26    NO_BREAK_PENALTY,
27};
28// Only the `load-pdf-image` importer builds these, and it is compiled out
29// without the `pdf-image` feature.
30#[cfg(feature = "pdf-image")]
31use rustyfi_backend::{ImportedObjects, ObjRepr, PdfPageResource};
32use rustyfi_syntax::RustyfiVersion;
33use std::collections::BTreeMap;
34#[cfg(feature = "pdf-image")]
35use std::collections::BTreeSet;
36use std::rc::Rc;
37use std::sync::Arc;
38// UAX #15 normalization / UAX #29 grapheme segmentation, for
39// `normalize-string-to-nf{c,d}`/`split-grapheme-cluster`.
40use unicode_normalization::UnicodeNormalization;
41use unicode_segmentation::UnicodeSegmentation;
42
43/// Font keys agreed with this port's base-14 metrics provider.
44const FONT_REGULAR: FontKey = FontKey(0);
45const FONT_BOLD: FontKey = FontKey(1);
46const FONT_OBLIQUE: FontKey = FontKey(2);
47
48/// Which target version(s) a `PrimDef` row is registered under. Mirrors
49/// `RustyfiVersion`'s two-variant shape today; `#[non_exhaustive]` for the
50/// same reason `RustyfiVersion` is (a future third generation gets a new
51/// arm here, not a redesign) — every `match` on this type needs a wildcard.
52#[non_exhaustive]
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum VersionSpan {
55    /// Registered under every version this port implements. The default
56    /// for every `prims!` line that omits a tag.
57    Both,
58    V0_0Only,
59    V0_1Only,
60}
61
62impl VersionSpan {
63    /// Whether a `PrimDef`/type-table row tagged `self` should be visible
64    /// under `version`. `Both` always allows; `V0_0Only`/`V0_1Only` allow
65    /// exactly their own version — no partial/future-version fallback (a
66    /// third generation gets its own new `VersionSpan` arm, not silent
67    /// inclusion under an existing one).
68    pub fn allows(self, version: RustyfiVersion) -> bool {
69        match (self, version) {
70            (VersionSpan::Both, _) => true,
71            (VersionSpan::V0_0Only, RustyfiVersion::V0_0) => true,
72            (VersionSpan::V0_1Only, RustyfiVersion::V0_1) => true,
73            _ => false,
74        }
75    }
76}
77
78pub struct PrimDef {
79    pub name: &'static str,
80    pub arity: usize,
81    pub run: fn(&mut Interp, Vec<Value>) -> Result<Value, EvalError>,
82    pub version: VersionSpan,
83}
84
85impl std::fmt::Debug for PrimDef {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(
88            f,
89            "PrimDef({}/{}, {:?})",
90            self.name, self.arity, self.version
91        )
92    }
93}
94
95macro_rules! prims {
96    ($($($tag:ident)? $name:literal ($arity:literal) => $f:path;)*) => {
97        static PRIM_DEFS: &[PrimDef] = &[
98            $(PrimDef {
99                name: $name,
100                arity: $arity,
101                run: $f,
102                version: prims!(@span $($tag)?),
103            },)*
104        ];
105    };
106    (@span) => { VersionSpan::Both };
107    (@span v006) => { VersionSpan::V0_0Only };
108    (@span v01) => { VersionSpan::V0_1Only };
109}
110
111/// Generate the `_v006`/`_v01` `PrimDef`-shaped pair for a primitive body
112/// that needs to know the generation of the code that CALLED it.
113///
114/// The graphics-callback family (the primitives below, and the deco family
115/// behind them) needs this: those bodies decide whether a callback returns `graphics
116/// list` (0.0.6) or one `graphics` collection (0.1). Do NOT read
117/// `interp.version` for that — it is a single whole-program field naming the
118/// ENTRY document's generation, while a spliced 0.0.6 package calls these
119/// primitives with its OWN convention.
120///
121/// Registering the body twice fixes it at compile time: `compile.rs`'s
122/// `Ast::VersionScope` arm folds a primitive reference against the innermost
123/// enclosing scope's version, so a call inside a 0.0.6 dependency picks the
124/// `_v006` row and one in the 0.1 entry picks `_v01`, with nothing threaded
125/// through the interpreter. The two rows share one type-table entry per
126/// version (`prim_types::primitive_type_with_version`).
127macro_rules! version_forked_prims {
128    ($($v006:ident, $v01:ident => $body:path;)*) => {$(
129        fn $v006(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
130            $body(interp, RustyfiVersion::V0_0, args)
131        }
132        fn $v01(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
133            $body(interp, RustyfiVersion::V0_1, args)
134        }
135    )*};
136}
137
138version_forked_prims! {
139    prim_inline_graphics_v006, prim_inline_graphics_v01 => prim_inline_graphics;
140    prim_inline_graphics_outer_v006, prim_inline_graphics_outer_v01
141        => prim_inline_graphics_outer;
142    prim_tabular_v006, prim_tabular_v01 => prim_tabular;
143    prim_inline_frame_outer_v006, prim_inline_frame_outer_v01 => prim_inline_frame_outer;
144    prim_inline_frame_inner_v006, prim_inline_frame_inner_v01 => prim_inline_frame_inner;
145    prim_inline_frame_breakable_v006, prim_inline_frame_breakable_v01
146        => prim_inline_frame_breakable;
147    prim_block_frame_breakable_v006, prim_block_frame_breakable_v01
148        => prim_block_frame_breakable;
149}
150
151prims! {
152    "read-inline" (2) => prim_read_inline;
153    "read-block" (2) => prim_read_block;
154    // v0.0.6 (vminst.ml `BackendLineBreaking`): `bool -> bool -> context ->
155    // inline-boxes -> block-boxes` — the two leading bools select whether
156    // the paragraph's top/bottom edge is breakable across a page boundary.
157    "line-break" (4) => prim_line_break;
158    // `page -> (pbinfo -> page-content-scheme) -> (pbinfo -> page-parts) ->
159    // block-boxes -> document` (vminst.ml:1024, `BackendPageBreaking`) —
160    // v0.0.6's `page` ADT argument. 0.1's `page` ADT is gone; the v01 arm's
161    // first argument becomes a plain `length * length` instead, same arity
162    // and same `page_break_core` backing loop.
163    v006 "page-break" (4) => prim_page_break_v006;
164    v01  "page-break" (4) => prim_page_break_v01;
165
166    // `page-break-multicolumn` (vminst.ml:1065
167    // `BackendPageBreakingMultiColumn`) / `page-break-two-column`
168    // (vminst.ml:1041 `BackendPageBreakingTwoColumn`): same v006/v01 fork
169    // shape as `page-break` above.
170    v006 "page-break-multicolumn" (7) => prim_page_break_multicolumn_v006;
171    v01  "page-break-multicolumn" (7) => prim_page_break_multicolumn_v01;
172    v006 "page-break-two-column" (6) => prim_page_break_two_column_v006;
173    v01  "page-break-two-column" (6) => prim_page_break_two_column_v01;
174
175    // ---- int arithmetic (vminst.ml: Plus/Minus/Times/Divides/Mod) --------
176    "+" (2) => prim_int_add;
177    "-" (2) => prim_int_sub;
178    "*" (2) => prim_int_mul;
179    "/" (2) => prim_int_div;
180    "mod" (2) => prim_int_mod;
181
182    // ---- int comparisons (vminst.ml: EqualTo/GreaterThan/LessThan; the "<>"/">="/"<=" trio comes from primitives.cppo.ml's `general_table`,
183    // defined there as `LogicalNot (EqualTo ..)` / `LogicalNot (LessThan ..)`
184    // / `LogicalNot (GreaterThan ..)`, typed `int -> int -> bool`) ----------
185    "==" (2) => prim_int_eq;
186    "<>" (2) => prim_int_ne;
187    "<" (2) => prim_int_lt;
188    ">" (2) => prim_int_gt;
189    "<=" (2) => prim_int_le;
190    ">=" (2) => prim_int_ge;
191
192    // ---- 0.1 bitwise ops (dev-0-1-0 vminst.ml: PrimitiveBitShiftLeft :2495, PrimitiveBitShiftRight :2477, PrimitiveBand :2527,
193    // PrimitiveBor :2541, PrimitiveBxor :2513, PrimitiveBnot :2556).
194    // 0.0.6 upstream has none of these; `<<`/`>>` lex as ordinary
195    // BinopLt/BinopGt opsymbol runs under BOTH versions (lexer.rs:634-653)
196    // and simply stay unbound names under 0.0.6. --
197    v01 "<<" (2) => prim_bit_shift_left;
198    v01 ">>" (2) => prim_bit_shift_right;
199    v01 "band" (2) => prim_band;
200    v01 "bor" (2) => prim_bor;
201    v01 "bxor" (2) => prim_bxor;
202    v01 "bnot" (1) => prim_bnot;
203
204    // ---- bool (vminst.ml: LogicalAnd/LogicalOr/LogicalNot) ----------------
205    // NOTE: registered here as strict 2-arg primitives (both arguments are
206    // evaluated before the call, since primitive application is call-by-
207    // value). Real SATySFi short-circuits "&&"/"||" via elaboration
208    // (build-in `if`); that desugaring lives in the (out-of-scope) elaborator.
209    "&&" (2) => prim_bool_and;
210    "||" (2) => prim_bool_or;
211    "not" (1) => prim_bool_not;
212
213    // ---- float (vminst.ml: FloatPlus/FloatMinus/FloatTimes/FloatDivides, PrimitiveFloat, PrimitiveRound) ---------------------------------------
214    "+." (2) => prim_float_add;
215    "-." (2) => prim_float_sub;
216    "*." (2) => prim_float_mul;
217    "/." (2) => prim_float_div;
218    "float" (1) => prim_float_of_int;
219    "round" (1) => prim_round;
220
221    // ---- 0.1 float comparisons (saphe-split@b836d512 vminst.ml:2679-2740:
222    // PrimitiveFloatGreaterThan/-LessThan/-GreaterThanOrEqualTo/
223    // -LessThanOrEqualTo, named ">."/"<."/">=."/"<=."). Confirmed absent
224    // from 0.0.6 upstream (0 hits in either its v0.0.6 tag or dev-0-1-0's
225    // vminst.ml/primitives.cppo.ml) — genuinely v01-only, unlike "+."/"-."/
226    // "*."/"/." above; float.satyg's `abs`/`max`/`min` need `>=.`/`<=.`.
227    // All four lex as ordinary BinopGt/BinopLt opsymbol runs under both
228    // versions (lexer.rs:634-653), same as the bitwise "<<"/">>" above.
229    v01 ">." (2) => prim_float_gt;
230    v01 "<." (2) => prim_float_lt;
231    v01 ">=." (2) => prim_float_ge;
232    v01 "<=." (2) => prim_float_le;
233
234    // ---- length arithmetic (vminst.ml: LengthPlus/LengthMinus/LengthTimes/ LengthDivides/LengthLessThan/LengthGreaterThan) -----------------------
235    "+'" (2) => prim_length_add;
236    "-'" (2) => prim_length_sub;
237    "*'" (2) => prim_length_scale;
238    "/'" (2) => prim_length_div;
239    "<'" (2) => prim_length_lt;
240    ">'" (2) => prim_length_gt;
241
242    // ---- string (vminst.ml: Concat, PrimitiveArabic, PrimitiveSame) -------
243    "^" (2) => prim_string_concat;
244    "arabic" (1) => prim_arabic;
245    "string-same" (2) => prim_string_same;
246
247    // ---- list cons ----------------------------------------------------------
248    // Upstream makes `::` syntax (`UTListCons`/`ListCons`), not a primitive.
249    // This port's elaborator flattens every binary operator into
250    // `Apply(Apply(Var(op_text), lhs), rhs)` (see `elaborate.rs`'s
251    // operator-precedence fold), so `::` needs an env-bound primitive like
252    // `+`/`^`.
253    "::" (2) => prim_list_cons;
254
255    // ---- mutable-cell dereference (evaluator.cppo.ml `Dereference`) --------
256    // Upstream's "!" *constructs* a `Dereference` AST node that a later pass
257    // reduces (primitives.cppo.ml: `lambda1 (fun v1 -> Dereference(v1))`);
258    // this port has no such two-step split, so "!" is an ordinary strict
259    // primitive that dereferences directly — structural deviation only.
260    "!" (1) => prim_deref;
261
262    // ---- string, continued (vminst.ml: PrimitiveStringLength/StringSub/ StringExplode; low-priority additions verified against vminst.ml) ----
263    "string-length" (1) => prim_string_length;
264    "string-sub" (3) => prim_string_sub;
265    "string-explode" (1) => prim_string_explode;
266    "regexp-of-string" (1) => prim_regexp_of_string;
267    "string-match" (2) => prim_string_match;
268    "string-scan" (2) => prim_string_scan;
269    "split-on-regexp" (2) => prim_split_on_regexp;
270
271    // ---- text embedding (vminst.ml:1707 PrimitiveEmbed: string -> inline- text; the interp body wraps the string as a one-element quoted text) --
272    "embed-string" (1) => prim_embed_string;
273
274    // ---- context ops -----------------------------------------------------
275    //
276    // vminst.ml:1434 `PrimitiveSetFontSize`: `~% (tLN @-> tCTX @-> tCTX)`.
277    "set-font-size" (2) => prim_set_font_size;
278    // vminst.ml:1449 `PrimitiveGetFontSize`: `~% (tCTX @-> tLN)`.
279    "get-font-size" (1) => prim_get_font_size;
280    // vminst.ml:1633 `PrimitiveSetLeading`: `~% (tLN @-> tCTX @-> tCTX)`,
281    // sets `ctx.leading` — the baseline-to-baseline distance, which is
282    // exactly our existing `Context::leading` field. (There is *also* a
283    // `set-min-gap-of-lines`, vminst.ml:1291-1292, which sets a *different*
284    // field, `min_gap_of_lines` — the minimum extra gap between two lines'
285    // bounding boxes, on top of `leading`. We don't model that separate
286    // field, so `set-leading` is the one that matches "baseline distance"
287    // and an existing Context field.)
288    "set-leading" (2) => prim_set_leading;
289    // vminst.ml:1396 `PrimitiveSetParagraphMargin`:
290    // `~% (tLN @-> tLN @-> tCTX @-> tCTX)`. Sets the new `paragraph_top`/
291    // `paragraph_bottom` fields (see context.rs); not wired into any
292    // box-producing primitive yet (a future `+p` would consult them).
293    "set-paragraph-margin" (3) => prim_set_paragraph_margin;
294    // vminst.ml:1648 `PrimitiveGetTextWidth`: `~% (tCTX @-> tLN)`.
295    "get-text-width" (1) => prim_get_text_width;
296    // vminst.ml:1247 `PrimitiveGetInitialContext`:
297    // `~% (tLN @-> tICMD tMATH @-> tCTX)` — a paragraph width and the
298    // *default math command* (the handler used for bare `${...}` math
299    // embedded directly in inline text). FAITHFUL: the second argument is
300    // interned via `Interp::register_math_command` and installed as
301    // `Context::math_command`, consulted by `read_inline`'s `EmbedMath` arm.
302    "get-initial-context" (2) => prim_get_initial_context;
303    // LOCAL, non-upstream primitive: `set-font-key : int -> context ->
304    // context`, sets `Context::font` directly to `FontKey(n)`. v0.0.6 has no
305    // primitive shaped like this at all — real font switching there goes
306    // through `set-font : script -> (string * float * float) -> context ->
307    // context` (choosing a font *by name* per script, vminst.ml's
308    // `PrimitiveSetFont`), which is far richer than this port's
309    // base-14-metrics-by-`FontKey` model can support. `set-font-key` is the
310    // minimal faithful-enough stand-in the `stdja-mini` stdlib package
311    // (lib-rustyfi/dist/packages/stdja-mini.satyh) needs to implement
312    // `\emph`/`\bold` by switching to the oblique/bold base-14 face
313    // (`FONT_OBLIQUE`/`FONT_BOLD` above) without inventing a whole font-name
314    // resolution layer. Out-of-range keys are accepted as-is (there is no
315    // registry to validate against yet); an unknown `FontKey` simply fails
316    // later, when a font metrics lookup for it comes up empty.
317    "set-font-key" (2) => prim_set_font_key;
318
319    // ---- box combinators (vminst.ml `HorzConcat`/`VertConcat`/ `BackendVertSkip`/`BackendFixedEmpty`/`BackendOuterEmpty`) ----------
320    //
321    // vminst.ml:803 `HorzConcat`: `~% (tIB @-> tIB @-> tIB)`.
322    "++" (2) => prim_inline_concat;
323    // vminst.ml:818 `VertConcat`: `~% (tBB @-> tBB @-> tBB)`.
324    "+++" (2) => prim_block_concat;
325    // vminst.ml:1757 `BackendFixedEmpty`: `~% (tLN @-> tIB)` — a fixed-width
326    // box with no stretch/shrink (`PureHorzBox::FixedEmpty`, hbox.rs).
327    "inline-skip" (1) => prim_inline_skip;
328    // vminst.ml:1771 `BackendOuterEmpty`: `~% (tLN @-> tLN @-> tLN @-> tIB)`,
329    // params `(widnat, widshrink, widstretch)` in that order — exactly the
330    // (natural, shrinkable, stretchable) field order `PureHorzBox::OuterEmpty`
331    // already uses, so this is a direct wrap, no new box variant needed.
332    "inline-glue" (3) => prim_inline_glue;
333    // vminst.ml:1171 `BackendVertSkip`: `~% (tLN @-> tBB)`, builds
334    // `VertFixedBreakable(len)` — our existing `VertBox::Skip(len)`.
335    "block-skip" (1) => prim_block_skip;
336
337    // ---- the reflow marker-box
338    // constructors. No vminst.ml entry — these are NEW primitives (not an
339    // upstream port), the minimal hook that is unavoidable since
340    // list/emphasis structure is 100% interpreted `.satyh` with no existing
341    // Rust interception point. Both take a plain `int` tag (there is no
342    // surface syntax to pass a Rust enum literal from `.satyh` source) —
343    // see `prim_list_mark`/`prim_inline_mark`'s doc comments for the exact
344    // tag encoding. Registered for `Both` versions (harmless/unused under
345    // 0.0.6 today; the 0.0.6 `itemize.satyh` may be wired to them later). ----
346    "list-mark" (1) => prim_list_mark;
347    "inline-mark" (1) => prim_inline_mark;
348
349    // `|>` (reverse application) is NOT a primitive: it is elaborated
350    // directly to `Apply(f, x)` (see `elaborate.rs`'s `climb`).
351
352    // ---- float trig / log / exp / rounding (vminst.ml 2729-2880) ----------
353    "sin" (1) => prim_sin;
354    "asin" (1) => prim_asin;
355    "cos" (1) => prim_cos;
356    "acos" (1) => prim_acos;
357    "tan" (1) => prim_tan;
358    "atan" (1) => prim_atan;
359    "atan2" (2) => prim_atan2;
360    "log" (1) => prim_log;
361    "exp" (1) => prim_exp;
362    // vminst.ml:2865/2880 `PrimitiveCeil`/`PrimitiveFloor`: both `float ->
363    // float` (NOT `int` — easy to mistype; contrast `round`, above, which
364    // does return `int`).
365    "ceil" (1) => prim_ceil;
366    "floor" (1) => prim_floor;
367    // vminst.ml:2319 `PrimitiveShowFloat`: `float -> string`, OCaml's
368    // `string_of_float`.
369    "show-float" (1) => prim_show_float;
370
371    // ---- byte-indexed string ops (vminst.ml 2056-2196) ---------------------
372    // vminst.ml:2159 `PrimitiveStringByteLength`: counts UTF-8 BYTES, unlike
373    // `string-length`'s Unicode-scalar-value count above.
374    "string-byte-length" (1) => prim_string_byte_length;
375    // vminst.ml:2123 `PrimitiveStringSubBytes`: byte-indexed `string-sub`.
376    "string-sub-bytes" (3) => prim_string_sub_bytes;
377    // vminst.ml:2196 `PrimitiveStringUnexplode`: inverse of `string-explode`.
378    "string-unexplode" (1) => prim_string_unexplode;
379
380    // ---- 0.1 Unicode string prims (dev-0-1-0 vminst.ml :2050/:2066/:2082),
381    // via the `unicode-normalization`/`unicode-segmentation` crates. --------
382    v01 "normalize-string-to-nfc" (1) => prim_normalize_string_to_nfc;
383    v01 "normalize-string-to-nfd" (1) => prim_normalize_string_to_nfd;
384    v01 "split-grapheme-cluster" (1) => prim_split_grapheme_cluster;
385
386    // ---- diagnostics (vminst.ml 2056, 3133) --------------------------------
387    // vminst.ml:2056 `PrimitiveDisplayMessage`: `string -> unit`. Upstream
388    // prints to stdout (`print_endline`); see `prim_display_message`'s doc
389    // comment for why this port deliberately prints to stderr instead.
390    "display-message" (1) => prim_display_message;
391    // vminst.ml:3133 `AbortWithMessage`: `string -> 'a` — raises a dynamic
392    // error carrying the message verbatim.
393    "abort-with-message" (1) => prim_abort_with_message;
394    // ---- images (raster images). Mirrors v0.0.6 vminstdef.yaml:540/:554. -
395    "load-image"          (1) => prim_load_image;         // string -> image
396    "use-image-by-width"  (2) => prim_use_image_by_width; // image -> length -> inline-boxes
397    // `load-pdf-image : string -> int -> image` (v0.0.6 vminstdef.yaml:525;
398    // dev-0-1-0 `PrimitiveLoadPdfImage` — same name/type/body across both
399    // versions).
400    "load-pdf-image" (2) => prim_load_pdf_image;
401    // `read-file : string -> list string` (dev-0-1-0 vminst.ml :3073) —
402    // REAL, `load-image`'s cwd-relative-path precedent
403    // (`prim_load_image`'s doc comment above): job-directory resolution
404    // isn't plumbed into `Interp` at all yet, so this resolves against the
405    // process cwd instead of upstream's job directory — documented
406    // deviation, see `prim_read_file`'s own doc comment.
407    //
408    // NOT `v01`-gated: it landed on the 0.0.6 dev line rather than in 0.1.
409    // See the matching note in `prim_types.rs` for the evidence.
410    "read-file" (1) => prim_read_file;
411    // `register-document-information : document-information-dictionary ->
412    // unit` (dev-0-1-0 vminst.ml :2978) — REAL:
413    // stores into `Interp::doc_info` (last-write-wins), drained into
414    // `DocExtras::doc_info`, emitted as the PDF `/Info` dictionary by both
415    // writers.
416    v01 "register-document-information" (1) => prim_register_document_information;
417    // ==== graphics primitives ====
418    // Paths, fill/stroke, and the `inline-graphics` on-page sink. Argument
419    // order transcribed from `tools/gencode/vminst.ml`: `start-path` :713,
420    // `line-to` :727, `terminate-path` :759, `close-with-line` :773,
421    // `fill` :2398, `stroke` :2381, `inline-graphics` :1872.
422    "start-path" (1) => prim_start_path;
423    "line-to" (2) => prim_line_to;
424    "terminate-path" (1) => prim_terminate_path;
425    "close-with-line" (1) => prim_close_with_line;
426    "fill" (2) => prim_fill;
427    "stroke" (3) => prim_stroke;
428    // These three take a graphics-producing CALLBACK whose result shape
429    // forks (`graphics list` vs one `graphics` collection) — see
430    // `version_forked_prims!`'s doc comment.
431    v006 "inline-graphics" (4) => prim_inline_graphics_v006;
432    v01  "inline-graphics" (4) => prim_inline_graphics_v01;
433    // `tabular : (cell list) list -> (length list -> length list ->
434    // graphics list) -> inline-boxes` (vminst.ml:539);
435    v006 "tabular" (2) => prim_tabular_v006;
436    v01  "tabular" (2) => prim_tabular_v01;
437    // `inline-graphics-outer : length -> length -> (length -> point ->
438    // graphics list) -> inline-boxes` (vminst.ml:1891
439    // `BackendInlineGraphicsOuter`).
440    v006 "inline-graphics-outer" (3) => prim_inline_graphics_outer_v006;
441    v01  "inline-graphics-outer" (3) => prim_inline_graphics_outer_v01;
442    // ---- gr.satyh prims — see tools/gencode/vminst.ml for exact
443    // signatures: `bezier-to` :742, `close-with-bezier` :787, `shift-path`
444    // :663, `linear-transform-path` :678, `shift-graphics` :2451,
445    // `linear-transform-graphics` :2432, `get-graphics-bbox` :2466,
446    // `get-path-bbox` :696, `dashed-stroke` :2414, `draw-text` :2363.
447    "bezier-to" (4) => prim_bezier_to;
448    "close-with-bezier" (3) => prim_close_with_bezier;
449    "shift-path" (2) => prim_shift_path;
450    "linear-transform-path" (5) => prim_linear_transform_path;
451    "shift-graphics" (2) => prim_shift_graphics;
452    "linear-transform-graphics" (5) => prim_linear_transform_graphics;
453    // `get-graphics-bbox`: v0.0.6 = un-optioned pair (vminst.ml:2466); v0.1
454    // wraps `option` (dev-0-1-0 vminst.ml:2301).
455    v006 "get-graphics-bbox" (1) => prim_get_graphics_bbox_v006;
456    v01  "get-graphics-bbox" (1) => prim_get_graphics_bbox_v01;
457    "get-path-bbox" (1) => prim_get_path_bbox;
458    "dashed-stroke" (4) => prim_dashed_stroke;
459    "draw-text" (2) => prim_draw_text;
460    // ---- 0.1 graphics-collection prims (dev-0-1-0 vminst.ml :3105/:3119).
461    // `graphics` is a collection under 0.1 — these two build/wrap it; the 6
462    // hidden callback-result retypes that make a `graphics`-producing
463    // callback return ONE collection instead of `list graphics` live at
464    // their existing (untagged `Both`) rows below, coerced per-version by
465    // `coerce_graphics_result`.
466    v01 "unite-graphics" (1) => prim_unite_graphics;
467    v01 "clip-graphics-by-path" (2) => prim_clip_graphics_by_path;
468
469    // ==== `pervasives.satyh` prims. Argument order transcribed from
470    // `tools/gencode/vminst.ml`: `get-natural-metrics` :2020,
471    // `inline-frame-outer` :1787, `set-manual-rising` :1661,
472    // `script-guard` :1908, `discretionary` :1969. ====
473    "get-natural-metrics" (1) => prim_get_natural_metrics;
474    // A `deco`'s result shape forks the same way, and its closure fires
475    // LONG after (a post-page-break pass), so the generation must be
476    // captured here — see `version_forked_prims!`/`DecoEntry`.
477    v006 "inline-frame-outer" (3) => prim_inline_frame_outer_v006;
478    v01  "inline-frame-outer" (3) => prim_inline_frame_outer_v01;
479    // vminst.ml:1807 `BackendInnerFrame`: same `tPADS @-> tDECO @-> tIB @->
480    // tIB` as `inline-frame-outer`.
481    v006 "inline-frame-inner" (3) => prim_inline_frame_inner_v006;
482    v01  "inline-frame-inner" (3) => prim_inline_frame_inner_v01;
483    "set-manual-rising" (2) => prim_set_manual_rising;
484    "script-guard" (2) => prim_script_guard;
485    "discretionary" (4) => prim_discretionary;
486
487    // `get-axis-height` (vminst.ml:1739 `PrimitiveGetAxisHeight`) —
488    // STAND-IN, see body; REMOVED in 0.1 (superseded by
489    // `get-math-axis-height-ratio`).
490    v006 "get-axis-height" (1) => prim_get_axis_height;
491
492    // ==== page-break-hook callback seam + cross-reference fixpoint ====
493    "hook-page-break" (1) => prim_hook_page_break;
494    "hook-page-break-block" (1) => prim_hook_page_break_block;
495    "register-cross-reference" (2) => prim_register_cross_reference;
496    "get-cross-reference" (1) => prim_get_cross_reference;
497    "probe-cross-reference" (1) => prim_probe_cross_reference;
498
499    // ==== `annot.satyh`'s prim surface (link annotations + the frame/
500    // script stand-ins it needs to type-check) ====
501    "get-leftmost-script" (1) => prim_get_leftmost_script;
502    "get-rightmost-script" (1) => prim_get_rightmost_script;
503    v006 "inline-frame-breakable" (3) => prim_inline_frame_breakable_v006;
504    v01  "inline-frame-breakable" (3) => prim_inline_frame_breakable_v01;
505    "register-destination" (2) => prim_register_destination;
506    "register-link-to-uri" (6) => prim_register_link_to_uri;
507    "register-link-to-location" (6) => prim_register_link_to_location;
508
509    // ==== the faithful `Value::Math` primitive layer `math.satyh` is built
510    // out of. 19 fork into v006/v01 pairs (v006 = zero behavior change; v01
511    // consumes/produces `Value::MathBoxes`); 5 more are REMOVED in 0.1
512    // outright (v006-tagged, untouched bodies). ====
513    v006 "math-char" (2) => prim_math_char_v006;
514    v01  "math-char" (3) => prim_math_char_v01;
515    v006 "math-big-char" (2) => prim_math_big_char_v006;
516    v01  "math-big-char" (3) => prim_math_big_char_v01;
517    v006 "math-char-with-kern" (4) => prim_math_char_with_kern_v006;
518    v01  "math-char-with-kern" (5) => prim_math_char_with_kern_v01;
519    v006 "math-big-char-with-kern" (4) => prim_math_big_char_with_kern_v006;
520    v01  "math-big-char-with-kern" (5) => prim_math_big_char_with_kern_v01;
521    v006 "math-concat" (2) => prim_math_concat_v006;
522    v01  "math-concat" (2) => prim_math_concat_v01;
523    v006 "math-group" (3) => prim_math_group_v006;
524    v01  "math-group" (3) => prim_math_group_v01;
525    v006 "math-sup" (2) => prim_math_sup_v006;
526    v01  "math-sup" (3) => prim_math_sup_v01;
527    v006 "math-sub" (2) => prim_math_sub_v006;
528    v01  "math-sub" (3) => prim_math_sub_v01;
529    v006 "math-frac" (2) => prim_math_frac_v006;
530    v01  "math-frac" (3) => prim_math_frac_v01;
531    v006 "math-radical" (2) => prim_math_radical_v006;
532    v01  "math-radical" (3) => prim_math_radical_v01;
533    v006 "math-lower" (2) => prim_math_lower_v006;
534    v01  "math-lower" (3) => prim_math_lower_v01;
535    v006 "math-upper" (2) => prim_math_upper_v006;
536    v01  "math-upper" (3) => prim_math_upper_v01;
537    // REMOVED in 0.1 outright — v006-tagged, untouched bodies.
538    v006 "math-pull-in-scripts" (3) => prim_math_pull_in_scripts;
539    v006 "math-color" (2) => prim_math_color;
540    v006 "math-char-class" (2) => prim_math_char_class;
541    v006 "math-variant-char" (2) => prim_math_variant_char;
542    // ==== the `set-math-variant-char`/`get-left-math-class`/
543    // `get-right-math-class` trio: no bundled `.satyh` consumer needed yet,
544    // built on `Context::math_variant_char_map` + `VariantCharPending`.
545    // Forked v006/v01. ====
546    v006 "set-math-variant-char" (4) => prim_set_math_variant_char_v006;
547    v01  "set-math-variant-char" (3) => prim_set_math_variant_char_v01;
548    v006 "get-left-math-class" (2) => prim_get_left_math_class_v006;
549    v01  "get-left-math-class" (1) => prim_get_left_math_class_v01;
550    v006 "get-right-math-class" (2) => prim_get_right_math_class_v006;
551    v01  "get-right-math-class" (1) => prim_get_right_math_class_v01;
552    v006 "math-paren" (3) => prim_math_paren_v006;
553    v01  "math-paren" (4) => prim_math_paren_v01;
554    v006 "math-paren-with-middle" (4) => prim_math_paren_with_middle_v006;
555    v01  "math-paren-with-middle" (5) => prim_math_paren_with_middle_v01;
556    // REMOVED in 0.1 outright.
557    v006 "text-in-math" (2) => prim_text_in_math;
558    "convert-string-for-math" (3) => prim_convert_string_for_math;
559    v006 "embed-math" (2) => prim_embed_math_v006;
560    v01  "embed-math" (2) => prim_embed_math_v01;
561    "set-math-command" (2) => prim_set_math_command;
562    // `set-math-font` forks in its argument, not its effect: 0.0.6 takes the
563    // math face's ABBREV (`string`), saphe-split takes the opaque `font`
564    // handle (`tFONTKEY`). Both end at the same `Context::math_font`.
565    v006 "set-math-font" (2) => prim_set_math_font_v006;
566    v01  "set-math-font" (2) => prim_set_math_font_v01;
567    // LOCAL, non-upstream, V0_1-only — the port's spelling for upstream's
568    // internal `LoadSingleFont{path}` node; see `prim_load_single_font`.
569    v01  "load-single-font" (1) => prim_load_single_font;
570    v006 "space-between-maths" (3) => prim_space_between_maths_v006;
571    v01  "space-between-maths" (3) => prim_space_between_maths_v01;
572    // ==== NEW in 0.1 — `math-text`/`math-boxes` split + `read-math` + the
573    // hidden `val math`-without-scripts wrapper prim. ====
574    v01 "read-math" (2) => prim_read_math;
575    v01 "stringify-math" (2) => prim_stringify_math;
576    v01 "set-math-char" (4) => prim_set_math_char;
577    v01 "set-math-char-class" (2) => prim_set_math_char_class;
578    v01 "get-math-char-class" (1) => prim_get_math_char_class;
579    v01 "embed-inline-to-math" (2) => prim_embed_inline_to_math;
580    v01 "get-math-axis-height-ratio" (1) => prim_get_math_axis_height_ratio;
581    v01 "%math-attach-scripts" (4) => prim_math_attach_scripts;
582
583    // ==== hyphenation/unidata loader + setter stand-ins, V0_1-only
584    // (genuinely absent from 0.0.6 upstream). FAITHFUL types
585    // (`prim_types.rs`); ACCEPT-AND-RETURN bodies, not hard-error
586    // stand-ins like `stringify-math` above — std-ja evaluates `val
587    // unidata = load-unicode-char-database …` at module LOAD time, so an
588    // erroring stand-in would break every consumer at load, not just at
589    // use. ====
590    v01 "load-hyphenation-dictionary" (1) => prim_load_hyphenation_dictionary;
591    v01 "load-unicode-char-database"  (3) => prim_load_unicode_char_database;
592    v01 "set-hyphenation-dictionary"  (2) => prim_set_hyphenation_dictionary;
593    v01 "set-unicode-char-database"   (2) => prim_set_unicode_char_database;
594
595    "raise-inline" (2) => prim_raise_inline;
596    "embed-block-breakable" (2) => prim_embed_block_breakable;
597    "unite-path" (2) => prim_unite_path;
598    "set-min-gap-of-lines" (2) => prim_set_min_gap_of_lines;
599
600    // ==== context-setter + box-combinator prims `code.satyh`/
601    // `itemize.satyh` need. Argument order from `tools/gencode/vminst.ml`:
602    // `set-text-color` :1603, `get-text-color` :1618, `set-hyphen-penalty`
603    // :1692, `set-space-ratio` :1309, `split-into-lines` :2269,
604    // `block-frame-breakable` :1090, `embed-block-top` :1145, `set-font`
605    // :1463; `set-code-text-command`/`get-natural-length` have no
606    // vminst.ml entry. ====
607    "set-text-color" (2) => prim_set_text_color;
608    "get-text-color" (1) => prim_get_text_color;
609    "set-hyphen-penalty" (2) => prim_set_hyphen_penalty;
610    // `set-hyphen-min : int -> int -> context -> context` (left_hyphen_min,
611    // right_hyphen_min).
612    "set-hyphen-min" (3) => prim_set_hyphen_min;
613    "set-space-ratio" (4) => prim_set_space_ratio;
614    "set-space-ratio-between-scripts" (6) => prim_set_space_ratio_between_scripts;
615    "split-into-lines" (1) => prim_split_into_lines;
616    v006 "block-frame-breakable" (4) => prim_block_frame_breakable_v006;
617    v01  "block-frame-breakable" (4) => prim_block_frame_breakable_v01;
618    "embed-block-top" (3) => prim_embed_block_top;
619    // `set-font` forks in its SECOND argument's head only: 0.0.6's
620    // `string * float * float` vs saphe-split's `font * float * float`.
621    v006 "set-font" (3) => prim_set_font_v006;
622    v01  "set-font" (3) => prim_set_font_v01;
623    // `get-font` (vminstdef.yaml:1350) forks in its RESULT's head, for the
624    // same reason and along the same seam.
625    v006 "get-font" (2) => prim_get_font_v006;
626    v01  "get-font" (2) => prim_get_font_v01;
627    "set-code-text-command" (2) => prim_set_code_text_command;
628    "get-natural-length" (1) => prim_get_natural_length;
629
630    // ==== `set-dominant-wide-script`/`set-dominant-narrow-script`/
631    // `set-language` are FAITHFUL stores with real getter round-trips
632    // below; `register-outline` is likewise FAITHFUL (drives real PDF
633    // `/Outlines` bookmarks). Only `set-every-word-break` remains a
634    // STAND-IN (accepted and dropped). ====
635    "set-dominant-wide-script" (2) => prim_set_dominant_wide_script;
636    "set-dominant-narrow-script" (2) => prim_set_dominant_narrow_script;
637    "set-language" (3) => prim_set_language;
638    "get-dominant-wide-script" (1) => prim_get_dominant_wide_script;
639    "get-dominant-narrow-script" (1) => prim_get_dominant_narrow_script;
640    "get-language" (2) => prim_get_language;
641    "set-every-word-break" (3) => prim_set_every_word_break;
642    "register-outline" (1) => prim_register_outline;
643    "extract-string" (1) => prim_extract_string;
644
645    // ==== proof.satyh/footnote-scheme.satyh prims: `embed-block-bottom`
646    // :1185, `line-stack-bottom` :1229 (both `tools/gencode/vminst.ml`),
647    // `add-footnote` :1130. ====
648    "embed-block-bottom" (3) => prim_embed_block_bottom;
649    "line-stack-bottom" (1) => prim_line_stack_bottom;
650    "line-stack-top" (1) => prim_line_stack_top;
651    "add-footnote" (1) => prim_add_footnote;
652
653    // ==== three PURE text-info prims — `get-initial-text-info` :953,
654    // `deepen-indent` :921, `break` :935 (tools/gencode/vminst.ml,
655    // text-mode). The text/html backends are OUT of scope for this PDF
656    // port, so all three live in the single shared env (upstream keys
657    // prims per mode).
658    //
659    // `get-initial-text-info` forks: v0.0.6 (vminst.ml:953) is `unit ->
660    // text-info`; v0.1 (dev-0-1-0 vminst.ml:904-925) threads a text-mode
661    // default math command + math-scripts stringifier into `tctxsub`. The
662    // v01 body ACCEPTS AND DROPS both (STAND-IN, same degenerate policy as
663    // `stringify-math`) — both bodies return `TextInfo{indent: 0}`. ====
664    v006 "get-initial-text-info" (1) => prim_get_initial_text_info_v006;
665    v01  "get-initial-text-info" (2) => prim_get_initial_text_info_v01;
666    "deepen-indent" (2) => prim_deepen_indent;
667    "break" (1) => prim_break;
668}
669
670/// The base environment v0.0.6 `document` programs start in. Back-compat
671/// wrapper over `base_env_with_version(V0_0)`.
672pub fn base_env() -> BaseEnv {
673    base_env_with_version(RustyfiVersion::V0_0)
674}
675
676/// The base environment for a given target version — filters `PRIM_DEFS` by
677/// `VersionSpan::allows`, so e.g. a `V0_1` env binds `prim_page_break_v01`
678/// under the name `"page-break"`, never `prim_page_break_v006`. The five
679/// bare-constant `env.define`s below (`inline-fil`/`inline-nil`/`block-nil`/
680/// `omit-skip-after`/`clear-page`) live outside `PrimDef`/`VersionSpan` and
681/// stay unconditional — all five exist in 0.1 upstream too (audited against
682/// `dev-0-1-0:src/frontend/primitives.cppo.ml`); `tests/v01_prims_scalar.rs`'s
683/// `bare_constants_bound_under_v01` proves it.
684pub fn base_env_with_version(version: RustyfiVersion) -> BaseEnv {
685    let mut env = BaseEnv::new();
686    for def in PRIM_DEFS {
687        if !def.version.allows(version) {
688            continue;
689        }
690        env.define(
691            def.name,
692            Value::Prim {
693                def,
694                applied: Vec::new(),
695            },
696        );
697    }
698    env.define(
699        "inline-fil",
700        Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::OuterFil)]),
701    );
702    // `inline-nil`/`block-nil`: no vminst.ml entry — v0.0.6 gets the empty
703    // list for free from literal `{}`/`<>` syntax, which this port's syntax
704    // layer doesn't produce standalone; these constants are the equivalent
705    // value bound to a name.
706    env.define("inline-nil", Value::InlineBoxes(Vec::new()));
707    env.define("block-nil", Value::BlockBoxes(Vec::new()));
708    // `omit-skip-after : inline-boxes` (`primitives.cppo.ml:567`) — a bare
709    // CONSTANT marking `HorzOmitSkipAfter`, a line-breaking hint to drop the
710    // interword glue that would otherwise follow (used at the tail of
711    // `math.satyh`'s `\eqn`/`\math-list`/`\align`). STAND-IN: this port's
712    // line-breaker has no such marker box, so it's the empty `inline-boxes`
713    // list — never consulted, since none of those wrappers is called by
714    // the file itself.
715    env.define("omit-skip-after", Value::InlineBoxes(Vec::new()));
716    // `clear-page : block-boxes` (`primitives.cppo.ml:569`) — a single-
717    // element list carrying `VertBox::ClearPage`, which `chop_page`
718    // (rustyfi-backend) treats as "end this page here". FAITHFUL.
719    env.define("clear-page", Value::BlockBoxes(vec![VertBox::ClearPage]));
720    // `here : string` — upstream `here` is a LEXER keyword expanding at lex
721    // time to the source file's directory (`Filename.dirname`). This port
722    // has no such lexer entry (`here` lexes as a plain `Token::Var`), so
723    // it's a V0_1-only nullary CONSTANT bound to the empty string. Never
724    // dereferenced as a real path: its consumers (`unidata.satyh`/
725    // `hyph-english.satyh`) feed `here ^ …` into the `load-*` stand-ins
726    // above, which drop the path unread.
727    if version == RustyfiVersion::V0_1 {
728        env.define("here", Value::Str(String::new()));
729    }
730    env
731}
732
733// ---- argument extractors ------------------------------------------------------
734
735fn as_context(v: Value) -> Result<Context, EvalError> {
736    match v {
737        Value::Context(c) => Ok(*c),
738        other => eval_error(format!("expected a context, got {}", other.type_name())),
739    }
740}
741
742fn as_text_info(v: Value) -> Result<TextInfo, EvalError> {
743    match v {
744        Value::TextInfo(t) => Ok(t),
745        other => eval_error(format!("expected a text-info, got {}", other.type_name())),
746    }
747}
748
749fn as_hyphenation(v: Value) -> Result<HyphenLang, EvalError> {
750    match v {
751        Value::Hyphenation(tag) => Ok(tag),
752        other => eval_error(format!("expected a hyphenation, got {}", other.type_name())),
753    }
754}
755
756fn as_inline_text(v: Value) -> Result<(Rc<Vec<IText>>, Env), EvalError> {
757    match v {
758        Value::InlineText { elems, env } => Ok((elems, env)),
759        other => eval_error(format!("expected inline-text, got {}", other.type_name())),
760    }
761}
762
763fn as_block_text(v: Value) -> Result<(Rc<Vec<BText>>, Env), EvalError> {
764    match v {
765        Value::BlockText { elems, env } => Ok((elems, env)),
766        other => eval_error(format!("expected block-text, got {}", other.type_name())),
767    }
768}
769
770fn as_inline_boxes(v: Value) -> Result<Vec<HorzBox>, EvalError> {
771    match v {
772        Value::InlineBoxes(b) => Ok(b),
773        other => eval_error(format!("expected inline-boxes, got {}", other.type_name())),
774    }
775}
776
777fn as_block_boxes(v: Value) -> Result<Vec<VertBox>, EvalError> {
778    match v {
779        Value::BlockBoxes(b) => Ok(b),
780        other => eval_error(format!("expected block-boxes, got {}", other.type_name())),
781    }
782}
783
784fn as_int(v: Value) -> Result<i64, EvalError> {
785    match v {
786        Value::Int(n) => Ok(n),
787        other => eval_error(format!("expected int, got {}", other.type_name())),
788    }
789}
790
791fn as_float(v: Value) -> Result<f64, EvalError> {
792    match v {
793        Value::Float(x) => Ok(x),
794        other => eval_error(format!("expected float, got {}", other.type_name())),
795    }
796}
797
798fn as_bool(v: Value) -> Result<bool, EvalError> {
799    match v {
800        Value::Bool(b) => Ok(b),
801        other => eval_error(format!("expected bool, got {}", other.type_name())),
802    }
803}
804
805fn as_str(v: Value) -> Result<String, EvalError> {
806    match v {
807        Value::Str(s) => Ok(s),
808        other => eval_error(format!("expected string, got {}", other.type_name())),
809    }
810}
811
812// `regexp-of-string : string -> regexp` — the port models a `regexp` as its
813// underlying pattern string, so this is the identity on the string.
814fn prim_regexp_of_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
815    let s = as_str(args.pop().unwrap())?;
816    Ok(Value::Str(s))
817}
818
819// `string-match : regexp -> string -> bool` — whether `input` matches the
820// pattern in full (anchored). Only the character-class subset `satysfi-base`'s
821// `char.satyg` uses (`[…]`, with `a-z` ranges and an optional leading `^`
822// negation) is modeled; any other pattern is compared literally.
823fn prim_string_match(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
824    let input = as_str(args.pop().unwrap())?;
825    let pattern = as_str(args.pop().unwrap())?;
826    Ok(Value::Bool(regexp_full_match(&pattern, &input)))
827}
828
829/// `string-scan : regexp -> string -> (string * string) option`
830/// (vminstdef.yaml:1961 `PrimitiveStringScan`) — FAITHFUL:
831///
832/// ```ocaml
833/// if Str.string_match pat str 0 then
834///   let matched = Str.matched_string str in
835///   ... Some (matched, rest)
836/// else None
837/// ```
838///
839/// i.e. an *anchored* match at offset 0, returning the matched prefix paired
840/// with everything after it. Unlike the two older regexp primitives beside it
841/// this goes through `crate::regexp`, a real backtracking engine for `Str`'s
842/// dialect, because its only consumer — `satysfi-code-printer`'s lexer —
843/// drives it with alternations, groups and quantifiers rather than the bare
844/// character classes `satysfi-base` uses.
845///
846/// Offsets are in `char`s throughout: `Str` counts bytes, but a byte split of
847/// a multi-byte character would produce an invalid `Value::Str`, and every
848/// pattern in the corpus is ASCII so the two agree wherever it matters.
849fn prim_string_scan(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
850    let input = as_str(args.pop().unwrap())?;
851    let pattern = as_str(args.pop().unwrap())?;
852    let re = crate::regexp::compile(&pattern);
853    let chars: Vec<char> = input.chars().collect();
854    let outcome = re.match_at(&chars, 0).map_err(|_| {
855        // The budget is gone: the pattern is backtracking superlinearly. Say
856        // so rather than answering "no match", which would silently produce
857        // wrong output — see `regexp::GaveUp`.
858        EvalError {
859            span: None,
860            msg: format!(
861            "string-scan: the pattern `{pattern}` needs more work, nesting or \
862             stack than the matcher allows against this input ({} characters). \
863             Usually that is catastrophic backtracking: a quantifier inside a \
864             quantified group — `\\(a*\\)*` and the like — costs a factor per \
865             nesting level, and rewriting it so the inner and outer repetitions \
866             cannot match the same text will make it fast. A pattern nesting \
867             `\\(` more than a thousand deep is refused outright.",
868                chars.len(),
869            ),
870        }
871    })?;
872    Ok(match outcome {
873        Some(end) => {
874            let matched: String = chars[..end].iter().collect();
875            let rest: String = chars[end..].iter().collect();
876            Value::Ctor(
877                "Some".to_string(),
878                Some(Box::new(Value::Tuple(vec![
879                    Value::Str(matched),
880                    Value::Str(rest),
881                ]))),
882            )
883        }
884        None => Value::Ctor("None".to_string(), None),
885    })
886}
887
888fn regexp_full_match(pattern: &str, input: &str) -> bool {
889    let p: Vec<char> = pattern.chars().collect();
890    if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
891        // A character class matches exactly one character.
892        let mut chars = input.chars();
893        match (chars.next(), chars.next()) {
894            (Some(c), None) => char_in_class(&p[1..p.len() - 1], c),
895            _ => false,
896        }
897    } else {
898        input == pattern
899    }
900}
901
902// `split-on-regexp : regexp -> string -> (int * string) list` — split `input`
903// at every character matching the (single-character) pattern, pairing each
904// resulting segment with its starting code-point offset. Handles the pattern
905// forms base uses: a `[…]` class, an escaped literal (`\.`), or a bare
906// literal character; anything else never matches (one segment = the whole
907// string).
908fn prim_split_on_regexp(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
909    let input = as_str(args.pop().unwrap())?;
910    let pattern = as_str(args.pop().unwrap())?;
911    let is_delim = single_char_matcher(&pattern);
912    let mut segments: Vec<Value> = Vec::new();
913    let mut seg_start = 0usize;
914    let mut cur = String::new();
915    for (idx, c) in input.chars().enumerate() {
916        if is_delim(c) {
917            segments.push(Value::Tuple(vec![
918                Value::Int(seg_start as i64),
919                Value::Str(std::mem::take(&mut cur)),
920            ]));
921            seg_start = idx + 1;
922        } else {
923            cur.push(c);
924        }
925    }
926    segments.push(Value::Tuple(vec![
927        Value::Int(seg_start as i64),
928        Value::Str(cur),
929    ]));
930    Ok(Value::List(segments))
931}
932
933/// A predicate matching one character against a `regexp` pattern's single-char
934/// forms (a `[…]` class, an escaped literal `\X`, or a bare literal char).
935fn single_char_matcher(pattern: &str) -> Box<dyn Fn(char) -> bool> {
936    let p: Vec<char> = pattern.chars().collect();
937    if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
938        let cls: Vec<char> = p[1..p.len() - 1].to_vec();
939        Box::new(move |c| char_in_class(&cls, c))
940    } else if p.len() == 2 && p[0] == '\\' {
941        let lit = p[1];
942        Box::new(move |c| c == lit)
943    } else if p.len() == 1 {
944        let lit = p[0];
945        Box::new(move |c| c == lit)
946    } else {
947        Box::new(|_| false)
948    }
949}
950
951fn char_in_class(cls: &[char], c: char) -> bool {
952    let (neg, cls) = match cls.first() {
953        Some('^') => (true, &cls[1..]),
954        _ => (false, cls),
955    };
956    let mut i = 0;
957    let mut found = false;
958    while i < cls.len() {
959        if i + 2 < cls.len() && cls[i + 1] == '-' {
960            if cls[i] <= c && c <= cls[i + 2] {
961                found = true;
962            }
963            i += 3;
964        } else {
965            if cls[i] == c {
966                found = true;
967            }
968            i += 1;
969        }
970    }
971    found ^ neg
972}
973
974fn as_length(v: Value) -> Result<Length, EvalError> {
975    match v {
976        Value::Length(l) => Ok(l),
977        other => eval_error(format!("expected length, got {}", other.type_name())),
978    }
979}
980
981fn as_list(v: Value) -> Result<Vec<Value>, EvalError> {
982    match v {
983        Value::List(items) => Ok(items),
984        other => eval_error(format!("expected list, got {}", other.type_name())),
985    }
986}
987
988fn as_image(v: Value) -> Result<ImageId, EvalError> {
989    match v {
990        Value::Image(id) => Ok(id),
991        other => eval_error(format!("expected image, got {}", other.type_name())),
992    }
993}
994
995// ---- graphics argument extractors ------------------------------------------
996
997/// `point` = `Value::Tuple([Length, Length])` (mirrors `evalUtil.ml:228`'s
998/// point extraction).
999fn as_point(v: Value) -> Result<Point, EvalError> {
1000    match v {
1001        Value::Tuple(vs) if vs.len() == 2 => {
1002            let mut it = vs.into_iter();
1003            let x = as_length(it.next().unwrap())?;
1004            let y = as_length(it.next().unwrap())?;
1005            Ok((x, y))
1006        }
1007        other => eval_error(format!(
1008            "expected a point (length * length), got {}",
1009            other.type_name()
1010        )),
1011    }
1012}
1013
1014/// `color` = `Value::Ctor("Gray"|"RGB"|"CMYK", ..)` (mirrors
1015/// `evalUtil.ml:124`'s `get_color` exactly — a wrong shape here would
1016/// surface only at draw time).
1017fn as_color(v: Value) -> Result<Color, EvalError> {
1018    match v {
1019        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1020            ("Gray", Some(p)) => Ok(Color::Gray(as_float(p)?)),
1021            ("RGB", Some(Value::Tuple(vs))) if vs.len() == 3 => {
1022                let mut it = vs.into_iter();
1023                let r = as_float(it.next().unwrap())?;
1024                let g = as_float(it.next().unwrap())?;
1025                let b = as_float(it.next().unwrap())?;
1026                Ok(Color::Rgb(r, g, b))
1027            }
1028            ("CMYK", Some(Value::Tuple(vs))) if vs.len() == 4 => {
1029                let mut it = vs.into_iter();
1030                let c = as_float(it.next().unwrap())?;
1031                let m = as_float(it.next().unwrap())?;
1032                let y = as_float(it.next().unwrap())?;
1033                let k = as_float(it.next().unwrap())?;
1034                Ok(Color::Cmyk(c, m, y, k))
1035            }
1036            (other, _) => eval_error(format!(
1037                "expected a color (Gray/RGB/CMYK), got variant '{other}'"
1038            )),
1039        },
1040        other => eval_error(format!("expected a color, got {}", other.type_name())),
1041    }
1042}
1043
1044/// `script` = nullary `Value::Ctor` (prim_types.rs `script_decl`); mirrors
1045/// upstream `get_script` (evalUtil.ml:235-241).
1046fn as_script(v: Value) -> Result<Script, EvalError> {
1047    match v {
1048        Value::Ctor(name, None) => match name.as_str() {
1049            "HanIdeographic" => Ok(Script::HanIdeographic),
1050            "Kana" => Ok(Script::Kana),
1051            "Latin" => Ok(Script::Latin),
1052            "OtherScript" => Ok(Script::OtherScript),
1053            other => eval_error(format!("expected a script, got variant '{other}'")),
1054        },
1055        other => eval_error(format!("expected a script, got {}", other.type_name())),
1056    }
1057}
1058
1059/// Inverse of [`as_script`] (upstream `make_script_value`, evalUtil.ml:244).
1060fn make_script_value(s: Script) -> Value {
1061    let name = match s {
1062        Script::HanIdeographic => "HanIdeographic",
1063        Script::Kana => "Kana",
1064        Script::Latin => "Latin",
1065        Script::OtherScript => "OtherScript",
1066    };
1067    Value::Ctor(name.to_string(), None)
1068}
1069
1070/// `language` = nullary `Value::Ctor` (prim_types.rs `language_decl`);
1071/// mirrors upstream `get_language_system` (evalUtil.ml:262).
1072fn as_language(v: Value) -> Result<Language, EvalError> {
1073    match v {
1074        Value::Ctor(name, None) => match name.as_str() {
1075            "Japanese" => Ok(Language::Japanese),
1076            "English" => Ok(Language::English),
1077            "NoLanguageSystem" => Ok(Language::NoLanguageSystem),
1078            other => eval_error(format!("expected a language, got variant '{other}'")),
1079        },
1080        other => eval_error(format!("expected a language, got {}", other.type_name())),
1081    }
1082}
1083
1084/// Inverse of [`as_language`] (upstream `make_language_system_value`).
1085fn make_language_value(l: Language) -> Value {
1086    let name = match l {
1087        Language::Japanese => "Japanese",
1088        Language::English => "English",
1089        Language::NoLanguageSystem => "NoLanguageSystem",
1090    };
1091    Value::Ctor(name.to_string(), None)
1092}
1093
1094/// `page` = `Value::Ctor("A4Paper"|.., None | Some(Tuple[Length;2]))`
1095/// — `page-break`'s first argument, mapped to the backend's
1096/// `PaperSize`.
1097fn as_page(v: Value) -> Result<PaperSize, EvalError> {
1098    match v {
1099        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1100            ("A0Paper", None) => Ok(PaperSize::A0),
1101            ("A1Paper", None) => Ok(PaperSize::A1),
1102            ("A2Paper", None) => Ok(PaperSize::A2),
1103            ("A3Paper", None) => Ok(PaperSize::A3),
1104            ("A4Paper", None) => Ok(PaperSize::A4),
1105            ("A5Paper", None) => Ok(PaperSize::A5),
1106            ("USLetter", None) => Ok(PaperSize::USLetter),
1107            ("USLegal", None) => Ok(PaperSize::USLegal),
1108            ("UserDefinedPaper", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1109                let mut it = vs.into_iter();
1110                let w = as_length(it.next().unwrap())?;
1111                let h = as_length(it.next().unwrap())?;
1112                Ok(PaperSize::UserDefined(w, h))
1113            }
1114            (other, _) => eval_error(format!(
1115                "expected a page (A4Paper/.../UserDefinedPaper), got variant '{other}'"
1116            )),
1117        },
1118        other => eval_error(format!("expected a page, got {}", other.type_name())),
1119    }
1120}
1121
1122/// v0.1's `page-break`'s first argument: a plain `(length * length)` tuple
1123/// — the `page` ADT (`as_page` above) no longer exists upstream in 0.1.
1124/// Maps straight into `PaperSize::UserDefined`, the exact same backend
1125/// value `as_page`'s own `UserDefinedPaper` arm produces: the retype drops
1126/// the ADT wrapper without changing what geometry `page-break` can
1127/// express, so only the source `Value` shape differs.
1128fn as_page_v01(v: Value) -> Result<PaperSize, EvalError> {
1129    match v {
1130        Value::Tuple(vs) if vs.len() == 2 => {
1131            let mut it = vs.into_iter();
1132            let w = as_length(it.next().unwrap())?;
1133            let h = as_length(it.next().unwrap())?;
1134            Ok(PaperSize::UserDefined(w, h))
1135        }
1136        other => eval_error(format!(
1137            "expected a page as (length * length), got {}",
1138            other.type_name()
1139        )),
1140    }
1141}
1142
1143/// `paddings` = `Value::Tuple([Length; 4])` in `(paddingL, paddingR,
1144/// paddingT, paddingB)` order (mirrors `evalUtil.ml`'s `get_paddings`).
1145/// `inline-frame-outer`'s first argument.
1146fn as_paddings(v: Value) -> Result<(Length, Length, Length, Length), EvalError> {
1147    match v {
1148        Value::Tuple(vs) if vs.len() == 4 => {
1149            let mut it = vs.into_iter();
1150            let l = as_length(it.next().unwrap())?;
1151            let r = as_length(it.next().unwrap())?;
1152            let t = as_length(it.next().unwrap())?;
1153            let b = as_length(it.next().unwrap())?;
1154            Ok((l, r, t, b))
1155        }
1156        other => eval_error(format!(
1157            "expected paddings (length * length * length * length), got {}",
1158            other.type_name()
1159        )),
1160    }
1161}
1162
1163/// `cell` = `Value::Ctor("NormalCell"|"EmptyCell"|"MultiCell", ..)` (mirrors
1164/// `evalUtil.ml:102`'s `get_cell`) — `tabular`'s grid entries;
1165fn as_cell(v: Value) -> Result<Cell, EvalError> {
1166    match v {
1167        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1168            ("NormalCell", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1169                let mut it = vs.into_iter();
1170                let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1171                let ib = as_inline_boxes(it.next().unwrap())?;
1172                Ok(Cell::Normal(Paddings { l, r, t, b }, ib))
1173            }
1174            ("EmptyCell", None) => Ok(Cell::Empty),
1175            ("MultiCell", Some(Value::Tuple(vs))) if vs.len() == 4 => {
1176                let mut it = vs.into_iter();
1177                let numrow = as_int(it.next().unwrap())?;
1178                let numcol = as_int(it.next().unwrap())?;
1179                let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1180                let ib = as_inline_boxes(it.next().unwrap())?;
1181                Ok(Cell::Multi(
1182                    numrow.max(0) as usize,
1183                    numcol.max(0) as usize,
1184                    Paddings { l, r, t, b },
1185                    ib,
1186                ))
1187            }
1188            (other, _) => eval_error(format!(
1189                "expected a cell (NormalCell/EmptyCell/MultiCell), got variant '{other}'"
1190            )),
1191        },
1192        other => eval_error(format!("expected a cell, got {}", other.type_name())),
1193    }
1194}
1195
1196/// `(cell list) list` — `tabular`'s first argument.
1197fn as_cell_grid(v: Value) -> Result<Vec<Vec<Cell>>, EvalError> {
1198    as_list(v)?
1199        .into_iter()
1200        .map(|row| -> Result<Vec<Cell>, EvalError> {
1201            as_list(row)?.into_iter().map(as_cell).collect()
1202        })
1203        .collect()
1204}
1205
1206fn as_prepath(v: Value) -> Result<PrePath, EvalError> {
1207    match v {
1208        Value::PrePath(p) => Ok(p),
1209        other => eval_error(format!("expected pre-path, got {}", other.type_name())),
1210    }
1211}
1212
1213fn as_path(v: Value) -> Result<Path, EvalError> {
1214    match v {
1215        Value::Path(p) => Ok(p),
1216        other => eval_error(format!("expected path, got {}", other.type_name())),
1217    }
1218}
1219
1220fn as_graphics(v: Value) -> Result<GraphicsElem, EvalError> {
1221    match v {
1222        Value::Graphics(g) => Ok(g),
1223        other => eval_error(format!("expected graphics, got {}", other.type_name())),
1224    }
1225}
1226
1227/// `dash` = `length * length * length` (mirrors `evalUtil.ml`'s `get_tuple3
1228/// get_length`) — `dashed-stroke`'s 2nd argument, `(d1, d2, d0)` = on-length,
1229/// off-length, phase.
1230fn as_dash(v: Value) -> Result<Dash, EvalError> {
1231    match v {
1232        Value::Tuple(vs) if vs.len() == 3 => {
1233            let mut it = vs.into_iter();
1234            let d1 = as_length(it.next().unwrap())?;
1235            let d2 = as_length(it.next().unwrap())?;
1236            let d0 = as_length(it.next().unwrap())?;
1237            Ok((d1, d2, d0))
1238        }
1239        other => eval_error(format!(
1240            "expected a dash pattern (length * length * length), got {}",
1241            other.type_name()
1242        )),
1243    }
1244}
1245
1246/// The inverse of `as_point` (mirrors `evalUtil.ml:228`'s point
1247/// construction) — used by `inline-graphics` to build the `(0pt, 0pt)`
1248/// origin its callback is (eagerly) invoked with; see that primitive's doc
1249/// comment for the shift-covariance caveat this stands in for.
1250fn make_point_value(pt: Point) -> Value {
1251    Value::Tuple(vec![Value::Length(pt.0), Value::Length(pt.1)])
1252}
1253
1254/// `length list` construction (mirrors `evalUtil.ml:709`) — builds the
1255/// box-local grid-line coordinates `tabular`'s rule callback is (eagerly)
1256/// invoked with; see `prim_tabular`'s doc comment.
1257fn make_length_list(lens: &[Length]) -> Value {
1258    Value::List(lens.iter().map(|l| Value::Length(*l)).collect())
1259}
1260
1261// ---- primitive-body macros ----------------------------------------------------
1262//
1263// The arithmetic, comparison, boolean, and unary-conversion primitives all
1264// share one strict-call shape: the (already-evaluated) operands are popped
1265// right-to-left through a type extractor, then the result is re-wrapped as a
1266// `Value`. These macros capture that shape so each primitive is a single line.
1267// The vminst.ml citations for each stay on the `prims!` registration table
1268// above; per-primitive notes ride along on the invocations below.
1269
1270/// A strict binary primitive. Pops `b` then `a` (i.e. rightmost argument
1271/// first, matching application order) through the given extractor(s) and wraps
1272/// `body` as `Value::$ctor`. Accepts either one extractor for both operands or
1273/// a `(as_a, as_b)` pair when the operands have different types.
1274macro_rules! binop_prim {
1275    ($name:ident, ($as_a:path, $as_b:path), $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1276        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1277            let $b = $as_b(args.pop().unwrap())?;
1278            let $a = $as_a(args.pop().unwrap())?;
1279            Ok(Value::$ctor($body))
1280        }
1281    };
1282    ($name:ident, $as:path, $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1283        binop_prim!($name, ($as, $as), $ctor, |$a, $b| $body);
1284    };
1285}
1286
1287/// A strict binary comparison: like `binop_prim!` but always wraps as
1288/// `Value::Bool`.
1289macro_rules! cmp_prim {
1290    ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1291        binop_prim!($name, ($as, $as), Bool, |$a, $b| $body);
1292    };
1293}
1294
1295/// A strict unary primitive: pops one operand through `as` and wraps `body`
1296/// as `Value::$ctor`.
1297macro_rules! unop_prim {
1298    ($name:ident, $as:path, $ctor:ident, |$a:ident| $body:expr) => {
1299        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1300            let $a = $as(args.pop().unwrap())?;
1301            Ok(Value::$ctor($body))
1302        }
1303    };
1304}
1305
1306/// A strict binary primitive with a fallible body: `body` is the function's
1307/// tail expression and must itself yield `Result<Value, EvalError>`, so it can
1308/// guard cases like division by zero.
1309macro_rules! binop_prim_try {
1310    ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1311        fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1312            let $b = $as(args.pop().unwrap())?;
1313            let $a = $as(args.pop().unwrap())?;
1314            $body
1315        }
1316    };
1317}
1318
1319// ---- text conversion ----------------------------------------------------------
1320
1321/// Convert quoted inline text to boxes under `ctx` (the core of
1322/// `read-inline`): words become measured `InnerString`s, whitespace becomes
1323/// glue, embedded commands are applied to `ctx` and their arguments.
1324pub fn read_inline(
1325    interp: &mut Interp,
1326    ctx: &Context,
1327    elems: &[IText],
1328    env: &Env,
1329) -> Result<Vec<HorzBox>, EvalError> {
1330    let mut out = Vec::new();
1331    for elem in elems {
1332        match elem {
1333            IText::Text(text) => text_to_boxes(interp, ctx, text, &mut out)?,
1334            // `ImInputHorzEmbeddedCodeText` (`evaluator.cppo.ml:768-779`): hand
1335            // the literal to the context's code-text command if one is
1336            // installed, else set it as ordinary text
1337            // (`DefaultCodeTextCommand`).
1338            IText::CodeText(text) => match ctx.code_text_command {
1339                Some(id) => {
1340                    let cmd = interp.math_commands[id.0].clone();
1341                    let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1342                    let v = interp.apply(v, Value::Str(text.clone()))?;
1343                    out.extend(as_inline_boxes(v)?);
1344                }
1345                None => text_to_boxes(interp, ctx, text, &mut out)?,
1346            },
1347            IText::Cmd { cmd, args } => {
1348                // Resolved at compile time (`crate::quoted`); running it can
1349                // still raise the same "unbound inline command" error for the
1350                // defensive case the compiler could not resolve.
1351                let cmd = cmd.run(env, interp)?;
1352                let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1353                for arg in args {
1354                    let mut opt_vals = Vec::with_capacity(arg.opts.len());
1355                    for (label, e) in &arg.opts {
1356                        opt_vals.push((label.clone(), e.run(env, interp)?));
1357                    }
1358                    let arg_v = arg.arg.run(env, interp)?;
1359                    v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1360                }
1361                out.extend(as_inline_boxes(v)?);
1362            }
1363            IText::Embed { expr, span } => {
1364                let v = expr.run(env, interp)?;
1365                match v {
1366                    Value::InlineText {
1367                        elems: sub_elems,
1368                        env: cap_env,
1369                    } => {
1370                        out.extend(read_inline(interp, ctx, &sub_elems, &cap_env)?);
1371                    }
1372                    other => {
1373                        return Err(EvalError {
1374                            span: Some(*span),
1375                            msg: format!(
1376                                "expected inline-text in '#…;' embed, got {}",
1377                                other.type_name()
1378                            ),
1379                        });
1380                    }
1381                }
1382            }
1383            IText::EmbedMath { elems, .. } => {
1384                // Upstream: a bare `${…}` in inline text evaluates by
1385                // applying the context's installed `[math] inline-cmd` to
1386                // (ctx, the math value) — `apply(cmd, ctx)` then
1387                // `apply(_, math)`, exactly like `IText::Cmd` above.
1388                let installed = ctx
1389                    .math_command
1390                    .and_then(|id| interp.math_commands.get(id.0).cloned());
1391                match installed {
1392                    Some(cmd) => {
1393                        let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1394                        let v = interp.apply(
1395                            v,
1396                            Value::MathText {
1397                                elems: Rc::clone(elems),
1398                                env: env.clone(),
1399                            },
1400                        )?;
1401                        out.extend(as_inline_boxes(v)?);
1402                    }
1403                    None => {
1404                        // No installed command (contexts built by
1405                        // `Context::initial` directly, i.e. unit tests):
1406                        // reflect + lay out through the faithful engine so
1407                        // `\cmd`/`#var` still evaluate — the same machinery
1408                        // `+math(${…})` uses via `as_math`. This fallback
1409                        // dispatches on `interp.version`
1410                        // — the installed-command path above is version-
1411                        // blind already (an ordinary `[math-text] inline-
1412                        // cmd` applied to `(ctx, math-text)`).
1413                        let mut atoms = Vec::new();
1414                        if interp.version.math_is_split() {
1415                            for e in elems.iter() {
1416                                reflect_math_elem_v01(interp, ctx, e, env, &mut atoms)?;
1417                            }
1418                        } else {
1419                            for e in elems.iter() {
1420                                reflect_math_elem(interp, e, env, &mut atoms)?;
1421                            }
1422                        }
1423                        out.push(HorzBox::Pure(layout_math_value(interp, ctx, &atoms)?));
1424                    }
1425                }
1426            }
1427        }
1428    }
1429    // Space inline `\code(…)`/`${…}` boxes against adjacent CJK prose the way
1430    // SATySFi does (the text-run glue in `text_to_boxes` can't see these
1431    // cross-element boundaries). Idempotent — a boundary already carrying glue
1432    // is skipped.
1433    Ok(insert_box_interscript_glue(out, ctx))
1434}
1435
1436/// Convert quoted block text to vertical boxes (the core of `read-block`).
1437fn read_block(
1438    interp: &mut Interp,
1439    ctx: &Context,
1440    elems: &[BText],
1441    env: &Env,
1442) -> Result<Vec<VertBox>, EvalError> {
1443    let mut out = Vec::new();
1444    for elem in elems {
1445        match elem {
1446            BText::Cmd { cmd, args } => {
1447                let cmd = cmd.run(env, interp)?;
1448                let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1449                for arg in args {
1450                    let mut opt_vals = Vec::with_capacity(arg.opts.len());
1451                    for (label, e) in &arg.opts {
1452                        opt_vals.push((label.clone(), e.run(env, interp)?));
1453                    }
1454                    let arg_v = arg.arg.run(env, interp)?;
1455                    v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1456                }
1457                out.extend(as_block_boxes(v)?);
1458            }
1459            BText::Embed { expr, span } => {
1460                let v = expr.run(env, interp)?;
1461                match v {
1462                    Value::BlockText {
1463                        elems: sub_elems,
1464                        env: cap_env,
1465                    } => {
1466                        out.extend(read_block(interp, ctx, &sub_elems, &cap_env)?);
1467                    }
1468                    other => {
1469                        return Err(EvalError {
1470                            span: Some(*span),
1471                            msg: format!(
1472                                "expected block-text in '#…;' embed, got {}",
1473                                other.type_name()
1474                            ),
1475                        });
1476                    }
1477                }
1478            }
1479        }
1480    }
1481    Ok(out)
1482}
1483
1484/// UAX#14 byte offsets in `text` that are a real, content-driven break
1485/// candidate: every `break_opportunities` boundary except the one always
1486/// reported at `text.len()` (the segmenter's "always break at the end of
1487/// text" convention — an artifact of segmenting this one run in isolation,
1488/// not a signal about what follows it in the paragraph, since
1489/// `text_to_boxes` is called once per `IText::Text` leaf and more content
1490/// may follow via a sibling `Cmd`, `Embed`, or `EmbedMath`).
1491fn uax14_boundaries(text: &str) -> Vec<Option<BreakKind>> {
1492    let mut boundary = vec![None; text.len() + 1];
1493    for (offset, kind) in break_opportunities(text) {
1494        if offset < text.len() {
1495            boundary[offset] = Some(kind);
1496        }
1497    }
1498    boundary
1499}
1500
1501/// This run's `(font, size, rising)` for `script` (see `Context::font_scheme`'s
1502/// doc comment): `Latin` reads `ctx.font` itself (NOT `font_scheme[Latin].font`)
1503/// so `set-font-key`/`\bold`/`\emph` keep working unchanged, while still
1504/// picking up `font_scheme[Latin]`'s ratio/rising (written in lockstep by
1505/// `set-font Latin ..`).
1506///
1507/// `OtherScript` first goes through `normalize_script` (`horzBox.ml:472`):
1508/// upstream's `CommonNarrow`/`Inherited` resolve to `ctx.dominant_narrow_script`
1509/// rather than to a scheme slot of their own; this port's `char_script` has no
1510/// separate Common bucket, so everything outside Latin-1..Latin-Ext-B and the
1511/// CJK ranges lands in `OtherScript` and gets the same treatment — the only
1512/// WIDE Common chars (`U+3000` fullwidth forms) already fall in
1513/// `char_script`'s `HanIdeographic` range, so this costs nothing there.
1514///
1515/// Real effect, not a niceness: without `set-dominant-narrow-script Kana`, a
1516/// document's `□`/`✓` both resolve to a Latin face with NEITHER glyph, degrade
1517/// to the same `.notdef` glyph id and `ToUnicode` entry, and one of the two
1518/// simply vanishes from the extracted text — enumitem's three missing `✓`,
1519/// each overprinted onto a `□` by the document's own `ooalign`.
1520///
1521/// Defaults to `OtherScript` (`Context::initial`, matching upstream), so a
1522/// document that never calls the primitive is unaffected and the recursion is
1523/// one step deep at most.
1524fn script_font(ctx: &Context, script: Script) -> ScriptFont {
1525    if script == Script::OtherScript && ctx.dominant_narrow_script != Script::OtherScript {
1526        return script_font(ctx, ctx.dominant_narrow_script);
1527    }
1528    if script == Script::Latin {
1529        ScriptFont {
1530            font: ctx.font,
1531            ..ctx.font_scheme[Script::Latin as usize]
1532        }
1533    } else {
1534        ctx.font_scheme[script as usize]
1535    }
1536}
1537
1538/// Measure `text` (already known to be one script run) at `size` under
1539/// `font`, falling back per-glyph to `fallback_font` (`ctx.font`) when
1540/// `font` has no glyph for a character — the "CJK per-glyph metrics path
1541/// stubbed" case: a character within a script-run's bucket
1542/// that its assigned font happens to lack (e.g. a fullwidth-form character
1543/// absent from a narrow CJK face) still measures via the Latin default
1544/// rather than failing the whole run. Errors (both fonts lack the glyph)
1545/// name the offending character and font key.
1546///
1547/// **Known limitation** (documented, not fixed): the
1548/// measurement here can fall back per-glyph, but `PureHorzBox::InnerString`
1549/// carries one `HorzStringInfo::font` for its WHOLE text run — so if a
1550/// fallback glyph is actually used, the PDF writer's `emit_box` still tries
1551/// to look it up in `font`'s face at render time and fails there instead.
1552/// Splitting a run into sub-boxes at the source-font-only/fallback boundary
1553/// (a faithful fix) is future work; every stdja default face configuration
1554/// covers its script's whole repertoire, so this path is not expected to
1555/// trigger in practice.
1556fn measure_run(
1557    interp: &Interp,
1558    font: FontKey,
1559    fallback_font: FontKey,
1560    text: &str,
1561    size: Length,
1562) -> Result<Length, EvalError> {
1563    let mut width = Length::ZERO;
1564    for c in text.chars() {
1565        // A character absent from BOTH the run font and the fallback degrades
1566        // to a `.notdef`-style box (half-em advance) rather than aborting the
1567        // whole document — the way real typesetters render an uncovered glyph.
1568        // (satysfi-base's `enumitem`/the SATySFi Book use a few glyphs — `□`,
1569        // `〚` — that the bundled Latin face lacks; a faithful per-glyph
1570        // font-fallback via run-splitting is the documented follow-up.) This
1571        // only ever changes behavior for a glyph that would otherwise be a
1572        // hard error, so covered-glyph documents are byte-identical.
1573        //
1574        // The math path DOES substitute an uncoverable Mathematical
1575        // Alphanumeric for its base letter (`degrade_unrenderable_variant`);
1576        // this one deliberately does NOT, for two reasons worth recording
1577        // here because this half-em is where the question gets asked:
1578        //
1579        //  * STRUCTURAL. This function computes a WIDTH and nothing else. The
1580        //    character that actually reaches the writer comes from the box's
1581        //    own `text` field (`make_inner_string_pure_box`), which
1582        //    `cid::encode_glyph_run` re-resolves to a gid. Substituting here
1583        //    would move the width and leave the ink alone — strictly worse
1584        //    than today, since the run would then be both blank AND
1585        //    mis-measured. A correct text-path fix has to rewrite the string
1586        //    in `text_to_boxes`, ahead of `Script` classification, hyphenation
1587        //    and ToUnicode, all of which read it.
1588        //  * QUANTITATIVE. Half an em is nowhere near what the substitute
1589        //    would measure, so doing it right WOULD reflow existing text.
1590        //    Measured over `A-Za-z0-9` plus the Greek bases in the bundled
1591        //    faces: the median |advance − 0.5 em| is 0.106 em in Junicode and
1592        //    0.125 em in `latinmodern-math.otf`, and the worst cases are `W`
1593        //    at 0.962 em (Junicode) and 1.028 em (LM Math) — an error of up to
1594        //    111% of the placeholder itself. The math path can substitute
1595        //    precisely because it re-measures the character it returns, so
1596        //    there the advance follows the ink instead of a placeholder.
1597        //
1598        // Uncoverable characters here are reported rather than repaired:
1599        // `cid::report_missing_glyphs` names every one, deduped per font.
1600        let advance = interp
1601            .metrics
1602            .advance(font, c, size)
1603            .or_else(|| interp.metrics.advance(fallback_font, c, size))
1604            .unwrap_or(size * 0.5);
1605        width += advance;
1606    }
1607    Ok(width)
1608}
1609
1610/// Build one `InnerString` box for `text`, measured through [`measure_run`]
1611/// with `sf`'s font/size/rising — the single construction site shared by
1612/// `text_to_boxes`'s `flush_word` for both the plain (no-hyphenation) path
1613/// and each hyphenated fragment / hyphen glyph. Factored out so both paths
1614/// measure/build identically — this is part of what makes the width-identity
1615/// argument hold: `measure_run` is purely additive per char (no
1616/// kerning/ligatures), so concatenating the fragments this produces
1617/// reconstructs exactly the box a single un-split call would have produced.
1618fn make_inner_string_pure_box(
1619    interp: &Interp,
1620    ctx: &Context,
1621    sf: ScriptFont,
1622    size: Length,
1623    rising: Length,
1624    text: String,
1625) -> Result<PureHorzBox, EvalError> {
1626    let width = measure_run(interp, sf.font, ctx.font, &text, size)?;
1627    // SATySFi measures a run's height/depth from the ACTUAL per-glyph bounding
1628    // boxes (fontInfo.ml `get_metrics_of_word`), not the font-level
1629    // ascender/descender — so a no-descender run (CJK, digits, TOC dots) is
1630    // shorter and packs tighter at block boundaries.
1631    let (height, depth) = interp.metrics.run_vextent(sf.font, &text, size);
1632    Ok(PureHorzBox::InnerString {
1633        info: HorzStringInfo {
1634            font: sf.font,
1635            size,
1636            rising,
1637            color: ctx.text_color,
1638        },
1639        height,
1640        depth,
1641        text,
1642        width,
1643    })
1644}
1645
1646/// Whether two adjacent runs' scripts form a Latin↔CJK boundary that gets
1647/// SATySFi's default inter-script glue (`primitives.ml:517-524`: entries for
1648/// `(Latin, Kana)`, `(Kana, Latin)`, `(Latin, Han)`, `(Han, Latin)` only —
1649/// NOT Kana↔Han, and not same-script).
1650fn is_latin_cjk_boundary(a: Script, b: Script) -> bool {
1651    let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
1652    (a == Script::Latin && is_cjk(b)) || (is_cjk(a) && b == Script::Latin)
1653}
1654
1655/// Upstream `is_open_punctuation` (`charBasis.ml:133`: `OP | QU | JLOP`) —
1656/// opening brackets and quotes. Consulted for the LEFT edge of a script
1657/// boundary only.
1658fn is_open_punct(c: char) -> bool {
1659    matches!(
1660        c,
1661        '(' | '['
1662            | '{'
1663            | '"'
1664            | '\''
1665            | '('
1666            | '「'
1667            | '『'
1668            | '【'
1669            | '〔'
1670            | '〈'
1671            | '《'
1672            | '['
1673            | '{'
1674            | '〖'
1675            | '〘'
1676            | '〚'
1677            | '“'
1678            | '‘'
1679    )
1680}
1681
1682/// Upstream `is_close_punctuation` (`charBasis.ml:139`: `CL | CP | QU | NS |
1683/// JLCP | JLNS | JLCM | JLFS`) — closing brackets, quotes, and the kuten/touten
1684/// family. Consulted for the RIGHT edge of a script boundary only.
1685///
1686/// Two families the port used to list are NOT in that set, and their absence is
1687/// upstream's own, not an oversight:
1688/// - `!` `?` `!` `?` are line-break class `EX` (`LineBreak.txt:2566,2582` for
1689///   the fullwidth pair), which appears in no arm of `is_close_punctuation`;
1690/// - `,` `.` `;` `:` are `IS`, likewise absent.
1691///
1692/// Their FULLWIDTH cousins are a different matter and stay: `,`/`.` are
1693/// overridden to `JLCM`/`JLFS` (`lineBreakDataMap.ml:95-96`) and `:`/`;` are
1694/// `NS` (`LineBreak.txt:2580`).
1695///
1696/// Listing the six suppressed the 0.24em inter-script glue — and, since that
1697/// glue is the boundary's only break candidate, the break opportunity with it —
1698/// before every sentence-final mark that is not a kuten.
1699fn is_close_punct(c: char) -> bool {
1700    matches!(
1701        c,
1702        ')' | ']'
1703            | '}'
1704            | '"'
1705            | '\''
1706            | ')'
1707            | '」'
1708            | '』'
1709            | '】'
1710            | '〕'
1711            | '〉'
1712            | '》'
1713            | ']'
1714            | '}'
1715            | '〗'
1716            | '〙'
1717            | '〛'
1718            | '”'
1719            | '’'
1720            | '、'
1721            | '。'
1722            | ','
1723            | '.'
1724            | '・'
1725            | ':'
1726            | ';'
1727    )
1728}
1729
1730/// Whether SATySFi's default inter-script glue is suppressed between a
1731/// left-hand character `l` and a right-hand `r`.
1732///
1733/// `pure_space_between_scripts` (`convertText.ml:31`) drops the glue when
1734/// `is_open_punctuation lbc1 || is_close_punctuation lbc2` — the LEFT edge being
1735/// OPENING punctuation, or the RIGHT edge being CLOSING punctuation. The aki
1736/// that would otherwise sit there is supplied by the separate JLreq
1737/// class-spacing layer.
1738///
1739/// The port used to test one symmetric "is punctuation" predicate against BOTH
1740/// edges, which suppressed far more than upstream: `、` before a Latin/math run
1741/// is a *closing* mark on the LEFT, which upstream does not suppress. Since this
1742/// glue is also the only break opportunity at such a boundary, suppressing it
1743/// left the breaker with nowhere to break — latexcmds ran
1744/// `…、${dropcolor}` 32pt past the margin because there was no legal break
1745/// between the touten and the math box.
1746fn interscript_glue_suppressed(l: char, r: char) -> bool {
1747    is_open_punct(l) || is_close_punct(r)
1748}
1749
1750/// JLreq character classes SATySFi's inter-CJK spacing distinguishes
1751/// (`charBasis.ml:116-122`). Only the classes that actually change spacing are
1752/// modelled; every other CJK character is `None` ("ordinary").
1753#[derive(Clone, Copy, PartialEq, Eq)]
1754enum JlClass {
1755    /// cl-01, fullwidth OPEN punctuation — carries a leading half-width kern.
1756    Open,
1757    /// cl-02, fullwidth CLOSE punctuation — trailing half-width kern.
1758    Close,
1759    /// cl-06, kuten (fullwidth full stop) — trailing half-width kern.
1760    FullStop,
1761    /// cl-07, touten (fullwidth comma) — trailing half-width kern.
1762    Comma,
1763    /// cl-05, nakaten (fullwidth middle dot) — quarter-width kern BOTH sides.
1764    MiddleDot,
1765}
1766
1767fn jl_class(c: char) -> Option<JlClass> {
1768    match c {
1769        '(' | '「' | '『' | '【' | '〔' | '〈' | '《' | '[' | '{' | '〖' | '〘' | '〚' => {
1770            Some(JlClass::Open)
1771        }
1772        ')' | '」' | '』' | '】' | '〕' | '〉' | '》' | ']' | '}' | '〗' | '〙' | '〛' => {
1773            Some(JlClass::Close)
1774        }
1775        '。' | '.' => Some(JlClass::FullStop),
1776        '、' | ',' => Some(JlClass::Comma),
1777        '・' | ':' | ';' => Some(JlClass::MiddleDot),
1778        _ => None,
1779    }
1780}
1781
1782/// `ideographic_single`'s TRAILING kern for `c` (`convertText.ml:266-283`), as a
1783/// negative ratio of `font_size`: JLCP/JLFS/JLCM are `[glyph; hwkern]`, JLMD is
1784/// `[qwkern; glyph; qwkern]`.
1785///
1786/// A kern belongs to the CHARACTER, not to the boundary — `ideographic_single`
1787/// runs per chunk and never consults its neighbours. Hence one char per
1788/// function, even though the only caller today is the pair-shaped
1789/// [`cjk_pair_space`]: at a CJK↔Latin boundary the CJK side still carries its own
1790/// kern upstream, and this port does not yet emit it there (see the
1791/// `PreventBreak` arm of `text_to_boxes` for what that costs and what unblocking
1792/// it needs).
1793fn cjk_trailing_kern(c: char) -> f64 {
1794    match jl_class(c) {
1795        Some(JlClass::Close) | Some(JlClass::FullStop) | Some(JlClass::Comma) => -0.5,
1796        Some(JlClass::MiddleDot) => -0.25,
1797        _ => 0.0,
1798    }
1799}
1800
1801/// `ideographic_single`'s LEADING kern for `c` — JLOP is `[hwkern; glyph]`,
1802/// JLMD `[qwkern; glyph; qwkern]`. See [`cjk_trailing_kern`].
1803fn cjk_leading_kern(c: char) -> f64 {
1804    match jl_class(c) {
1805        Some(JlClass::Open) => -0.5,
1806        Some(JlClass::MiddleDot) => -0.25,
1807        _ => 0.0,
1808    }
1809}
1810
1811/// The glue SATySFi puts between two directly adjacent CJK characters, as an
1812/// absolute `(natural, shrink, stretch)` — `space_between_chunks`
1813/// (`convertText.ml:220`) with `ideographic_single`'s compensating kerns
1814/// (`convertText.ml:266`) folded in.
1815///
1816/// Upstream renders CJK punctuation at its full em and kerns it back:
1817/// `。`/`、`/`)` carry a trailing −0.5em kern, `(` a leading one, `・` −0.25em
1818/// on both sides. `pure_space_between_classes` (`convertText.ml:194`) then adds
1819/// a half-width space back — natural 0.5em, stretch 0.25em, shrink 0.25em
1820/// unless the pair is "hard" (after a full stop). Net natural width is
1821/// unchanged, but each punctuation mark contributes **0.25em of stretch** — ten
1822/// times the 0.025em `adjacent_stretch` between ordinary characters, and the
1823/// bulk of a Japanese line's elasticity. Two punctuation marks in a row
1824/// (`」、`, `」。`) get NO space back, so the pair sets 0.5em tighter.
1825///
1826/// **Which font size each part scales against is not uniform, and that is the
1827/// point of taking three sizes rather than one.** Upstream applies the kerns
1828/// (`halfwidth_kern`/`quarterwidth_kern`, `convertText.ml:110-118`) and all
1829/// four `pure_halfwidth_space_*` sizes to `get_corrected_font_size ctx script`
1830/// (`convertText.ml:76-79`) — the font size TIMES the script's own ratio, 0.88
1831/// for stdja's CJK face, so a half-width kern at 12pt is −5.28pt and not −6pt.
1832/// Only `adjacent_space` (`:101-106`) takes the RAW `ctx.font_size`. Scaling
1833/// everything by the raw size made every JLreq class space 13.6% too elastic
1834/// (0.25 × 12 = 3pt of stretch where upstream has 0.25 × 10.56 = 2.64pt);
1835/// since punctuation carries ten times the stretch of an ordinary
1836/// inter-character gap, that error set the stretch budget of a whole Japanese
1837/// line and so its justified glyph positions.
1838///
1839/// `size_a`/`size_b` are the corrected sizes of the LEFT and RIGHT characters,
1840/// upstream's `size1`/`size2` (`convertText.ml:196-198`); the `hwsoftM`/
1841/// `hwhardM` arms take `Length::max` of the two, exactly as `sizeM` does. They
1842/// differ only when the two characters resolve to different `font_scheme` slots
1843/// (`Kana` vs `HanIdeographic`) carrying different ratios.
1844fn cjk_pair_space(
1845    a: char,
1846    size_a: Length,
1847    b: char,
1848    size_b: Length,
1849    raw_size: Length,
1850    adjacent_stretch: f64,
1851) -> (Length, Length, Length) {
1852    use JlClass::*;
1853    let (ca, cb) = (jl_class(a), jl_class(b));
1854    // Kerns from `ideographic_single`, each a NEGATIVE ratio of its OWN
1855    // character's corrected size. Between two CJK characters the pair's kern is
1856    // exactly `a`'s trailing plus `b`'s leading one, which is what makes the
1857    // pair form equivalent to upstream's per-character one here.
1858    let kern = size_a * cjk_trailing_kern(a) + size_b * cjk_leading_kern(b);
1859    let size_m = Length::max(size_a, size_b);
1860    // `pure_space_between_classes`, in its own match order. The third component
1861    // of each arm is the size that arm's space scales against.
1862    let hwsoft = |s: Length| (s * 0.5, s * 0.25, s * 0.25);
1863    let hwhard = |s: Length| (s * 0.5, Length::ZERO, s * 0.25);
1864    let cls = match (ca, cb) {
1865        (Some(Close), Some(Open)) | (Some(Comma), Some(Open)) => Some(hwsoft(size_m)),
1866        (Some(FullStop), Some(Open)) => Some(hwhard(size_m)),
1867        (_, Some(Open)) => Some(hwsoft(size_b)),
1868        (Some(Close), Some(Comma)) | (Some(Close), Some(FullStop)) => None,
1869        (Some(Close), _) | (Some(Comma), _) => Some(hwsoft(size_a)),
1870        (Some(FullStop), _) => Some(hwhard(size_a)),
1871        _ => None,
1872    };
1873    match cls {
1874        Some((n, sh, st)) => (kern + n, sh, st),
1875        // No class space: `adjacent_space` (natural 0, shrink 0, stretch
1876        // `adjacent_stretch` × the RAW size), plus whatever kern the pair
1877        // carries.
1878        None => (kern, Length::ZERO, raw_size * adjacent_stretch),
1879    }
1880}
1881
1882/// A box's LEADING glyph for inter-script spacing, or `None` for
1883/// glue/discretionary/skip/image (a "transparent" separator — an inter-script
1884/// space is never inserted adjacent to one) and for math (reported as a Latin
1885/// `'x'`, matching SATySFi where a `${…}` chunk spaces against CJK like Western
1886/// text). The char lets the caller apply the `is_interscript_punct` guard.
1887fn box_leading_char(b: &HorzBox) -> Option<char> {
1888    match b {
1889        HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().next(),
1890        HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1891        _ => None,
1892    }
1893}
1894
1895/// A box's TRAILING glyph (see `box_leading_char`).
1896fn box_trailing_char(b: &HorzBox) -> Option<char> {
1897    match b {
1898        HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().last(),
1899        HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1900        _ => None,
1901    }
1902}
1903
1904/// Insert SATySFi's inter-script glue (`default_script_space_map`) between two
1905/// DIRECTLY-adjacent boxes whose touching edges are Latin↔CJK — the boundary
1906/// that `text_to_boxes` can't see because it spans separate inline elements: a
1907/// `\code(…)`/`${…}` box against surrounding CJK prose ("cellfmt 型", "𝑛 番目").
1908/// A boundary already carrying a glue/discretionary reads as `None` on one edge
1909/// and is skipped, so this is idempotent and never doubles the text-run glue.
1910/// The Latin↔CJK inter-script space, `pure_space_between_scripts`
1911/// (`convertText.ml:29-50`).
1912///
1913/// **RIGID — natural `0.24 * font_size`, no shrink, no stretch** — and that is
1914/// upstream's behaviour, not a simplification. `default_script_space_map`
1915/// (`primitives.cppo.ml:488`) really does carry the triple
1916/// `(0.24, 0.08, 0.16)`, but `pure_space_between_scripts` spends it like this:
1917///
1918/// ```ocaml
1919/// Some(LBAtom((natural (size *% r0), size *% r1, size *% r2), EvHorzEmpty))
1920/// ```
1921///
1922/// `LBAtom`'s first field is `metrics = length_info * length * length`, i.e.
1923/// *(width info, HEIGHT, DEPTH)* (`lineBreakBox.ml:7`), and `natural wid`
1924/// builds `{natural = wid; shrinkable = zero; stretchable = zero}`
1925/// (`lineBreakBox.ml:54-59`). So `r1` and `r2` land in the height and depth
1926/// slots; only `r0` reaches the width. Contrast the sibling
1927/// `pure_halfwidth_space_soft` (`convertText.ml:83-85`), which builds its
1928/// elasticity with `make_width_info` and passes `Length.zero, Length.zero` for
1929/// height and depth — the correct shape, right next door. The commented-out
1930/// predecessor at `convertText.ml:58` has the same misplacement, so v0.0.6 has
1931/// never had an elastic inter-script space.
1932///
1933/// The stray height (`0.08 * size`) and depth (`0.16 * size`) are swallowed by
1934/// `get_total_metrics`'s `max hacc h` / `min dacc d` (`lineBreak.ml:55-59`) —
1935/// a 0.96pt height never exceeds a real glyph's and a POSITIVE depth never
1936/// wins a `min` against a descender — so rigidity is the only observable
1937/// consequence, and it is the one that matters: this glue sits at every
1938/// Japanese/Latin junction, and giving it 0.16em of stretch let the port soak
1939/// up justification slack that upstream is forced to push into the
1940/// inter-character `adjacent_space` inside the CJK runs themselves.
1941///
1942/// Still a break point: upstream wraps this box in `discretionary_if_breakable`
1943/// (`convertText.ml:228`) exactly as it does the elastic glues, and an
1944/// `OuterEmpty` with zero shrink and stretch is still `is_glue()` — which is
1945/// also why a ratio of 0.0 emits a zero-width box rather than nothing.
1946///
1947/// The ratio comes from `ctx.script_space_map`, so
1948/// `set-space-ratio-between-scripts` reaches it (slydifi's arctic theme zeroes
1949/// all four Latin↔CJK directions).
1950fn interscript_glue(ctx: &Context, left: Script, right: Script) -> PureHorzBox {
1951    PureHorzBox::OuterEmpty {
1952        natural: ctx.font_size * ctx.script_space_map[left as usize][right as usize],
1953        shrinkable: Length::ZERO,
1954        stretchable: Length::ZERO,
1955    }
1956}
1957
1958fn insert_box_interscript_glue(boxes: Vec<HorzBox>, ctx: &Context) -> Vec<HorzBox> {
1959    if boxes.len() < 2 {
1960        return boxes;
1961    }
1962    let mut out: Vec<HorzBox> = Vec::with_capacity(boxes.len());
1963    for b in boxes {
1964        if let (Some(pc), Some(cc)) = (out.last().and_then(box_trailing_char), box_leading_char(&b))
1965        {
1966            let (ls, rs) = (char_script(pc), char_script(cc));
1967            if is_latin_cjk_boundary(ls, rs) && !interscript_glue_suppressed(pc, cc) {
1968                out.push(HorzBox::Pure(interscript_glue(ctx, ls, rs)));
1969            }
1970        }
1971        out.push(b);
1972    }
1973    out
1974}
1975
1976fn text_to_boxes(
1977    interp: &mut Interp,
1978    ctx: &Context,
1979    text: &str,
1980    out: &mut Vec<HorzBox>,
1981) -> Result<(), EvalError> {
1982    // Interword glue is upstream's
1983    // `context_main.space_natural`/`space_shrink`/`space_stretch`
1984    // (`set-space-ratio`), each a ratio of `font_size` — NOT a measured
1985    // glyph-advance of the space character and NOT a fraction of the natural
1986    // width. `ctx.space_*` always carries a value (defaults 0.33/0.08/0.16,
1987    // matching `Context::initial`'s own upstream-faithful defaults), so this
1988    // is a plain formula, no fallback needed.
1989    let space_width = ctx.font_size * ctx.space_natural;
1990    let boundary = uax14_boundaries(text);
1991    let mut word = String::new();
1992    let flush_word =
1993        |word: &mut String, script: Script, out: &mut Vec<HorzBox>| -> Result<(), EvalError> {
1994            if word.is_empty() {
1995                return Ok(());
1996            }
1997            let sf = script_font(ctx, script);
1998            let size = ctx.font_size * sf.ratio;
1999            // The script-font's own baseline raise (a ratio of font_size) PLUS the
2000            // manual raise from `set-manual-rising` (`ctx.manual_rising`, an
2001            // absolute Length). Both feed `HorzStringInfo.rising`, which every
2002            // render path adds to the baseline before `Tj`. `manual_rising`
2003            // defaults to `Length::ZERO` (`Context::initial`), so a document that
2004            // never calls `set-manual-rising` is byte-identical. Real effect: the
2005            // `\SATySFi`/`\LaTeX`/`\TeX` logo kerning.
2006            let rising = ctx.font_size * sf.rising + ctx.manual_rising;
2007
2008            // Knuth-Liang hyphenation opt-in injection: fires ONLY when a
2009            // dictionary has been installed (`ctx.hyphen_dictionary ==
2010            // Some(tag)`) and the run's script is Latin. With `hyphen_dictionary
2011            // == None` (the `Context::initial` default), `breaks` is always
2012            // empty and the code below falls straight through to the
2013            // single-`InnerString` path.
2014            let breaks = match ctx.hyphen_dictionary {
2015                Some(tag) if script == Script::Latin => {
2016                    // An explicit soft hyphen (U+00AD) authored in the word
2017                    // takes priority over dictionary-derived breaks (matches the
2018                    // `hyphenation` crate's own `Standard::hyphenate` priority
2019                    // rule). Only reachable here with a soft hyphen still
2020                    // embedded in `word` because the tokenizer above
2021                    // (`text_to_boxes`'s per-char loop) defers to this branch
2022                    // instead of splitting on it as an ordinary UAX#14 boundary
2023                    // — gated on this same `Some(tag) && Latin` condition, so
2024                    // `hyphen_dictionary == None` never reaches
2025                    // `strip_soft_hyphens` and reproduces exactly today's
2026                    // split-at-soft-hyphen behavior.
2027                    let (clean, shy_breaks) = crate::hyphenation::strip_soft_hyphens(word);
2028                    if !shy_breaks.is_empty() {
2029                        *word = clean;
2030                        shy_breaks
2031                    } else {
2032                        crate::hyphenation::hyphenate_word(
2033                            tag,
2034                            word,
2035                            ctx.left_hyphen_min.max(0) as usize,
2036                            ctx.right_hyphen_min.max(0) as usize,
2037                        )
2038                    }
2039                }
2040                _ => Vec::new(),
2041            };
2042
2043            if breaks.is_empty() {
2044                out.push(HorzBox::Pure(make_inner_string_pure_box(
2045                    interp,
2046                    ctx,
2047                    sf,
2048                    size,
2049                    rising,
2050                    std::mem::take(word),
2051                )?));
2052                return Ok(());
2053            }
2054
2055            // Width-identity invariant (also see
2056            // `make_inner_string_pure_box`'s doc comment): `measure_run` is
2057            // purely additive per char (no
2058            // kerning/ligatures), so splitting `word` into fragments here and
2059            // rejoining them via empty-slot `Discretionary`s (taken only at a
2060            // chosen line break) reproduces the exact width/height/depth of the
2061            // un-split box when no break is actually taken — only words the DP
2062            // *does* break render differently, which is the intended new
2063            // behavior, confined to documents that opt in.
2064            let chars: Vec<char> = word.chars().collect();
2065            let penalty = ctx.hyphen_badness.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
2066            let mut prev = 0usize;
2067            for &b in &breaks {
2068                let fragment: String = chars[prev..b].iter().collect();
2069                out.push(HorzBox::Pure(make_inner_string_pure_box(
2070                    interp, ctx, sf, size, rising, fragment,
2071                )?));
2072                let hyphen_box =
2073                    make_inner_string_pure_box(interp, ctx, sf, size, rising, "-".to_string())?;
2074                out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2075                    penalty,
2076                    pre_break: vec![hyphen_box],
2077                    post_break: Vec::new(),
2078                    no_break: Vec::new(),
2079                }));
2080                prev = b;
2081            }
2082            let tail: String = chars[prev..].iter().collect();
2083            out.push(HorzBox::Pure(make_inner_string_pure_box(
2084                interp, ctx, sf, size, rising, tail,
2085            )?));
2086            word.clear();
2087            Ok(())
2088        };
2089    // `Some(s)` exactly when `word` is non-empty — the script of the run
2090    // currently being accumulated (a run also breaks on a script
2091    // change, not just on whitespace/UAX#14, see `char_script`).
2092    let mut word_script: Option<Script> = None;
2093    // The script of the immediately-preceding *typeset* character (persists
2094    // across the UAX#14 discretionary flushing that resets `word_script`), so
2095    // an inter-script boundary can be detected even between two single-char
2096    // CJK/Latin runs. Reset by an explicit space (no auto inter-script glue is
2097    // added adjacent to a real space). See the `is_latin_cjk_boundary` insert.
2098    let mut prev_script: Option<Script> = None;
2099    // The preceding typeset char itself, for the `is_interscript_punct` guard.
2100    let mut prev_char: Option<char> = None;
2101    for (i, c) in text.char_indices() {
2102        // Whitespace normalization around CJK — upstream's rewrite table
2103        // (`lineBreakDataMap.ml:143-157`, applied before any box is built):
2104        //
2105        //   CJK + (SP|BR) + Latin -> deleted      Latin + (SP|BR) + CJK -> deleted
2106        //   CJK + BR      + CJK   -> deleted      CJK   + SP      + CJK -> KEPT
2107        //   any remaining (SP|BR) touching CJK -> deleted; a leftover BR -> space
2108        //
2109        // Every space/line break adjacent to CJK is dropped EXCEPT a single
2110        // literal space between two CJK characters — the Latin/CJK boundary's
2111        // spacing is supplied by the inter-script glue below (0.24em), not by
2112        // the author's whitespace, so keeping it both double-counted that
2113        // boundary and turned every source line break into a space (the port
2114        // set `あります。 1 つは`/`これは 指定した` where SATySFi sets both
2115        // tight — figbox `manual.saty:116-120`). Deleting is a plain
2116        // `continue`: the characters either side still space against each
2117        // other through the inter-script rule, as if the whitespace had never
2118        // been written.
2119        if c == ' ' || c == '\n' {
2120            let is_cjk_script = |s| matches!(s, Script::HanIdeographic | Script::Kana);
2121            let prev_cjk = prev_script.is_some_and(is_cjk_script);
2122            let rest = &text[i + c.len_utf8()..];
2123            // Whether more whitespace follows: upstream's rules only ever match
2124            // ONE space between the two CJK characters (a longer run falls
2125            // through to the delete-everything rules), so a run collapses away.
2126            let run_continues = rest
2127                .chars()
2128                .next()
2129                .is_some_and(|ch| matches!(ch, ' ' | '\n'));
2130            let next_cjk = rest
2131                .chars()
2132                .find(|ch| !matches!(ch, ' ' | '\n'))
2133                .is_some_and(|ch| is_cjk_script(char_script(ch)));
2134            if prev_cjk || next_cjk {
2135                let keep = c == ' ' && !run_continues && prev_cjk && next_cjk;
2136                if !keep {
2137                    continue;
2138                }
2139            }
2140        }
2141        if c == ' ' || c == '\n' {
2142            if let Some(s) = word_script.take() {
2143                flush_word(&mut word, s, out)?;
2144            }
2145            prev_script = None;
2146            prev_char = None;
2147            // Avoid piling up doubled glue at text-run boundaries — but ONLY
2148            // for elastic (prose) spaces. A RIGID space (shrink == stretch == 0,
2149            // i.e. `code.satyh`'s `set-space-ratio (charwid/fs) 0. 0.`) is a
2150            // fixed-width verbatim column: SATySFi never collapses consecutive
2151            // ones, so the aligned source in a `+code` block keeps its spacing
2152            // (`| How       | I`, not the collapsed `| How | I`). Collapsing them
2153            // shortened code lines and let the port pack code blocks too tight.
2154            let rigid_space = ctx.space_shrink == 0.0 && ctx.space_stretch == 0.0;
2155            if rigid_space
2156                || !matches!(
2157                    out.last(),
2158                    Some(HorzBox::Pure(PureHorzBox::OuterEmpty { .. }))
2159                )
2160            {
2161                out.push(HorzBox::Pure(PureHorzBox::OuterEmpty {
2162                    natural: space_width,
2163                    // Upstream derives shrink/stretch directly as a ratio
2164                    // of `font_size` (`ctx.space_shrink`/`space_stretch`),
2165                    // NOT as a fraction of `space_width` — the previous
2166                    // `space_width * 0.25`/`* 0.5` was a port-invented
2167                    // approximation.
2168                    shrinkable: ctx.font_size * ctx.space_shrink,
2169                    stretchable: ctx.font_size * ctx.space_stretch,
2170                }));
2171            }
2172            continue;
2173        }
2174        let script = char_script(c);
2175        // Inter-script glue (`primitives.ml:517-524` `default_script_space_map`,
2176        // applied in `convertText.ml` `pure_space_between_scripts`): SATySFi's
2177        // default context inserts a `0.24 * size` space between a Latin run and
2178        // an adjacent CJK (Kana/Han) run — the space visible as "2 つ" /
2179        // "+easytable は" that the port otherwise packs tight ("2つ"). Emitted
2180        // at the boundary using `prev_script` (a CJK char resets `word_script`
2181        // via its UAX#14 discretionary, so this can't rely on `word_script`
2182        // alone). The glue is also an `is_break_point`, matching upstream (the
2183        // boundary is a legal break). See [`interscript_glue`] for why it is
2184        // RIGID even though `default_script_space_map` carries a triple.
2185        if let (Some(prev), Some(pc)) = (prev_script, prev_char) {
2186            if is_latin_cjk_boundary(prev, script) && !interscript_glue_suppressed(pc, c) {
2187                if let Some(s) = word_script.take() {
2188                    if !word.is_empty() {
2189                        flush_word(&mut word, s, out)?;
2190                    }
2191                }
2192                out.push(HorzBox::Pure(interscript_glue(ctx, prev, script)));
2193            }
2194        }
2195        if let Some(cur) = word_script {
2196            if cur != script {
2197                flush_word(&mut word, cur, out)?;
2198            }
2199        }
2200        word_script = Some(script);
2201        prev_script = Some(script);
2202        prev_char = Some(c);
2203        word.push(c);
2204        // Only non-ASCII text gets UAX#14 discretionaries: plain ASCII stays
2205        // on exactly today's space/newline-only splitter, so existing Latin
2206        // fixtures wrap identically (a real, tested divergence otherwise —
2207        // UAX#14 allows a break after a hyphen, which would fragment e.g.
2208        // "SATySFi-in-Rust" into three `InnerString`s instead of one,
2209        // changing the PDF content stream even though the zero-width
2210        // discretionaries between them render no differently when unchosen).
2211        // CJK and other non-ASCII scripts have no such existing behavior to
2212        // preserve, and are exactly where UAX#14 breaking is the whole point
2213        // (no interword glue at all otherwise, see `is_break_point`'s doc).
2214        // A soft hyphen (U+00AD) inside a run that the Knuth-Liang injection
2215        // above will consume (dictionary installed, Latin script) must NOT
2216        // be split here as an ordinary UAX#14 break-after point — doing so
2217        // would flush/fragment the word right at the soft hyphen before
2218        // `flush_word`'s hyphenation branch ever sees the whole word,
2219        // pre-empting `strip_soft_hyphens`'s explicit-break handling.
2220        // Instead let it accumulate into `word` like any other Latin letter.
2221        // Gated on the exact same `Some(_) && Latin` condition as that
2222        // branch, so `hyphen_dictionary == None` (or a non-Latin run)
2223        // reproduces exactly today's split-at-soft-hyphen behavior.
2224        let is_gated_soft_hyphen =
2225            c == '\u{ad}' && script == Script::Latin && ctx.hyphen_dictionary.is_some();
2226        // UAX#14 break opportunities apply to ALL text, ASCII included — that
2227        // is simply what upstream's line-break engine does (it runs over the
2228        // whole run with no script gate). Do NOT narrow this to non-ASCII, or
2229        // to the explicit hyphen — both approximations leave a load-bearing
2230        // gap:
2231        //
2232        //   - `+fig-center` (54.7pt, unbreakable) made the candidate widths jump
2233        //     clean over the feasible window — 400.32pt (ratio 2.72, dropped) to
2234        //     455.00pt (overfull) with nothing between — so the breaker fell back
2235        //     to a degenerate one-character line.
2236        //   - a `+code` line `…?:(drop) ?:(dropcolor)` ran 80pt past the column
2237        //     and clean off the paper, because the only break the port allowed
2238        //     was at a space, and breaking there left a rigid line 7.7pt short
2239        //     (dropped). UAX#14 grants a break between `:` and `(` — offset 2 of
2240        //     `?:(drop)…` — which is exactly where SATySFi breaks it.
2241        //
2242        // Cost: an ASCII run is now split into one `InnerString` per break
2243        // opportunity. Widths are unaffected (`measure_run` is purely additive,
2244        // see `make_inner_string_pure_box`), so this only changes how the text
2245        // is CHUNKED, not where any glyph lands.
2246        if !is_gated_soft_hyphen {
2247            let after = i + c.len_utf8();
2248            // The inter-chunk spacing between two DIRECTLY ADJACENT CJK
2249            // characters: `cjk_pair_space` folds `pure_space_between_classes` /
2250            // `adjacent_space` (`convertText.ml:101/194`) together with
2251            // `ideographic_single`'s compensating kerns (`convertText.ml:266`).
2252            //
2253            // The elastic part is the give a Japanese line justifies with.
2254            // Without it a CJK line's only give was whatever incidental Latin
2255            // spaces it happened to contain — a handful of points across a whole
2256            // line — so the breaker could neither fill to the column nor accept a
2257            // break that needed a hair of stretch.
2258            //
2259            // Only between two CJK characters: a CJK/Latin boundary is
2260            // `pure_space_between_scripts`'s job (the inter-script glue
2261            // above), and upstream falls through to `adjacent_space` only
2262            // once that has returned `None` (`space_between_chunks`,
2263            // `convertText.ml:220`).
2264            let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
2265            let next_char = text[after..].chars().next();
2266            let next_is_cjk = next_char.is_some_and(|nc| is_cjk(char_script(nc)));
2267            let pair = if is_cjk(script) && next_is_cjk {
2268                let nc = next_char.expect("checked");
2269                // `get_corrected_font_size` per SIDE (`convertText.ml:76-79`):
2270                // font size times the script's own `font_scheme` ratio. The
2271                // kerns and the JLreq class spaces scale against these; only
2272                // `adjacent_space` takes the raw size. See `cjk_pair_space`.
2273                let size_a = ctx.font_size * script_font(ctx, script).ratio;
2274                let size_b = ctx.font_size * script_font(ctx, char_script(nc)).ratio;
2275                Some(cjk_pair_space(
2276                    c,
2277                    size_a,
2278                    nc,
2279                    size_b,
2280                    ctx.font_size,
2281                    ctx.adjacent_stretch,
2282                ))
2283            } else {
2284                None
2285            };
2286            // `discretionary_if_breakable alw badns lphb`
2287            // (`convertText.ml:183-190`) — the ONE decision upstream makes at a
2288            // chunk boundary. The spacing is computed the same way either way;
2289            // only its container depends on whether UAX#14 grants a break:
2290            //
2291            //   AllowBreak    -> LBDiscretionary(badns, id, [glue], [], [])
2292            //   PreventBreak  -> LBPure(glue)
2293            //
2294            // The port used to emit the `AllowBreak` arm and *nothing* for
2295            // `PreventBreak`, so at every prohibited boundary — and in Japanese
2296            // prose that is one boundary in several, since LB13 forbids a break
2297            // before `、`/`。`/`」`/`)` and LB14 after `(`/`「` — a CJK line
2298            // carried give only at the subset of its boundaries that happened to
2299            // be breakable. A line with no give has to FILL its measure with
2300            // characters, which is part of why the port packs more per line than
2301            // SATySFi.
2302            match boundary[after] {
2303                Some(kind) => {
2304                    flush_word(&mut word, script, out)?;
2305                    word_script = None;
2306                    let mut no_break = Vec::new();
2307                    if let Some((n, sh, st)) = pair {
2308                        // The kern part is RIGID and must never be a break point,
2309                        // so it rides as a `FixedEmpty` rather than as glue.
2310                        if n != Length::ZERO {
2311                            no_break.push(PureHorzBox::FixedEmpty { width: n });
2312                        }
2313                        no_break.push(PureHorzBox::OuterEmpty {
2314                            natural: Length::ZERO,
2315                            shrinkable: sh,
2316                            stretchable: st,
2317                        });
2318                    }
2319                    out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2320                        penalty: match kind {
2321                            BreakKind::Allowed => 0,
2322                            BreakKind::Mandatory => FORCED_BREAK_PENALTY,
2323                        },
2324                        pre_break: Vec::new(),
2325                        post_break: Vec::new(),
2326                        no_break,
2327                    }));
2328                }
2329                // The `PreventBreak` arm: `LBPure(glue)`, spelled as a
2330                // `Discretionary` whose every break slot is empty and whose
2331                // penalty is `NO_BREAK_PENALTY` (a bare `OuterEmpty` IS a
2332                // breakpoint in this box model, so a pure elastic box has no
2333                // other spelling).
2334                //
2335                // Only the ELASTIC half, deliberately — the one place this
2336                // port knowingly diverges from `discretionary_if_breakable`.
2337                // Landing the RIGID half too was written and MEASURED: it makes
2338                // the kern model ASYMMETRIC, since `cjk_pair_space`'s kern is a
2339                // property of the PAIR while upstream's is a property of the
2340                // CHARACTER, and the two agree only when BOTH neighbours are
2341                // CJK — `生成・変換` would get the nakaten's kern on both sides
2342                // while `(例:textbox` gets only its leading one (the trailing
2343                // side faces Latin, unreached by `cjk_pair_space`) — figbox's
2344                // largest intra-line divergence from upstream (mean |dx| on
2345                // that line 2.5pt -> 6.5pt).
2346                //
2347                // Completing it needs the per-character kerns at CJK<->Latin
2348                // boundaries and run edges too, which needs the source-
2349                // whitespace rewrite applied BEFORE `uax14_boundaries` rather
2350                // than during this loop (upstream's own order). Without that a
2351                // `。` before a deleted source newline gets its trailing kern
2352                // while the class space that pays it back is skipped (the
2353                // lookahead sees the newline, not the character after it), and
2354                // the layout-fidelity gate fails 12 ways. So the natural-width
2355                // bug the rigid half would fix — `」。`/`」、` a half-em too
2356                // wide, `末・雲` a quarter — stays exactly as open as before.
2357                None => {
2358                    if let Some((_, sh, st)) = pair {
2359                        if sh != Length::ZERO || st != Length::ZERO {
2360                            flush_word(&mut word, script, out)?;
2361                            word_script = None;
2362                            out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2363                                penalty: NO_BREAK_PENALTY,
2364                                pre_break: Vec::new(),
2365                                post_break: Vec::new(),
2366                                no_break: vec![PureHorzBox::OuterEmpty {
2367                                    natural: Length::ZERO,
2368                                    shrinkable: sh,
2369                                    stretchable: st,
2370                                }],
2371                            }));
2372                        }
2373                    }
2374                }
2375            }
2376        }
2377    }
2378    match word_script {
2379        Some(s) => flush_word(&mut word, s, out),
2380        None => Ok(()),
2381    }
2382}
2383
2384// ---- math conversion ----------------------
2385//
2386// Walks the already-elaborated `MathElem` tree straight into one
2387// `PureHorzBox::Math`, fixed-constant shift/scale (no MATH table).
2388
2389/// Superscript/subscript size ratio, used ONLY as `MathC`'s fallback when
2390/// the current math font has no OpenType MATH table (`script_percent_scale_down
2391/// / 100`). Not read anywhere outside `MathC` — every layout site goes
2392/// through `MathC::script_scale`/`sup_shift_clamped`/etc. so a MATH-table
2393/// font gets the real per-font ratio instead.
2394const SCRIPT_SCALE: f64 = 0.7;
2395/// Superscript raise, as a fraction of `ctx.font_size` — `MathC`'s
2396/// no-MATH-table fallback (`superscript_shift_up` clamped per
2397/// `math.ml:527`). Not read outside `MathC`.
2398const SUP_SHIFT: f64 = 0.5;
2399/// Cramped-style superscript raise fallback — the no-MATH-table fallback
2400/// `sup_shift_clamped` uses in place of `SuperscriptShiftUpCramped` when there
2401/// is no real MATH table to read. Deliberately set EQUAL to `SUP_SHIFT`: every
2402/// checked-in fixture font has no MATH table, so cramped and uncramped
2403/// superscripts get the identical fallback shift there. Only a real MATH
2404/// font (host-installed, test-guarded) makes cramped/uncramped diverge.
2405const SUP_SHIFT_CRAMPED: f64 = SUP_SHIFT;
2406/// Subscript drop, as a fraction of `ctx.font_size` — `MathC`'s
2407/// no-MATH-table fallback (`subscript_shift_down` per
2408/// `math.ml:545`). Not read outside `MathC`.
2409const SUB_SHIFT: f64 = 0.25;
2410/// `MathC::frac_numer_shift`'s no-MATH-table fallback: a flat,
2411/// content-independent numerator raise, as a fraction of the fraction's own
2412/// LOCAL size (mirrors `sup_shift_clamped`'s None-branch style, which
2413/// also ignores ink extent with no MATH table). Not read outside `MathC`.
2414const FRAC_NUMER_SHIFT_FALLBACK: f64 = 0.33;
2415/// `MathC::frac_denom_shift`'s no-MATH-table fallback (mirrors
2416/// `FRAC_NUMER_SHIFT_FALLBACK`; applied as a downward, i.e. negative, shift
2417/// by the caller). Not read outside `MathC`.
2418const FRAC_DENOM_SHIFT_FALLBACK: f64 = 0.33;
2419
2420/// MATH-table resolver: one query of `interp.metrics.math_constants(font)`
2421/// per laid-out math run, memoized here so every shift/scale/kern site in
2422/// that run reads the SAME `Option` instead of re-querying — and so a font
2423/// with no MATH table (every `Base14Metrics` call, and any TTF that lacks
2424/// one) transparently falls back to the flat pre-MATH-table constants
2425/// above. Fields are ratios of
2426/// the font size; callers multiply by whichever size is in scope
2427/// (`ctx.font_size` for the shift magnitudes — matching the pre-existing
2428/// "shift doesn't shrink with nesting" behavior these constants always had
2429/// — or the atom's own local `size` for glyph-relative queries like
2430/// `script_scale`/kerning).
2431struct MathC {
2432    c: Option<MathConstants>,
2433    /// `ctx.math_cramped` at the point this `MathC` was built — whether the
2434    /// current math sub-formula is laid out cramped. Consulted only by
2435    /// `sup_shift`/`sup_shift_clamped`, the sole positioning formula cramped
2436    /// changes in this port's feature set.
2437    cramped: bool,
2438}
2439
2440impl MathC {
2441    fn of(interp: &Interp, ctx: &Context) -> Self {
2442        Self {
2443            c: interp.metrics.math_constants(ctx.math_font),
2444            cramped: ctx.math_cramped,
2445        }
2446    }
2447
2448    /// Flat, unclamped superscript raise (`math.ml:527`'s `h_supstd` alone,
2449    /// no `math.ml:524-533` clamp) — the shape `layout_math_atom`'s callers
2450    /// need when no base/script ink extent is at hand yet.
2451    fn sup_shift(&self, s: Length) -> Length {
2452        match self.c {
2453            None => {
2454                s * if self.cramped {
2455                    SUP_SHIFT_CRAMPED
2456                } else {
2457                    SUP_SHIFT
2458                }
2459            }
2460            Some(c) => {
2461                s * if self.cramped {
2462                    c.superscript_shift_up_cramped
2463                } else {
2464                    c.superscript_shift_up
2465                }
2466            }
2467        }
2468    }
2469
2470    /// Flat, unclamped subscript drop (mirrors `sup_shift`).
2471    fn sub_shift(&self, s: Length) -> Length {
2472        self.c
2473            .map(|c| s * c.subscript_shift_down)
2474            .unwrap_or(s * SUB_SHIFT)
2475    }
2476
2477    /// `script_percent_scale_down / 100`, or the fixed `SCRIPT_SCALE`
2478    /// fallback. Nesting-level scale gap: upstream
2479    /// switches to `script_script_percent_scale_down` one level deeper;
2480    /// this port applies `script_scale_down` uniformly at every depth.
2481    fn script_scale(&self) -> f64 {
2482        self.c.map(|c| c.script_scale_down).unwrap_or(SCRIPT_SCALE)
2483    }
2484
2485    /// `math.ml`'s `h_bar` (axis height): the vertical center math content
2486    /// (fraction bars, `get-axis-height`) aligns to. Falls back to a fixed
2487    /// `0.25` ratio with no MATH table.
2488    fn axis(&self, s: Length) -> Length {
2489        self.c.map(|c| s * c.axis_height).unwrap_or(s * 0.25)
2490    }
2491
2492    /// `math.ml:524-533` `superscript_baseline_height`, clamped: the
2493    /// MAGNITUDE of the upward shift a superscript needs given the base's
2494    /// own ink height (`h_base`, a positive extent above ITS baseline) and
2495    /// the superscript's own ink depth (`d_sup`, a positive extent below
2496    /// ITS baseline — i.e. `MathGlyph.height`/`.depth`, not upstream's
2497    /// signed `Length.negate`d fields). Falls back to the flat `sup_shift`
2498    /// (ignoring `h_base`/`d_sup`) when there's no MATH table, so base-14
2499    /// output is untouched by this clamp.
2500    fn sup_shift_clamped(&self, s: Length, h_base: Length, d_sup: Length) -> Length {
2501        match self.c {
2502            None => self.sup_shift(s),
2503            Some(c) => {
2504                let shift_up = if self.cramped {
2505                    c.superscript_shift_up_cramped
2506                } else {
2507                    c.superscript_shift_up
2508                };
2509                let cand1 = s * shift_up;
2510                let cand2 = h_base - s * c.superscript_baseline_drop_max;
2511                let cand3 = s * c.superscript_bottom_min + d_sup;
2512                cand1.max(cand2).max(cand3)
2513            }
2514        }
2515    }
2516
2517    /// `math.ml:545-553` `subscript_baseline_depth`, clamped: the MAGNITUDE
2518    /// of the downward shift, given the base's own ink depth (`d_base`) and
2519    /// the subscript's own ink height (`h_sub`). Mirrors
2520    /// `sup_shift_clamped`'s fallback behavior.
2521    fn sub_shift_clamped(&self, s: Length, d_base: Length, h_sub: Length) -> Length {
2522        match self.c {
2523            None => self.sub_shift(s),
2524            Some(c) => {
2525                let cand1 = s * c.subscript_shift_down;
2526                let cand2 = d_base + s * c.subscript_baseline_drop_min;
2527                let cand3 = h_sub - s * c.subscript_top_max;
2528                cand1.max(cand2).max(cand3)
2529            }
2530        }
2531    }
2532
2533    /// `math.ml:562-573` `correct_script_baseline_heights`: when a base
2534    /// carries BOTH a subscript and a superscript, nudge the two
2535    /// already-clamped shift magnitudes apart so their ink keeps at least
2536    /// `sub_superscript_gap_min` clearance. `d_sup`/`h_sub` are the same ink
2537    /// extents `sup_shift_clamped`/`sub_shift_clamped` took; `sup`/`sub` are
2538    /// their (already clamped) outputs. A no-op when there's no MATH table
2539    /// — the flat fallback shifts are never additionally corrected, so
2540    /// base-14 output stays exactly `(sup, sub)`.
2541    fn correct_script_gap(
2542        &self,
2543        s: Length,
2544        d_sup: Length,
2545        h_sub: Length,
2546        sup: Length,
2547        sub: Length,
2548    ) -> (Length, Length) {
2549        let Some(c) = self.c else {
2550            return (sup, sub);
2551        };
2552        let gap_min = s * c.sub_superscript_gap_min;
2553        let gap = (sup - d_sup) - (h_sub - sub);
2554        if gap < gap_min {
2555            let corr = (gap_min - gap) * 0.5;
2556            (sup + corr, sub + corr)
2557        } else {
2558            (sup, sub)
2559        }
2560    }
2561
2562    /// `math.ml:596-602` `upper_limit_baseline_height`, clamped: the
2563    /// MAGNITUDE of the upward shift for an `\overset`-like upper limit,
2564    /// given the base's own ink height (`h_base`) and the limit content's
2565    /// own ink depth (`d_up`). Falls back to the flat `sup_shift` (same
2566    /// shape upstream's superscript raise uses) with no MATH table.
2567    fn upper_limit_shift(&self, s: Length, h_base: Length, d_up: Length) -> Length {
2568        match self.c {
2569            None => self.sup_shift(s),
2570            Some(c) => {
2571                let cand1 = h_base + s * c.upper_limit_baseline_rise_min;
2572                let cand2 = h_base + s * c.upper_limit_gap_min + d_up;
2573                cand1.max(cand2)
2574            }
2575        }
2576    }
2577
2578    /// `math.ml:605-611` `lower_limit_baseline_depth`, clamped: mirrors
2579    /// `upper_limit_shift` for a lower limit, given the base's own ink
2580    /// depth (`d_base`) and the limit content's own ink height (`h_low`).
2581    fn lower_limit_shift(&self, s: Length, d_base: Length, h_low: Length) -> Length {
2582        match self.c {
2583            None => self.sub_shift(s),
2584            Some(c) => {
2585                let cand1 = d_base + s * c.lower_limit_baseline_drop_min;
2586                let cand2 = d_base + s * c.lower_limit_gap_min + h_low;
2587                cand1.max(cand2)
2588            }
2589        }
2590    }
2591
2592    /// `math.ml:982-991` `horz_fraction_bar`'s rule thickness (also
2593    /// `radical_bar_metrics`'s `t_bar` — both are "the same generic rule
2594    /// ratio" in the pre-MATH-table fixed-constant world):
2595    /// `fraction_rule_thickness`, or the fixed `0.04` fallback. Multiplied
2596    /// by the ambient LOCAL nesting `size` (not `ctx.font_size` — a
2597    /// fraction/radical's own metrics DO shrink with nesting, matching
2598    /// upstream's `FontInfo.actual_math_font_size`, unlike the sup/sub shift
2599    /// constants' documented `ctx.font_size` simplification above).
2600    fn frac_rule(&self, s: Length) -> Length {
2601        self.c
2602            .map(|c| s * c.fraction_rule_thickness)
2603            .unwrap_or(s * 0.04)
2604    }
2605
2606    /// `math.ml:574-583` `numerator_baseline_height`, clamped: the
2607    /// MAGNITUDE of the upward shift a numerator needs given its own ink
2608    /// depth (`d_numer`, a positive extent below ITS baseline — this port's
2609    /// convention, see `sup_shift_clamped`'s doc comment; upstream's
2610    /// `Length.negate d_numer` becomes a plain ADD of `d_numer` here, not a
2611    /// subtract — getting this sign wrong would shrink the raise for a
2612    /// deeper numerator instead of growing it, overlapping the bar). Falls
2613    /// back to a flat, content-independent ratio with no MATH table
2614    /// (mirrors `sup_shift_clamped`'s None-branch style).
2615    fn frac_numer_shift(&self, s: Length, d_numer: Length) -> Length {
2616        match self.c {
2617            None => s * FRAC_NUMER_SHIFT_FALLBACK,
2618            Some(c) => {
2619                let std = s * c.fraction_numer_shift_up;
2620                let gap =
2621                    self.axis(s) + self.frac_rule(s) * 0.5 + s * c.fraction_numer_gap_min + d_numer;
2622                std.max(gap)
2623            }
2624        }
2625    }
2626
2627    /// `math.ml:585-594` `denominator_baseline_depth`, clamped: mirrors
2628    /// `frac_numer_shift`. Returns the SIGNED (already-negative) drop the
2629    /// caller applies straight to `dy` — unlike the sup/sub methods'
2630    /// positive-magnitude-then-caller-negates convention — because
2631    /// upstream's own `d_denombl` is signed too, so there's no sign flip to
2632    /// make here (and `h_denom`, a HEIGHT not a depth, is subtracted
2633    /// directly, matching upstream's un-negated use of it).
2634    fn frac_denom_shift(&self, s: Length, h_denom: Length) -> Length {
2635        match self.c {
2636            None => -(s * FRAC_DENOM_SHIFT_FALLBACK),
2637            Some(c) => {
2638                let std = -(s * c.fraction_denom_shift_down);
2639                let gap =
2640                    self.axis(s) - self.frac_rule(s) * 0.5 - s * c.fraction_denom_gap_min - h_denom;
2641                std.min(gap)
2642            }
2643        }
2644    }
2645
2646    /// `math.ml:620-626` `radical_bar_metrics`: `(h_bar, t_bar, l_extra)` —
2647    /// the bar's height above baseline (radicand height + gap, so the bar
2648    /// always clears the radicand with no separate raise needed), its rule
2649    /// thickness, and the extra ascender the WHOLE radical run reports
2650    /// above the bar. Fallback ratios (no MATH table):
2651    /// vertical_gap=0.06, rule=0.04 (same fixed ratio `frac_rule` falls back
2652    /// to), extra_ascender=0.06.
2653    fn radical_bar_metrics(&self, s: Length, h_cont: Length) -> (Length, Length, Length) {
2654        match self.c {
2655            Some(c) => (
2656                h_cont + s * c.radical_vertical_gap,
2657                s * c.radical_rule_thickness,
2658                s * c.radical_extra_ascender,
2659            ),
2660            None => (h_cont + s * 0.06, s * 0.04, s * 0.06),
2661        }
2662    }
2663}
2664
2665/// The ink height/depth of an already-laid-out run, as positive magnitudes
2666/// (`MathGlyph.dy` is signed, up-positive; `.height`/`.depth` are always
2667/// non-negative extents from EACH glyph's own local baseline) — the same
2668/// aggregate `read_math`/`layout_math_value` compute for a whole
2669/// `PureHorzBox::Math`, reused here per sub-run so `MathC`'s clamp formulas
2670/// have an `h_base`/`d_sup`/etc to clamp against. Empty input -> `(ZERO,
2671/// ZERO)` (an empty base/script contributes no clamp pressure).
2672fn glyphs_extent(glyphs: &[MathGlyph]) -> (Length, Length) {
2673    let mut height = Length::ZERO;
2674    let mut depth = Length::ZERO;
2675    for g in glyphs {
2676        height = height.max(g.dy + g.height);
2677        depth = depth.max(g.depth - g.dy);
2678    }
2679    (height, depth)
2680}
2681
2682/// `glyphs_extent` plus `rules`' own bounding boxes folded in — exactly the
2683/// aggregate `layout_math_value` computes for a whole `PureHorzBox::Math`
2684/// (see that function's doc comment on why a bare `Fill`, e.g. a fraction
2685/// bar/radical sign, needs its own bbox folded in rather than being silently
2686/// undercounted). Also reused to size a stretchy delimiter to its
2687/// enclosed run's REAL ink (glyphs + any drawn rules), not just its glyphs.
2688///
2689/// This — NOT bare `glyphs_extent` — is what every `layout_math_value` arm
2690/// must use for a sub-run's `h_base`/`d_base`/`d_sup`/`h_sub`/`d_numer`/…,
2691/// because it is upstream's `convert_to_low` return value: each arm's
2692/// `(_, h, d, _, _)` is the whole sub-run's `h_whole`/`d_whole`, and a
2693/// `MathParen`'s is `max(hC, hL, hR)` / `min(dC, dL, dR)` over the
2694/// DELIMITER boxes too (`math.ml:908-909`). A `math.satyh` delimiter is
2695/// `inline-graphics` ink, so it lands in `rules` and in nothing else: with
2696/// `glyphs_extent` a `\paren{…}` base reported only its CONTENT's height,
2697/// which is smaller than the delimiter it just sized, and
2698/// `sup_shift_clamped`'s `h_base - SuperscriptBaselineDropMax` candidate
2699/// therefore lost when upstream's wins. `${\paren{\frac{1}{1-v}}^{2}}` at
2700/// 12pt: `h_base` 16.116pt (content) vs upstream's 17.316pt (`hgtaxis +
2701/// halflen`, the paren's own declared box), i.e. a 1.2pt-too-low superscript
2702/// — `layout-tests/probes/math_box_extent.saty` row 4. The bbox of
2703/// `math.satyh`'s `paren-left`/`angle-left`/… path is exactly that declared
2704/// box (its extreme points ARE `ycenter ± halflen`), so folding the rule in
2705/// reproduces upstream's number rather than approximating it.
2706fn inner_ink_extent(glyphs: &[MathGlyph], rules: &[GraphicsElem]) -> (Length, Length) {
2707    let (mut height, mut depth) = glyphs_extent(glyphs);
2708    for r in rules {
2709        // `graphics_bbox` is now `Option` (`None` for an empty `Group`
2710        // — unreachable here under 0.0.6 math rules, but the fold is
2711        // version-blind and correct either way: a `None` rule contributes
2712        // nothing to the ink extent).
2713        if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
2714            height = height.max(max_y);
2715            depth = depth.max(-min_y);
2716        }
2717    }
2718    (height, depth)
2719}
2720
2721/// `math.ml:1040-1075`'s superscript kern tuck: the italic correction of
2722/// the base's TRAILING glyph plus the two corner kerns — the base's
2723/// top-right sampled at the height the raised superscript's ink starts
2724/// (`l_base = sup_shift - d_sup`, `superscript_correction_heights`'s first
2725/// component), and the superscript's own bottom-left sampled (at the
2726/// superscript's OWN size) at the height the base's ink ends (`l_sup =
2727/// h_base - sup_shift`, that function's second component) — the extra
2728/// horizontal gap upstream inserts between a base and a raised superscript
2729/// so slanted glyphs (an italic integral, say) don't collide with what's
2730/// stacked above them. `size`/`script_size` are the local sizes the base/
2731/// script glyphs were actually measured at (NOT `ctx.font_size`, unlike the
2732/// shift magnitude — these feed a design-units conversion that must match
2733/// each glyph's own em square). Every lookup misses to `Length::ZERO` (no
2734/// MATH table, no glyph, no kern data, ...), so base-14 output is
2735/// untouched: this returns exactly `Length::ZERO` whenever `ctx.math_font`
2736/// has no MATH table.
2737#[allow(clippy::too_many_arguments)]
2738fn superscript_kern(
2739    interp: &Interp,
2740    ctx: &Context,
2741    size: Length,
2742    script_size: Length,
2743    base_glyphs: &[MathGlyph],
2744    script_glyphs: &[MathGlyph],
2745    sup_shift: Length,
2746    h_base: Length,
2747    d_sup: Length,
2748) -> Length {
2749    let font = ctx.math_font;
2750    let last_base = base_glyphs.last().and_then(|g| g.text.chars().last());
2751    let first_script = script_glyphs.first().and_then(|g| g.text.chars().next());
2752    let l_italic = last_base
2753        .and_then(|c| interp.metrics.italic_correction(font, c, size))
2754        .unwrap_or(Length::ZERO);
2755    let l_base = sup_shift - d_sup;
2756    let l_sup = h_base - sup_shift;
2757    let l_kernbase = last_base
2758        .and_then(|c| {
2759            interp
2760                .metrics
2761                .math_kern(font, c, size, MathCorner::TopRight, l_base)
2762        })
2763        .unwrap_or(Length::ZERO);
2764    let l_kernsup = first_script
2765        .and_then(|c| {
2766            interp
2767                .metrics
2768                .math_kern(font, c, script_size, MathCorner::BottomLeft, l_sup)
2769        })
2770        .unwrap_or(Length::ZERO);
2771    l_italic + l_kernbase + l_kernsup
2772}
2773
2774/// A minimal stand-in for v0.0.6's per-codepoint math-class table
2775/// (`primitives.cppo.ml`) + `normalize_math_kind` (`math.ml:240`) — just
2776/// enough for `${a+b}` to get binary-operator spacing. Letters/digits/
2777/// everything else default to `Ord`.
2778fn ascii_math_kind(c: char) -> MathKind {
2779    match c {
2780        '+' | '-' | '*' | '/' => MathKind::Bin,
2781        '=' | '<' | '>' => MathKind::Rel,
2782        ',' | ';' | ':' | '.' => MathKind::Punct,
2783        _ => MathKind::Ord,
2784    }
2785}
2786
2787/// `normalize_math_kind` (`math.ml:238-277`): a BINARY atom whose neighbours
2788/// make it unary is really an ORDINARY one, and gets none of `Bin`'s spacing.
2789/// Upstream demotes on `mkprev in {Op, Bin, Rel, Open, Punct}` or `mknext in
2790/// {Rel, Close, Punct}`; `MathEnd` — the sentinel `math.ml:1270` passes for
2791/// both ends of a formula — is included here on the LEFT, which is what makes
2792/// `${-------}` set seven tight glyphs rather than a leading binary minus
2793/// followed by six ordinaries, and what keeps `${-N, -N + 1}`'s minus signs
2794/// tight against their operands the way the reference sets them. Every other
2795/// class passes through unchanged.
2796///
2797/// Reachable at all only since the math lexer stopped gluing a run of symbols
2798/// into one token: before that a `--` was a single `Ord` atom and there was no
2799/// adjacent pair to normalize.
2800fn normalize_math_kind(prev: MathKind, next: MathKind, raw: MathKind) -> MathKind {
2801    if raw != MathKind::Bin {
2802        return raw;
2803    }
2804    let unary_left = matches!(
2805        prev,
2806        MathKind::Op
2807            | MathKind::Bin
2808            | MathKind::Rel
2809            | MathKind::Open
2810            | MathKind::Punct
2811            | MathKind::End
2812    );
2813    let unary_right = matches!(next, MathKind::Rel | MathKind::Close | MathKind::Punct);
2814    if unary_left || unary_right {
2815        MathKind::Ord
2816    } else {
2817        MathKind::Bin
2818    }
2819}
2820
2821/// The six inter-atom space ratios `space_between_math_kinds` multiplies the
2822/// math font size by — `primitives.cppo.ml:528-533`'s `space_math_bin`, `_rel`,
2823/// `_op`, `_punct`, `_inner`, `_prefix`. Upstream keeps them as a
2824/// natural/shrink/stretch triple on `HorzBox.context_main` that no v0.0.6
2825/// primitive writes, and this port's math spacer emits a fixed kern rather than
2826/// glue, so only the natural component is needed.
2827const SPACE_MATH_BIN: f64 = 0.25;
2828const SPACE_MATH_REL: f64 = 0.375;
2829const SPACE_MATH_OP: f64 = 0.125;
2830const SPACE_MATH_PUNCT: f64 = 0.125;
2831const SPACE_MATH_INNER: f64 = 0.125;
2832const SPACE_MATH_PREFIX: f64 = 0.125;
2833
2834/// `space_between_math_kinds` (`math.ml:319-410`), arm for arm and in the same
2835/// ORDER: OCaml's `match` is first-match, so `(Punct, Close)` must reach the
2836/// `(Punct, _)` arm and not `(_, Close)`. Every ratio is
2837/// `primitives.cppo.ml:528-533`'s.
2838///
2839/// `in_script` is `not (MathContext.is_in_base_level mathctx)` — inside a
2840/// sub/superscript upstream suppresses the whole table except the five operator
2841/// pairs. `font_size` is `FontInfo.actual_math_font_size mathctx`, the size of
2842/// the LEVEL being laid out and not the ambient base size, which is why callers
2843/// pass their local `size`.
2844///
2845/// Upstream's `space_correction` channel is pinned at `NoSpace` here, so the
2846/// three arms that read it — `(_, Close)`, `(Ord|Prefix, Open)` and the
2847/// fallthrough — all yield nothing: exact for `NoSpace`, and the conservative
2848/// reading of a trailing italics correction and the MATH table's
2849/// `space_after_script`.
2850fn space_before(prev: MathKind, cur: MathKind, in_script: bool, font_size: Length) -> Length {
2851    use MathKind::*;
2852    let ratio = if in_script {
2853        match (prev, cur) {
2854            (Op, Ord) | (Ord, Op) | (Op, Op) | (Close, Op) | (Inner, Op) => SPACE_MATH_OP,
2855            _ => return Length::ZERO,
2856        }
2857    } else {
2858        match (prev, cur) {
2859            (Punct, _) => SPACE_MATH_PUNCT,
2860
2861            (Inner, Ord) | (Inner, Open) | (Inner, Punct) | (Inner, Inner) | (Ord, Inner)
2862            | (Prefix, Inner) | (Close, Inner) => SPACE_MATH_INNER,
2863
2864            // `corr = NoSpace`: no italics correction to append.
2865            (_, Close) => return Length::ZERO,
2866
2867            // `corr = NoSpace`: no `space_after_script` either.
2868            (Ord, Open) | (Prefix, Open) => return Length::ZERO,
2869
2870            (Bin, Ord) | (Bin, Prefix) | (Bin, Op) | (Bin, Open) | (Bin, Inner) | (Ord, Bin)
2871            | (Close, Bin) | (Inner, Bin) => SPACE_MATH_BIN,
2872
2873            (Rel, Ord) | (Rel, Op) | (Rel, Inner) | (Rel, Open) | (Rel, Prefix) | (Ord, Rel)
2874            | (Op, Rel) | (Inner, Rel) | (Close, Rel) => SPACE_MATH_REL,
2875
2876            (Op, Ord) | (Op, Op) | (Op, Inner) | (Op, Prefix) | (Ord, Op) | (Close, Op)
2877            | (Inner, Op) => SPACE_MATH_OP,
2878
2879            (Ord, Prefix) | (Inner, Prefix) => SPACE_MATH_PREFIX,
2880
2881            (_, End) | (End, _) => return Length::ZERO,
2882
2883            _ => return Length::ZERO,
2884        }
2885    };
2886    font_size * ratio
2887}
2888
2889/// `not (MathContext.is_in_base_level mathctx)` for the `(ctx, size)` pair this
2890/// port threads instead of upstream's `math_context`. BOTH witnesses are
2891/// needed: `layout_math_atom`'s script arms shrink only the local `size` and
2892/// pass the ambient `ctx` down, while `enter_script` (`attach_scripts`,
2893/// `read-math`'s `Math::WithContext`) advances `Context::math_script_level` and
2894/// scales `font_size` together — so a `WithContext` captured under a script
2895/// arrives with `size == ctx.font_size` and is recognisable only by its level.
2896fn math_in_script(ctx: &Context, size: Length) -> bool {
2897    size != ctx.font_size || ctx.math_script_level != MathScriptLevel::Base
2898}
2899
2900/// FontKey a math glyph c@size should measure/emit in: dedicated ctx.math_font
2901/// when it can render c, else text ctx.font. The one place math diverges from
2902/// text font; the MATH-table slice keys lookups on the same returned FontKey.
2903fn math_glyph_font(interp: &Interp, ctx: &Context, c: char, size: Length) -> FontKey {
2904    if interp.metrics.advance(ctx.math_font, c, size).is_some() {
2905        ctx.math_font
2906    } else {
2907        ctx.font
2908    }
2909}
2910
2911/// gap-5 metrics-probe predicate, now math-font-aware.
2912fn math_char_available(interp: &Interp, ctx: &Context, c: char, size: Length) -> bool {
2913    interp.metrics.advance(ctx.math_font, c, size).is_some()
2914        || interp.metrics.advance(ctx.font, c, size).is_some()
2915}
2916
2917/// Last-resort un-styling for a Mathematical Alphanumeric codepoint that
2918/// NEITHER the math font nor the text font can draw: hand back the plain
2919/// letter it decomposes to (`rustyfi_backend::math_alphanumeric_base`) when
2920/// that one IS drawable, otherwise leave `c` alone.
2921///
2922/// **The failure this replaces.** An uncovered codepoint reaches the writer
2923/// as gid 0. On a TrueType face that is a tofu box; on a CFF/OTF face — which
2924/// `latinmodern-math.otf`, this port's own default math font, is — `.notdef`
2925/// is typically EMPTY, so the character takes up its advance and paints
2926/// nothing whatsoever. Either way the author gets no error and no warning,
2927/// which is exactly the "some glyphs are not drawn in PDF mode" report: with
2928/// a single uploaded text font (how the playground is configured, and how
2929/// `--font` behaves) every Greek letter in `math.satyh` is such a codepoint,
2930/// because `\pi` and friends are `greek-lowercase 0x1D70B 0x1D745` —
2931/// PRE-STYLED, with no plain `π` anywhere for the existing probe to fall back
2932/// to.
2933///
2934/// **Why it is safe.** The guard is `!math_char_available`, i.e. this can
2935/// only fire where today's output is a `.notdef`, so no glyph that renders
2936/// today changes; and the substitute is only taken when it is itself
2937/// covered, so this never trades one missing glyph for another. Measurement
2938/// follows the substitution (the caller measures the char this returns), so
2939/// the advance matches the ink instead of being `.notdef`'s.
2940///
2941/// **Why probing exactly two fonts is the WHOLE set**, which is what makes
2942/// "only fires where today's output is `.notdef`" airtight rather than
2943/// merely likely:
2944///
2945///  * The only font a math glyph can be drawn in is `math_glyph_font`'s
2946///    result, and that is `ctx.math_font` when it covers the char and
2947///    `ctx.font` otherwise — precisely the two [`math_char_available`]
2948///    probes, OR-ed the same way. There is no per-script selection in math
2949///    (no CJK face, no bold/oblique sibling), and no fallback inside the
2950///    store: `TtfFontStore::advance` resolves `FontKey -> file` through the
2951///    same `file_index` that `cid::encode_glyph_run` uses, then asks the same
2952///    `Face::glyph_index`. So the predicate and the writer agree by
2953///    construction, not by coincidence.
2954///  * When ONE of the two covers the codepoint and the other does not,
2955///    `math_char_available` is already true and nothing happens — the case
2956///    a naive `advance(ctx.math_font, ..).is_none()` guard would have got
2957///    wrong (`a_char_only_the_text_font_covers_is_not_degraded`).
2958///  * The `ssty` and `MathVariants` lookups that can substitute a DIFFERENT
2959///    gid for the same char (`math_script_variant`, `math_vertical_variant`)
2960///    all begin with `Face::glyph_index(c)?`, so none of them can render a
2961///    char whose `advance` is `None`. A miss there falls back here.
2962///  * Under `Base14Metrics` the probe is ASCII-only, so a styled codepoint
2963///    degrades only when its base letter is ASCII — and that path's
2964///    alternative was a hard `PdfError` (`winansi`), not a blank.
2965///
2966/// **Why not in `resolve_variant_char`.** That one declines the FORWARD
2967/// remap `default_math_variant_char` proposes, so it only ever sees a pair it
2968/// created itself and cannot help a codepoint that arrived already styled.
2969/// Sitting in `push_char_glyph` instead covers every math atom uniformly —
2970/// `VariantChar` (whose own arm documents having no probe), `Char`,
2971/// `CharWithKern` and the class-map path alike.
2972///
2973/// **Why the plain TEXT path is left warning-only** is argued at
2974/// [`measure_run`], with the measurements: that function is width-only, so a
2975/// substitution there would move the width without moving the ink.
2976///
2977/// Upstream does not substitute; `fontInfo.ml:180-187` warns and takes
2978/// `notdef`. See `cid::report_missing_glyphs` for the warning half.
2979fn degrade_unrenderable_variant(
2980    interp: &Interp,
2981    ctx: &Context,
2982    c: char,
2983    size: Length,
2984) -> char {
2985    if math_char_available(interp, ctx, c, size) {
2986        return c;
2987    }
2988    match rustyfi_backend::math_alphanumeric_base(c) {
2989        Some(base) if math_char_available(interp, ctx, base, size) => base,
2990        _ => c,
2991    }
2992}
2993
2994/// Measure one math character at `size` under `math_glyph_font(ctx, c)` and
2995/// push it as a `MathGlyph` at the running `*x` (`dy = 0`; callers shift
2996/// scripts afterward), advancing `*x` past it.
2997fn push_char_glyph(
2998    interp: &mut Interp,
2999    ctx: &Context,
3000    c: char,
3001    size: Length,
3002    out: &mut Vec<MathGlyph>,
3003    x: &mut Length,
3004) -> Result<(), EvalError> {
3005    let c = degrade_unrenderable_variant(interp, ctx, c, size);
3006    let font = math_glyph_font(interp, ctx, c, size);
3007    // `fontInfo.ml:379-383`: a math glyph below base level is set in the font's
3008    // `ssty` variant — a purpose-drawn form with its own advance, not the base
3009    // glyph shrunk (see `FontMetrics::math_script_variant`). Any miss falls
3010    // through to the base glyph unchanged.
3011    if math_in_script(ctx, size) {
3012        if let Some(v) = interp.metrics.math_script_variant(font, c, size) {
3013            out.push(MathGlyph {
3014                info: HorzStringInfo {
3015                    font,
3016                    size,
3017                    rising: Length::ZERO,
3018                    color: ctx.text_color,
3019                },
3020                text: c.to_string(),
3021                gid: Some(v.gid),
3022                dx: *x,
3023                dy: Length::ZERO,
3024                width: v.advance,
3025                height: v.height,
3026                depth: v.depth,
3027            });
3028            *x += v.advance;
3029            return Ok(());
3030        }
3031    }
3032    // Graceful degradation for a math character neither the math font nor the
3033    // text font can render (e.g. `⋯` U+22EF under the bundled faces): fall back
3034    // to a half-em advance and let the glyph degrade to `.notdef` at render
3035    // time (`gid: None`, resolved by `cid::encode_glyph_run`), exactly as the
3036    // text path does in `measure_run` — a missing glyph must not abort the whole
3037    // document. This only ever changes behavior for a glyph that would otherwise
3038    // be a hard error, so covered-glyph documents stay byte-identical.
3039    let advance = interp.metrics.advance(font, c, size).unwrap_or(size * 0.5);
3040    let (height, depth) = math_glyph_vextent(interp, font, c, size);
3041    out.push(MathGlyph {
3042        info: HorzStringInfo {
3043            font,
3044            size,
3045            rising: Length::ZERO,
3046            color: ctx.text_color,
3047        },
3048        text: c.to_string(),
3049        gid: None,
3050        dx: *x,
3051        dy: Length::ZERO,
3052        width: advance,
3053        height,
3054        depth,
3055    });
3056    *x += advance;
3057    Ok(())
3058}
3059
3060/// One math glyph's vertical ink extent, the way upstream measures it:
3061/// `FontFormat.get_math_glyph_metrics` (`fontFormat.ml:2257-2264`) takes the
3062/// glyph's OWN bounding box and truncates each side towards the baseline —
3063/// `hgt = truncate_negative ymax` (so a wholly-subscripted glyph reports
3064/// height 0, never a negative height) and `dpt = truncate_positive ymin` (so a
3065/// glyph entirely above the baseline, `*` at ymin=+320, reports depth 0).
3066///
3067/// This is NOT the font-level ascender/descender:
3068/// `MathC::sub_shift_clamped`/`sup_shift_clamped` clamp against these extents
3069/// (`math.ml:527-552`), so feeding them latinmodern-math's hhea ascender
3070/// (806/1000 em) and descender (194/1000 em) instead of `m`'s real ink box
3071/// (ymax 442, ymin 0) made the `superscript_baseline_drop_max` /
3072/// `subscript_baseline_drop_min` candidate win every time. At 12pt that is
3073/// `9.672 - 3.0 = 6.672pt` of superscript rise where upstream's own clamp
3074/// picks `SuperscriptShiftUp = 4.356pt` (plus a gap correction, 4.525pt), and
3075/// `2.328 + 2.4 = 4.728pt` of subscript drop where upstream picks
3076/// `SubscriptShiftDown = 2.964pt` — measured by `layout-tests/probes/
3077/// math_script_drop.saty`.
3078///
3079/// Falls back to `ascender`/`descender` when the provider exposes no per-glyph
3080/// bbox (base-14 metrics, test stubs).
3081fn math_glyph_vextent(interp: &Interp, font: FontKey, c: char, size: Length) -> (Length, Length) {
3082    match interp.metrics.glyph_vextent(font, c, size) {
3083        Some((h, d)) => (h.max(Length::ZERO), d.max(Length::ZERO)),
3084        None => (
3085            interp.metrics.ascender(font, size),
3086            interp.metrics.descender(font, size),
3087        ),
3088    }
3089}
3090
3091/// `push_char_glyph`'s big-operator sibling: try the v0.0.6 `BigOp`
3092/// vertical variant (`fontInfo.ml:386-401` — the 2nd `MathVariants` record if
3093/// present, else the 1st) unconditionally. Upstream's own guard is
3094/// `is_in_display && is_big`, but `math.ml`'s `convert_math_char` hardcodes
3095/// `is_in_display = true`, so it reduces to just `is_big` — a big operator
3096/// grows even inline, even at script size, exactly like upstream; the port
3097/// tracks no display/inline distinction and needs none here. On any miss (no
3098/// MATH table, no vertical construction for `c`, or a variant/hmtx/bbox
3099/// lookup failure — every base-14 call, always) falls back to
3100/// `push_char_glyph`, byte-identical to the base output.
3101fn push_big_char_glyph(
3102    interp: &mut Interp,
3103    ctx: &Context,
3104    c: char,
3105    size: Length,
3106    out: &mut Vec<MathGlyph>,
3107    x: &mut Length,
3108) -> Result<(), EvalError> {
3109    let font = math_glyph_font(interp, ctx, c, size);
3110    match interp
3111        .metrics
3112        .math_vertical_variant(font, c, size, VertVariantPolicy::BigOp)
3113    {
3114        Some(v) => {
3115            out.push(MathGlyph {
3116                info: HorzStringInfo {
3117                    font,
3118                    size,
3119                    rising: Length::ZERO,
3120                    color: ctx.text_color,
3121                },
3122                text: c.to_string(),
3123                gid: Some(v.gid),
3124                dx: *x,
3125                dy: Length::ZERO,
3126                width: v.advance,
3127                height: v.height,
3128                depth: v.depth,
3129            });
3130            *x += v.advance;
3131            Ok(())
3132        }
3133        None => push_char_glyph(interp, ctx, c, size, out, x),
3134    }
3135}
3136
3137/// One stretchy-delimiter glyph: the smallest `MathVariants`
3138/// record whose `advance_measurement` covers `target` (else the largest
3139/// record — `VertVariantPolicy::AtLeast`), centered on the math axis
3140/// (`dy = axis - (h - d) / 2`; y-**up**, same sign convention as
3141/// `shift_and_append`'s `dy_shift` — see that function's doc comment on the
3142/// mirroring trap a flipped sign causes). Falls back to the baseline
3143/// base glyph (`push_char_glyph`) when there's no vertical construction.
3144fn push_delimiter_glyph(
3145    interp: &mut Interp,
3146    ctx: &Context,
3147    c: char,
3148    size: Length,
3149    target: Length,
3150    axis: Length,
3151    out: &mut Vec<MathGlyph>,
3152    x: &mut Length,
3153) -> Result<(), EvalError> {
3154    let font = math_glyph_font(interp, ctx, c, size);
3155    let variant =
3156        interp
3157            .metrics
3158            .math_vertical_variant(font, c, size, VertVariantPolicy::AtLeast(target));
3159    // `GlyphAssembly`: if even the largest discrete variant's own ink
3160    // extent (`height + depth`) still doesn't span `target` — a delimiter
3161    // taller than anything the font enumerates as a prepared variant — grow
3162    // it from the assembly parts instead (stack top + repeated extenders +
3163    // bottom). `None` (no MATH table / no assembly / base-14) leaves the
3164    // discrete/base path below byte-identical.
3165    let discrete_covers = variant
3166        .map(|v| (v.height + v.depth).0 >= target.0)
3167        .unwrap_or(false);
3168    if !discrete_covers {
3169        if let Some(parts) = interp.metrics.math_vertical_assembly(font, c, size, target) {
3170            if !parts.is_empty() {
3171                // Horizontal advance of the delimiter column: the largest
3172                // discrete variant's own hmtx advance when we have one (the
3173                // parts share the same nominal delimiter width), else the base
3174                // glyph's advance.
3175                let hadv = match variant {
3176                    Some(v) => v.advance,
3177                    None => interp
3178                        .metrics
3179                        .advance(font, c, size)
3180                        .unwrap_or(Length::ZERO),
3181                };
3182                // Total vertical extent of the stacked assembly (local, from
3183                // the bottom part's baseline at 0), then center it on the math
3184                // axis exactly like the discrete path centers a variant's ink.
3185                let total = parts
3186                    .last()
3187                    .map(|(_, dy, adv)| *dy + *adv)
3188                    .unwrap_or(Length::ZERO);
3189                let base_off = axis - total * 0.5;
3190                for (i, (gid, dy_local, adv)) in parts.iter().enumerate() {
3191                    out.push(MathGlyph {
3192                        info: HorzStringInfo {
3193                            font,
3194                            size,
3195                            rising: Length::ZERO,
3196                            color: ctx.text_color,
3197                        },
3198                        text: c.to_string(),
3199                        gid: Some(*gid),
3200                        dx: *x,
3201                        dy: base_off + *dy_local,
3202                        // Only the first part carries the column's horizontal
3203                        // width (all parts are stacked in the SAME x column);
3204                        // its baseline-relative extent is the part's vertical
3205                        // advance (up), so `glyphs_extent` folds the whole
3206                        // stacked column into the box's height/depth.
3207                        width: if i == 0 { hadv } else { Length::ZERO },
3208                        height: *adv,
3209                        depth: Length::ZERO,
3210                    });
3211                }
3212                *x += hadv;
3213                return Ok(());
3214            }
3215        }
3216    }
3217    match variant {
3218        Some(v) => {
3219            let dy = axis - (v.height - v.depth) * 0.5;
3220            out.push(MathGlyph {
3221                info: HorzStringInfo {
3222                    font,
3223                    size,
3224                    rising: Length::ZERO,
3225                    color: ctx.text_color,
3226                },
3227                text: c.to_string(),
3228                gid: Some(v.gid),
3229                dx: *x,
3230                dy,
3231                width: v.advance,
3232                height: v.height,
3233                depth: v.depth,
3234            });
3235            *x += v.advance;
3236            Ok(())
3237        }
3238        None => push_char_glyph(interp, ctx, c, size, out, x),
3239    }
3240}
3241
3242/// Lay out `elems` in isolation (its own local `x` starting at 0, its own
3243/// spacing state) at `size` — the shape a `Sup`/`Sub`/`Primes` script needs
3244/// before its glyphs get re-anchored onto the base's running `x` and
3245/// shifted by the caller. `size` is the caller's `MathC::script_scale`-
3246/// derived script size (real MATH-table ratio when available, `SCRIPT_SCALE`
3247/// otherwise). Returns the glyphs (still at local coordinates) and
3248/// the script's total width.
3249fn layout_script(
3250    interp: &mut Interp,
3251    ctx: &Context,
3252    elems: &[MathElem],
3253    size: Length,
3254) -> Result<(Vec<MathGlyph>, Length), EvalError> {
3255    let mut glyphs = Vec::new();
3256    let mut x = Length::ZERO;
3257    let mut last_kind: Option<MathKind> = None;
3258    for e in elems {
3259        layout_math_elem(interp, ctx, e, size, &mut glyphs, &mut x, &mut last_kind)?;
3260    }
3261    Ok((glyphs, x))
3262}
3263
3264/// Re-anchor an isolated script's glyphs (`layout_script`'s output) onto the
3265/// base's running `*x`, adding `dy_shift` to every glyph's vertical offset —
3266/// `dy_shift > 0` raises (superscript), `< 0` lowers (subscript). Advances
3267/// `*x` past the whole script.
3268fn place_script(
3269    out: &mut Vec<MathGlyph>,
3270    x: &mut Length,
3271    script_glyphs: Vec<MathGlyph>,
3272    script_width: Length,
3273    dy_shift: Length,
3274) {
3275    let base_x = *x;
3276    for mut g in script_glyphs {
3277        g.dx = base_x + g.dx;
3278        g.dy = g.dy + dy_shift;
3279        out.push(g);
3280    }
3281    *x = base_x + script_width;
3282}
3283
3284/// The recursive core of `read_math`: lays out one `MathElem` into `out`,
3285/// advancing `*x` and threading `*last_kind` (the trailing `MathKind` of
3286/// whatever was laid out immediately before, for `space_before`) through
3287/// siblings — the analog of `convert_to_low` + `horz_of_low_math`
3288/// (`math.ml:753`/`:1016`), fused and with fixed constants.
3289fn layout_math_elem(
3290    interp: &mut Interp,
3291    ctx: &Context,
3292    elem: &MathElem,
3293    size: Length,
3294    out: &mut Vec<MathGlyph>,
3295    x: &mut Length,
3296    last_kind: &mut Option<MathKind>,
3297) -> Result<(), EvalError> {
3298    match elem {
3299        MathElem::Chars(s) => {
3300            for c in s.chars() {
3301                let kind = ascii_math_kind(c);
3302                if let Some(prev) = *last_kind {
3303                    *x += space_before(prev, kind, math_in_script(ctx, size), size);
3304                }
3305                push_char_glyph(interp, ctx, c, size, out, x)?;
3306                *last_kind = Some(kind);
3307            }
3308            Ok(())
3309        }
3310        MathElem::Group(elems) => {
3311            for e in elems {
3312                layout_math_elem(interp, ctx, e, size, out, x, last_kind)?;
3313            }
3314            Ok(())
3315        }
3316        MathElem::Sup(base, script) => {
3317            let base_start = out.len();
3318            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3319            let mc = MathC::of(interp, ctx);
3320            let script_size = ctx.font_size * mc.script_scale();
3321            let (h_base, _) = glyphs_extent(&out[base_start..]);
3322            let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3323            let (_, d_sup) = glyphs_extent(&script_glyphs);
3324            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3325            let kern = superscript_kern(
3326                interp,
3327                ctx,
3328                size,
3329                script_size,
3330                &out[base_start..],
3331                &script_glyphs,
3332                sup_shift,
3333                h_base,
3334                d_sup,
3335            );
3336            *x += kern;
3337            place_script(out, x, script_glyphs, script_width, sup_shift);
3338            Ok(())
3339        }
3340        MathElem::Sub(base, script) => {
3341            let base_start = out.len();
3342            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3343            let mc = MathC::of(interp, ctx);
3344            let script_size = ctx.font_size * mc.script_scale();
3345            let (_, d_base) = glyphs_extent(&out[base_start..]);
3346            let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3347            let (h_sub, _) = glyphs_extent(&script_glyphs);
3348            let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
3349            place_script(out, x, script_glyphs, script_width, -sub_shift);
3350            Ok(())
3351        }
3352        MathElem::Primes(base, n) => {
3353            let base_start = out.len();
3354            layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3355            let mc = MathC::of(interp, ctx);
3356            let script_size = ctx.font_size * mc.script_scale();
3357            let (h_base, _) = glyphs_extent(&out[base_start..]);
3358            // Upstream desugars primes to exactly this: a superscript of `n`
3359            // U+2032 `′` chars (`parser.mly:1082`).
3360            let primes = vec![MathElem::Chars("\u{2032}".repeat(*n))];
3361            let (script_glyphs, script_width) = layout_script(interp, ctx, &primes, script_size)?;
3362            let (_, d_sup) = glyphs_extent(&script_glyphs);
3363            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3364            let kern = superscript_kern(
3365                interp,
3366                ctx,
3367                size,
3368                script_size,
3369                &out[base_start..],
3370                &script_glyphs,
3371                sup_shift,
3372                h_base,
3373                d_sup,
3374            );
3375            *x += kern;
3376            place_script(out, x, script_glyphs, script_width, sup_shift);
3377            Ok(())
3378        }
3379        MathElem::Cmd { name, span, .. } => Err(EvalError {
3380            span: Some(*span),
3381            msg: format!("math command `{name}` needs the math package (phase 7 roadmap A)"),
3382        }),
3383        MathElem::Embed { span, .. } => Err(EvalError {
3384            span: Some(*span),
3385            msg: "embedding a program value in math needs the math package \
3386                  (phase 7 roadmap A)"
3387                .into(),
3388        }),
3389    }
3390}
3391
3392/// Walk an elaborated `${…}` tree (`read_inline`'s `EmbedMath` arm) into one
3393/// `PureHorzBox::Math`, measuring every glyph through `interp.metrics` at
3394/// `ctx.font`/`ctx.font_size` — the same `FontMetrics` seam `text_to_boxes`
3395/// uses. Box-model rationale: a math run carries its own pre-shifted
3396/// sub-glyphs, since the line model has no per-box vertical slot.
3397pub fn read_math(
3398    interp: &mut Interp,
3399    ctx: &Context,
3400    elems: &[MathElem],
3401) -> Result<PureHorzBox, EvalError> {
3402    let mut glyphs: Vec<MathGlyph> = Vec::new();
3403    let mut x = Length::ZERO;
3404    let mut last_kind: Option<MathKind> = None;
3405    for e in elems {
3406        layout_math_elem(
3407            interp,
3408            ctx,
3409            e,
3410            ctx.font_size,
3411            &mut glyphs,
3412            &mut x,
3413            &mut last_kind,
3414        )?;
3415    }
3416    let width = x;
3417    let mut height = Length::ZERO;
3418    let mut depth = Length::ZERO;
3419    for g in &glyphs {
3420        height = height.max(g.dy + g.height);
3421        depth = depth.max(g.depth - g.dy);
3422    }
3423    Ok(PureHorzBox::Math {
3424        width,
3425        height,
3426        depth,
3427        glyphs,
3428        rules: Vec::new(),
3429    })
3430}
3431
3432// ---- primitive bodies ----------------------------------------------------------
3433
3434fn prim_read_inline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3435    let it = args.pop().unwrap();
3436    let ctx = as_context(args.pop().unwrap())?;
3437    let (elems, env) = as_inline_text(it)?;
3438    Ok(Value::InlineBoxes(read_inline(interp, &ctx, &elems, &env)?))
3439}
3440
3441fn prim_read_block(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3442    let bt = args.pop().unwrap();
3443    let ctx = as_context(args.pop().unwrap())?;
3444    let (elems, env) = as_block_text(bt)?;
3445    Ok(Value::BlockBoxes(read_block(interp, &ctx, &elems, &env)?))
3446}
3447
3448/// `line-break : bool -> bool -> context -> inline-boxes -> block-boxes`
3449/// (vminst.ml `BackendLineBreaking`). The two leading bools tell the real
3450/// line breaker whether the paragraph's top/bottom edge may break across a
3451/// page; this port's `break_into_lines` does not yet model breakability
3452/// at all, so both are accepted (to keep the arity/signature faithful to
3453/// v0.0.6) and ignored for now.
3454///
3455/// This is upstream's `form_paragraph` seam — every stdlib caller
3456/// (`form-paragraph = line-break true true`, and every direct `line-break _
3457/// _ (ctx |> set-paragraph-margin …)` call for headings/itemize/footnotes)
3458/// relies on `line-break` itself to apply
3459/// `ctx.paragraph_top`/`paragraph_bottom` around the formed lines,
3460/// unconditionally of the two breakability bools (those only ever gate
3461/// page-break eligibility upstream, never whether the margin applies).
3462/// Prepending/appending `VertBox::Skip` here is a no-op in extent for a
3463/// caller that already zeroed the margin (e.g. `footnote-scheme.satyh`'s
3464/// `set-paragraph-margin 0pt 0pt`), and the leading skip specifically is
3465/// further discarded by `chop_page` when it lands at the very top of a
3466/// page/column (see that function's `pending_skip` handling) — mirroring
3467/// upstream's page-top glue suppression so a page's first paragraph does not
3468/// get a spurious gap above it.
3469fn prim_line_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3470    let ib = as_inline_boxes(args.pop().unwrap())?;
3471    let ctx = as_context(args.pop().unwrap())?;
3472    let _is_breakable_bottom = as_bool(args.pop().unwrap())?;
3473    let _is_breakable_top = as_bool(args.pop().unwrap())?;
3474    let lines = break_into_lines(&ctx, ib);
3475    // No lines were actually formed (empty inline content, `break_into_lines`'s
3476    // own `n == 0` early return) — don't manufacture a margin around nothing.
3477    let mut out = Vec::with_capacity(lines.len() + 2);
3478    if !lines.is_empty() {
3479        // `min_first_line_ascender` (9pt, `primitives.cppo.ml:516`) is folded
3480        // into the paragraph's OWN top margin, exactly as `lineBreak.ml:855-857`
3481        // does — `margin_top = paragraph_margin_top + max(0, 9pt - hgt)` over
3482        // the FIRST formed line's height. That padded value is what
3483        // `pageBreak.ml`'s `squash_margins` (`:596-601`) then max-collapses
3484        // against the previous block's bottom margin, so a larger predecessor
3485        // ABSORBS the pad instead of stacking it on top. Applying the floor
3486        // downstream to the line HEIGHT, after the collapse, is a different
3487        // function: it over-spaced every block whose predecessor had the larger
3488        // bottom margin — 5pt at every stdjabook section heading, whose 4pt
3489        // rule lines are shorter than the floor.
3490        //
3491        // The BOTTOM margin takes no pad: `min_last_descender` is assigned at
3492        // `lineBreak.ml:1144` and never read.
3493        let first_height = lines
3494            .iter()
3495            .find_map(|vb| match vb {
3496                VertBox::Line { height, .. } => Some(*height),
3497                _ => None,
3498            })
3499            .unwrap_or(Length::ZERO);
3500        let pad = (MIN_FIRST_ASCENDER - first_height).max(Length::ZERO);
3501        out.push(VertBox::ParagTop(ctx.paragraph_top + pad));
3502        out.extend(lines);
3503        out.push(VertBox::Skip(ctx.paragraph_bottom));
3504    }
3505    for vb in &mut out {
3506        if let VertBox::Line { contents, .. } = vb {
3507            resolve_outer_graphics_in_contents(interp, contents)?;
3508        }
3509    }
3510    Ok(Value::BlockBoxes(out))
3511}
3512
3513/// Look up `field` in a scheme record's fields, erroring with the
3514/// available-fields hint (mirrors `evalUtil.ml`'s `report_bug_value` arms
3515/// for a missing/mistyped scheme field) if it's absent.
3516fn record_field(
3517    fields: &BTreeMap<String, Value>,
3518    record_name: &str,
3519    field: &str,
3520) -> Result<Value, EvalError> {
3521    match fields.get(field) {
3522        Some(v) => Ok(v.clone()),
3523        None => eval_error(format!(
3524            "{record_name} record is missing field '{field}' (available fields: {})",
3525            available_fields(fields)
3526        )),
3527    }
3528}
3529
3530/// Extract `(text-origin, text-height)` from a `page-content-scheme`
3531/// record (`{| text-origin : point; text-height : length |}`) — the direct
3532/// port of `make_page_content_scheme_func`'s field pull (`evalUtil.ml:558-
3533/// 565`).
3534fn read_content_scheme(v: Value) -> Result<(Point, Length), EvalError> {
3535    let fields = match v {
3536        Value::Record(m) => m,
3537        other => {
3538            return eval_error(format!(
3539                "a page-content-scheme closure must return a record, got {}",
3540                other.type_name()
3541            ))
3542        }
3543    };
3544    let origin = as_point(record_field(&fields, "page-content-scheme", "text-origin")?)?;
3545    let height = as_length(record_field(&fields, "page-content-scheme", "text-height")?)?;
3546    Ok((origin, height))
3547}
3548
3549/// Extract `(header-origin, header-content, footer-origin, footer-content)`
3550/// from a `page-parts` record — the direct port of
3551/// `make_page_parts_scheme_func`'s field pull (`evalUtil.ml:576-595`).
3552fn read_parts_scheme(v: Value) -> Result<(Point, Vec<VertBox>, Point, Vec<VertBox>), EvalError> {
3553    let fields = match v {
3554        Value::Record(m) => m,
3555        other => {
3556            return eval_error(format!(
3557                "a page-parts closure must return a record, got {}",
3558                other.type_name()
3559            ))
3560        }
3561    };
3562    let header_origin = as_point(record_field(&fields, "page-parts", "header-origin")?)?;
3563    let header_content = as_block_boxes(record_field(&fields, "page-parts", "header-content")?)?;
3564    let footer_origin = as_point(record_field(&fields, "page-parts", "footer-origin")?)?;
3565    let footer_content = as_block_boxes(record_field(&fields, "page-parts", "footer-content")?)?;
3566    Ok((header_origin, header_content, footer_origin, footer_content))
3567}
3568
3569/// Upstream's `--page-number-limit` default (main.ml:1029). v0.0.6 guards
3570/// only the multicolumn loop (pageBreak.ml:765, `PageNumberLimitExceeded`);
3571/// the port guards the shared loop unconditionally — a hook-less run is
3572/// already bounded by the vbox count (`chop_page`'s progress guarantee), so
3573/// the guard can only fire when column hooks inject content, exactly the
3574/// case upstream added it for.
3575const PAGE_NUMBER_LIMIT: i64 = 10_000;
3576
3577/// The real 4-arg `page-break`, v0.0.6 arm — upstream `BCDocument(pagesize,
3578/// SingleColumn, (fun () -> []), (fun () -> []), …)` (vminst.ml:1039): one
3579/// zero-shift column, no hooks. Forked from the v0.1 arm below ONLY in its
3580/// first-argument extraction (`as_page` vs `as_page_v01`) — deliberately two
3581/// separate functions per tag rather than one branching on a `version`
3582/// parameter, so that a "shared" function is genuinely shared code.
3583/// `page_break_core` below IS that shared code.
3584fn prim_page_break_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3585    let bb = as_block_boxes(args.pop().unwrap())?;
3586    let pagepartsf = args.pop().unwrap();
3587    let pagecontf = args.pop().unwrap();
3588    let paper = as_page(args.pop().unwrap())?;
3589    page_break_core(
3590        interp,
3591        paper,
3592        vec![Length::ZERO],
3593        None,
3594        None,
3595        pagecontf,
3596        pagepartsf,
3597        bb,
3598    )
3599}
3600
3601/// v0.1 arm of `page-break`. Identical to `prim_page_break_v006` above
3602/// except `as_page_v01` in place of `as_page`; everything downstream
3603/// (`page_break_core`, `chop_page`, `place_block_at`, `DocumentValue`
3604/// assembly) is the SAME shared code both arms call, unedited by this fork.
3605fn prim_page_break_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3606    let bb = as_block_boxes(args.pop().unwrap())?;
3607    let pagepartsf = args.pop().unwrap();
3608    let pagecontf = args.pop().unwrap();
3609    let paper = as_page_v01(args.pop().unwrap())?;
3610    page_break_core(
3611        interp,
3612        paper,
3613        vec![Length::ZERO],
3614        None,
3615        None,
3616        pagecontf,
3617        pagepartsf,
3618        bb,
3619    )
3620}
3621
3622/// `page-break-two-column : page -> length -> (unit -> block-boxes) ->
3623/// (pbinfo -> page-content-scheme) -> (pbinfo -> page-parts) ->
3624/// block-boxes -> document` (vminst.ml:1041 `BackendPageBreakingTwoColumn`),
3625/// v0.0.6 arm — upstream builds `MultiColumn([origin_shift])` with the
3626/// user's column hook and a trivial column-end hook (vminst.ml:1062); the
3627/// `length` is the x-shift of the SECOND column's origin. See
3628/// `prim_page_break_v006`'s doc comment for the fork rationale.
3629fn prim_page_break_two_column_v006(
3630    interp: &mut Interp,
3631    mut args: Vec<Value>,
3632) -> Result<Value, EvalError> {
3633    let bb = as_block_boxes(args.pop().unwrap())?;
3634    let pagepartsf = args.pop().unwrap();
3635    let pagecontf = args.pop().unwrap();
3636    let columnhookf = args.pop().unwrap();
3637    let origin_shift = as_length(args.pop().unwrap())?;
3638    let paper = as_page(args.pop().unwrap())?;
3639    page_break_core(
3640        interp,
3641        paper,
3642        vec![Length::ZERO, origin_shift],
3643        Some(columnhookf),
3644        None,
3645        pagecontf,
3646        pagepartsf,
3647        bb,
3648    )
3649}
3650
3651/// v0.1 arm of `page-break-two-column`, using `as_page_v01` in place of
3652/// `as_page`.
3653fn prim_page_break_two_column_v01(
3654    interp: &mut Interp,
3655    mut args: Vec<Value>,
3656) -> Result<Value, EvalError> {
3657    let bb = as_block_boxes(args.pop().unwrap())?;
3658    let pagepartsf = args.pop().unwrap();
3659    let pagecontf = args.pop().unwrap();
3660    let columnhookf = args.pop().unwrap();
3661    let origin_shift = as_length(args.pop().unwrap())?;
3662    let paper = as_page_v01(args.pop().unwrap())?;
3663    page_break_core(
3664        interp,
3665        paper,
3666        vec![Length::ZERO, origin_shift],
3667        Some(columnhookf),
3668        None,
3669        pagecontf,
3670        pagepartsf,
3671        bb,
3672    )
3673}
3674
3675/// `page-break-multicolumn : page -> length list -> (unit -> block-boxes)
3676/// -> (unit -> block-boxes) -> (pbinfo -> page-content-scheme) -> (pbinfo
3677/// -> page-parts) -> block-boxes -> document` (vminst.ml:1065
3678/// `BackendPageBreakingMultiColumn`), v0.0.6 arm — FAITHFUL: the shift list
3679/// gives columns 2..N's x-origin shifts; upstream prepends `Length.zero` for
3680/// column 1 (pageBreak.ml:762), so `stdjareport.satyh:403`'s `[]` is a
3681/// one-column layout whose hooks still fire per column/page.
3682fn prim_page_break_multicolumn_v006(
3683    interp: &mut Interp,
3684    mut args: Vec<Value>,
3685) -> Result<Value, EvalError> {
3686    let bb = as_block_boxes(args.pop().unwrap())?;
3687    let pagepartsf = args.pop().unwrap();
3688    let pagecontf = args.pop().unwrap();
3689    let columnendhookf = args.pop().unwrap();
3690    let columnhookf = args.pop().unwrap();
3691    let mut origin_shifts = vec![Length::ZERO];
3692    for v in as_list(args.pop().unwrap())? {
3693        origin_shifts.push(as_length(v)?);
3694    }
3695    let paper = as_page(args.pop().unwrap())?;
3696    page_break_core(
3697        interp,
3698        paper,
3699        origin_shifts,
3700        Some(columnhookf),
3701        Some(columnendhookf),
3702        pagecontf,
3703        pagepartsf,
3704        bb,
3705    )
3706}
3707
3708/// v0.1 arm of `page-break-multicolumn`, using `as_page_v01` in place of
3709/// `as_page`.
3710fn prim_page_break_multicolumn_v01(
3711    interp: &mut Interp,
3712    mut args: Vec<Value>,
3713) -> Result<Value, EvalError> {
3714    let bb = as_block_boxes(args.pop().unwrap())?;
3715    let pagepartsf = args.pop().unwrap();
3716    let pagecontf = args.pop().unwrap();
3717    let columnendhookf = args.pop().unwrap();
3718    let columnhookf = args.pop().unwrap();
3719    let mut origin_shifts = vec![Length::ZERO];
3720    for v in as_list(args.pop().unwrap())? {
3721        origin_shifts.push(as_length(v)?);
3722    }
3723    let paper = as_page_v01(args.pop().unwrap())?;
3724    page_break_core(
3725        interp,
3726        paper,
3727        origin_shifts,
3728        Some(columnhookf),
3729        Some(columnendhookf),
3730        pagecontf,
3731        pagepartsf,
3732        bb,
3733    )
3734}
3735
3736/// Apply a `unit -> block-boxes` column hook and PREPEND its result to the
3737/// remaining content — the port of `chop_single_column_with_insertion`
3738/// (pageBreak.ml:699-702; the upstream `normalize` is a no-op here because
3739/// block-boxes are already solid `Vec<VertBox>`).
3740///
3741/// Reports whether the hook actually inserted anything: for the COLUMN-END
3742/// hook that is the difference between a remainder upstream would normalize
3743/// away and one it would turn into a real page — see `page_break_core`'s
3744/// blank-page suppression.
3745fn apply_column_hook(
3746    interp: &mut Interp,
3747    hook: &Value,
3748    remaining: &mut Vec<VertBox>,
3749) -> Result<bool, EvalError> {
3750    let inserted = as_block_boxes(interp.apply(hook.clone(), Value::Unit)?)?;
3751    let any = !inserted.is_empty();
3752    remaining.splice(0..0, inserted);
3753    Ok(any)
3754}
3755
3756/// Walk one run of a page's just-placed lines with the hooks-only half of
3757/// [`crate::PlacedWalk`].
3758///
3759/// Both flags are set for the duration of the walk and cleared after it, so
3760/// nothing ELSE the page loop evaluates — the content scheme, the parts
3761/// scheme, the column hooks — inherits either: `fire_pass` because those are
3762/// not walks, and `current_page` because it is upstream's
3763/// `State.during_page_break` window, which a `register-destination` outside a
3764/// fired callback must still be refused by.
3765fn walk_hooks(
3766    interp: &mut Interp,
3767    walk: &mut crate::PlacedWalk,
3768    paper_height: Length,
3769    page: usize,
3770    lines: &[rustyfi_backend::PlacedLine],
3771    body: bool,
3772) -> Result<(), EvalError> {
3773    interp.fire_pass = crate::eval::FirePass::HooksOnly;
3774    interp.current_page = Some(page);
3775    let r = walk.lines(interp, paper_height, page, lines, body);
3776    interp.current_page = None;
3777    interp.fire_pass = crate::eval::FirePass::All;
3778    r
3779}
3780
3781/// [`walk_hooks`]' page-closing twin. Fires nothing in this pass — every frame
3782/// fragment is a decoration — but it is what advances the cross-page frame
3783/// state, so the two passes stay in step about which fragment is which.
3784fn end_page_hooks(
3785    interp: &mut Interp,
3786    walk: &mut crate::PlacedWalk,
3787    paper_height: Length,
3788    page: usize,
3789) -> Result<(), EvalError> {
3790    interp.fire_pass = crate::eval::FirePass::HooksOnly;
3791    interp.current_page = Some(page);
3792    let r = walk.end_page(interp, paper_height, page);
3793    interp.current_page = None;
3794    interp.fire_pass = crate::eval::FirePass::All;
3795    r
3796}
3797
3798/// The shared per-page loop backing `page-break`, `page-break-two-column`,
3799/// and `page-break-multicolumn` — the port of `PageBreak.main` /
3800/// `main_multicolumn` (pageBreak.ml:705-781). Lang-side because it is the
3801/// one place that legally holds `&mut Interp` to apply the scheme/hook
3802/// closures (the `fire_hooks` seam). `origin_shifts` is the FULL column
3803/// list (leading zero included by the callers); `None` hooks are upstream's
3804/// `(fun () -> [])`.
3805///
3806/// Per page: apply `pagecontf` once; per column: fire `columnhookf`
3807/// (start of EVERY column, pageBreak.ml:700), chop one column at
3808/// `(x0 + shift, y0)` (footnotes bottom-place per column inside
3809/// `chop_page`), stop early when content runs out; then fire
3810/// `columnendhookf` exactly once (both upstream arms — exhausted
3811/// mid-columns `:751` and shifts-exhausted `:736` — reduce to "prepend its
3812/// output to the remainder"); then apply `pagepartsf` and place the parts.
3813#[allow(clippy::too_many_arguments)]
3814fn page_break_core(
3815    interp: &mut Interp,
3816    paper: PaperSize,
3817    origin_shifts: Vec<Length>,
3818    columnhookf: Option<Value>,
3819    columnendhookf: Option<Value>,
3820    pagecontf: Value,
3821    pagepartsf: Value,
3822    bb: Vec<VertBox>,
3823) -> Result<Value, EvalError> {
3824    let (paper_w, paper_h) = paper.dims();
3825
3826    // Capture the flat pre-page-break `Vec<VertBox>` BEFORE
3827    // `chop_page`/`apply_column_hook` below start draining/mutating
3828    // `remaining` — this clone is the document's natural linear flow exactly
3829    // as `bb` arrived here (no pages, no injected headers/footers, no
3830    // column-hook-inserted content). Unconditional (not gated on which
3831    // output format was requested — see `DocumentValue::reflow_source`'s doc
3832    // comment): PDF and the faithful HTML backend never read the field, so
3833    // this costs them only the clone itself, never a byte of their rendered
3834    // output.
3835    let reflow_source = bb.clone();
3836
3837    let mut remaining = bb;
3838    let mut pages: Vec<Page> = Vec::new();
3839    let mut pageno: i64 = 1;
3840    // The per-page `hook-page-break` pass, threaded through the whole loop so
3841    // that a `block-frame-breakable` straddling a page break keeps its state —
3842    // see `crate::PlacedWalk` for what the two passes divide between them, and
3843    // `eval::FirePass` for why there are two.
3844    let mut walk = crate::PlacedWalk::default();
3845    // Did the PREVIOUS page's column-end hook inject the content this page is
3846    // being made out of? See the blank-page suppression below.
3847    let mut columnend_injected = false;
3848    loop {
3849        if pageno > PAGE_NUMBER_LIMIT {
3850            return eval_error(format!(
3851                "page number limit exceeded ({PAGE_NUMBER_LIMIT}); a column hook keeps injecting content"
3852            ));
3853        }
3854        let page_index = (pageno - 1) as usize;
3855        let mut pb_fields = BTreeMap::new();
3856        pb_fields.insert("page-number".to_string(), Value::Int(pageno));
3857        let pbinfo = Value::Record(pb_fields);
3858
3859        // ---- content scheme: this page's text area (applied ONCE per page, shared by all its columns — pageBreak.ml:769) ----
3860        let sch = interp.apply(pagecontf.clone(), pbinfo.clone())?;
3861        let (origin, height) = read_content_scheme(sch)?;
3862        let (x0, y0) = origin;
3863
3864        // ---- columns ----
3865        let mut lines: Vec<rustyfi_backend::PlacedLine> = Vec::new();
3866        walk.begin_page();
3867        for shift in &origin_shifts {
3868            if let Some(hook) = &columnhookf {
3869                apply_column_hook(interp, hook, &mut remaining)?;
3870            }
3871            let placed_before = lines.len();
3872            lines.extend(chop_page((x0 + *shift, y0), height, &mut remaining));
3873            // Upstream fires this column's hooks HERE, and says so:
3874            // `pageBreak.ml:747` is commented "Adds the column to the page and
3875            // invokes hook functions". `add_column_to_page` (`:748`) builds
3876            // the column's PDF ops eagerly and `EvVertHookPageBreak` invokes
3877            // the closure as they are built (`handlePdf.ml:336`) — after
3878            // `chop_single_column_with_insertion` (`:744`) and before BOTH
3879            // `columnendhookf` (`:752`) and `write_page`'s `pagepartsf`
3880            // (`:775` -> `handlePdf.ml:464`). Single-column `main` is the same
3881            // three steps at `:715-717`.
3882            //
3883            // That order is the whole mechanism behind a floating figure:
3884            // `stdjareport`'s `\figure` is a `hook-page-break` that pushes the
3885            // figure onto `ref-float-boxes`, and the page-parts callback drains
3886            // that list onto a page whose number EXCEEDS the one it was pushed
3887            // on. Firing after the page loop (which is what this port used to
3888            // do) left every page-parts callback reading an empty list, and no
3889            // figure was ever emitted. `columnendhookf` reads hook state too:
3890            // `does-page-breaking-reach-last`, set by the
3891            // `hook-page-break-block` at the very end of the document.
3892            walk_hooks(
3893                interp,
3894                &mut walk,
3895                paper_h,
3896                page_index,
3897                &lines[placed_before..],
3898                true,
3899            )?;
3900            if remaining.is_empty() {
3901                break; // content exhausted: remaining columns are skipped
3902            }
3903        }
3904        let injected_now = match &columnendhookf {
3905            Some(hook) => apply_column_hook(interp, hook, &mut remaining)?,
3906            None => false,
3907        };
3908
3909        // A trailing pure-skip/glue (e.g. the last block's `paragraph_bottom`)
3910        // can roll past the previous page's bottom into a final `chop_page`
3911        // that places NO real line — `chop_page` discards it as a page-top
3912        // skip, leaving an empty body. SATySFi never emits such a trailing
3913        // blank page (glue at the end of the vertical list is dropped), so when
3914        // the body is empty AND content is now exhausted, stop before turning
3915        // that leftover into a spurious blank page (header/footer included).
3916        //
3917        // UNLESS the previous page's COLUMN-END HOOK is what put the content
3918        // here, which is a page the document deliberately asked for. Upstream
3919        // draws exactly that line: leftovers from the chop are normalized
3920        // (`normalize_after_break`, pageBreak.ml:101-121, maps `[]` and a lone
3921        // trailing breakable skip to `NormalizedEmpty`, so `restopt` is `None`
3922        // and no further page is created), but the column-end hook's insertion
3923        // bypasses it — `iter_on_column` returns it as the remainder (`:737`,
3924        // `:752`) and `iter_on_page` only tests that remainder for emptiness
3925        // (`:776-778`).
3926        // `stdjareport`'s `columnendhookf` buys the extra page its TRAILING
3927        // figures float onto with precisely that `block-skip 0pt`, and a
3928        // blanket suppression deleted the page and the figures with it.
3929        if !columnend_injected
3930            && remaining.is_empty()
3931            && !lines.iter().any(|l| placed_line_extent(l).is_some())
3932        {
3933            break;
3934        }
3935        columnend_injected = injected_now;
3936
3937        // ---- parts scheme: this page's header + footer ----
3938        // Everything placed so far is body/column content; the header and
3939        // footer append AFTER it (see `Page::body_lines`).
3940        let body_lines = lines.len();
3941        let parts = interp.apply(pagepartsf.clone(), pbinfo)?;
3942        let (header_origin, header_content, footer_origin, footer_content) =
3943            read_parts_scheme(parts)?;
3944        lines.extend(place_block_at(header_origin, header_content));
3945        lines.extend(place_block_at(footer_origin, footer_content));
3946        // The header's and footer's own hooks, in upstream's position too:
3947        // `write_page` runs `pagepartsf` (handlePdf.ml:464) and then walks the
3948        // parts' boxes through the same `ops_of_evaled_vert_box_list` (`:467`,
3949        // `:471`) that fired the body's.
3950        walk_hooks(
3951            interp,
3952            &mut walk,
3953            paper_h,
3954            page_index,
3955            &lines[body_lines..],
3956            false,
3957        )?;
3958        end_page_hooks(interp, &mut walk, paper_h, page_index)?;
3959
3960        pages.push(Page { lines, body_lines });
3961        if remaining.is_empty() {
3962            break;
3963        }
3964        pageno += 1;
3965    }
3966    // Tells the `fire_hooks` that runs once this document is returned to fire
3967    // the DECORATIONS only: every `hook-page-break` above has already run.
3968    interp.page_break_hooks_fired = true;
3969
3970    // Every image `load-image` decoded while evaluating this document (see
3971    // `Interp::images`'s doc comment) rides along in the packaged
3972    // `DocumentValue` so the PDF writer can emit XObjects for the ones
3973    // actually placed on a page.
3974    let images = interp.images.clone();
3975    Ok(Value::Document(Rc::new(DocumentValue {
3976        geometry: PageGeometry::for_paper(paper_w, paper_h),
3977        pages,
3978        images,
3979        // Filled in by `compile_document_cst_with_trials` once `fire_hooks`
3980        // has walked the final trial's placed geometry (see
3981        // `DocumentValue::extras`'s doc comment) — hooks/decos haven't fired
3982        // yet at this point in `page-break`'s own evaluation.
3983        extras: DocExtras::default(),
3984        reflow_source: Some(reflow_source),
3985        // Filled in alongside `extras` once `fire_hooks` has run — see
3986        // `DocumentValue::reflow_links`'s doc comment.
3987        reflow_links: Vec::new(),
3988        reflow_dests: Vec::new(),
3989        reflow_frame_decos: Vec::new(),
3990    })))
3991}
3992
3993// ---- int arithmetic -------------------------------------------------------
3994
3995// Wrapping arithmetic to match OCaml's native `int` (SATySFi's `int` is an
3996// OCaml int, which wraps on overflow) — and, decisively, so a debug build does
3997// not panic on the large intermediate products base's float bit-twiddling
3998// (`exp2i`, `ldexp`, `frexp`) computes.
3999binop_prim!(prim_int_add, as_int, Int, |a, b| a.wrapping_add(b));
4000binop_prim!(prim_int_sub, as_int, Int, |a, b| a.wrapping_sub(b));
4001binop_prim!(prim_int_mul, as_int, Int, |a, b| a.wrapping_mul(b));
4002
4003// OCaml catches `Division_by_zero` and reports `"division by zero"`; `mod`
4004// (see `Mod` in vminst.ml) shares that behavior.
4005binop_prim_try!(prim_int_div, as_int, |a, b| if b == 0 {
4006    eval_error("division by zero")
4007} else {
4008    Ok(Value::Int(a / b))
4009});
4010binop_prim_try!(prim_int_mod, as_int, |a, b| if b == 0 {
4011    eval_error("division by zero")
4012} else {
4013    Ok(Value::Int(a % b))
4014});
4015
4016// ---- int comparisons -------------------------------------------------------
4017
4018cmp_prim!(prim_int_eq, as_int, |a, b| a == b);
4019cmp_prim!(prim_int_ne, as_int, |a, b| a != b);
4020cmp_prim!(prim_int_lt, as_int, |a, b| a < b);
4021cmp_prim!(prim_int_gt, as_int, |a, b| a > b);
4022cmp_prim!(prim_int_le, as_int, |a, b| a <= b);
4023cmp_prim!(prim_int_ge, as_int, |a, b| a >= b);
4024
4025// ---- 0.1 bitwise ops -------------------------------------------------------
4026//
4027// `band`/`bor`/`bxor` mirror OCaml's `land`/`lor`/`lxor`; `bnot` mirrors
4028// `lnot` (bitwise complement). DOCUMENTED DEVIATION: this port's `int` is a
4029// 64-bit two's-complement `i64`, vs upstream's 63-bit boxed OCaml `int` — a
4030// value that actually uses bit 62 (the port's sign-adjacent bit upstream
4031// doesn't have) will complement/shift differently than upstream on that
4032// platform; upstream's own results are themselves platform-width-dependent
4033// there, and no bundled package relies on it.
4034binop_prim!(prim_band, as_int, Int, |a, b| a & b);
4035binop_prim!(prim_bor, as_int, Int, |a, b| a | b);
4036binop_prim!(prim_bxor, as_int, Int, |a, b| a ^ b);
4037unop_prim!(prim_bnot, as_int, Int, |a| !a);
4038
4039// `<<`/`>>` (dev-0-1-0 vminst.ml :2495/:2477): logical shifts (OCaml's
4040// `lsl`/`lsr`, NOT arithmetic — `>>` on a negative int does NOT sign-extend,
4041// see the `-16 >> 2` witness in the test suite), with upstream's exact
4042// dynamic-error message when the shift amount is out of `0..=63`.
4043binop_prim_try!(
4044    prim_bit_shift_left,
4045    as_int,
4046    |a, b| if !(0..=63).contains(&b) {
4047        eval_error("Bit offset out of bounds for '<<'")
4048    } else {
4049        Ok(Value::Int(((a as u64) << b) as i64))
4050    }
4051);
4052binop_prim_try!(
4053    prim_bit_shift_right,
4054    as_int,
4055    |a, b| if !(0..=63).contains(&b) {
4056        eval_error("Bit offset out of bounds for '>>'")
4057    } else {
4058        Ok(Value::Int(((a as u64) >> b) as i64))
4059    }
4060);
4061
4062// ---- bool -------------------------------------------------------------------
4063
4064// Strict (both arguments already evaluated by the caller before these natives
4065// run): real SATySFi source-level `&&`/`||` short-circuit via elaboration into
4066// `if`, which is out of scope here.
4067binop_prim!(prim_bool_and, as_bool, Bool, |a, b| a && b);
4068binop_prim!(prim_bool_or, as_bool, Bool, |a, b| a || b);
4069unop_prim!(prim_bool_not, as_bool, Bool, |a| !a);
4070
4071// ---- float --------------------------------------------------------------------
4072
4073binop_prim!(prim_float_add, as_float, Float, |a, b| a + b);
4074binop_prim!(prim_float_sub, as_float, Float, |a, b| a - b);
4075binop_prim!(prim_float_mul, as_float, Float, |a, b| a * b);
4076binop_prim!(prim_float_div, as_float, Float, |a, b| a / b);
4077unop_prim!(prim_float_of_int, as_int, Float, |n| n as f64);
4078
4079// `PrimitiveRound` in vminst.ml is, despite the name, `int_of_float`
4080// (truncation toward zero), not rounding to nearest.
4081unop_prim!(prim_round, as_float, Int, |x| x as i64);
4082
4083// ---- 0.1 float comparisons (saphe-split vminst.ml:2679-2740) ----
4084cmp_prim!(prim_float_gt, as_float, |a, b| a > b);
4085cmp_prim!(prim_float_lt, as_float, |a, b| a < b);
4086cmp_prim!(prim_float_ge, as_float, |a, b| a >= b);
4087cmp_prim!(prim_float_le, as_float, |a, b| a <= b);
4088
4089// ---- length ---------------------------------------------------------------------
4090
4091binop_prim!(prim_length_add, as_length, Length, |a, b| a + b);
4092binop_prim!(prim_length_sub, as_length, Length, |a, b| a - b);
4093binop_prim!(prim_length_scale, (as_length, as_float), Length, |a, b| a
4094    * b);
4095binop_prim!(prim_length_div, as_length, Float, |a, b| a / b);
4096cmp_prim!(prim_length_lt, as_length, |a, b| a < b);
4097
4098// `LengthGreaterThan` in vminst.ml is implemented as `len2 <% len1`, i.e.
4099// `a >' b` iff `b <' a` — the same ordering, just flipped operands.
4100cmp_prim!(prim_length_gt, as_length, |a, b| b < a);
4101
4102// ---- string -----------------------------------------------------------------------
4103
4104binop_prim!(prim_string_concat, as_str, Str, |a, b| a + &b);
4105unop_prim!(prim_arabic, as_int, Str, |n| n.to_string());
4106cmp_prim!(prim_string_same, as_str, |a, b| a == b);
4107
4108// ---- list -----------------------------------------------------------------
4109
4110/// `x :: xs` — prepend `x` onto the list `xs`.
4111fn prim_list_cons(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4112    let tail = args.pop().unwrap();
4113    let head = args.pop().unwrap();
4114    let mut list = match tail {
4115        Value::List(v) => v,
4116        other => return eval_error(format!("expected list, got {}", other.type_name())),
4117    };
4118    list.insert(0, head);
4119    Ok(Value::List(list))
4120}
4121
4122// ---- mutable-cell dereference ----------------------------------------------
4123
4124/// `!` — read the current contents of a mutable cell (see the `prims!`
4125/// registration above for how this differs structurally, not semantically,
4126/// from v0.0.6's `Dereference`/`Location` handling).
4127fn prim_deref(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4128    let v = args.pop().unwrap();
4129    match v {
4130        Value::Ref(cell) => Ok(cell.borrow().clone()),
4131        other => eval_error(format!(
4132            "expected a mutable cell for '!', got {}",
4133            other.type_name()
4134        )),
4135    }
4136}
4137
4138// ---- string, continued -----------------------------------------------------
4139
4140// `string-length : string -> int` (vminst.ml `PrimitiveStringLength`) —
4141// counts Unicode scalar values (`BatUTF8.length`), not UTF-8 bytes.
4142unop_prim!(prim_string_length, as_str, Int, |s| s.chars().count()
4143    as i64);
4144
4145/// `string-sub : string -> int -> int -> string` (vminst.ml
4146/// `PrimitiveStringSub`) — a substring addressed by Unicode-scalar-value
4147/// offset/width (`BatUTF8.sub`), not byte offset. Upstream raises a dynamic
4148/// error ("illegal index for string-sub") on an out-of-range index; we do
4149/// the same.
4150fn prim_string_sub(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4151    let wid = as_int(args.pop().unwrap())?;
4152    let pos = as_int(args.pop().unwrap())?;
4153    let s = as_str(args.pop().unwrap())?;
4154    if wid < 0 || pos < 0 {
4155        return eval_error("illegal index for string-sub");
4156    }
4157    let chars: Vec<char> = s.chars().collect();
4158    let pos = pos as usize;
4159    let wid = wid as usize;
4160    match pos.checked_add(wid) {
4161        Some(end) if end <= chars.len() => Ok(Value::Str(chars[pos..end].iter().collect())),
4162        _ => eval_error("illegal index for string-sub"),
4163    }
4164}
4165
4166// `string-explode : string -> int list` (vminst.ml `PrimitiveStringExplode`)
4167// — the string's Unicode scalar values (code points) in order, not its bytes.
4168unop_prim!(prim_string_explode, as_str, List, |s| s
4169    .chars()
4170    .map(|c| Value::Int(c as i64))
4171    .collect());
4172
4173fn prim_embed_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4174    let s = as_str(args.pop().unwrap())?;
4175    Ok(Value::InlineText {
4176        elems: Rc::new(vec![IText::Text(s)]),
4177        env: Env::root(),
4178    })
4179}
4180
4181// ---- context ops ------------------------------------------------------------
4182
4183/// `set-font-size : length -> context -> context` (vminst.ml
4184/// `PrimitiveSetFontSize`).
4185fn prim_set_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4186    let ctx = as_context(args.pop().unwrap())?;
4187    let size = as_length(args.pop().unwrap())?;
4188    Ok(Value::Context(Box::new(Context {
4189        font_size: size,
4190        ..ctx
4191    })))
4192}
4193
4194/// `get-font-size : context -> length` (vminst.ml `PrimitiveGetFontSize`).
4195fn prim_get_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4196    let ctx = as_context(args.pop().unwrap())?;
4197    Ok(Value::Length(ctx.font_size))
4198}
4199
4200/// `set-leading : length -> context -> context` (vminst.ml
4201/// `PrimitiveSetLeading`; see the `prims!` table comment for why this is
4202/// the baseline-distance setter and not `set-min-gap-of-lines`).
4203fn prim_set_leading(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4204    let ctx = as_context(args.pop().unwrap())?;
4205    let leading = as_length(args.pop().unwrap())?;
4206    Ok(Value::Context(Box::new(Context { leading, ..ctx })))
4207}
4208
4209/// `set-paragraph-margin : length -> length -> context -> context`
4210/// (vminst.ml `PrimitiveSetParagraphMargin`).
4211fn prim_set_paragraph_margin(
4212    _interp: &mut Interp,
4213    mut args: Vec<Value>,
4214) -> Result<Value, EvalError> {
4215    let ctx = as_context(args.pop().unwrap())?;
4216    let bottom = as_length(args.pop().unwrap())?;
4217    let top = as_length(args.pop().unwrap())?;
4218    Ok(Value::Context(Box::new(Context {
4219        paragraph_top: top,
4220        paragraph_bottom: bottom,
4221        ..ctx
4222    })))
4223}
4224
4225/// `get-text-width : context -> length` (vminst.ml `PrimitiveGetTextWidth`).
4226fn prim_get_text_width(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4227    let ctx = as_context(args.pop().unwrap())?;
4228    Ok(Value::Length(ctx.paragraph_width))
4229}
4230
4231/// `get-initial-context : length -> [math] inline-cmd -> context`
4232/// (vminst.ml `PrimitiveGetInitialContext`) — the second argument is the
4233/// default math command a bare `${…}` in inline text dispatches to (v0.0.6
4234/// `context_main.math_command`); interned via
4235/// `Interp::register_math_command`, carried as `Context::math_command`.
4236fn prim_get_initial_context(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4237    let cmd = args.pop().unwrap();
4238    let width = as_length(args.pop().unwrap())?;
4239    let mut ctx = Context::initial(width);
4240    ctx.math_command = Some(interp.register_math_command(cmd));
4241    // Overlay the configured `default-font.satysfi-hash` `scripts`
4242    // block, if any, so a bare document with a configured font root renders
4243    // CJK/etc. with zero `set-font` calls (`interp.metrics.
4244    // default_script_font` is `None` for every script on a provider with no
4245    // such config — `Base14Metrics` and a bare `TtfFontStore::load` both —
4246    // so this loop is a no-op there).
4247    for (idx, script) in [
4248        Script::HanIdeographic,
4249        Script::Kana,
4250        Script::Latin,
4251        Script::OtherScript,
4252    ]
4253    .into_iter()
4254    .enumerate()
4255    {
4256        if let Some((font, ratio, rising)) = interp.metrics.default_script_font(script) {
4257            ctx.font_scheme[idx] = ScriptFont {
4258                font,
4259                ratio,
4260                rising,
4261            };
4262            if script == Script::Latin {
4263                ctx.font = font;
4264            }
4265        }
4266    }
4267    // Overlay the configured `default-font.satysfi-hash` `"math"`
4268    // abbrev, if any, so a document with a bundled MATH-table font renders
4269    // real cramped/uncramped math metrics with zero `set-math-font` calls.
4270    // `interp.metrics.default_math_font` is `None` on a provider with no such
4271    // config, the same no-op-by-default shape as the `scripts` overlay above.
4272    if let Some(font) = interp.metrics.default_math_font() {
4273        ctx.math_font = font;
4274    }
4275    Ok(Value::Context(Box::new(ctx)))
4276}
4277
4278/// `set-font-key : int -> context -> context` — LOCAL, non-upstream
4279/// primitive; see the `prims!` table comment on `"set-font-key"` for why it
4280/// exists. Sets `Context::font` directly to `FontKey(n)`.
4281fn prim_set_font_key(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4282    let ctx = as_context(args.pop().unwrap())?;
4283    let key = as_int(args.pop().unwrap())?;
4284    if key < 0 || key > i64::from(u16::MAX) {
4285        return eval_error(format!("set-font-key: font key {key} is out of range"));
4286    }
4287    Ok(Value::Context(Box::new(Context {
4288        font: FontKey(key as u16),
4289        ..ctx
4290    })))
4291}
4292
4293// ---- box combinators ---------------------------------------------------------
4294
4295/// `++ : inline-boxes -> inline-boxes -> inline-boxes` (vminst.ml
4296/// `HorzConcat`).
4297fn prim_inline_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4298    let mut b = as_inline_boxes(args.pop().unwrap())?;
4299    let mut a = as_inline_boxes(args.pop().unwrap())?;
4300    a.append(&mut b);
4301    Ok(Value::InlineBoxes(a))
4302}
4303
4304/// `+++ : block-boxes -> block-boxes -> block-boxes` (vminst.ml
4305/// `VertConcat`).
4306fn prim_block_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4307    let mut b = as_block_boxes(args.pop().unwrap())?;
4308    let mut a = as_block_boxes(args.pop().unwrap())?;
4309    a.append(&mut b);
4310    Ok(Value::BlockBoxes(a))
4311}
4312
4313/// `inline-skip : length -> inline-boxes` (vminst.ml `BackendFixedEmpty`).
4314fn prim_inline_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4315    let width = as_length(args.pop().unwrap())?;
4316    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4317        PureHorzBox::FixedEmpty { width },
4318    )]))
4319}
4320
4321/// `inline-glue : length -> length -> length -> inline-boxes` (vminst.ml
4322/// `BackendOuterEmpty`; params `(widnat, widshrink, widstretch)`, i.e.
4323/// natural, then shrink, then stretch — the same order `OuterEmpty`'s
4324/// fields are already declared in).
4325fn prim_inline_glue(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4326    let stretchable = as_length(args.pop().unwrap())?;
4327    let shrinkable = as_length(args.pop().unwrap())?;
4328    let natural = as_length(args.pop().unwrap())?;
4329    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4330        PureHorzBox::OuterEmpty {
4331            natural,
4332            shrinkable,
4333            stretchable,
4334        },
4335    )]))
4336}
4337
4338/// `block-skip : length -> block-boxes` (vminst.ml `BackendVertSkip`).
4339fn prim_block_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4340    let len = as_length(args.pop().unwrap())?;
4341    Ok(Value::BlockBoxes(vec![VertBox::Skip(len)]))
4342}
4343
4344/// `list-mark : int -> block-boxes` — the block-level reflow marker
4345/// constructor `itemize.satyh`'s `listing`/`listing-item`/`listing-item-
4346/// breakable`/`enumerate`/`enumerate-item` call to fence list/item
4347/// boundaries. Returns a single-element `block-boxes` carrying an INERT
4348/// `VertBox::ListMark` — zero height/depth, stripped with zero contribution
4349/// by `chop_page`/`place_block_at`/`measure_block` before it can ever reach a
4350/// `PlacedLine`, so PDF and faithful HTML render identically whether or not
4351/// a document's stdlib calls this. Only `page_break_core`'s `reflow_source`
4352/// clone (taken BEFORE `chop_page` drains its input) retains it, for the
4353/// `html-support` branch's reflow HTML walker to read back.
4354///
4355/// `tag` encoding (the only "int tag" scheme any caller needs to know,
4356/// since this primitive is never reflected through the type system beyond
4357/// `int -> block-boxes`):
4358/// - `0` = `ListStart { ordered: false }` (opens a `<ul>`)
4359/// - `1` = `ListStart { ordered: true }` (opens an `<ol>`)
4360/// - `2` = `ListEnd`
4361/// - `3` = `ItemStart`
4362/// - `4` = `ItemEnd`
4363fn prim_list_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4364    let tag = as_int(args.pop().unwrap())?;
4365    let kind = match tag {
4366        0 => ListMarkKind::ListStart { ordered: false },
4367        1 => ListMarkKind::ListStart { ordered: true },
4368        2 => ListMarkKind::ListEnd,
4369        3 => ListMarkKind::ItemStart,
4370        4 => ListMarkKind::ItemEnd,
4371        other => return eval_error(format!("list-mark: unknown tag {other}")),
4372    };
4373    Ok(Value::BlockBoxes(vec![VertBox::ListMark(kind)]))
4374}
4375
4376/// `inline-mark : int -> inline-boxes` — the inline-level reflow marker
4377/// constructor: `itemize.satyh`'s `make-bullet`/`enumerate-item` fence the
4378/// drawn bullet/number glyph run with `BulletStart`/`BulletEnd`, and the
4379/// repo-controlled `\emph`/`\bold` definitions (an opt-in, per-command wrap)
4380/// fence their body with `EmphStart`/`EmphEnd`. Same INERT-marker contract as
4381/// `list-mark` above — a zero-size `PureHorzBox::InlineMark`, ignored by
4382/// `measure`/`natural_metrics`/`justify_line`
4383/// (rustyfi-backend's `linebreak.rs`),
4384/// `math_glyphs_of_inline_boxes`/`math_boxes_of_inline_boxes` below, and both
4385/// the PDF and faithful HTML writers; read only by the `html-support`
4386/// branch's reflow HTML walker.
4387///
4388/// `tag` encoding:
4389/// - `0` = `EmphStart { strong: false }` (opens `<em>`)
4390/// - `1` = `EmphStart { strong: true }` (opens `<strong>`)
4391/// - `2` = `EmphEnd`
4392/// - `3` = `BulletStart`
4393/// - `4` = `BulletEnd`
4394fn prim_inline_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4395    let tag = as_int(args.pop().unwrap())?;
4396    let kind = match tag {
4397        0 => InlineMarkKind::EmphStart { strong: false },
4398        1 => InlineMarkKind::EmphStart { strong: true },
4399        2 => InlineMarkKind::EmphEnd,
4400        3 => InlineMarkKind::BulletStart,
4401        4 => InlineMarkKind::BulletEnd,
4402        other => return eval_error(format!("inline-mark: unknown tag {other}")),
4403    };
4404    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4405        PureHorzBox::InlineMark(kind),
4406    )]))
4407}
4408
4409// ---- pure float primitives --------------------------------------------------
4410//
4411// All bodies below are `float -> float` (or `float -> float -> float`)
4412// straight wraps of the matching `f64` method — vminst.ml's OCaml bodies
4413// (`make_float (sin flt1)`, etc.) are themselves direct wraps of the same
4414// IEEE-754 libm functions, so there is no behavioral daylight here.
4415
4416binop_prim!(prim_atan2, as_float, Float, |a, b| a.atan2(b));
4417unop_prim!(prim_sin, as_float, Float, |x| x.sin());
4418unop_prim!(prim_asin, as_float, Float, |x| x.asin());
4419unop_prim!(prim_cos, as_float, Float, |x| x.cos());
4420unop_prim!(prim_acos, as_float, Float, |x| x.acos());
4421unop_prim!(prim_tan, as_float, Float, |x| x.tan());
4422unop_prim!(prim_atan, as_float, Float, |x| x.atan());
4423// vminst.ml:2834 `FloatLogarithm`: OCaml's `log` is the NATURAL logarithm
4424// (`ln`), not `log10`.
4425unop_prim!(prim_log, as_float, Float, |x| x.ln());
4426unop_prim!(prim_exp, as_float, Float, |x| x.exp());
4427// `ceil`/`floor` return `float`, unlike `round` (above), which returns
4428// `int` — see this file's `prims!` table comment on `"ceil"`/`"floor"`.
4429unop_prim!(prim_ceil, as_float, Float, |x| x.ceil());
4430unop_prim!(prim_floor, as_float, Float, |x| x.floor());
4431
4432// `show-float : float -> string` (vminst.ml:2319 `PrimitiveShowFloat`) —
4433// OCaml's `string_of_float`. See `ocaml_show_float`'s doc comment (below)
4434// for the emulation and its known fidelity limits.
4435unop_prim!(prim_show_float, as_float, Str, |x| ocaml_show_float(x));
4436
4437/// A from-scratch emulation of OCaml's `Stdlib.string_of_float`: format via
4438/// a C `%.12g` equivalent (12 significant digits; fixed-point when the
4439/// decimal exponent falls in `-4..12`, scientific otherwise; trailing
4440/// fractional zeros trimmed), then apply `valid_float_lexem`'s post-pass —
4441/// append a trailing `.` when the result would otherwise print as a bare
4442/// integer (`"1."`, never `"1"`, so a `float`'s printed form always reads
4443/// as a float, not an `int`). Known limitation: this is a Rust
4444/// reimplementation of the same specification (OCaml itself defers to the
4445/// platform C library's `%.12g`), so it may disagree with OCaml in obscure
4446/// corner cases, though it agrees on ordinary values (verified by hand
4447/// against real OCaml output for `0.`, `-0.`, `1.`, `100.`, `0.0025`,
4448/// `1e+20`, `1e-05`).
4449fn ocaml_show_float(x: f64) -> String {
4450    if x.is_nan() {
4451        return "nan".to_string();
4452    }
4453    if x.is_infinite() {
4454        return if x < 0.0 { "-infinity" } else { "infinity" }.to_string();
4455    }
4456    const PREC: i32 = 12;
4457    // Style-E rendering at precision PREC-1 recovers the correctly-rounded
4458    // decimal exponent (a naive `log10().floor()` can be off by one right
4459    // at a power of ten, because of binary/decimal rounding).
4460    let sci = format!("{:.*e}", (PREC - 1) as usize, x);
4461    let epos = sci
4462        .find('e')
4463        .expect("scientific formatting always emits 'e'");
4464    let exp: i32 = sci[epos + 1..].parse().expect("well-formed exponent");
4465    let body = if exp < -4 || exp >= PREC {
4466        let mantissa = trim_trailing_fractional_zeros(&sci[..epos]);
4467        format!(
4468            "{mantissa}e{}{:02}",
4469            if exp < 0 { "-" } else { "+" },
4470            exp.abs()
4471        )
4472    } else {
4473        let decimals = (PREC - 1 - exp).max(0) as usize;
4474        trim_trailing_fractional_zeros(&format!("{:.*}", decimals, x)).to_string()
4475    };
4476    if body.bytes().all(|b| b.is_ascii_digit() || b == b'-') {
4477        format!("{body}.")
4478    } else {
4479        body
4480    }
4481}
4482
4483/// Strip trailing zeros after a decimal point, then the point itself if
4484/// nothing remains after it (`"3.140" -> "3.14"`, `"5.000" -> "5"`);
4485/// already-integer-shaped strings (no `.`) pass through unchanged.
4486fn trim_trailing_fractional_zeros(s: &str) -> &str {
4487    if !s.contains('.') {
4488        return s;
4489    }
4490    s.trim_end_matches('0').trim_end_matches('.')
4491}
4492
4493// `string-byte-length : string -> int` (vminst.ml:2159
4494// `PrimitiveStringByteLength`) — UTF-8 BYTE count (`String.length` in
4495// OCaml, whose native strings are raw byte sequences), unlike
4496// `string-length`'s Unicode-scalar-value count.
4497unop_prim!(prim_string_byte_length, as_str, Int, |s| s.len() as i64);
4498
4499/// `string-sub-bytes : string -> int -> int -> string` (vminst.ml:2123
4500/// `PrimitiveStringSubBytes`) — byte-indexed substring (OCaml's
4501/// `String.sub`), unlike `string-sub`'s Unicode-scalar-value indexing.
4502/// Guards an out-of-range span exactly like `prim_string_sub`'s "illegal
4503/// index" dynamic error, AND a split landing inside a multi-byte UTF-8
4504/// sequence — impossible for OCaml's byte-oriented strings, but a
4505/// `Value::Str` here is a Rust `String`, which must stay valid UTF-8.
4506fn prim_string_sub_bytes(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4507    let wid = as_int(args.pop().unwrap())?;
4508    let pos = as_int(args.pop().unwrap())?;
4509    let s = as_str(args.pop().unwrap())?;
4510    if wid < 0 || pos < 0 {
4511        return eval_error("illegal index for string-sub-bytes");
4512    }
4513    let (pos, wid) = (pos as usize, wid as usize);
4514    match pos.checked_add(wid) {
4515        Some(end) if end <= s.len() && s.is_char_boundary(pos) && s.is_char_boundary(end) => {
4516            Ok(Value::Str(s[pos..end].to_string()))
4517        }
4518        _ => eval_error("illegal index for string-sub-bytes"),
4519    }
4520}
4521
4522/// `string-unexplode : int list -> string` (vminst.ml:2196
4523/// `PrimitiveStringUnexplode`) — the inverse of `string-explode` (above):
4524/// each int is a Unicode scalar value (code point), concatenated into one
4525/// UTF-8 string. Upstream's `Uchar.of_int` raises on an int that isn't a
4526/// valid Unicode scalar value (a surrogate, or out of range); reported here
4527/// as the same kind of dynamic error rather than panicking.
4528fn prim_string_unexplode(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4529    let items = as_list(args.pop().unwrap())?;
4530    let mut s = String::new();
4531    for v in items {
4532        let n = as_int(v)?;
4533        match u32::try_from(n).ok().and_then(char::from_u32) {
4534            Some(c) => s.push(c),
4535            None => {
4536                return eval_error(format!(
4537                    "string-unexplode: {n} is not a valid Unicode scalar value"
4538                ))
4539            }
4540        }
4541    }
4542    Ok(Value::Str(s))
4543}
4544
4545/// `normalize-string-to-nfc : string -> string` (dev-0-1-0 vminst.ml:2050
4546/// `NormalizeStringToNFC`) — REAL: UAX #15 Normalization Form C, via the
4547/// `unicode-normalization` crate (`UnicodeNormalization::nfc`), a pure-Rust
4548/// stand-in for upstream's uunf-backed `NormalizeString.of_utf8_nfc`.
4549/// DOCUMENTED NON-RISK: this crate's embedded Unicode table version may lag
4550/// or lead upstream's uunf pin — both track recent Unicode, and no bundled
4551/// package/test relies on a normalization pair that changed between
4552/// versions.
4553fn prim_normalize_string_to_nfc(
4554    _interp: &mut Interp,
4555    mut args: Vec<Value>,
4556) -> Result<Value, EvalError> {
4557    let s = as_str(args.pop().unwrap())?;
4558    Ok(Value::Str(s.nfc().collect()))
4559}
4560
4561/// `normalize-string-to-nfd : string -> string` (dev-0-1-0 vminst.ml:2066
4562/// `NormalizeStringToNFD`) — REAL: UAX #15 Normalization Form D, same
4563/// crate/caveats as [`prim_normalize_string_to_nfc`] above.
4564fn prim_normalize_string_to_nfd(
4565    _interp: &mut Interp,
4566    mut args: Vec<Value>,
4567) -> Result<Value, EvalError> {
4568    let s = as_str(args.pop().unwrap())?;
4569    Ok(Value::Str(s.nfd().collect()))
4570}
4571
4572/// `split-grapheme-cluster : string -> list string` (dev-0-1-0 vminst.ml:
4573/// 2082 `SplitOnGraphemeCluster` / `GraphemeCluster.split_utf8`) — REAL: UAX
4574/// #29 EXTENDED grapheme clusters, via the `unicode-segmentation` crate's
4575/// `graphemes(s, true)` (`true` selects the extended, not legacy, cluster
4576/// rules — what upstream's uuseg default segmenter produces).
4577fn prim_split_grapheme_cluster(
4578    _interp: &mut Interp,
4579    mut args: Vec<Value>,
4580) -> Result<Value, EvalError> {
4581    let s = as_str(args.pop().unwrap())?;
4582    let clusters: Vec<Value> = s
4583        .graphemes(true)
4584        .map(|g| Value::Str(g.to_string()))
4585        .collect();
4586    Ok(Value::List(clusters))
4587}
4588
4589/// `display-message : string -> unit` (vminst.ml:2056
4590/// `PrimitiveDisplayMessage`) — upstream prints via `print_endline`
4591/// (STDOUT); this port deliberately prints to STDERR instead (`eprintln!`),
4592/// keeping stdout reserved for actual document output. This matches the
4593/// existing house convention: the CLI's own "output written" status line
4594/// (`rustyfi`'s `main.rs`) is likewise stderr-only, never stdout — a
4595/// documented deviation, not an oversight.
4596fn prim_display_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4597    let msg = as_str(args.pop().unwrap())?;
4598    eprintln!("{msg}");
4599    Ok(Value::Unit)
4600}
4601
4602/// `abort-with-message : string -> 'a` (vminst.ml:3133 `AbortWithMessage`)
4603/// — raises a dynamic error carrying `msg` verbatim. The polymorphic result
4604/// type (`prim_types.rs`'s `poly1`) is vacuously satisfiable: this always
4605/// evaluates to `Err`, never actually producing a value of whatever type
4606/// the call site expected.
4607fn prim_abort_with_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4608    let msg = as_str(args.pop().unwrap())?;
4609    eval_error(msg)
4610}
4611
4612// ---- images (raster images) -----------
4613
4614/// `load-image : string -> image` (v0.0.6 vminstdef.yaml:540). Resolves
4615/// `path` against the process's current working directory — this port
4616/// has no "job directory" threaded through `Interp` yet, so this is a
4617/// deliberately simple stand-in for v0.0.6's real job-directory-relative
4618/// resolution, good enough for a CLI invoked from the document's own
4619/// directory and for this crate's fixture-driven tests (which pass an
4620/// absolute path).
4621///
4622/// Decoding is eager (via the `image` crate, to 8-bit `DeviceRGB` — see
4623/// `ImageResource`'s doc comment for the alpha-dropping/format caveats),
4624/// matching v0.0.6's `ImageInfo.add_image` (imageInfo.ml): a missing or
4625/// undecodable file is a clean `EvalError` here, not deferred to the PDF
4626/// writer.
4627///
4628/// JPEG DCTDecode passthrough: in addition to the eager RGB8 decode above
4629/// (still needed for `use-image-by-width`'s aspect ratio and the HTML
4630/// backend's `<img>` data URI), this re-reads the same path's raw bytes and
4631/// sniffs them for a baseline JPEG (`ImageResource::sniff_baseline_jpeg_dct`)
4632/// so the PDF writer can embed the ORIGINAL DCT-encoded bytes instead of
4633/// re-encoding the flattened samples. The second read is best-effort: a
4634/// failure just leaves `jpeg_dct` as `None` and falls back to flat-RGB8
4635/// embedding, since the file already decoded fine above.
4636fn prim_load_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4637    let path = as_str(args.pop().unwrap())?;
4638    let decoded = image::open(&path).map_err(|e| EvalError {
4639        span: None,
4640        msg: format!("load-image: cannot decode '{path}': {e}"),
4641    })?;
4642    let rgb = decoded.to_rgb8();
4643    let (px_w, px_h) = rgb.dimensions();
4644    let jpeg_dct = std::fs::read(&path)
4645        .ok()
4646        .and_then(ImageResource::sniff_baseline_jpeg_dct);
4647    let id = ImageId(interp.images.len());
4648    interp.images.push(ImageResource {
4649        samples: rgb.into_raw(),
4650        px_w,
4651        px_h,
4652        jpeg_dct,
4653        pdf: None,
4654    });
4655    Ok(Value::Image(id))
4656}
4657
4658/// `use-image-by-width : image -> length -> inline-boxes` (v0.0.6
4659/// vminstdef.yaml:554). Computes the on-page height from the source
4660/// image's own pixel aspect ratio (v0.0.6
4661/// `ImageInfo.get_height_from_width`, imageInfo.ml:44): `height = width *
4662/// px_h / px_w`.
4663fn prim_use_image_by_width(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4664    let width = as_length(args.pop().unwrap())?;
4665    let image = as_image(args.pop().unwrap())?;
4666    let resource = interp.images.get(image.0).ok_or_else(|| EvalError {
4667        span: None,
4668        msg: format!("internal error: image id {} out of range", image.0),
4669    })?;
4670    let (iw, ih) = resource.intrinsic_dims_pt();
4671    if iw == 0.0 {
4672        return eval_error("use-image-by-width: image has zero width, cannot scale");
4673    }
4674    let height = width * (ih / iw);
4675    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4676        PureHorzBox::Image {
4677            width,
4678            height,
4679            image,
4680        },
4681    )]))
4682}
4683
4684/// `load-pdf-image : string -> int -> image` (v0.0.6 vminstdef.yaml:525-538
4685/// `BackendRegisterPdfImage`; dev-0-1-0 renames it `PrimitiveLoadPdfImage`
4686/// with the identical type/body). Loads page `pageno` (1-based) of the PDF
4687/// at `path`, parsed eagerly with `lopdf`, and stores a `PdfPageResource` —
4688/// the page's `/MediaBox` (for `use-image-by-width`'s aspect ratio), its
4689/// content stream(s) (already inflated/concatenated by
4690/// `lopdf::Document::get_page_content`), and its imported `/Resources`
4691/// object subtree (for the PDF writer's Form XObject).
4692///
4693/// Path resolution is cwd-relative, the same documented deviation as
4694/// `prim_load_image`/`prim_read_file` (no job-directory threaded through
4695/// `Interp` yet).
4696///
4697/// Errors (all clean `EvalError`, no panics):
4698/// - file missing/unreadable → "cannot open '<path>': <e>";
4699/// - malformed/unparseable PDF → "cannot parse PDF '<path>': <e>";
4700/// - `pageno < 1` → "page number must be >= 1 (got <n>)";
4701/// - `pageno` beyond the page count → "'<path>' has no page <n>";
4702/// - `/Encrypt` present in the trailer → "'<path>' is encrypted; not
4703///   supported" (decryption is never attempted);
4704/// - no usable `/MediaBox` (missing at every level of the inherited page
4705///   tree, wrong array length, or non-numeric entries) → "page <n> of
4706///   '<path>' has no usable MediaBox".
4707#[cfg(feature = "pdf-image")]
4708fn prim_load_pdf_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4709    let pageno = as_int(args.pop().unwrap())?;
4710    let path = as_str(args.pop().unwrap())?;
4711    if pageno < 1 {
4712        return eval_error(format!(
4713            "load-pdf-image: page number must be >= 1 (got {pageno})"
4714        ));
4715    }
4716    let doc = lopdf::Document::load(&path).map_err(|e| {
4717        let msg = match &e {
4718            lopdf::Error::IO(io_e) => format!("load-pdf-image: cannot open '{path}': {io_e}"),
4719            other => format!("load-pdf-image: cannot parse PDF '{path}': {other}"),
4720        };
4721        EvalError { span: None, msg }
4722    })?;
4723    if doc.is_encrypted() {
4724        return eval_error(format!(
4725            "load-pdf-image: '{path}' is encrypted; not supported"
4726        ));
4727    }
4728    let pages = doc.get_pages();
4729    let page_id = *pages.get(&(pageno as u32)).ok_or_else(|| EvalError {
4730        span: None,
4731        msg: format!("load-pdf-image: '{path}' has no page {pageno}"),
4732    })?;
4733    let page_dict = doc.get_dictionary(page_id).map_err(|e| EvalError {
4734        span: None,
4735        msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4736    })?;
4737    let media_box = resolve_pdf_media_box(&doc, page_dict).ok_or_else(|| EvalError {
4738        span: None,
4739        msg: format!("load-pdf-image: page {pageno} of '{path}' has no usable MediaBox"),
4740    })?;
4741    let content = doc.get_page_content(page_id).map_err(|e| EvalError {
4742        span: None,
4743        msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4744    })?;
4745    let resources = import_pdf_resources(&doc, page_dict);
4746    let id = ImageId(interp.images.len());
4747    interp.images.push(ImageResource {
4748        samples: Vec::new(),
4749        px_w: 0,
4750        px_h: 0,
4751        jpeg_dct: None,
4752        pdf: Some(PdfPageResource {
4753            media_box,
4754            content,
4755            resources,
4756        }),
4757    });
4758    Ok(Value::Image(id))
4759}
4760
4761/// Without the `pdf-image` feature no PDF reader is linked in, so the
4762/// primitive can only fail — which it does at the call site, naming why.
4763///
4764/// The name stays REGISTERED rather than being gated out of the primitive
4765/// tables: `typecheck::PRIMITIVE_NAMES`, `prim_types::primitive_type` and this
4766/// table are cross-checked against each other (`tests/typecheck.rs`), and a
4767/// document that reaches for `load-pdf-image` is better told that this build
4768/// cannot read PDFs than that the name does not exist.
4769#[cfg(not(feature = "pdf-image"))]
4770fn prim_load_pdf_image(_interp: &mut Interp, _args: Vec<Value>) -> Result<Value, EvalError> {
4771    eval_error(
4772        "load-pdf-image: this build has no PDF reader (the `pdf-image` feature \
4773         is off). It is off for WebAssembly, where the primitive could not work \
4774         regardless: it takes a filesystem path."
4775            .to_string(),
4776    )
4777}
4778
4779/// `/MediaBox` lookup with page-tree inheritance (`lopdf` does not resolve
4780/// this automatically, unlike upstream camlpdf's `Pdfpage` helpers): walk
4781/// `page_dict`, then its `/Parent` chain, returning the first `/MediaBox`
4782/// found as `(x0, y0, x1, y1)` in raw PDF points. `None`
4783/// if no ancestor carries a well-formed 4-element numeric array, or if a
4784/// `/Parent` cycle is detected.
4785#[cfg(feature = "pdf-image")]
4786fn resolve_pdf_media_box(
4787    doc: &lopdf::Document,
4788    page_dict: &lopdf::Dictionary,
4789) -> Option<(f64, f64, f64, f64)> {
4790    let mut cur = page_dict;
4791    let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4792    loop {
4793        if let Ok(obj) = cur.get(b"MediaBox") {
4794            if let Ok(arr) = obj.as_array() {
4795                if arr.len() == 4 {
4796                    let mut v = [0f64; 4];
4797                    let mut ok = true;
4798                    for (slot, item) in v.iter_mut().zip(arr.iter()) {
4799                        match item.as_float() {
4800                            Ok(f) => *slot = f as f64,
4801                            Err(_) => {
4802                                ok = false;
4803                                break;
4804                            }
4805                        }
4806                    }
4807                    if ok {
4808                        return Some((v[0], v[1], v[2], v[3]));
4809                    }
4810                }
4811            }
4812        }
4813        match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4814            Ok(parent_id) => {
4815                if !seen.insert(parent_id) {
4816                    return None; // cycle
4817                }
4818                cur = doc.get_dictionary(parent_id).ok()?;
4819            }
4820            Err(_) => return None,
4821        }
4822    }
4823}
4824
4825/// Import the page's `/Resources` subtree (walking page-tree inheritance
4826/// like `resolve_pdf_media_box`) into a neutral `ImportedObjects` table for
4827/// the PDF writer. Local id `0` always holds the
4828/// (possibly inline) `/Resources` dictionary itself; every other entry is a
4829/// real source PDF object number, keyed by `convert_pdf_object`'s
4830/// transitive walk of every `Reference` reachable from it.
4831#[cfg(feature = "pdf-image")]
4832fn import_pdf_resources(doc: &lopdf::Document, page_dict: &lopdf::Dictionary) -> ImportedObjects {
4833    let mut out: Vec<(u32, ObjRepr)> = Vec::new();
4834    let mut seen: BTreeSet<u32> = BTreeSet::new();
4835    let root_repr = match resolve_pdf_resources_object(doc, page_dict) {
4836        Some(obj) => convert_pdf_object(doc, obj, &mut out, &mut seen),
4837        None => ObjRepr::Dict(Vec::new()),
4838    };
4839    out.insert(0, (0, root_repr));
4840    ImportedObjects(out)
4841}
4842
4843/// `/Resources` lookup with page-tree inheritance, mirroring
4844/// `resolve_pdf_media_box` but returning the raw (possibly-inline)
4845/// `&lopdf::Object` rather than a decoded value, since `/Resources` may
4846/// legally be either a direct dictionary or an indirect reference.
4847#[cfg(feature = "pdf-image")]
4848fn resolve_pdf_resources_object<'a>(
4849    doc: &'a lopdf::Document,
4850    page_dict: &'a lopdf::Dictionary,
4851) -> Option<&'a lopdf::Object> {
4852    let mut cur = page_dict;
4853    let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4854    loop {
4855        if let Ok(obj) = cur.get(b"Resources") {
4856            return Some(obj);
4857        }
4858        match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4859            Ok(parent_id) => {
4860                if !seen.insert(parent_id) {
4861                    return None;
4862                }
4863                cur = doc.get_dictionary(parent_id).ok()?;
4864            }
4865            Err(_) => return None,
4866        }
4867    }
4868}
4869
4870/// Recursively convert one `lopdf::Object` into the neutral `ObjRepr`
4871/// grammar, following every `Reference` transitively and
4872/// appending newly-visited indirect objects to `out` keyed by their source
4873/// object number (`seen` guards against re-visiting/cycles — a shared
4874/// object referenced from multiple places is emitted once and pointed at by
4875/// `ObjRepr::Ref` from every occurrence). Stream objects are copied
4876/// **verbatim** (still-filtered bytes, `/Filter`/`/DecodeParms` kept as-is;
4877/// only `/Length` is dropped since the writer derives it) — unlike the
4878/// page's own content stream (`Document::get_page_content`, inflated
4879/// separately in `prim_load_pdf_image`), a resource stream (font program,
4880/// embedded image XObject, ICC profile, ...) is re-emitted byte-for-byte,
4881/// so no decode/re-encode risk is taken on data this importer doesn't need
4882/// to understand.
4883#[cfg(feature = "pdf-image")]
4884fn convert_pdf_object(
4885    doc: &lopdf::Document,
4886    obj: &lopdf::Object,
4887    out: &mut Vec<(u32, ObjRepr)>,
4888    seen: &mut BTreeSet<u32>,
4889) -> ObjRepr {
4890    use lopdf::Object as LObj;
4891    match obj {
4892        LObj::Null => ObjRepr::Null,
4893        LObj::Boolean(b) => ObjRepr::Bool(*b),
4894        LObj::Integer(n) => ObjRepr::Int(*n),
4895        LObj::Real(r) => ObjRepr::Real(*r as f64),
4896        LObj::Name(n) => ObjRepr::Name(n.clone()),
4897        LObj::String(s, _) => ObjRepr::String(s.clone()),
4898        LObj::Array(items) => ObjRepr::Array(
4899            items
4900                .iter()
4901                .map(|it| convert_pdf_object(doc, it, out, seen))
4902                .collect(),
4903        ),
4904        LObj::Dictionary(d) => ObjRepr::Dict(convert_pdf_dict(doc, d, out, seen)),
4905        LObj::Stream(s) => {
4906            let dict_entries = convert_pdf_dict(doc, &s.dict, out, seen)
4907                .into_iter()
4908                .filter(|(k, _)| k.as_slice() != b"Length")
4909                .collect();
4910            ObjRepr::Stream(dict_entries, s.content.clone())
4911        }
4912        LObj::Reference((obj_num, gen)) => {
4913            let (obj_num, gen) = (*obj_num, *gen);
4914            if obj_num != 0 && seen.insert(obj_num) {
4915                if let Ok(target) = doc.get_object((obj_num, gen)) {
4916                    let repr = convert_pdf_object(doc, target, out, seen);
4917                    out.push((obj_num, repr));
4918                }
4919            }
4920            ObjRepr::Ref(obj_num)
4921        }
4922    }
4923}
4924
4925#[cfg(feature = "pdf-image")]
4926fn convert_pdf_dict(
4927    doc: &lopdf::Document,
4928    dict: &lopdf::Dictionary,
4929    out: &mut Vec<(u32, ObjRepr)>,
4930    seen: &mut BTreeSet<u32>,
4931) -> Vec<(Vec<u8>, ObjRepr)> {
4932    dict.iter()
4933        .map(|(k, v)| (k.clone(), convert_pdf_object(doc, v, out, seen)))
4934        .collect()
4935}
4936
4937/// `read-file : string -> list string` (dev-0-1-0 vminst.ml:3073
4938/// `PrimitiveReadFile`) — REAL, with two documented
4939/// deviations:
4940///
4941/// 1. **Path resolution**: resolves `path` against the process's current
4942///    working directory, the same `load-image` precedent
4943///    (`prim_load_image`'s doc comment) — this port has no job-directory
4944///    notion threaded through `Interp` yet. Upstream resolves against
4945///    `OptionState.job_directory ()` (the input document's own directory).
4946/// 2. **Containment tightening**: upstream rejects any `..` path component
4947///    (`"cannot access files by using '..'"`, vminst.ml:3084-3090) but
4948///    otherwise resolves `Filename.concat jobdir path` literally — an
4949///    absolute `path` silently escapes the job directory upstream. This
4950///    port ALSO rejects absolute paths (same error class), making the
4951///    containment upstream's own message implies actually real.
4952///
4953/// Line splitting is faithful to OCaml's `input_line` loop: split on `'\n'`,
4954/// drop a trailing empty piece (file ends with `\n`), keep `'\r'` (do NOT
4955/// use `BufRead::lines`, which strips `\r\n`) — an empty file yields `[]`.
4956/// Non-UTF-8 content is a clean `EvalError` (upstream's OCaml strings are
4957/// byte-transparent; this port's `Value::Str` must stay valid UTF-8 —
4958/// documented deviation).
4959fn prim_read_file(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4960    let path_str = as_str(args.pop().unwrap())?;
4961    let path = std::path::Path::new(&path_str);
4962    if path.is_absolute() {
4963        return eval_error(
4964            "read-file: cannot access files by using an absolute path (job-directory containment)",
4965        );
4966    }
4967    if path
4968        .components()
4969        .any(|c| matches!(c, std::path::Component::ParentDir))
4970    {
4971        return eval_error("cannot access files by using '..'");
4972    }
4973    let bytes = std::fs::read(path).map_err(|e| EvalError {
4974        span: None,
4975        msg: format!("read-file: cannot open '{path_str}': {e}"),
4976    })?;
4977    let text = String::from_utf8(bytes).map_err(|_| EvalError {
4978        span: None,
4979        msg: format!("read-file '{path_str}': not valid UTF-8"),
4980    })?;
4981    let mut lines: Vec<Value> = text
4982        .split('\n')
4983        .map(|s| Value::Str(s.to_string()))
4984        .collect();
4985    if matches!(lines.last(), Some(Value::Str(s)) if s.is_empty()) {
4986        lines.pop();
4987    }
4988    Ok(Value::List(lines))
4989}
4990
4991/// `(string) option` — `register-document-information`'s `title`/`subject`/
4992/// `author` fields, parsed the same way [`as_border_option`] reads a
4993/// `Value::Ctor`.
4994fn as_option_string(v: Value) -> Result<Option<String>, EvalError> {
4995    match v {
4996        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
4997            ("None", None) => Ok(None),
4998            ("Some", Some(Value::Str(s))) => Ok(Some(s)),
4999            (other, _) => eval_error(format!(
5000                "expected a string option (None / Some(string)), got variant '{other}'"
5001            )),
5002        },
5003        other => eval_error(format!("expected an option, got {}", other.type_name())),
5004    }
5005}
5006
5007/// `register-document-information : document-information-dictionary ->
5008/// unit` (dev-0-1-0 vminst.ml:2978 `PrimitiveRegisterDocumentInformation`)
5009/// — REAL: extracts `title`/`subject`/`author`
5010/// (`option string`) and `keywords` (`list string`) from the record
5011/// argument (`t_doc_info_dictionary()`'s shape, `prim_types.rs`) and stores
5012/// them onto `Interp::doc_info` — LAST WRITE WINS (upstream's `register`,
5013/// `documentInformationDictionary.ml`), matching the `outline`/
5014/// `annotations`/`destinations` accumulator policy (`eval.rs`): reset per
5015/// trial (fresh `Interp`), the final trial's value drained into
5016/// `DocExtras::doc_info` (`lib.rs`) and emitted as the PDF `/Info`
5017/// dictionary by both writers (`rustyfi-pdf`'s `lib.rs`/`cid.rs`).
5018fn prim_register_document_information(
5019    interp: &mut Interp,
5020    mut args: Vec<Value>,
5021) -> Result<Value, EvalError> {
5022    let fields = match args.pop().unwrap() {
5023        Value::Record(m) => m,
5024        other => {
5025            return eval_error(format!(
5026                "register-document-information: expected a document-information-dictionary \
5027                 record, got {}",
5028                other.type_name()
5029            ))
5030        }
5031    };
5032    let record_name = "document-information-dictionary";
5033    let title = as_option_string(record_field(&fields, record_name, "title")?)?;
5034    let subject = as_option_string(record_field(&fields, record_name, "subject")?)?;
5035    let author = as_option_string(record_field(&fields, record_name, "author")?)?;
5036    let keywords = as_list(record_field(&fields, record_name, "keywords")?)?
5037        .into_iter()
5038        .map(as_str)
5039        .collect::<Result<Vec<_>, _>>()?;
5040    interp.doc_info = Some(DocInfo {
5041        title,
5042        subject,
5043        author,
5044        keywords,
5045    });
5046    Ok(Value::Unit)
5047}
5048
5049// ============================================================================
5050// ---- graphics primitives ------
5051// `start-path`/`line-to`/`terminate-path`/`close-with-line`/`fill`/`stroke`/
5052// `inline-graphics`. Argument order matches `tools/gencode/vminst.ml`
5053// (point-first for `line-to`, width-first for `stroke`).
5054// ============================================================================
5055
5056/// `start-path : point -> pre-path` (vminst.ml:713).
5057fn prim_start_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5058    let start = as_point(args.pop().unwrap())?;
5059    Ok(Value::PrePath(PrePath {
5060        start,
5061        segs: Vec::new(),
5062    }))
5063}
5064
5065/// `line-to : point -> pre-path -> pre-path` (vminst.ml:727) — appends a
5066/// straight segment to the pre-path's forward-accumulated `segs`.
5067fn prim_line_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5068    let mut pp = as_prepath(args.pop().unwrap())?;
5069    let pt = as_point(args.pop().unwrap())?;
5070    pp.segs.push(PathSeg::Line(pt));
5071    Ok(Value::PrePath(pp))
5072}
5073
5074/// `terminate-path : pre-path -> path` (vminst.ml:759) — finishes an OPEN
5075/// subpath (no closing segment).
5076fn prim_terminate_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5077    let pp = as_prepath(args.pop().unwrap())?;
5078    Ok(Value::Path(Path {
5079        subpaths: vec![Subpath {
5080            start: pp.start,
5081            segs: pp.segs,
5082            closing: Closing::Open,
5083        }],
5084    }))
5085}
5086
5087/// `close-with-line : pre-path -> path` (vminst.ml:773) — closes the subpath
5088/// with a straight segment back to its start (PDF `h`).
5089fn prim_close_with_line(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5090    let pp = as_prepath(args.pop().unwrap())?;
5091    Ok(Value::Path(Path {
5092        subpaths: vec![Subpath {
5093            start: pp.start,
5094            segs: pp.segs,
5095            closing: Closing::Line,
5096        }],
5097    }))
5098}
5099
5100/// `fill : color -> path -> graphics` (vminst.ml:2398) — a filled region;
5101/// the PDF writer (`place_graphics`, rustyfi-pdf) paints it with the
5102/// even-odd rule, matching upstream's `op_f'`.
5103fn prim_fill(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5104    let path = as_path(args.pop().unwrap())?;
5105    let color = as_color(args.pop().unwrap())?;
5106    Ok(Value::Graphics(GraphicsElem::Fill(color, path)))
5107}
5108
5109/// `stroke : length -> color -> path -> graphics` (vminst.ml:2381) — width
5110/// first, then color, then path.
5111fn prim_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5112    let path = as_path(args.pop().unwrap())?;
5113    let color = as_color(args.pop().unwrap())?;
5114    let wid = as_length(args.pop().unwrap())?;
5115    Ok(Value::Graphics(GraphicsElem::Stroke(wid, color, path)))
5116}
5117
5118/// Apply a graphics callback under the eager-call window, returning its
5119/// resolved elements with every `register-destination` it made appended as a
5120/// [`GraphicsElem::Destination`] marker. Markers go AFTER the real elements, so
5121/// ink z-order is untouched and a callback that registers nothing yields the
5122/// same `elems` as before.
5123///
5124/// The window is deliberately NOT opened inside a page-break walk
5125/// (`current_page` is `Some` — a deco can build an `inline-graphics` of its
5126/// own): a box built there is drawn straight into `page_graphics`, which
5127/// `fire_hooks` never re-walks, so a marker minted for it would be silently
5128/// dropped. There the direct registration is available and correct.
5129fn apply_graphics_callback(
5130    interp: &mut Interp,
5131    version: RustyfiVersion,
5132    apply: impl FnOnce(&mut Interp) -> Result<Value, EvalError>,
5133) -> Result<Vec<GraphicsElem>, EvalError> {
5134    let defer = interp.current_page.is_none();
5135    let saved = if defer {
5136        interp.pending_dests.replace(Vec::new())
5137    } else {
5138        None
5139    };
5140    let result = apply(interp).and_then(|v| coerce_graphics_result_for(version, v));
5141    let pending = if defer {
5142        interp.pending_dests.take().unwrap_or_default()
5143    } else {
5144        Vec::new()
5145    };
5146    if defer {
5147        interp.pending_dests = saved;
5148    }
5149    let mut elems = result?;
5150    elems.extend(
5151        pending
5152            .into_iter()
5153            .map(|(key, pt)| GraphicsElem::Destination { key, pt }),
5154    );
5155    Ok(elems)
5156}
5157
5158/// `inline-graphics : length -> length -> length -> (point -> graphics
5159/// list) -> inline-boxes` (vminst.ml:1872 `BackendInlineGraphics`) — a box
5160/// of size `(w, h, d)` carrying the callback's resolved graphics elements,
5161/// the minimal on-page sink for a `graphics` value.
5162///
5163/// **Eager-callback shortcut.** Upstream defers the callback until
5164/// the box's *placed* point is known on the page, then calls
5165/// `gfun(placed_point)`. A lang closure cannot live inside a backend box
5166/// (`PureHorzBox::Graphics` only holds resolved `GraphicsElem`s), and the
5167/// placed point isn't known until page-break/render time — so instead this
5168/// calls `gfun` immediately at `(0pt, 0pt)`, and the PDF writer
5169/// (`place_graphics`, rustyfi-pdf) translates the *whole* box to its placed
5170/// position via a single `cm` at render time. This equals upstream's
5171/// behavior if and only if `gfun` uses its point argument purely additively
5172/// (shift-covariant) — true of every real `Gr`/`deco` generator, but not
5173/// enforced by this signature.
5174///
5175/// **The one SIDE EFFECT that survives the shortcut** is
5176/// `register-destination`: at construction time there is no page, so
5177/// `annotation.ml:15`'s gate would refuse it and fail the document (azmath's
5178/// `equation.satyh` anchors every `\label`ed equation this way).
5179/// [`apply_graphics_callback`] turns each call into a
5180/// `GraphicsElem::Destination` marker riding in `elems` — rather than a field
5181/// of its own, so it inherits the `origin_independent` probe below and the
5182/// existing `fire_hooks`/`shift_graphics` pipeline.
5183fn prim_inline_graphics(
5184    interp: &mut Interp,
5185    version: RustyfiVersion,
5186    mut args: Vec<Value>,
5187) -> Result<Value, EvalError> {
5188    let gfun = args.pop().unwrap();
5189    let d = as_length(args.pop().unwrap())?;
5190    let h = as_length(args.pop().unwrap())?;
5191    let w = as_length(args.pop().unwrap())?;
5192    // The callback's result type is `list graphics` under v0.0.6, one
5193    // `graphics` collection under v0.1 — see `coerce_graphics_result`'s doc
5194    // comment.
5195    let gf = gfun.clone();
5196    let elems = apply_graphics_callback(interp, version, move |it| {
5197        let origin = make_point_value((Length::ZERO, Length::ZERO));
5198        it.apply(gf, origin)
5199    })?;
5200    // Detect a PAGE-ABSOLUTE callback: run it again at a far-off probe point
5201    // and compare. If the output is byte-identical the callback ignored its
5202    // placed-point argument (`fun _ -> …`, e.g. slydifi's frame background /
5203    // figbox's `draw-text pt`), so its coordinates are already page-absolute
5204    // and the PDF writer must NOT translate them by the box's placed position
5205    // (which is often a negative text-origin, shifting the decoration off the
5206    // page). A position-relative callback yields different output here, so
5207    // `origin_independent` stays false and the per-box `cm` applies as before.
5208    // Upstream (`handlePdf.ml`) always calls the callback with the true placed
5209    // point and never post-translates; this recovers that for the constant
5210    // case without a post-layout deferral. (The extra evaluation must be free
5211    // of observable side effects — true of every `Gr`/`draw-text` generator;
5212    // `register-destination` is captured per call rather than committed, so
5213    // the probe's copy is dropped.)
5214    //
5215    // The comparison includes the markers on purpose: an ANCHOR-ONLY callback
5216    // draws no ink at either point, so comparing ink alone would classify it
5217    // page-absolute and pin every anchor at the raw callback argument.
5218    let origin_independent = {
5219        let probe = make_point_value((Length::pt(4096.0), Length::pt(2731.0)));
5220        match apply_graphics_callback(interp, version, move |it| it.apply(gfun, probe)) {
5221            Ok(e2) => e2 == elems,
5222            Err(_) => false,
5223        }
5224    };
5225    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5226        PureHorzBox::Graphics {
5227            width: w,
5228            height: h,
5229            depth: d,
5230            elems,
5231            origin_independent,
5232        },
5233    )]))
5234}
5235
5236/// `inline-graphics-outer : length -> length -> (length -> point -> graphics
5237/// list) -> inline-boxes` (vminst.ml:1891 `BackendInlineGraphicsOuter`) — a
5238/// graphics box whose width stretches like `inline-fil` (upstream widinfo
5239/// `Fils(1)`). The callback needs the RESOLVED width, unknown until line
5240/// layout, so it is deferred through `Interp::outer_graphics` (the `HookId`
5241/// pattern) and fired by `resolve_outer_graphics_in_contents` (called from
5242/// `line-break`/`tabular`/`draw-text`) with the width `justify_line` wrote
5243/// into the box and the point `(0pt, 0pt)` — the same shift-covariance
5244/// shortcut as `inline-graphics` above (the writer's `cm` supplies the
5245/// placed point); the width argument is faithful.
5246fn prim_inline_graphics_outer(
5247    interp: &mut Interp,
5248    version: RustyfiVersion,
5249    mut args: Vec<Value>,
5250) -> Result<Value, EvalError> {
5251    let gfun = args.pop().unwrap();
5252    let d = as_length(args.pop().unwrap())?;
5253    let h = as_length(args.pop().unwrap())?;
5254    interp.outer_graphics.push((gfun, version));
5255    let fn_id = GraphicsFnId(interp.outer_graphics.len() - 1);
5256    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5257        PureHorzBox::GraphicsOuter {
5258            height: h,
5259            depth: d,
5260            width: Length::ZERO,
5261            fn_id,
5262        },
5263    )]))
5264}
5265
5266/// Fire every deferred `inline-graphics-outer` callback in an already-
5267/// justified run, replacing its `GraphicsOuter` marker with a resolved
5268/// `Graphics` box (see `prim_inline_graphics_outer`). Idempotent (a resolved
5269/// box no longer matches) and cheap when nothing matches (one pass, no
5270/// allocation).
5271fn resolve_outer_graphics_in_contents(
5272    interp: &mut Interp,
5273    contents: &mut [(Length, PureHorzBox)],
5274) -> Result<(), EvalError> {
5275    for (_, bx) in contents.iter_mut() {
5276        if let PureHorzBox::GraphicsOuter {
5277            height,
5278            depth,
5279            width,
5280            fn_id,
5281        } = bx
5282        {
5283            let (w, h, d) = (*width, *height, *depth);
5284            let (gfun, gver) = match interp.outer_graphics.get(fn_id.0) {
5285                Some((f, v)) => (f.clone(), *v),
5286                None => {
5287                    return eval_error(format!(
5288                        "inline-graphics-outer: dangling callback index {}",
5289                        fn_id.0
5290                    ))
5291                }
5292            };
5293            // Same per-version coercion as `prim_inline_graphics`
5294            // above, shared with `tabular`'s per-cell use. The generation is
5295            // the one the callback was REGISTERED under, carried alongside it in
5296            // `Interp::outer_graphics`: this pass is a DEFERRED one
5297            // (`line-break`/`tabular`/`draw-text`), so `interp.version` here
5298            // is the entry document's, not the callback author's.
5299            //
5300            // Deferred to LINE-BREAK time, not page break, so a
5301            // `register-destination` here still has no page: same capture as
5302            // `prim_inline_graphics`.
5303            let elems = apply_graphics_callback(interp, gver, move |it| {
5304                let partial = it.apply(gfun, Value::Length(w))?;
5305                it.apply(partial, make_point_value((Length::ZERO, Length::ZERO)))
5306            })?;
5307            *bx = PureHorzBox::Graphics {
5308                width: w,
5309                height: h,
5310                depth: d,
5311                elems,
5312                origin_independent: false,
5313            };
5314        }
5315    }
5316    Ok(())
5317}
5318
5319/// `tabular : (cell list) list -> (length list -> length list -> graphics
5320/// list) -> inline-boxes` (vminst.ml:539) — solve the grid (backend
5321/// `rustyfi_backend::tabular::main`) and eagerly drive the rule callback
5322/// with the solved box-local grid-line coordinates.
5323///
5324/// **Why eager is faithful here, unlike `inline-graphics`.** The callback's
5325/// arguments are the grid-line coordinates, fully determined by cell
5326/// content alone (`main` computes them before any placement) — so calling
5327/// it once at construction time with the true box-local `xs`/`ys` is exactly
5328/// what upstream's later, placement-time call produces once the PDF
5329/// writer's per-box `cm` translate (shared with `place_graphics`, see
5330/// `rustyfi-pdf`) shifts the resulting rule paths into position. No
5331/// shift-covariance caveat (contrast `prim_inline_graphics` above).
5332fn prim_tabular(
5333    interp: &mut Interp,
5334    version: RustyfiVersion,
5335    mut args: Vec<Value>,
5336) -> Result<Value, EvalError> {
5337    let rulesf = args.pop().unwrap();
5338    let rows = as_cell_grid(args.pop().unwrap())?;
5339    let mut solved = rustyfi_backend::tabular::main(rows);
5340    for cell in &mut solved.cells {
5341        resolve_outer_graphics_in_contents(interp, &mut cell.contents)?;
5342    }
5343
5344    let xs = make_length_list(&solved.xs);
5345    let ys = make_length_list(&solved.ys);
5346    let partial = interp.apply(rulesf, xs)?;
5347    let gval = interp.apply(partial, ys)?;
5348    // The rules callback returns `list graphics` under v0.0.6, one
5349    // `graphics` collection under v0.1 — per the CALLER's generation
5350    // (`version`), which is the one whose `tabular` type this call was
5351    // checked against.
5352    let rules = coerce_graphics_result_for(version, gval)?;
5353
5354    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5355        PureHorzBox::Tabular(TabularBox {
5356            width: solved.width,
5357            height: solved.height,
5358            depth: Length::ZERO,
5359            cells: solved.cells,
5360            rules,
5361        }),
5362    )]))
5363}
5364
5365// ============================================================================
5366// ---- gr.satyh graphics primitives -------------------------------------------
5367// ============================================================================
5368
5369/// `bezier-to : point -> point -> point -> pre-path -> pre-path`
5370/// (vminst.ml:742) — appends a cubic Bézier segment (`ptS`/`ptT` control
5371/// points, `pt1` destination) to the pre-path's forward-accumulated `segs`.
5372fn prim_bezier_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5373    let mut pp = as_prepath(args.pop().unwrap())?;
5374    let pt1 = as_point(args.pop().unwrap())?;
5375    let pt_t = as_point(args.pop().unwrap())?;
5376    let pt_s = as_point(args.pop().unwrap())?;
5377    pp.segs.push(PathSeg::Bezier(pt_s, pt_t, pt1));
5378    Ok(Value::PrePath(pp))
5379}
5380
5381/// `close-with-bezier : point -> point -> pre-path -> path` (vminst.ml:787)
5382/// — closes the subpath with a cubic Bézier back to its start (`ptS`/`ptT`
5383/// control points; the destination is always the subpath's own `start`, per
5384/// `Closing::Bezier`'s doc comment).
5385fn prim_close_with_bezier(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5386    let pp = as_prepath(args.pop().unwrap())?;
5387    let pt_t = as_point(args.pop().unwrap())?;
5388    let pt_s = as_point(args.pop().unwrap())?;
5389    Ok(Value::Path(Path {
5390        subpaths: vec![Subpath {
5391            start: pp.start,
5392            segs: pp.segs,
5393            closing: Closing::Bezier(pt_s, pt_t),
5394        }],
5395    }))
5396}
5397
5398/// `shift-path : point -> path -> path` (vminst.ml:663) — translate every
5399/// point of the path by the given vector (`rustyfi_backend::shift_path`).
5400fn prim_shift_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5401    let path = as_path(args.pop().unwrap())?;
5402    let v = as_point(args.pop().unwrap())?;
5403    Ok(Value::Path(shift_path(v, &path)))
5404}
5405
5406/// `linear-transform-path : float -> float -> float -> float -> path ->
5407/// path` (vminst.ml:678) — apply the 2x2 matrix `(a, b, c, d)` to every
5408/// point of the path (`rustyfi_backend::linear_transform_path`).
5409fn prim_linear_transform_path(
5410    _interp: &mut Interp,
5411    mut args: Vec<Value>,
5412) -> Result<Value, EvalError> {
5413    let path = as_path(args.pop().unwrap())?;
5414    let d = as_float(args.pop().unwrap())?;
5415    let c = as_float(args.pop().unwrap())?;
5416    let b = as_float(args.pop().unwrap())?;
5417    let a = as_float(args.pop().unwrap())?;
5418    Ok(Value::Path(linear_transform_path((a, b, c, d), &path)))
5419}
5420
5421/// `shift-graphics : point -> graphics -> graphics` (vminst.ml:2451) —
5422/// translate every point of the graphics element by the given vector.
5423fn prim_shift_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5424    let g = as_graphics(args.pop().unwrap())?;
5425    let v = as_point(args.pop().unwrap())?;
5426    Ok(Value::Graphics(shift_graphics(v, &g)))
5427}
5428
5429/// `linear-transform-graphics : float -> float -> float -> float -> graphics
5430/// -> graphics` (vminst.ml:2432). **Eager, unlike upstream**:
5431/// `graphicD.ml`'s `make_linear_trans` lazily wraps the element in a
5432/// `LinearTrans` node, deferring the matrix to a PDF `cm` operator at render
5433/// time — which also scales any wrapped `Stroke`/`DashedStroke`'s effective
5434/// line width (width is specified in the pre-transform coordinate space).
5435/// This port instead rewrites every point up front (a pure coordinate map,
5436/// no PDF change needed) and leaves `width` untouched, so
5437/// a non-uniform `scale-graphics` (`gr.satyh`) will NOT scale a stroke's
5438/// line width the way upstream does — invisible for pure rotation
5439/// (`rotate-graphics`, orthonormal, preserves lengths) and for `Fill`, which
5440/// is the only `GraphicsElem` shape any bundled package actually
5441/// strokes-then-scales.
5442fn prim_linear_transform_graphics(
5443    _interp: &mut Interp,
5444    mut args: Vec<Value>,
5445) -> Result<Value, EvalError> {
5446    let g = as_graphics(args.pop().unwrap())?;
5447    let d = as_float(args.pop().unwrap())?;
5448    let c = as_float(args.pop().unwrap())?;
5449    let b = as_float(args.pop().unwrap())?;
5450    let a = as_float(args.pop().unwrap())?;
5451    Ok(Value::Graphics(linear_transform_graphics((a, b, c, d), &g)))
5452}
5453
5454/// `get-graphics-bbox : graphics -> point * point` (v0.0.6 vminst.ml:2466)
5455/// — the v006 fork side. `.unwrap_or(…)` is UNREACHABLE under 0.0.6 (no
5456/// 0.0.6-visible constructor produces `Group`/`Clip`, so `graphics_bbox`
5457/// never returns `None` here); documented rather than `.expect`ed so a
5458/// future faithful `Group`/`Clip` leak (a bug) fails soft instead of
5459/// panicking.
5460fn prim_get_graphics_bbox_v006(
5461    _interp: &mut Interp,
5462    mut args: Vec<Value>,
5463) -> Result<Value, EvalError> {
5464    let g = as_graphics(args.pop().unwrap())?;
5465    let (pmin, pmax) =
5466        graphics_bbox(&g).unwrap_or(((Length::ZERO, Length::ZERO), (Length::ZERO, Length::ZERO)));
5467    Ok(Value::Tuple(vec![
5468        make_point_value(pmin),
5469        make_point_value(pmax),
5470    ]))
5471}
5472
5473/// `get-graphics-bbox : graphics -> option (point * point)` (dev-0-1-0
5474/// vminst.ml:2301) — the v01 fork side: `graphics` is a collection,
5475/// so an empty `unite-graphics []` (or an empty `Clip`'s contents-blind
5476/// bbox is still `Some`, but an empty `Group` folds to nothing)
5477/// legitimately has no bbox — surfaced as the SATySFi `option` variant,
5478/// the `probe-cross-reference` building pattern.
5479fn prim_get_graphics_bbox_v01(
5480    _interp: &mut Interp,
5481    mut args: Vec<Value>,
5482) -> Result<Value, EvalError> {
5483    let g = as_graphics(args.pop().unwrap())?;
5484    Ok(match graphics_bbox(&g) {
5485        Some((pmin, pmax)) => Value::Ctor(
5486            "Some".to_string(),
5487            Some(Box::new(Value::Tuple(vec![
5488                make_point_value(pmin),
5489                make_point_value(pmax),
5490            ]))),
5491        ),
5492        None => Value::Ctor("None".to_string(), None),
5493    })
5494}
5495
5496/// `unite-graphics : list graphics -> graphics` (dev-0-1-0 vminst.ml:3119)
5497/// — `GraphicD.concat` = `List.concat`, ported as the `Group` container.
5498/// `unite-graphics []` is legal and yields the
5499/// empty collection (the `None`-bbox witness `get-graphics-bbox` exercises
5500/// above).
5501fn prim_unite_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5502    let items = as_list(args.pop().unwrap())?;
5503    let mut elems = Vec::with_capacity(items.len());
5504    for it in items {
5505        elems.push(as_graphics(it)?);
5506    }
5507    Ok(Value::Graphics(GraphicsElem::Group(elems)))
5508}
5509
5510/// `clip-graphics-by-path : path -> graphics -> graphics` (dev-0-1-0
5511/// vminst.ml:3105) — `GraphicD.make_clip gr pathlst` = `Clip(paths, gr)`;
5512/// the port's single-element `g` (possibly itself a `Group`) IS the
5513/// collection upstream's `gr` argument names.
5514fn prim_clip_graphics_by_path(
5515    _interp: &mut Interp,
5516    mut args: Vec<Value>,
5517) -> Result<Value, EvalError> {
5518    let g = as_graphics(args.pop().unwrap())?;
5519    let path = as_path(args.pop().unwrap())?;
5520    Ok(Value::Graphics(GraphicsElem::Clip(path, vec![g])))
5521}
5522
5523/// `get-path-bbox : path -> point * point` (vminst.ml:696
5524/// `PathGetBoundingBox`) — `rustyfi_backend::path_bbox` (see that function's
5525/// doc comment for the exact cubic-extrema policy shared with
5526/// `get-graphics-bbox`).
5527fn prim_get_path_bbox(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5528    let path = as_path(args.pop().unwrap())?;
5529    let (pmin, pmax) = path_bbox(&path);
5530    Ok(Value::Tuple(vec![
5531        make_point_value(pmin),
5532        make_point_value(pmax),
5533    ]))
5534}
5535
5536/// `dashed-stroke : length -> (length*length*length) -> color -> path ->
5537/// graphics` (vminst.ml:2414) — width first, then the dash pattern, then
5538/// color, then path (mirrors `stroke`'s argument order with one extra
5539/// dash-pattern argument inserted).
5540fn prim_dashed_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5541    let path = as_path(args.pop().unwrap())?;
5542    let color = as_color(args.pop().unwrap())?;
5543    let dash = as_dash(args.pop().unwrap())?;
5544    let wid = as_length(args.pop().unwrap())?;
5545    Ok(Value::Graphics(GraphicsElem::DashedStroke(
5546        wid, dash, color, path,
5547    )))
5548}
5549
5550/// `draw-text : point -> inline-boxes -> graphics` (vminst.ml:2363
5551/// `PrimitiveDrawText`) — FAITHFUL: lays the run out at natural width
5552/// (upstream `LineBreak.natural`; here `natural_metrics` + `fit_cell` at that
5553/// width, so slack is 0 and every box keeps its natural advance) and stores
5554/// the placed run in `GraphicsElem::Text`. Also resolves any
5555/// `inline-graphics-outer` marker the run carries (`resolve_outer_graphics_
5556/// in_contents` — width 0 there, since slack is 0 at natural width, upstream
5557/// identical: `widperfil = 0`).
5558fn prim_draw_text(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5559    let ib = as_inline_boxes(args.pop().unwrap())?;
5560    let pt = as_point(args.pop().unwrap())?;
5561    let (width, height, depth) = natural_metrics(&ib);
5562    let (mut contents, _, _) = fit_cell(ib, width);
5563    resolve_outer_graphics_in_contents(interp, &mut contents)?;
5564    Ok(Value::Graphics(GraphicsElem::Text {
5565        pt,
5566        contents,
5567        width,
5568        height,
5569        depth,
5570        transform: None,
5571    }))
5572}
5573
5574// ============================================================================
5575// ---- pervasives.satyh prims -------------------
5576// ============================================================================
5577
5578/// `get-natural-metrics : inline-boxes -> length * length * length`
5579/// (vminst.ml:2020 `PrimitiveGetNaturalMetrics`) — FAITHFUL: delegates to
5580/// `rustyfi_backend::natural_metrics` (see that function's doc comment for
5581/// why no depth sign-flip is needed here, unlike upstream).
5582fn prim_get_natural_metrics(
5583    _interp: &mut Interp,
5584    mut args: Vec<Value>,
5585) -> Result<Value, EvalError> {
5586    let ib = as_inline_boxes(args.pop().unwrap())?;
5587    let (width, height, depth) = natural_metrics(&ib);
5588    Ok(Value::Tuple(vec![
5589        Value::Length(width),
5590        Value::Length(height),
5591        Value::Length(depth),
5592    ]))
5593}
5594
5595/// Build the atomic `PureHorzBox::Frame` for `inline-frame-outer`/`-inner`
5596/// (upstream keeps both atomic too; `-breakable` is transparent instead, see
5597/// [`prim_inline_frame_breakable`]): fit `inner` at its natural width
5598/// (`fit_cell` — the same
5599/// no-Context fit tabular cells use), pad the fitted run by `pads`, intern
5600/// `deco` into `interp.decos`. `deco` is fired lang-side, after
5601/// placement, by `fire_hooks`/`fire_inline_frame` — this constructor never
5602/// calls it.
5603fn make_inline_frame(
5604    interp: &mut Interp,
5605    version: RustyfiVersion,
5606    (pad_l, pad_r, pad_t, pad_b): (Length, Length, Length, Length),
5607    deco: Value,
5608    inner: Vec<HorzBox>,
5609) -> Value {
5610    let (w, _, _) = natural_metrics(&inner);
5611    let (contents, height, depth) = fit_cell(inner, w);
5612    let contents = contents.into_iter().map(|(x, b)| (x + pad_l, b)).collect();
5613    let id = DecoId(interp.decos.len());
5614    // `version` is the CALLING code's generation, threaded in by the
5615    // per-version prim rows below — fire time is a post-page-break pass with
5616    // no version context of its own, so this is the only moment the answer
5617    // is available. See `DecoEntry`'s doc comment.
5618    interp.decos.push(DecoEntry::Inline { deco, version });
5619    Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::Frame {
5620        width: pad_l + w + pad_r,
5621        height: height + pad_t,
5622        depth: depth + pad_b,
5623        deco: id,
5624        contents,
5625    })])
5626}
5627
5628/// `inline-frame-outer : paddings -> deco -> inline-boxes -> inline-boxes`
5629/// (vminst.ml:1787 `BackendOuterFrame`) — FAITHFUL: builds the atomic
5630/// `PureHorzBox::Frame`; see [`make_inline_frame`]. Upstream's
5631/// outer/inner distinction is glue participation in the enclosing line
5632/// (`PHGOuterFrame` vs `PHGInnerFrame`), which this atomic box model
5633/// collapses — both this and [`prim_inline_frame_inner`] build the exact
5634/// same box.
5635fn prim_inline_frame_outer(
5636    interp: &mut Interp,
5637    version: RustyfiVersion,
5638    mut args: Vec<Value>,
5639) -> Result<Value, EvalError> {
5640    let inner = as_inline_boxes(args.pop().unwrap())?;
5641    let deco = args.pop().unwrap();
5642    let pads = as_paddings(args.pop().unwrap())?;
5643    Ok(make_inline_frame(interp, version, pads, deco, inner))
5644}
5645
5646/// `inline-frame-inner : paddings -> deco -> inline-boxes -> inline-boxes`
5647/// (vminst.ml:1807 `BackendInnerFrame`) — same construction as
5648/// [`prim_inline_frame_outer`]; see that function's doc comment for the
5649/// outer/inner distinction this atomic model collapses.
5650fn prim_inline_frame_inner(
5651    interp: &mut Interp,
5652    version: RustyfiVersion,
5653    mut args: Vec<Value>,
5654) -> Result<Value, EvalError> {
5655    let inner = as_inline_boxes(args.pop().unwrap())?;
5656    let deco = args.pop().unwrap();
5657    let pads = as_paddings(args.pop().unwrap())?;
5658    Ok(make_inline_frame(interp, version, pads, deco, inner))
5659}
5660
5661/// `set-manual-rising : length -> context -> context` (vminst.ml:1661
5662/// `PrimitiveSetManualRising`) — FAITHFUL store into
5663/// `Context::manual_rising`, the same shape as `set-font-size`/
5664/// `set-leading` above. Read by `text_to_boxes`'s `flush_word`, which adds
5665/// it to the script font's own baseline raise; the default is
5666/// `Length::ZERO`, so a document that never calls this is unaffected.
5667fn prim_set_manual_rising(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5668    let ctx = as_context(args.pop().unwrap())?;
5669    let rising = as_length(args.pop().unwrap())?;
5670    Ok(Value::Context(Box::new(Context {
5671        manual_rising: rising,
5672        ..ctx
5673    })))
5674}
5675
5676/// `script-guard : script -> inline-boxes -> inline-boxes` (vminst.ml:1908
5677/// `BackendScriptGuard`).
5678///
5679/// STAND-IN: upstream wraps `hblst` in a `HorzScriptGuard` that tells the
5680/// line breaker which script to assume at each edge, for inter-script
5681/// spacing rules (`lineBreak.ml`'s script-boundary handling). This port's
5682/// line breaker has no script-aware spacing at all yet, so this is the
5683/// identity function: the `script` argument is accepted (so callers like
5684/// pervasives.satyh's `\SATySFi`/`\LaTeX`/`\TeX` type-check and run) and
5685/// discarded.
5686fn prim_script_guard(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5687    let ib = as_inline_boxes(args.pop().unwrap())?;
5688    let _script = args.pop().unwrap();
5689    Ok(Value::InlineBoxes(ib))
5690}
5691
5692/// Unwrap `inline-boxes`' `Vec<HorzBox>` down to the bare `Vec<PureHorzBox>`
5693/// a `PureHorzBox::Discretionary` slot stores (mirrors `prim_line_break`'s
5694/// identical unwrap, linebreak.rs's only other consumer of this shape).
5695fn into_pure(boxes: Vec<HorzBox>) -> Vec<PureHorzBox> {
5696    boxes.into_iter().map(|HorzBox::Pure(p)| p).collect()
5697}
5698
5699/// `discretionary : int -> inline-boxes -> inline-boxes -> inline-boxes ->
5700/// inline-boxes` (vminst.ml:1969 `BackendDiscretionary`), params `(pb,
5701/// hblst0, hblst1, hblst2)` — FAITHFUL: builds the same
5702/// `PureHorzBox::Discretionary` the UAX#14 line breaker already produces
5703/// internally. `hblst0` (`no_break`) renders when this point is NOT chosen
5704/// as a line break; `hblst1`/`hblst2` (`pre_break`/`post_break`) render at
5705/// the end/start of the two lines a break here would produce.
5706fn prim_discretionary(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5707    let post_break = as_inline_boxes(args.pop().unwrap())?;
5708    let pre_break = as_inline_boxes(args.pop().unwrap())?;
5709    let no_break = as_inline_boxes(args.pop().unwrap())?;
5710    let penalty = as_int(args.pop().unwrap())?;
5711    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5712        PureHorzBox::Discretionary {
5713            penalty: penalty.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
5714            pre_break: into_pure(pre_break),
5715            post_break: into_pure(post_break),
5716            no_break: into_pure(no_break),
5717        },
5718    )]))
5719}
5720
5721/// `get-axis-height : context -> length` (vminst.ml:1739
5722/// `PrimitiveGetAxisHeight`), needed by `picture.satyh`'s `Picture.node`
5723/// (Tier-2 decoration/graphics wave) — centers text vertically around the
5724/// math axis.
5725///
5726/// FAITHFUL: reads `axis_height` from `ctx.math_font`'s OpenType MATH
5727/// table via `MathC` (`FontInfo.get_axis_height mfabbrev fontsize`), falling
5728/// back to a fixed `0.25` ratio of `ctx.font_size` (`pervasives.satyh`'s
5729/// `\SATySFi`/`\LaTeX` manual-rising ratio) whenever the font has no MATH
5730/// table — so base-14/non-math output is unchanged.
5731fn prim_get_axis_height(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5732    let ctx = as_context(args.pop().unwrap())?;
5733    let mc = MathC::of(interp, &ctx);
5734    Ok(Value::Length(mc.axis(ctx.font_size)))
5735}
5736
5737// ============================================================================
5738// ---- page-break hooks + cross-references -----------------------------------
5739// ============================================================================
5740
5741/// `hook-page-break : (page-break-info -> point -> unit) -> inline-boxes`
5742/// (vminstdef.yaml:576). Pushes the closure argument onto `interp.hooks`
5743/// (the lang-side table `fire_hooks` reads back after placement) and
5744/// returns an inline box carrying only the opaque `HookId` — exactly
5745/// `prim_load_image`'s shape (`ImageId`/`interp.images`), applied to a
5746/// deferred *computation* instead of a resource. The backend places this
5747/// box like any other zero-width content and never sees the closure.
5748fn prim_hook_page_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5749    let closure = args.pop().unwrap();
5750    let id = HookId(interp.hooks.len());
5751    interp.hooks.push(closure);
5752    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5753        PureHorzBox::HookPageBreak { id },
5754    )]))
5755}
5756
5757/// `hook-page-break-block : (page-break-info -> point -> unit) ->
5758/// block-boxes` (vminst.ml:632 `BackendHookPageBreakBlock`) — the
5759/// block-level analog of `prim_hook_page_break` above, FAITHFUL: same
5760/// `interp.hooks` push, same opaque `HookId`, but wrapped in a
5761/// `VertBox::HookPageBreak` marker instead of an inline box. `chop_page`/
5762/// `place_block_at` (rustyfi-backend) place it as a zero-height
5763/// `PlacedLine` carrying the SAME `PureHorzBox::HookPageBreak` wrapper the
5764/// inline primitive uses, so `fire_hooks` (lib.rs) fires it through the
5765/// exact same scan with no changes of its own.
5766fn prim_hook_page_break_block(
5767    interp: &mut Interp,
5768    mut args: Vec<Value>,
5769) -> Result<Value, EvalError> {
5770    let closure = args.pop().unwrap();
5771    let id = HookId(interp.hooks.len());
5772    interp.hooks.push(closure);
5773    Ok(Value::BlockBoxes(vec![VertBox::HookPageBreak(id)]))
5774}
5775
5776/// `register-cross-reference : string -> string -> unit` (vminstdef.yaml:1793).
5777/// Callable anywhere (not just from a hook) — ordinary strict primitive
5778/// over the shared `crossrefs` table.
5779fn prim_register_cross_reference(
5780    interp: &mut Interp,
5781    mut args: Vec<Value>,
5782) -> Result<Value, EvalError> {
5783    let value = as_str(args.pop().unwrap())?;
5784    let key = as_str(args.pop().unwrap())?;
5785    interp.crossrefs.borrow_mut().register(key, value);
5786    Ok(Value::Unit)
5787}
5788
5789/// `get-cross-reference : string -> string option` (vminstdef.yaml:1808).
5790/// A miss is recorded (`CrossRefs::get`) so an unresolved forward reference
5791/// forces another fixpoint trial; the result surfaces as the SATySFi
5792/// `option` variant (`None` / `Some(string)`).
5793fn prim_get_cross_reference(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5794    let key = as_str(args.pop().unwrap())?;
5795    Ok(match interp.crossrefs.borrow_mut().get(&key) {
5796        Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5797        None => Value::Ctor("None".to_string(), None),
5798    })
5799}
5800
5801/// `probe-cross-reference : string -> string option` (vminst.ml:3043
5802/// `BackendProbeCrossReference`) — FAITHFUL: `get-cross-reference` minus the
5803/// miss bookkeeping (`CrossRefs::probe`, crossRef.ml:112), so a `None` here
5804/// never forces another fixpoint trial.
5805fn prim_probe_cross_reference(
5806    interp: &mut Interp,
5807    mut args: Vec<Value>,
5808) -> Result<Value, EvalError> {
5809    let key = as_str(args.pop().unwrap())?;
5810    Ok(match interp.crossrefs.borrow().probe(&key) {
5811        Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5812        None => Value::Ctor("None".to_string(), None),
5813    })
5814}
5815
5816// ============================================================================
5817// ---- annot.satyh's prim surface (link annotations + the frame/script
5818// stand-ins it needs) --------------------------------------------------------
5819// ============================================================================
5820
5821/// `get-leftmost-script`/`get-rightmost-script : inline-boxes -> script
5822/// option` (vminstdef.yaml:1754/1767 `BackendGetLeftmostScript`/
5823/// `BackendGetRightmostScript`) — STAND-IN: upstream inspects the actual
5824/// Unicode script of the first/last character in `hblst`
5825/// (`LineBreak.get_leftmost_script`/`get_rightmost_script`), which
5826/// `annot.satyh`'s `\href` uses to `script-guard` the link's edges so
5827/// inter-script spacing isn't inserted right at the boundary. This port's
5828/// `PureHorzBox::InnerString` carries no per-character script tag (no
5829/// script-aware line breaking at all yet — `script-guard` above is already
5830/// an identity stand-in for the same reason), so both primitives
5831/// unconditionally return `None`: `\href` then takes its `None` arm
5832/// (`inline-nil`, no guard inserted) — a safe, honest default rather than
5833/// fabricating a script this port cannot actually see.
5834fn prim_get_leftmost_script(
5835    _interp: &mut Interp,
5836    mut args: Vec<Value>,
5837) -> Result<Value, EvalError> {
5838    let _ib = as_inline_boxes(args.pop().unwrap())?;
5839    Ok(Value::Ctor("None".to_string(), None))
5840}
5841
5842/// See [`prim_get_leftmost_script`] — the rightmost-edge twin, identical
5843/// stand-in reasoning.
5844fn prim_get_rightmost_script(
5845    _interp: &mut Interp,
5846    mut args: Vec<Value>,
5847) -> Result<Value, EvalError> {
5848    let _ib = as_inline_boxes(args.pop().unwrap())?;
5849    Ok(Value::Ctor("None".to_string(), None))
5850}
5851
5852/// `inline-frame-breakable : paddings -> deco-set -> inline-boxes ->
5853/// inline-boxes` (vminstdef.yaml:1672 `BackendOuterFrameBreakable`) —
5854/// FAITHFUL: upstream's `HorzFrameBreakable` is *transparent* to the
5855/// paragraph breaker (`lineBreak.ml:1094` threads the enclosing width map
5856/// straight through the frame's contents), so the frame's own glue and
5857/// discretionaries are break candidates of the enclosing paragraph, and
5858/// `cut` (`:824`) re-frames the chosen fragments one line at a time —
5859/// `decoS` for a frame that came out unbroken, `decoH`/`decoM`/`decoT` per
5860/// fragment for one that split.
5861///
5862/// This port's breaker is a flat index DP rather than upstream's recursive
5863/// one, so transparency is spelled by SPLICING: the contents go straight into
5864/// the returned box list, bracketed by a zero-width
5865/// [`PureHorzBox::InlineFrameMarker`] pair that `fire_hooks` walks to
5866/// reassemble the fragments and fire the right closure for each. The
5867/// horizontal paddings become `FixedEmpty` boxes inside the bracket, exactly
5868/// upstream's `append_horz_padding` (`lineBreak.ml:79`); the vertical ones
5869/// ride on the markers (which is how they still reach the line's height and
5870/// depth) and are re-applied per fragment at fire time.
5871///
5872/// The atomic `PureHorzBox::Frame` is NOT usable here — it fires only
5873/// `decoS` and, being width-rigid, can neither break nor let an interior
5874/// `inline-fil` stretch. It is reserved for `inline-frame-outer`/`-inner`,
5875/// which upstream really does keep atomic.
5876fn prim_inline_frame_breakable(
5877    interp: &mut Interp,
5878    version: RustyfiVersion,
5879    mut args: Vec<Value>,
5880) -> Result<Value, EvalError> {
5881    let inner = as_inline_boxes(args.pop().unwrap())?;
5882    let decoset = as_decoset(args.pop().unwrap())?;
5883    let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
5884    let (_, height, depth) = natural_metrics(&inner);
5885    let id = DecoId(interp.decos.len());
5886    // `version` is the CALLING code's generation — see `make_inline_frame`'s
5887    // identical comment and `DecoEntry`'s doc comment.
5888    interp.decos.push(DecoEntry::InlineBreakable {
5889        pads: Paddings {
5890            l: pad_l,
5891            r: pad_r,
5892            t: pad_t,
5893            b: pad_b,
5894        },
5895        decoset,
5896        version,
5897    });
5898    let marker = |end| {
5899        HorzBox::Pure(PureHorzBox::InlineFrameMarker {
5900            id,
5901            end,
5902            height: height + pad_t,
5903            depth: depth + pad_b,
5904        })
5905    };
5906    let mut out = Vec::with_capacity(inner.len() + 4);
5907    out.push(marker(false));
5908    // Upstream emits both padding boxes unconditionally; a zero-width
5909    // `FixedEmpty` is inert everywhere in this port too, but skipping it keeps
5910    // the box stream (and every placed-line snapshot) unchanged for the
5911    // zero-padding callers, which is every bundled one.
5912    if pad_l != Length::ZERO {
5913        out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_l }));
5914    }
5915    out.extend(inner);
5916    if pad_r != Length::ZERO {
5917        out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_r }));
5918    }
5919    out.push(marker(true));
5920    Ok(Value::InlineBoxes(out))
5921}
5922
5923/// `deco-set` = `Value::Tuple` of 4 closures (`(decoS, decoH, decoM,
5924/// decoT)`, evalUtil.ml:169 `get_decoset`) — no type check on the elements
5925/// themselves (they're closures, applied later by `apply_deco`).
5926fn as_decoset(v: Value) -> Result<[Value; 4], EvalError> {
5927    match v {
5928        Value::Tuple(vs) if vs.len() == 4 => {
5929            let mut it = vs.into_iter();
5930            let a = it.next().unwrap();
5931            let b = it.next().unwrap();
5932            let c = it.next().unwrap();
5933            let d = it.next().unwrap();
5934            Ok([a, b, c, d])
5935        }
5936        other => eval_error(format!(
5937            "expected a deco-set (4-tuple of decorations), got {}",
5938            other.type_name()
5939        )),
5940    }
5941}
5942
5943/// 0.0.6 graphics-producing callbacks return `list graphics` (`tL tGR`);
5944/// 0.1's return one `graphics` collection (`tGR` — dev-0-1-0
5945/// `primitives.cppo.ml:75-85`). STRICT per
5946/// version: a 0.1 program returning a list here is a bug the type checker
5947/// already rejected; don't mask it with tolerant decoding. Shared by every
5948/// coercion site (`prim_inline_graphics`, `inline-graphics-outer`/
5949/// `tabular`'s `resolve_outer_graphics_in_contents`, `tabular`'s own rules
5950/// callback, and `apply_deco` below).
5951///
5952/// `version` is EXPLICIT rather than read off `interp.version`, and that is
5953/// the whole point. `interp.version` is one whole-program field, set once by
5954/// `lib.rs`'s `eval_document_trials`; in a cross-version program it names
5955/// the ENTRY document's generation, while the callback being decoded here
5956/// may have been written by a spliced 0.0.6 dependency. Every caller gets
5957/// the right answer from a place that genuinely knows it: the six carrier
5958/// prim bodies are registered per version
5959/// (`version_forked_prims!`, folded at compile time by
5960/// `compile.rs`'s `Ast::VersionScope` arm), and the two DEFERRED consumers
5961/// read the generation captured when the closure was interned
5962/// (`DecoEntry::version`, `Interp::outer_graphics`'s second component).
5963fn coerce_graphics_result_for(
5964    version: RustyfiVersion,
5965    v: Value,
5966) -> Result<Vec<GraphicsElem>, EvalError> {
5967    if version.graphics_is_collection() {
5968        Ok(vec![as_graphics(v)?])
5969    } else {
5970        as_list(v)?.into_iter().map(as_graphics).collect()
5971    }
5972}
5973
5974/// `make_frame_deco` (evalUtil.ml:604): apply a curried
5975/// `point -> length -> length -> length -> graphics list` deco and coerce
5976/// the result. Depths here are already user-sign (nonnegative), so no
5977/// negate (upstream negates because ITS internal depths are nonpositive).
5978/// The deco closure's result is `list graphics` under v0.0.6, one `graphics`
5979/// collection under v0.1 — see `coerce_graphics_result`'s doc comment.
5980///
5981/// `version` is the generation the closure was CAPTURED under
5982/// (`DecoEntry::version`), not `interp.version`: this runs from `lib.rs`'s
5983/// post-page-break firing pass, which is outside every `VersionScope`
5984/// window, so `interp.version` there is the entry document's generation. In
5985/// a single-version program the two are the same value.
5986pub(crate) fn apply_deco(
5987    interp: &mut Interp,
5988    version: RustyfiVersion,
5989    deco: Value,
5990    pt: Point,
5991    w: Length,
5992    h: Length,
5993    d: Length,
5994) -> Result<Vec<GraphicsElem>, EvalError> {
5995    let v = interp.apply(deco, make_point_value(pt))?;
5996    let v = interp.apply(v, Value::Length(w))?;
5997    let v = interp.apply(v, Value::Length(h))?;
5998    let v = interp.apply(v, Value::Length(d))?;
5999    coerce_graphics_result_for(version, v)
6000}
6001
6002/// `(length * color) option` — `register-link-to-uri`/`-to-location`'s
6003/// trailing border argument (vminstdef.yaml:2755/2775's `vborderopt`),
6004/// parsed the same way [`as_color`]/[`as_page`] read a `Value::Ctor`.
6005fn as_border_option(v: Value) -> Result<Option<(Length, Color)>, EvalError> {
6006    match v {
6007        Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
6008            ("None", None) => Ok(None),
6009            ("Some", Some(Value::Tuple(vs))) if vs.len() == 2 => {
6010                let mut it = vs.into_iter();
6011                let w = as_length(it.next().unwrap())?;
6012                let c = as_color(it.next().unwrap())?;
6013                Ok(Some((w, c)))
6014            }
6015            (other, _) => eval_error(format!(
6016                "expected a border option (None / Some(length * color)), got variant '{other}'"
6017            )),
6018        },
6019        other => eval_error(format!("expected an option, got {}", other.type_name())),
6020    }
6021}
6022
6023/// `register-destination : string -> point -> unit` (vminstdef.yaml:2738) —
6024/// FAITHFUL: upstream `NamedDest.register` + `notify_pagebreak` collapsed
6025/// into one step, since our firing window (`fire_hooks`) already knows the
6026/// page. Errors outside that window (`annotation.ml:15`'s
6027/// `State.during_page_break` gate).
6028///
6029/// **ONE exception, a re-timing rather than a relaxation of the gate.** Inside
6030/// an eagerly-applied `inline-graphics` callback this port is running code
6031/// upstream would only run DURING page breaking (see `prim_inline_graphics`),
6032/// so refusing here would refuse a call upstream accepts. Such a call is
6033/// recorded in `Interp::pending_dests` instead, and the caller mints a
6034/// `GraphicsElem::Destination` marker from it. Outside that one window the gate
6035/// is unchanged: no page, no destination.
6036fn prim_register_destination(
6037    interp: &mut Interp,
6038    mut args: Vec<Value>,
6039) -> Result<Value, EvalError> {
6040    let (x, y) = as_point(args.pop().unwrap())?;
6041    let key = as_str(args.pop().unwrap())?;
6042    if interp.current_page.is_none() {
6043        if let Some(pending) = interp.pending_dests.as_mut() {
6044            pending.push((key, (x, y)));
6045            return Ok(Value::Unit);
6046        }
6047    }
6048    let Some(page) = interp.current_page else {
6049        return eval_error(
6050            "register-destination can only be called during page breaking \
6051             (from a page-break hook or a decoration)",
6052        );
6053    };
6054    let name = interp.dest_name(&key);
6055    // See `prim_register_link_to_uri`'s identical comment —
6056    // `register-location-frame`'s `decoR` fires this from inside a firing
6057    // block-frame deco.
6058    if let Some(deco_id) = interp.current_deco_id {
6059        interp.dest_decos.push((deco_id, name.clone()));
6060    }
6061    interp.destinations.push(NamedDest { page, name, x, y });
6062    Ok(Value::Unit)
6063}
6064
6065/// Shared body of `register-link-to-uri` / `register-link-to-location`
6066/// (vminstdef.yaml:2753/2773): pops the common `point/w/h/d/border` suffix,
6067/// builds `annotation.ml:22`'s rect `(x, y - d, x + w, y + h)` (PDF y-up
6068/// points; our depths are already nonnegative), and pushes the `Annot`.
6069fn register_link(
6070    interp: &mut Interp,
6071    mut args: Vec<Value>,
6072    prim_name: &str,
6073    make_action: impl FnOnce(&mut Interp, String) -> AnnotAction,
6074) -> Result<Value, EvalError> {
6075    let border = as_border_option(args.pop().unwrap())?;
6076    let dpt = as_length(args.pop().unwrap())?;
6077    let hgt = as_length(args.pop().unwrap())?;
6078    let wid = as_length(args.pop().unwrap())?;
6079    let (x, y) = as_point(args.pop().unwrap())?;
6080    let target = as_str(args.pop().unwrap())?;
6081    let Some(page) = interp.current_page else {
6082        return eval_error(format!(
6083            "{prim_name} can only be called during page breaking \
6084             (from a page-break hook or a decoration)"
6085        ));
6086    };
6087    let action = make_action(interp, target);
6088    // Tag this link with the DecoId of whatever deco closure is currently
6089    // firing (set by `fire_hooks`'s two `apply_deco` call sites, `lib.rs`) —
6090    // `annot.satyh`'s `\href` always calls this from inside one, so
6091    // `current_deco_id` is `Some` for every real `\href`; a hand-built test
6092    // calling this prim directly (not through a firing deco) legitimately
6093    // leaves it `None`, and the reflow backend just won't find a Frame to
6094    // wrap for that link.
6095    if let Some(deco_id) = interp.current_deco_id {
6096        interp.link_decos.push((deco_id, action.clone()));
6097    }
6098    interp.annotations.push(Annot {
6099        page,
6100        rect: (x, y - dpt, x + wid, y + hgt),
6101        action,
6102        border,
6103    });
6104    Ok(Value::Unit)
6105}
6106
6107/// `register-link-to-uri : string -> point -> length -> length -> length ->
6108/// (length * color) option -> unit` (vminstdef.yaml:2753
6109/// `BackendRegisterLinkToUri`) — FAITHFUL: see [`register_link`].
6110fn prim_register_link_to_uri(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
6111    register_link(interp, args, "register-link-to-uri", |_, uri| {
6112        AnnotAction::Uri(uri)
6113    })
6114}
6115
6116/// `register-link-to-location : string -> point -> length -> length ->
6117/// length -> (length * color) option -> unit` (vminstdef.yaml:2773
6118/// `BackendRegisterLinkToLocation`) — FAITHFUL: same shape as
6119/// [`prim_register_link_to_uri`], but upstream's action is
6120/// `GotoName(NamedDest.get name)` — the key goes through the SAME name table
6121/// as [`prim_register_destination`], so a link to a not-(yet-)registered
6122/// destination still mints a stable name (a viewer no-ops on it), exactly
6123/// like upstream.
6124fn prim_register_link_to_location(
6125    interp: &mut Interp,
6126    args: Vec<Value>,
6127) -> Result<Value, EvalError> {
6128    register_link(interp, args, "register-link-to-location", |interp, key| {
6129        AnnotAction::GotoName(interp.dest_name(&key))
6130    })
6131}
6132
6133// ============================================================================
6134// The faithful `Value::Math` primitive layer `math.satyh` is built
6135// out of. Every `math-*` primitive here builds or consumes a
6136// `Value::Math(Rc<Vec<Math>>)` (`value.rs`'s `Math`); a `math`-typed
6137// argument may equally arrive as a `Value::MathText` (a `${…}` literal —
6138// `as_math` accepts either, reflecting a `MathText`'s `MathElem` tree into
6139// `Math` nodes on the fly, see below).
6140// ============================================================================
6141
6142use crate::value::{Math, MathElement, MathVariantStyle};
6143
6144/// `math-class` = `Value::Ctor("MathOrd"|"MathBin"|…, None)` — mirrors
6145/// `as_color`/`as_page`'s shape exactly.
6146fn as_math_kind(v: Value) -> Result<MathKind, EvalError> {
6147    match v {
6148        Value::Ctor(name, None) => match name.as_str() {
6149            "MathOrd" => Ok(MathKind::Ord),
6150            "MathBin" => Ok(MathKind::Bin),
6151            "MathRel" => Ok(MathKind::Rel),
6152            "MathOp" => Ok(MathKind::Op),
6153            "MathPunct" => Ok(MathKind::Punct),
6154            "MathOpen" => Ok(MathKind::Open),
6155            "MathClose" => Ok(MathKind::Close),
6156            "MathPrefix" => Ok(MathKind::Prefix),
6157            "MathInner" => Ok(MathKind::Inner),
6158            other => eval_error(format!("expected a math-class constructor, got '{other}'")),
6159        },
6160        other => eval_error(format!("expected a math-class, got {}", other.type_name())),
6161    }
6162}
6163
6164/// `math-char-class` = `Value::Ctor("MathItalic"|…, None)`, resolved to the
6165/// backend's [`MathCharClass`] (see `value.rs`'s
6166/// `Math::ChangeCharClass` doc comment).
6167fn as_math_char_class(v: Value) -> Result<MathCharClass, EvalError> {
6168    match v {
6169        Value::Ctor(name, None) => match name.as_str() {
6170            "MathItalic" => Ok(MathCharClass::Italic),
6171            "MathBoldItalic" => Ok(MathCharClass::BoldItalic),
6172            "MathRoman" => Ok(MathCharClass::Roman),
6173            "MathBoldRoman" => Ok(MathCharClass::BoldRoman),
6174            "MathScript" => Ok(MathCharClass::Script),
6175            "MathBoldScript" => Ok(MathCharClass::BoldScript),
6176            "MathFraktur" => Ok(MathCharClass::Fraktur),
6177            "MathBoldFraktur" => Ok(MathCharClass::BoldFraktur),
6178            "MathDoubleStruck" => Ok(MathCharClass::DoubleStruck),
6179            // V0_1-only registration — these 5
6180            // ctor names are only ever declared by `builtin_variants` under
6181            // V0_1, so under V0_0 this arm is simply never reached: the
6182            // ctor name itself is rejected earlier, at typecheck, as
6183            // unknown.
6184            "MathSansSerif" => Ok(MathCharClass::SansSerif),
6185            "MathBoldSansSerif" => Ok(MathCharClass::BoldSansSerif),
6186            "MathItalicSansSerif" => Ok(MathCharClass::ItalicSansSerif),
6187            "MathBoldItalicSansSerif" => Ok(MathCharClass::BoldItalicSansSerif),
6188            "MathTypewriter" => Ok(MathCharClass::Typewriter),
6189            other => eval_error(format!(
6190                "expected a math-char-class constructor, got '{other}'"
6191            )),
6192        },
6193        other => eval_error(format!(
6194            "expected a math-char-class, got {}",
6195            other.type_name()
6196        )),
6197    }
6198}
6199
6200/// `math-variant-char`'s 9-field style record (`value.rs`'s
6201/// `MathVariantStyle`; `prim_types::t_math_variant_style`'s runtime
6202/// counterpart).
6203fn as_math_variant_style(v: Value) -> Result<MathVariantStyle, EvalError> {
6204    match v {
6205        Value::Record(mut fields) => {
6206            let mut take = |label: &str| -> Result<String, EvalError> {
6207                match fields.remove(label) {
6208                    Some(v) => as_str(v),
6209                    None => eval_error(format!(
6210                        "math-variant-char style record missing field '{label}'"
6211                    )),
6212                }
6213            };
6214            Ok(MathVariantStyle {
6215                italic: take("italic")?,
6216                bold_italic: take("bold-italic")?,
6217                roman: take("roman")?,
6218                bold_roman: take("bold-roman")?,
6219                script: take("script")?,
6220                bold_script: take("bold-script")?,
6221                fraktur: take("fraktur")?,
6222                bold_fraktur: take("bold-fraktur")?,
6223                double_struck: take("double-struck")?,
6224            })
6225        }
6226        other => eval_error(format!(
6227            "expected a math-variant-char style record, got {}",
6228            other.type_name()
6229        )),
6230    }
6231}
6232
6233/// A `math` argument: either an already-faithful `Value::Math` (built by
6234/// another `math-*` primitive), or a `${…}` literal `Value::MathText`,
6235/// reflected into `Math` nodes on the fly via [`reflect_math_elem`] — see
6236/// `value.rs`'s `Value::Math` doc comment for why both are interchangeable.
6237fn as_math(interp: &mut Interp, v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
6238    match v {
6239        Value::Math(m) => Ok(m),
6240        Value::MathText { elems, env } => {
6241            let mut out = Vec::new();
6242            for e in elems.iter() {
6243                reflect_math_elem(interp, e, &env, &mut out)?;
6244            }
6245            Ok(Rc::new(out))
6246        }
6247        other => eval_error(format!("expected math, got {}", other.type_name())),
6248    }
6249}
6250
6251/// Reflect one elaborated `${…}` literal `MathElem` (a fused,
6252/// math-class-free form) into zero-or-more faithful `Math` atoms, pushed
6253/// onto `out` — the "less churn" resolution:
6254/// `MathElem` stays the fast path for a bare `${x^2}` in prose
6255/// (`read_inline`'s `EmbedMath` arm, untouched), and only gets reflected
6256/// into `Value::Math` at a command/primitive boundary (here — whenever a
6257/// `${…}` literal is passed where a faithful `math` value is expected).
6258/// `Cmd`/`Embed` are resolved by actually evaluating them against `env` (the
6259/// literal's own captured environment) and recursively reflecting/flattening
6260/// the result — the "Embed of a `#…` program value that itself
6261/// evaluates to math" case.
6262fn reflect_math_elem(
6263    interp: &mut Interp,
6264    elem: &MathElem,
6265    env: &Env,
6266    out: &mut Vec<Math>,
6267) -> Result<(), EvalError> {
6268    match elem {
6269        MathElem::Chars(s) => {
6270            // One atom per MATHCHAR token ("one atom per run" —
6271            // the lexer already grouped a symbol run or a single latin
6272            // digit/letter into `s`); class + codepoint remap are both
6273            // deferred to `layout_math_atom`'s `VariantCharPending` arm,
6274            // where `Context::math_class_map`/`math_variant_char_map` and
6275            // the current font are available.
6276            out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
6277            Ok(())
6278        }
6279        MathElem::Group(elems) => {
6280            for e in elems {
6281                reflect_math_elem(interp, e, env, out)?;
6282            }
6283            Ok(())
6284        }
6285        MathElem::Sub(base, script) => {
6286            let mut base_v = Vec::new();
6287            reflect_math_elem(interp, base, env, &mut base_v)?;
6288            let mut script_v = Vec::new();
6289            for e in script {
6290                reflect_math_elem(interp, e, env, &mut script_v)?;
6291            }
6292            out.push(Math::Sub(base_v, script_v));
6293            Ok(())
6294        }
6295        MathElem::Sup(base, script) => {
6296            let mut base_v = Vec::new();
6297            reflect_math_elem(interp, base, env, &mut base_v)?;
6298            let mut script_v = Vec::new();
6299            for e in script {
6300                reflect_math_elem(interp, e, env, &mut script_v)?;
6301            }
6302            out.push(Math::Sup(base_v, script_v));
6303            Ok(())
6304        }
6305        MathElem::Primes(base, n) => {
6306            let mut base_v = Vec::new();
6307            reflect_math_elem(interp, base, env, &mut base_v)?;
6308            let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
6309            out.push(Math::Sup(
6310                base_v,
6311                vec![Math::Pure(MathElement::Char {
6312                    class: MathKind::Ord,
6313                    big: false,
6314                    chars: primes,
6315                })],
6316            ));
6317            Ok(())
6318        }
6319        MathElem::Cmd { cmd, args, .. } => {
6320            let mut v = cmd.run(env, interp)?;
6321            for arg in args {
6322                // `arg.opts` is always empty here — the math-mode application
6323                // grammar has no `?(l=e)` bundle form (see `MathElem::Cmd`'s
6324                // doc comment, `ast.rs`) — but fold through `apply_with_opts`
6325                // uniformly with `read_inline`/`read_block` regardless.
6326                let mut opt_vals = Vec::with_capacity(arg.opts.len());
6327                for (label, e) in &arg.opts {
6328                    opt_vals.push((label.clone(), e.run(env, interp)?));
6329                }
6330                let arg_v = arg.arg.run(env, interp)?;
6331                v = interp.apply_with_opts(v, opt_vals, arg_v)?;
6332            }
6333            let m = as_math(interp, v)?;
6334            out.extend(m.iter().cloned());
6335            Ok(())
6336        }
6337        MathElem::Embed { expr, span: _ } => {
6338            let v = expr.run(env, interp)?;
6339            let m = as_math(interp, v)?;
6340            out.extend(m.iter().cloned());
6341            Ok(())
6342        }
6343    }
6344}
6345
6346fn single_math(m: Math) -> Value {
6347    Value::Math(Rc::new(vec![m]))
6348}
6349
6350// ============================================================================
6351// V0_1's `math-text`/`math-boxes` split + `read-math`.
6352// Everything below is additive and V0_1-only — no 0.0.6 path calls any of
6353// this (`as_math`/`reflect_math_elem`/`single_math` above stay byte-
6354// identical and untouched).
6355// ============================================================================
6356
6357fn single_math_boxes(m: Math) -> Value {
6358    Value::MathBoxes(Rc::new(vec![m]))
6359}
6360
6361/// V0_1 strict `math-boxes` extractor: accepts only `Value::MathBoxes` — a
6362/// `math-text` literal reaching a V0_1 `math-*` primitive is a genuine 0.1
6363/// type error (well-typed programs never hit this; it's the runtime
6364/// fallback for a call built by hand, e.g. from a unit test).
6365fn as_math_boxes(v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
6366    match v {
6367        Value::MathBoxes(m) => Ok(m),
6368        other => eval_error(format!(
6369            "expected math-boxes, got {} (V0_1: math-text and math-boxes \
6370             are distinct types — bridge with `read-math`)",
6371            other.type_name()
6372        )),
6373    }
6374}
6375
6376/// V0_1 strict `math-text` extractor: accepts only `Value::MathText`,
6377/// returning its elements together with the environment they were captured
6378/// under (needed to evaluate any `#x` embed / math-command lookup inside).
6379fn as_math_text(v: Value) -> Result<(Rc<Vec<MathElem>>, Env), EvalError> {
6380    match v {
6381        Value::MathText { elems, env } => Ok((elems, env)),
6382        other => eval_error(format!("expected math-text, got {}", other.type_name())),
6383    }
6384}
6385
6386/// `option math-text` extractor (`None`/`Some math-text`) — `%math-attach-
6387/// scripts`' sub/sup arguments.
6388fn as_option_math_text(v: Value) -> Result<Option<(Rc<Vec<MathElem>>, Env)>, EvalError> {
6389    match v {
6390        Value::Ctor(name, None) if name == "None" => Ok(None),
6391        Value::Ctor(name, Some(payload)) if name == "Some" => {
6392            let (elems, env) = as_math_text(*payload)?;
6393            Ok(Some((elems, env)))
6394        }
6395        other => eval_error(format!(
6396            "expected an option (None/Some), got {}",
6397            other.type_name()
6398        )),
6399    }
6400}
6401
6402/// Wrap a raw (ambient-`env`-sharing) script `MathElem` slice as an `option
6403/// math-text` VALUE — `Cmd`'s uniform V0_1 calling convention always
6404/// passes its command's sub/sup arguments this way, never pre-reflected.
6405fn option_math_text_value(opt: Option<&[MathElem]>, env: &Env) -> Value {
6406    match opt {
6407        None => Value::Ctor("None".to_string(), None),
6408        Some(elems) => Value::Ctor(
6409            "Some".to_string(),
6410            Some(Box::new(Value::MathText {
6411                elems: Rc::new(elems.to_vec()),
6412                env: env.clone(),
6413            })),
6414        ),
6415    }
6416}
6417
6418/// `math-char-class` ctor-name mapper — the inverse of `as_math_char_class`
6419/// (above), used by `get-math-char-class` and by `set-math-variant-char`'s
6420/// V0_1 body (which must build a `math-char-class` VALUE to feed the
6421/// caller's selector closure).
6422fn math_char_class_ctor_name(c: MathCharClass) -> &'static str {
6423    match c {
6424        MathCharClass::Italic => "MathItalic",
6425        MathCharClass::BoldItalic => "MathBoldItalic",
6426        MathCharClass::Roman => "MathRoman",
6427        MathCharClass::BoldRoman => "MathBoldRoman",
6428        MathCharClass::Script => "MathScript",
6429        MathCharClass::BoldScript => "MathBoldScript",
6430        MathCharClass::Fraktur => "MathFraktur",
6431        MathCharClass::BoldFraktur => "MathBoldFraktur",
6432        MathCharClass::DoubleStruck => "MathDoubleStruck",
6433        MathCharClass::SansSerif => "MathSansSerif",
6434        MathCharClass::BoldSansSerif => "MathBoldSansSerif",
6435        MathCharClass::ItalicSansSerif => "MathItalicSansSerif",
6436        MathCharClass::BoldItalicSansSerif => "MathBoldItalicSansSerif",
6437        MathCharClass::Typewriter => "MathTypewriter",
6438    }
6439}
6440
6441fn math_char_class_value(c: MathCharClass) -> Value {
6442    Value::Ctor(math_char_class_ctor_name(c).to_string(), None)
6443}
6444
6445/// Port of `dev-0-1-0 src/frontend/context.ml:52-68`: bump `ctx`'s
6446/// `math_script_level` and scale `font_size`
6447/// accordingly. `Base -> Script`: scale by the font's MATH-table
6448/// `script_scale_down` (fallback `0.7`, consistent with the engine's other
6449/// fixed-fraction fallbacks). `Script -> ScriptScript`: scale by
6450/// `script_script_scale_down / script_scale_down` (fallback `5.0/7.0`).
6451/// `ScriptScript`: no-op — saturates at the deepest level, matching
6452/// upstream (no `ScriptScriptScript`).
6453fn enter_script(interp: &Interp, ctx: &Context) -> Context {
6454    let mc = MathC::of(interp, ctx);
6455    let (scale, next_level) = match ctx.math_script_level {
6456        MathScriptLevel::Base => (
6457            mc.c.map(|c| c.script_scale_down).unwrap_or(0.7),
6458            MathScriptLevel::Script,
6459        ),
6460        MathScriptLevel::Script => (
6461            mc.c.map(|c| c.script_script_scale_down / c.script_scale_down)
6462                .unwrap_or(5.0 / 7.0),
6463            MathScriptLevel::ScriptScript,
6464        ),
6465        MathScriptLevel::ScriptScript => return ctx.clone(),
6466    };
6467    Context {
6468        font_size: ctx.font_size * scale,
6469        math_script_level: next_level,
6470        ..ctx.clone()
6471    }
6472}
6473
6474/// Flatten a `Sub`/`Sup` `MathElem`'s (at most two-deep) nesting into `(base,
6475/// sub_opt, sup_opt)` — `elaborate.rs::fold_math_scripts` always builds a
6476/// both-scripts element as `Sup(Box::new(Sub(base, sub)), sup)` regardless
6477/// of source order (`x_a^b` and `x^b_a` both fold this way), so a bare
6478/// `Sub`/`Sup` and the fused two-level shape are the only cases to handle.
6479/// `elem` MUST be `MathElem::Sub` or `MathElem::Sup` — every caller already
6480/// matched on that.
6481fn flatten_math_scripts(elem: &MathElem) -> (&MathElem, Option<&[MathElem]>, Option<&[MathElem]>) {
6482    match elem {
6483        MathElem::Sup(base, sup) => match base.as_ref() {
6484            MathElem::Sub(inner, sub) => {
6485                (inner.as_ref(), Some(sub.as_slice()), Some(sup.as_slice()))
6486            }
6487            _ => (base.as_ref(), None, Some(sup.as_slice())),
6488        },
6489        MathElem::Sub(base, sub) => (base.as_ref(), Some(sub.as_slice()), None),
6490        _ => unreachable!("flatten_math_scripts called on a non-Sub/Sup MathElem"),
6491    }
6492}
6493
6494/// `attach_scripts` — mirrors upstream's
6495/// `append_sub_and_super_scripts` + its `enter_script` iteration
6496/// (`evaluator.cppo.ml:901-904`): reflects `sub_opt`/`sup_opt` (each an
6497/// already-extracted math-text payload — an ambient-env script slice for
6498/// the `reflect_scripted_v01` caller, or a genuine runtime `Value::MathText`
6499/// for the `%math-attach-scripts` primitive caller, both the SAME shape)
6500/// under `enter_script(interp, ctx)` — so commands *inside* a script observe
6501/// script-level context — then wraps `Math::Sub`/`Math::Sup` around `base`.
6502/// Both scripts present wraps as `Sup(Sub(base, sub), sup)`, matching the
6503/// shape `layout_math_atom`'s `check_subscript` already knows how to merge.
6504fn attach_scripts(
6505    interp: &mut Interp,
6506    ctx: &Context,
6507    base: Vec<Math>,
6508    sub_opt: Option<(Rc<Vec<MathElem>>, Env)>,
6509    sup_opt: Option<(Rc<Vec<MathElem>>, Env)>,
6510) -> Result<Vec<Math>, EvalError> {
6511    if sub_opt.is_none() && sup_opt.is_none() {
6512        return Ok(base);
6513    }
6514    let script_ctx = enter_script(interp, ctx);
6515    let mut cur = base;
6516    if let Some((elems, senv)) = sub_opt {
6517        let mut sub_v = Vec::new();
6518        for e in elems.iter() {
6519            reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sub_v)?;
6520        }
6521        cur = vec![Math::Sub(cur, sub_v)];
6522    }
6523    if let Some((elems, senv)) = sup_opt {
6524        let mut sup_v = Vec::new();
6525        for e in elems.iter() {
6526            reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sup_v)?;
6527        }
6528        cur = vec![Math::Sup(cur, sup_v)];
6529    }
6530    Ok(cur)
6531}
6532
6533/// One base `MathElem` (already stripped of any wrapping `Sub`/`Sup`) plus
6534/// its (possibly absent) `sub`/`sup` script slices — the shared tail of
6535/// `reflect_math_elem_v01`'s `Sub`/`Sup` arm (after flattening) AND its bare
6536/// `Cmd` arm (`sub = sup = None`). `base` a `Cmd`: route ctx+sub+sup into
6537/// the application per the uniform V0_1 calling convention — a
6538/// SEPARATE math-command value shape does not exist in this port, so every
6539/// V0_1 math command, scripted or not, is applied exactly this way. `base`
6540/// anything else: reflect it plainly, then `attach_scripts`.
6541fn reflect_scripted_v01(
6542    interp: &mut Interp,
6543    ctx: &Context,
6544    base: &MathElem,
6545    sub: Option<&[MathElem]>,
6546    sup: Option<&[MathElem]>,
6547    env: &Env,
6548    out: &mut Vec<Math>,
6549) -> Result<(), EvalError> {
6550    if let MathElem::Cmd { cmd, args, .. } = base {
6551        let mut v = cmd.run(env, interp)?;
6552        for arg in args {
6553            // `arg.opts` is always empty here too (see the bare-`Cmd` arm
6554            // above, `reflect_math_elem`) — folded through `apply_with_opts`
6555            // uniformly regardless.
6556            let mut opt_vals = Vec::with_capacity(arg.opts.len());
6557            for (label, e) in &arg.opts {
6558                opt_vals.push((label.clone(), e.run(env, interp)?));
6559            }
6560            let arg_v = arg.arg.run(env, interp)?;
6561            v = interp.apply_with_opts(v, opt_vals, arg_v)?;
6562        }
6563        // A 0.0.6-authored math command reached from a 0.1 document: the two
6564        // generations invoke a command differently, though `math` relabels
6565        // to `math-text` and both type-check. 0.0.6 gets `\cmd a1..an ->
6566        // math` with scripts attached STRUCTURALLY afterward
6567        // (`reflect_math_elem`'s `Sub`/`Sup` arms); 0.1 applies three extra
6568        // arguments (`ctx sub sup -> math-boxes`, `sub`/`sup : math-text
6569        // option`) so a command can typeset its own scripts. Applying those
6570        // three to a 0.0.6 command used to die with `cannot apply a value of
6571        // type math as a function`.
6572        //
6573        // Discrimination here is DYNAMIC, and total: after its declared
6574        // arguments a 0.1 command is by construction still a function (ends
6575        // `.. -> context -> ..`), so a math VALUE at this point can only be
6576        // a 0.0.6 command's result — a static check can't recover the
6577        // authoring generation, since `Ast::VersionScope` governs which
6578        // `PrimDef` the body folds to and nothing on the resulting closure
6579        // records where it came from. `as_math` runs 0.0.6's own reflection
6580        // (so nested commands in a returned `${..}` literal stay 0.0.6
6581        // commands), and its `Rc<Vec<Math>>` payload is byte-for-byte what
6582        // `Value::MathBoxes` carries, so crossing needs no conversion;
6583        // untaken scripts then attach via `attach_scripts`, the same
6584        // structural `Math::Sub`/`Math::Sup` shape 0.0.6's own reflector
6585        // would have built. A 0.0.6 command still can't RESTYLE its own
6586        // scripts — it never could, in 0.0.6 either.
6587        if matches!(v, Value::Math(_) | Value::MathText { .. }) {
6588            let base_v = as_math(interp, v)?.as_ref().clone();
6589            let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6590            let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6591            let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6592            out.extend(attached);
6593            return Ok(());
6594        }
6595        v = interp.apply(v, Value::Context(Box::new(ctx.clone())))?;
6596        v = interp.apply(v, option_math_text_value(sub, env))?;
6597        v = interp.apply(v, option_math_text_value(sup, env))?;
6598        let m = as_math_boxes(v)?;
6599        out.extend(m.iter().cloned());
6600        return Ok(());
6601    }
6602    let mut base_v = Vec::new();
6603    reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6604    let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6605    let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6606    let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6607    out.extend(attached);
6608    Ok(())
6609}
6610
6611/// V0_1 twin of `reflect_math_elem` (differs only where upstream's
6612/// `read_pdf_mode_math_text` (`evaluator.cppo.ml:887-930`) differs from
6613/// 0.0.6 reflection): `Chars`/`Group`/`Primes` are
6614/// identical to the v006 arms (class/variant resolution stays deferred to
6615/// layout, where `ctx`'s maps live); `Sub`/`Sup` flatten and route through
6616/// [`reflect_scripted_v01`]; a bare `Cmd` also routes through it (with
6617/// `sub = sup = None`) so the uniform ctx+sub+sup calling convention
6618/// applies uniformly, scripted or not; `Embed` (`#x`) requires the embedded
6619/// value to be `math-text` (it typechecked as `math-text`) and
6620/// recurses — upstream `MathTextValueGroup` (`evaluator.cppo.ml:944-949`);
6621/// scripts on an embed attach via [`reflect_scripted_v01`]'s generic
6622/// (non-`Cmd`) path, same as any other non-command base.
6623fn reflect_math_elem_v01(
6624    interp: &mut Interp,
6625    ctx: &Context,
6626    elem: &MathElem,
6627    env: &Env,
6628    out: &mut Vec<Math>,
6629) -> Result<(), EvalError> {
6630    match elem {
6631        MathElem::Chars(s) => {
6632            out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
6633            Ok(())
6634        }
6635        MathElem::Group(elems) => {
6636            for e in elems {
6637                reflect_math_elem_v01(interp, ctx, e, env, out)?;
6638            }
6639            Ok(())
6640        }
6641        MathElem::Primes(base, n) => {
6642            let mut base_v = Vec::new();
6643            reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6644            let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
6645            out.push(Math::Sup(
6646                base_v,
6647                vec![Math::Pure(MathElement::Char {
6648                    class: MathKind::Ord,
6649                    big: false,
6650                    chars: primes,
6651                })],
6652            ));
6653            Ok(())
6654        }
6655        MathElem::Sub(_, _) | MathElem::Sup(_, _) => {
6656            let (base, sub, sup) = flatten_math_scripts(elem);
6657            reflect_scripted_v01(interp, ctx, base, sub, sup, env, out)
6658        }
6659        MathElem::Cmd { .. } => reflect_scripted_v01(interp, ctx, elem, None, None, env, out),
6660        MathElem::Embed { expr, span: _ } => {
6661            let v = expr.run(env, interp)?;
6662            let (elems2, env2) = as_math_text(v)?;
6663            for e in elems2.iter() {
6664                reflect_math_elem_v01(interp, ctx, e, &env2, out)?;
6665            }
6666            Ok(())
6667        }
6668    }
6669}
6670
6671/// `read-math : context -> math-text -> math-boxes` (dev-0-1-0
6672/// vminst.ml:790-793). Reflects every element of
6673/// `mt` under `ctx` via [`reflect_math_elem_v01`], then wraps the whole run
6674/// in a single `Math::WithContext` node so `ctx` (including any color/font/
6675/// size override the caller composed onto it) reaches the layout engine —
6676/// see [`layout_math_list`]'s `Math::WithContext` arm.
6677fn prim_read_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6678    let mt = args.pop().unwrap();
6679    let ctx = as_context(args.pop().unwrap())?;
6680    let (elems, env) = as_math_text(mt)?;
6681    let mut out = Vec::new();
6682    for e in elems.iter() {
6683        reflect_math_elem_v01(interp, &ctx, e, &env, &mut out)?;
6684    }
6685    Ok(Value::MathBoxes(Rc::new(vec![Math::WithContext(
6686        Box::new(ctx),
6687        out,
6688    )])))
6689}
6690
6691/// `stringify-math : text-info -> math-text -> string` (vminst.ml:858) —
6692/// STAND-IN: the text-mode backend is out of scope for this PDF port (same
6693/// scoping note as `prim_convert_string_for_math`'s doc comment); registered
6694/// so 0.1 packages that reference it still typecheck.
6695fn prim_stringify_math(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6696    let _mt = args.pop().unwrap();
6697    let _tctx = args.pop().unwrap();
6698    eval_error(
6699        "stringify-math: the text-mode backend is out of scope for this PDF port \
6700         (see primitives.rs's prim_convert_string_for_math doc comment)"
6701            .to_string(),
6702    )
6703}
6704
6705/// `set-math-char : int -> int -> math-class -> context -> context`
6706/// (vminst.ml:59) — REAL: inserts `(char(cp_from)) -> (char(cp_to), kind)`
6707/// into `Context::math_class_map` (single-char string key, matching the
6708/// map's existing token-keying convention — see `prim_convert_string_for_
6709/// math`'s doc comment on how that map is consulted).
6710fn prim_set_math_char(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6711    let mut ctx = as_context(args.pop().unwrap())?;
6712    let kind = as_math_kind(args.pop().unwrap())?;
6713    let cpto = as_int(args.pop().unwrap())?;
6714    let cpfrom = as_int(args.pop().unwrap())?;
6715    let from = u32::try_from(cpfrom)
6716        .ok()
6717        .and_then(char::from_u32)
6718        .ok_or_else(|| EvalError {
6719            span: None,
6720            msg: format!("set-math-char: {cpfrom} is not a valid Unicode codepoint"),
6721        })?;
6722    let to = u32::try_from(cpto)
6723        .ok()
6724        .and_then(char::from_u32)
6725        .ok_or_else(|| EvalError {
6726            span: None,
6727            msg: format!("set-math-char: {cpto} is not a valid Unicode codepoint"),
6728        })?;
6729    Arc::make_mut(&mut ctx.math_class_map).insert(from.to_string(), (to.to_string(), kind));
6730    Ok(Value::Context(Box::new(ctx)))
6731}
6732
6733/// `set-math-char-class : math-char-class -> context -> context`
6734/// (vminst.ml:445) — REAL: sets `Context::math_char_class`.
6735fn prim_set_math_char_class(
6736    _interp: &mut Interp,
6737    mut args: Vec<Value>,
6738) -> Result<Value, EvalError> {
6739    let ctx = as_context(args.pop().unwrap())?;
6740    let cls = as_math_char_class(args.pop().unwrap())?;
6741    Ok(Value::Context(Box::new(Context {
6742        math_char_class: cls,
6743        ..ctx
6744    })))
6745}
6746
6747/// `get-math-char-class : context -> math-char-class` (vminst.ml:459) —
6748/// REAL: inverse of `as_math_char_class`.
6749fn prim_get_math_char_class(
6750    _interp: &mut Interp,
6751    mut args: Vec<Value>,
6752) -> Result<Value, EvalError> {
6753    let ctx = as_context(args.pop().unwrap())?;
6754    Ok(math_char_class_value(ctx.math_char_class))
6755}
6756
6757/// `embed-inline-to-math : math-class -> inline-boxes -> math-boxes`
6758/// (vminst.ml:432) — REAL data, stand-in render (`MathElement::
6759/// EmbeddedBoxes`'s doc comment).
6760fn prim_embed_inline_to_math(
6761    _interp: &mut Interp,
6762    mut args: Vec<Value>,
6763) -> Result<Value, EvalError> {
6764    let ib = as_inline_boxes(args.pop().unwrap())?;
6765    let class = as_math_kind(args.pop().unwrap())?;
6766    Ok(single_math_boxes(Math::Pure(MathElement::EmbeddedBoxes {
6767        class,
6768        boxes: ib,
6769    })))
6770}
6771
6772/// `get-math-axis-height-ratio : context -> float` (vminst.ml:1305) — REAL:
6773/// the axis-height ratio `MathC` already scales font sizes by
6774/// (`MathC::axis`).
6775fn prim_get_math_axis_height_ratio(
6776    interp: &mut Interp,
6777    mut args: Vec<Value>,
6778) -> Result<Value, EvalError> {
6779    let ctx = as_context(args.pop().unwrap())?;
6780    let ratio = MathC::of(interp, &ctx)
6781        .c
6782        .map(|c| c.axis_height)
6783        .unwrap_or(0.25);
6784    Ok(Value::Float(ratio))
6785}
6786
6787/// `%math-attach-scripts : context -> math-boxes -> option math-text ->
6788/// option math-text -> math-boxes` — hidden:
6789/// the synthesized script-attacher `val math` commands WITHOUT `with sub
6790/// sup` lower to. Body = [`attach_scripts`] directly — the same function
6791/// `reflect_scripted_v01`'s non-`Cmd` path calls.
6792fn prim_math_attach_scripts(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6793    let sup_v = args.pop().unwrap();
6794    let sub_v = args.pop().unwrap();
6795    let base_v = args.pop().unwrap();
6796    let ctx = as_context(args.pop().unwrap())?;
6797    let base = as_math_boxes(base_v)?;
6798    let sub_opt = as_option_math_text(sub_v)?;
6799    let sup_opt = as_option_math_text(sup_v)?;
6800    let out = attach_scripts(interp, &ctx, (*base).clone(), sub_opt, sup_opt)?;
6801    Ok(Value::MathBoxes(Rc::new(out)))
6802}
6803
6804/// `load-hyphenation-dictionary : string -> hyphenation` (`vminst.ml`'s
6805/// `LoadHyphenationDictionary`: upstream calls `LoadHyph.main abspath` to
6806/// build a `BCHyphenation` constant). REAL: unlike upstream, which
6807/// loads a dictionary from an on-disk `.rustyfi-hyph` path, this port has no
6808/// filesystem-loaded pattern data — the argument is instead treated as a
6809/// dictionary NAME (`"english"`/`"en-US"`, matching the `hyph-english.satyh`
6810/// stdlib package's usage) and mapped to the compiled-in `HyphenLang` tag.
6811/// An unrecognized name is a hard error rather than a silent no-op, since a
6812/// document that asks for a dictionary and gets none would silently render
6813/// without hyphenation. The heavy `hyphenation::Standard` dictionary itself
6814/// is not loaded here — only the lightweight tag is; the actual load is
6815/// deferred (load-once, cached) to `crate::hyphenation::hyphenate_word`'s
6816/// first call for that tag.
6817fn prim_load_hyphenation_dictionary(
6818    _interp: &mut Interp,
6819    mut args: Vec<Value>,
6820) -> Result<Value, EvalError> {
6821    let arg = as_str(args.pop().unwrap())?;
6822    // Accept either a bare dictionary NAME ("english"/"en-US") or an
6823    // upstream-style PATH ending `.../<name>.rustyfi-hyph` — this is what
6824    // the real, vendored `hyph-english.satyh` stand-in package actually
6825    // passes (`here ^ "/../hyph/english.rustyfi-hyph"`, mirroring
6826    // upstream's `LoadHyph.main abspath` convention). This port has no
6827    // on-disk pattern-file loader (the dictionary is compiled in via
6828    // `embed_en-us`), so the path's file stem doubles as the dictionary
6829    // name.
6830    let stem = std::path::Path::new(&arg)
6831        .file_stem()
6832        .and_then(|s| s.to_str())
6833        .unwrap_or(arg.as_str())
6834        .to_ascii_lowercase();
6835    let tag = match stem.as_str() {
6836        "english" | "en-us" => HyphenLang::EnglishUS,
6837        // en-GB (en-GB option): "british"/"en-GB"/"british-english",
6838        // mirroring the "english"/ "en-US" naming pair above.
6839        "british" | "en-gb" | "british-english" => HyphenLang::EnglishGB,
6840        _ => {
6841            return eval_error(format!(
6842                "load-hyphenation-dictionary: unknown dictionary {arg:?} \
6843                 (supported: \"english\"/\"en-US\", \"british\"/\"en-GB\"/\"british-english\", \
6844                 bare or as a `.../<name>.rustyfi-hyph`-style path)"
6845            ))
6846        }
6847    };
6848    Ok(Value::Hyphenation(tag))
6849}
6850
6851/// `load-unicode-char-database : string -> string -> string ->
6852/// unicode-char-database` (`vminst.ml`'s `LoadUnicodeCharDatabase`:
6853/// upstream builds `(ScriptDataMap, LineBreakDataMap)` from the three
6854/// Unicode data file paths into a `BCUnidata` constant). STAND-IN: no-op,
6855/// same rationale as `prim_load_hyphenation_dictionary` above — all three
6856/// paths are popped and dropped.
6857fn prim_load_unicode_char_database(
6858    _interp: &mut Interp,
6859    mut args: Vec<Value>,
6860) -> Result<Value, EvalError> {
6861    args.truncate(0);
6862    Ok(Value::Unit)
6863}
6864
6865/// `set-hyphenation-dictionary : hyphenation -> context -> context`
6866/// (`vminst.ml`'s setter: upstream stores `{ ctx with hyphen_dictionary }`).
6867/// REAL: writes `Context::hyphen_dictionary = Some(tag)`. This is the
6868/// ONLY way a `Context` acquires a dictionary — `Context::initial` seeds
6869/// `None`, so a document that never calls this gets no hyphenation at all.
6870fn prim_set_hyphenation_dictionary(
6871    _interp: &mut Interp,
6872    mut args: Vec<Value>,
6873) -> Result<Value, EvalError> {
6874    let ctx = as_context(args.pop().unwrap())?;
6875    let tag = as_hyphenation(args.pop().unwrap())?;
6876    Ok(Value::Context(Box::new(Context {
6877        hyphen_dictionary: Some(tag),
6878        ..ctx
6879    })))
6880}
6881
6882/// `set-unicode-char-database : unicode-char-database -> context ->
6883/// context` (`vminst.ml`'s setter: upstream stores `{ ctx with script_map;
6884/// line_break_map }`). STAND-IN no-op, same shape as
6885/// `prim_set_hyphenation_dictionary` above.
6886fn prim_set_unicode_char_database(
6887    _interp: &mut Interp,
6888    mut args: Vec<Value>,
6889) -> Result<Value, EvalError> {
6890    let ctx = args.pop().unwrap();
6891    let _db = args.pop().unwrap();
6892    Ok(ctx)
6893}
6894
6895fn prim_math_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6896    let s = as_str(args.pop().unwrap())?;
6897    let class = as_math_kind(args.pop().unwrap())?;
6898    let _ = interp;
6899    Ok(single_math(Math::Pure(MathElement::Char {
6900        class,
6901        big: false,
6902        chars: s,
6903    })))
6904}
6905
6906/// `math-char : context -> math-class -> string -> math-boxes` (dev-0-1-0
6907/// vminst.ml:358) — ctx ACCEPTED, not stored on the atom (coarse,
6908/// `read-math`-granularity context capture only).
6909fn prim_math_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6910    let s = as_str(args.pop().unwrap())?;
6911    let class = as_math_kind(args.pop().unwrap())?;
6912    let _ctx = as_context(args.pop().unwrap())?;
6913    let _ = interp;
6914    Ok(single_math_boxes(Math::Pure(MathElement::Char {
6915        class,
6916        big: false,
6917        chars: s,
6918    })))
6919}
6920
6921fn prim_math_big_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6922    let s = as_str(args.pop().unwrap())?;
6923    let class = as_math_kind(args.pop().unwrap())?;
6924    let _ = interp;
6925    Ok(single_math(Math::Pure(MathElement::Char {
6926        class,
6927        big: true,
6928        chars: s,
6929    })))
6930}
6931
6932/// `math-big-char : context -> math-class -> string -> math-boxes`
6933/// (vminst.ml:374) — same fork as `math-char`.
6934fn prim_math_big_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6935    let s = as_str(args.pop().unwrap())?;
6936    let class = as_math_kind(args.pop().unwrap())?;
6937    let _ctx = as_context(args.pop().unwrap())?;
6938    let _ = interp;
6939    Ok(single_math_boxes(Math::Pure(MathElement::Char {
6940        class,
6941        big: true,
6942        chars: s,
6943    })))
6944}
6945
6946fn prim_math_char_with_kern_v006(
6947    interp: &mut Interp,
6948    mut args: Vec<Value>,
6949) -> Result<Value, EvalError> {
6950    let kern_r = args.pop().unwrap();
6951    let kern_l = args.pop().unwrap();
6952    let s = as_str(args.pop().unwrap())?;
6953    let class = as_math_kind(args.pop().unwrap())?;
6954    let _ = interp;
6955    Ok(single_math(Math::Pure(MathElement::CharWithKern {
6956        class,
6957        big: false,
6958        chars: s,
6959        kern_l: Box::new(kern_l),
6960        kern_r: Box::new(kern_r),
6961    })))
6962}
6963
6964/// `math-char-with-kern : context -> math-class -> string -> kernf -> kernf
6965/// -> math-boxes` (vminst.ml:390).
6966fn prim_math_char_with_kern_v01(
6967    interp: &mut Interp,
6968    mut args: Vec<Value>,
6969) -> Result<Value, EvalError> {
6970    let kern_r = args.pop().unwrap();
6971    let kern_l = args.pop().unwrap();
6972    let s = as_str(args.pop().unwrap())?;
6973    let class = as_math_kind(args.pop().unwrap())?;
6974    let _ctx = as_context(args.pop().unwrap())?;
6975    let _ = interp;
6976    Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
6977        class,
6978        big: false,
6979        chars: s,
6980        kern_l: Box::new(kern_l),
6981        kern_r: Box::new(kern_r),
6982    })))
6983}
6984
6985fn prim_math_big_char_with_kern_v006(
6986    interp: &mut Interp,
6987    mut args: Vec<Value>,
6988) -> Result<Value, EvalError> {
6989    let kern_r = args.pop().unwrap();
6990    let kern_l = args.pop().unwrap();
6991    let s = as_str(args.pop().unwrap())?;
6992    let class = as_math_kind(args.pop().unwrap())?;
6993    let _ = interp;
6994    Ok(single_math(Math::Pure(MathElement::CharWithKern {
6995        class,
6996        big: true,
6997        chars: s,
6998        kern_l: Box::new(kern_l),
6999        kern_r: Box::new(kern_r),
7000    })))
7001}
7002
7003/// `math-big-char-with-kern : context -> math-class -> string -> kernf ->
7004/// kernf -> math-boxes` (vminst.ml:411) — same fork as
7005/// `math-char-with-kern`.
7006fn prim_math_big_char_with_kern_v01(
7007    interp: &mut Interp,
7008    mut args: Vec<Value>,
7009) -> Result<Value, EvalError> {
7010    let kern_r = args.pop().unwrap();
7011    let kern_l = args.pop().unwrap();
7012    let s = as_str(args.pop().unwrap())?;
7013    let class = as_math_kind(args.pop().unwrap())?;
7014    let _ctx = as_context(args.pop().unwrap())?;
7015    let _ = interp;
7016    Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
7017        class,
7018        big: true,
7019        chars: s,
7020        kern_l: Box::new(kern_l),
7021        kern_r: Box::new(kern_r),
7022    })))
7023}
7024
7025/// `math-concat : math -> math -> math` (vminst.ml:193) — FAITHFUL: a plain
7026/// list append (`math` is always a flat sequence of atoms; see `value.rs`'s
7027/// `Value::Math` doc comment).
7028fn prim_math_concat_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7029    let m2 = args.pop().unwrap();
7030    let m1 = args.pop().unwrap();
7031    let m1 = as_math(interp, m1)?;
7032    let m2 = as_math(interp, m2)?;
7033    let mut out = (*m1).clone();
7034    out.extend((*m2).iter().cloned());
7035    Ok(Value::Math(Rc::new(out)))
7036}
7037
7038/// `math-concat : math-boxes -> math-boxes -> math-boxes` (vminst.ml:181).
7039fn prim_math_concat_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7040    let m2 = as_math_boxes(args.pop().unwrap())?;
7041    let m1 = as_math_boxes(args.pop().unwrap())?;
7042    let mut out = (*m1).clone();
7043    out.extend((*m2).iter().cloned());
7044    Ok(Value::MathBoxes(Rc::new(out)))
7045}
7046
7047fn prim_math_group_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7048    let m = args.pop().unwrap();
7049    let cls2 = as_math_kind(args.pop().unwrap())?;
7050    let cls1 = as_math_kind(args.pop().unwrap())?;
7051    let inner = as_math(interp, m)?;
7052    Ok(single_math(Math::Group(cls1, cls2, (*inner).clone())))
7053}
7054
7055/// `math-group : math-class -> math-class -> math-boxes -> math-boxes`
7056/// (vminst.ml:194).
7057fn prim_math_group_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7058    let m = as_math_boxes(args.pop().unwrap())?;
7059    let cls2 = as_math_kind(args.pop().unwrap())?;
7060    let cls1 = as_math_kind(args.pop().unwrap())?;
7061    Ok(single_math_boxes(Math::Group(cls1, cls2, (*m).clone())))
7062}
7063
7064fn prim_math_sup_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7065    let m2 = args.pop().unwrap();
7066    let m1 = args.pop().unwrap();
7067    let base = as_math(interp, m1)?;
7068    let script = as_math(interp, m2)?;
7069    Ok(single_math(Math::Sup((*base).clone(), (*script).clone())))
7070}
7071
7072/// `math-sup : context -> math-boxes -> (context -> math-boxes) ->
7073/// math-boxes` (vminst.ml:208) — the script argument is a context-taking
7074/// callback, run under `enter_script`.
7075fn prim_math_sup_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7076    let f = args.pop().unwrap();
7077    let base_v = args.pop().unwrap();
7078    let ctx = as_context(args.pop().unwrap())?;
7079    let base = as_math_boxes(base_v)?;
7080    let script_ctx = enter_script(interp, &ctx);
7081    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
7082    let script = as_math_boxes(script_v)?;
7083    Ok(single_math_boxes(Math::Sup(
7084        (*base).clone(),
7085        (*script).clone(),
7086    )))
7087}
7088
7089fn prim_math_sub_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7090    let m2 = args.pop().unwrap();
7091    let m1 = args.pop().unwrap();
7092    let base = as_math(interp, m1)?;
7093    let script = as_math(interp, m2)?;
7094    Ok(single_math(Math::Sub((*base).clone(), (*script).clone())))
7095}
7096
7097/// `math-sub : context -> math-boxes -> (context -> math-boxes) ->
7098/// math-boxes` (vminst.ml:228) — same shape as `math-sup`.
7099fn prim_math_sub_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7100    let f = args.pop().unwrap();
7101    let base_v = args.pop().unwrap();
7102    let ctx = as_context(args.pop().unwrap())?;
7103    let base = as_math_boxes(base_v)?;
7104    let script_ctx = enter_script(interp, &ctx);
7105    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
7106    let script = as_math_boxes(script_v)?;
7107    Ok(single_math_boxes(Math::Sub(
7108        (*base).clone(),
7109        (*script).clone(),
7110    )))
7111}
7112
7113fn prim_math_frac_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7114    let m2 = args.pop().unwrap();
7115    let m1 = args.pop().unwrap();
7116    let num = as_math(interp, m1)?;
7117    let den = as_math(interp, m2)?;
7118    Ok(single_math(Math::Fraction((*num).clone(), (*den).clone())))
7119}
7120
7121/// `math-frac : context -> math-boxes -> math-boxes -> math-boxes`
7122/// (vminst.ml:248).
7123fn prim_math_frac_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7124    let m2 = as_math_boxes(args.pop().unwrap())?;
7125    let m1 = as_math_boxes(args.pop().unwrap())?;
7126    let _ctx = as_context(args.pop().unwrap())?;
7127    Ok(single_math_boxes(Math::Fraction(
7128        (*m1).clone(),
7129        (*m2).clone(),
7130    )))
7131}
7132
7133/// `math-radical : math option -> math -> math` (vminst.ml:274) — `None`
7134/// degree is `\sqrt`; upstream's `MathRadicalWithDegree` (`\sqrt[n]`) is
7135/// unimplemented too (`math.ml:886`), carried faithfully but not rendered
7136/// specially, matching upstream by parity (see `value.rs`'s `Math::Radical`).
7137fn prim_math_radical_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7138    let m2 = args.pop().unwrap();
7139    let opt = args.pop().unwrap();
7140    let radicand = as_math(interp, m2)?;
7141    let degree = match opt {
7142        Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
7143        Value::Ctor(name, Some(payload)) if name == "Some" => {
7144            Some((*as_math(interp, *payload)?).clone())
7145        }
7146        other => {
7147            return eval_error(format!(
7148                "expected a math option (None/Some), got {}",
7149                other.type_name()
7150            ))
7151        }
7152    };
7153    Ok(single_math(Math::Radical(degree, (*radicand).clone())))
7154}
7155
7156/// `math-radical : context -> option math-boxes -> math-boxes ->
7157/// math-boxes` (vminst.ml:262).
7158fn prim_math_radical_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7159    let m2 = args.pop().unwrap();
7160    let opt = args.pop().unwrap();
7161    let _ctx = as_context(args.pop().unwrap())?;
7162    let radicand = as_math_boxes(m2)?;
7163    let degree = match opt {
7164        Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
7165        Value::Ctor(name, Some(payload)) if name == "Some" => {
7166            Some((*as_math_boxes(*payload)?).clone())
7167        }
7168        other => {
7169            return eval_error(format!(
7170                "expected a math-boxes option (None/Some), got {}",
7171                other.type_name()
7172            ))
7173        }
7174    };
7175    Ok(single_math_boxes(Math::Radical(
7176        degree,
7177        (*radicand).clone(),
7178    )))
7179}
7180
7181fn prim_math_lower_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7182    let m2 = args.pop().unwrap();
7183    let m1 = args.pop().unwrap();
7184    let base = as_math(interp, m1)?;
7185    let lower = as_math(interp, m2)?;
7186    Ok(single_math(Math::LowerLimit(
7187        (*base).clone(),
7188        (*lower).clone(),
7189    )))
7190}
7191
7192/// `math-lower : context -> math-boxes -> (context -> math-boxes) ->
7193/// math-boxes` (vminst.ml:338) — same script-callback shape as `math-sup`.
7194fn prim_math_lower_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7195    let f = args.pop().unwrap();
7196    let base_v = args.pop().unwrap();
7197    let ctx = as_context(args.pop().unwrap())?;
7198    let base = as_math_boxes(base_v)?;
7199    let script_ctx = enter_script(interp, &ctx);
7200    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
7201    let lower = as_math_boxes(script_v)?;
7202    Ok(single_math_boxes(Math::LowerLimit(
7203        (*base).clone(),
7204        (*lower).clone(),
7205    )))
7206}
7207
7208fn prim_math_upper_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7209    let m2 = args.pop().unwrap();
7210    let m1 = args.pop().unwrap();
7211    let base = as_math(interp, m1)?;
7212    let upper = as_math(interp, m2)?;
7213    Ok(single_math(Math::UpperLimit(
7214        (*base).clone(),
7215        (*upper).clone(),
7216    )))
7217}
7218
7219/// `math-upper : context -> math-boxes -> (context -> math-boxes) ->
7220/// math-boxes` (vminst.ml:318) — same script-callback shape as `math-sup`.
7221fn prim_math_upper_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7222    let f = args.pop().unwrap();
7223    let base_v = args.pop().unwrap();
7224    let ctx = as_context(args.pop().unwrap())?;
7225    let base = as_math_boxes(base_v)?;
7226    let script_ctx = enter_script(interp, &ctx);
7227    let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
7228    let upper = as_math_boxes(script_v)?;
7229    Ok(single_math_boxes(Math::UpperLimit(
7230        (*base).clone(),
7231        (*upper).clone(),
7232    )))
7233}
7234
7235/// `math-pull-in-scripts : math-class -> math-class -> (math option -> math
7236/// option -> math) -> math` (vminst.ml:368) — FAITHFUL construction: the
7237/// resolver closure is stored opaquely here, only ever invoked by
7238/// `layout_pull_in_scripts` — with
7239/// the subscript/superscript actually pulled in off an enclosing `Sub`/`Sup`
7240/// (`{scripts} m^{sup}`-style), or with `(None, None)` for the common
7241/// unscripted case (a bare `\sum`/`\int` with nothing pulled in).
7242fn prim_math_pull_in_scripts(
7243    interp: &mut Interp,
7244    mut args: Vec<Value>,
7245) -> Result<Value, EvalError> {
7246    let resolver = args.pop().unwrap();
7247    let cls2 = as_math_kind(args.pop().unwrap())?;
7248    let cls1 = as_math_kind(args.pop().unwrap())?;
7249    let _ = interp;
7250    Ok(single_math(Math::PullInScripts(
7251        cls1,
7252        cls2,
7253        Box::new(resolver),
7254    )))
7255}
7256
7257fn prim_math_color(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7258    let m = args.pop().unwrap();
7259    let color = as_color(args.pop().unwrap())?;
7260    let inner = as_math(interp, m)?;
7261    Ok(single_math(Math::ChangeColor(color, (*inner).clone())))
7262}
7263
7264fn prim_math_char_class(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7265    let m = args.pop().unwrap();
7266    let cls = as_math_char_class(args.pop().unwrap())?;
7267    let inner = as_math(interp, m)?;
7268    Ok(single_math(Math::ChangeCharClass(cls, (*inner).clone())))
7269}
7270
7271fn prim_math_variant_char(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7272    let style = as_math_variant_style(args.pop().unwrap())?;
7273    let class = as_math_kind(args.pop().unwrap())?;
7274    let _ = interp;
7275    Ok(single_math(Math::Pure(MathElement::VariantChar {
7276        class,
7277        big: false,
7278        style: Box::new(style),
7279    })))
7280}
7281
7282fn prim_math_paren_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7283    let m = args.pop().unwrap();
7284    let paren_r = args.pop().unwrap();
7285    let paren_l = args.pop().unwrap();
7286    let inner = as_math(interp, m)?;
7287    Ok(single_math(Math::Paren(
7288        Box::new(paren_l),
7289        Box::new(paren_r),
7290        (*inner).clone(),
7291    )))
7292}
7293
7294/// `math-paren : context -> paren -> paren -> math-boxes -> math-boxes`
7295/// (vminst.ml:279).
7296fn prim_math_paren_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7297    let m = args.pop().unwrap();
7298    let paren_r = args.pop().unwrap();
7299    let paren_l = args.pop().unwrap();
7300    let _ctx = as_context(args.pop().unwrap())?;
7301    let inner = as_math_boxes(m)?;
7302    Ok(single_math_boxes(Math::Paren(
7303        Box::new(paren_l),
7304        Box::new(paren_r),
7305        (*inner).clone(),
7306    )))
7307}
7308
7309fn prim_math_paren_with_middle_v006(
7310    interp: &mut Interp,
7311    mut args: Vec<Value>,
7312) -> Result<Value, EvalError> {
7313    let mlst = args.pop().unwrap();
7314    let middle = args.pop().unwrap();
7315    let paren_r = args.pop().unwrap();
7316    let paren_l = args.pop().unwrap();
7317    let items = as_list(mlst)?;
7318    let mut mlstlst = Vec::with_capacity(items.len());
7319    for it in items {
7320        mlstlst.push((*as_math(interp, it)?).clone());
7321    }
7322    Ok(single_math(Math::ParenWithMiddle(
7323        Box::new(paren_l),
7324        Box::new(paren_r),
7325        Box::new(middle),
7326        mlstlst,
7327    )))
7328}
7329
7330/// `math-paren-with-middle : context -> paren -> paren -> paren -> list
7331/// math-boxes -> math-boxes` (vminst.ml:297).
7332fn prim_math_paren_with_middle_v01(
7333    _interp: &mut Interp,
7334    mut args: Vec<Value>,
7335) -> Result<Value, EvalError> {
7336    let mlst = args.pop().unwrap();
7337    let middle = args.pop().unwrap();
7338    let paren_r = args.pop().unwrap();
7339    let paren_l = args.pop().unwrap();
7340    let _ctx = as_context(args.pop().unwrap())?;
7341    let items = as_list(mlst)?;
7342    let mut mlstlst = Vec::with_capacity(items.len());
7343    for it in items {
7344        mlstlst.push((*as_math_boxes(it)?).clone());
7345    }
7346    Ok(single_math_boxes(Math::ParenWithMiddle(
7347        Box::new(paren_l),
7348        Box::new(paren_r),
7349        Box::new(middle),
7350        mlstlst,
7351    )))
7352}
7353
7354fn prim_text_in_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7355    let body = args.pop().unwrap();
7356    let class = as_math_kind(args.pop().unwrap())?;
7357    let _ = interp;
7358    Ok(single_math(Math::Pure(MathElement::EmbeddedText {
7359        class,
7360        body: Box::new(body),
7361    })))
7362}
7363
7364/// `convert-string-for-math : context -> math-char-class -> string ->
7365/// string` (`vminstdef.yaml` `PrimitiveConvertStringForMath`). Faithful to
7366/// upstream: it overrides the context's `math_char_class` with the passed
7367/// `mccls`, then runs `MathContext.convert_math_variant_char`
7368/// (`types.cppo.ml:1602`) over the whole string —
7369///  1. if the WHOLE string is a key of the (token-level) `math_class_map`
7370///     (`default_math_class_map`, e.g. `"-"` → `"−"` U+2212), return its
7371///     replacement codepoints; else
7372///  2. remap each char via the runtime `math_variant_char_map`
7373///     (`set-math-variant-char` overrides, keyed by `(char, mccls)`) first,
7374///     then the built-in `default_math_variant_char` table (the
7375///     Mathematical-Alphanumeric-Symbols remap), keeping any char with no
7376///     mapping.
7377/// Unlike the *rendering*-path `resolve_variant_char`, this string primitive
7378/// does NOT gate on font glyph availability (upstream's
7379/// `convert_math_variant_char` never does — it returns codepoints, not
7380/// glyphs), so `abc` under `MathItalic` yields U+1D44E/44F/450 regardless of
7381/// the active font.
7382fn prim_convert_string_for_math(
7383    _interp: &mut Interp,
7384    mut args: Vec<Value>,
7385) -> Result<Value, EvalError> {
7386    let s = as_str(args.pop().unwrap())?;
7387    let class = as_math_char_class(args.pop().unwrap())?;
7388    let ctx = as_context(args.pop().unwrap())?;
7389    // (1) whole-token class-map hit -> its replacement codepoints verbatim.
7390    if let Some((target, _mk)) = ctx.math_class_map.get(&s) {
7391        return Ok(Value::Str(target.clone()));
7392    }
7393    // (2) per-char variant remap under the PASSED class (which upstream
7394    // installs as the effective `math_char_class` before converting).
7395    let mut out = String::with_capacity(s.len());
7396    for ch in s.chars() {
7397        let mapped = ctx
7398            .math_variant_char_map
7399            .get(&(ch, class))
7400            .copied()
7401            .or_else(|| default_math_variant_char(class, ch))
7402            .unwrap_or(ch);
7403        out.push(mapped);
7404    }
7405    Ok(Value::Str(out))
7406}
7407
7408/// `set-math-variant-char : math-char-class -> int -> int -> context ->
7409/// context` — FAITHFUL: installs a per-`(source char, style)`
7410/// override into `Context::math_variant_char_map`, consulted by
7411/// `resolve_variant_char` BEFORE the built-in `default_math_variant_char`
7412/// table. `Arc::make_mut` copy-on-writes the map so contexts that never
7413/// call this keep sharing one `Arc`-refcounted empty table.
7414fn prim_set_math_variant_char_v006(
7415    _interp: &mut Interp,
7416    mut args: Vec<Value>,
7417) -> Result<Value, EvalError> {
7418    let mut ctx = as_context(args.pop().unwrap())?;
7419    let cpto = as_int(args.pop().unwrap())?;
7420    let cpfrom = as_int(args.pop().unwrap())?;
7421    let cls = as_math_char_class(args.pop().unwrap())?;
7422    let from = u32::try_from(cpfrom)
7423        .ok()
7424        .and_then(char::from_u32)
7425        .ok_or_else(|| EvalError {
7426            span: None,
7427            msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
7428        })?;
7429    let to = u32::try_from(cpto)
7430        .ok()
7431        .and_then(char::from_u32)
7432        .ok_or_else(|| EvalError {
7433            span: None,
7434            msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
7435        })?;
7436    Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
7437    Ok(Value::Context(Box::new(ctx)))
7438}
7439
7440/// `set-math-variant-char : int -> (math-char-class -> int) -> context ->
7441/// context` (vminst.ml:36) — the v01 body applies the selector once per
7442/// each of the 9 `MathCharClass` values and inserts into `math_variant_
7443/// char_map` (an eager materialization of upstream's stored selector
7444/// closure; the observable map is the same either way).
7445fn prim_set_math_variant_char_v01(
7446    interp: &mut Interp,
7447    mut args: Vec<Value>,
7448) -> Result<Value, EvalError> {
7449    let mut ctx = as_context(args.pop().unwrap())?;
7450    let selector = args.pop().unwrap();
7451    let cpfrom = as_int(args.pop().unwrap())?;
7452    let from = u32::try_from(cpfrom)
7453        .ok()
7454        .and_then(char::from_u32)
7455        .ok_or_else(|| EvalError {
7456            span: None,
7457            msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
7458        })?;
7459    const CLASSES: [MathCharClass; 9] = [
7460        MathCharClass::Italic,
7461        MathCharClass::BoldItalic,
7462        MathCharClass::Roman,
7463        MathCharClass::BoldRoman,
7464        MathCharClass::Script,
7465        MathCharClass::BoldScript,
7466        MathCharClass::Fraktur,
7467        MathCharClass::BoldFraktur,
7468        MathCharClass::DoubleStruck,
7469    ];
7470    for cls in CLASSES {
7471        let cpto_v = interp.apply(selector.clone(), math_char_class_value(cls))?;
7472        let cpto = as_int(cpto_v)?;
7473        let to = u32::try_from(cpto)
7474            .ok()
7475            .and_then(char::from_u32)
7476            .ok_or_else(|| EvalError {
7477                span: None,
7478                msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
7479            })?;
7480        Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
7481    }
7482    Ok(Value::Context(Box::new(ctx)))
7483}
7484
7485/// The `MathKind` one `MathElement` atom presents as its own boundary class
7486/// — `Char`/`CharWithKern`/`EmbeddedText`/`VariantChar` carry an explicit
7487/// `class` field; `VariantCharPending` (not yet resolved to a class
7488/// at this point in the tree) consults `ctx.math_class_map` the same way
7489/// `layout_math_atom`'s own arm does, defaulting to `Ord` when the token
7490/// isn't a whole-token class-map entry (mirrors `layout_math_atom`'s
7491/// fallback path, whose per-char variant remap never changes the class).
7492fn math_element_kind(ctx: &Context, me: &MathElement) -> MathKind {
7493    match me {
7494        MathElement::Char { class, .. }
7495        | MathElement::CharWithKern { class, .. }
7496        | MathElement::EmbeddedText { class, .. }
7497        | MathElement::VariantChar { class, .. }
7498        | MathElement::EmbeddedBoxes { class, .. } => *class,
7499        MathElement::VariantCharPending(s) => ctx
7500            .math_class_map
7501            .get(s.as_str())
7502            .map(|(_, kind)| *kind)
7503            .unwrap_or(MathKind::Ord),
7504    }
7505}
7506
7507/// Upstream `get_left_math_kind`/`get_right_math_kind` (math.ml:481-524),
7508/// fused into one direction-parameterized walk over a `&[Math]` list's
7509/// FIRST (`left = true`) or LAST (`left = false`) element: `Pure` atoms
7510/// report their own class (`math_element_kind`); `Group`/`PullInScripts`
7511/// present an explicit, possibly-asymmetric left/right pair; `Sup`/`Sub`/
7512/// `UpperLimit`/`LowerLimit` recurse into their `base`; `Fraction`/
7513/// `Radical` are always `Inner`; `Paren`/`ParenWithMiddle` are always
7514/// `Open`/`Close`; `ChangeColor`/`ChangeCharClass` recurse into `inner`; an
7515/// empty list is the synthetic `End` boundary sentinel (`MathKind::End`,
7516/// `horzBox.ml:134`) — `make_math_class_option_value` maps that to `None`,
7517/// same as upstream's own list-boundary handling.
7518fn boundary_math_kind(ctx: &Context, ms: &[Math], left: bool) -> MathKind {
7519    let m = if left { ms.first() } else { ms.last() };
7520    let Some(m) = m else {
7521        return MathKind::End;
7522    };
7523    match m {
7524        Math::Pure(me) => math_element_kind(ctx, me),
7525        Math::Group(cls1, cls2, _) => {
7526            if left {
7527                *cls1
7528            } else {
7529                *cls2
7530            }
7531        }
7532        Math::PullInScripts(cls1, cls2, _) => {
7533            if left {
7534                *cls1
7535            } else {
7536                *cls2
7537            }
7538        }
7539        Math::Sup(base, _)
7540        | Math::Sub(base, _)
7541        | Math::UpperLimit(base, _)
7542        | Math::LowerLimit(base, _) => boundary_math_kind(ctx, base, left),
7543        Math::Fraction(..) | Math::Radical(..) => MathKind::Inner,
7544        Math::Paren(..) | Math::ParenWithMiddle(..) => {
7545            if left {
7546                MathKind::Open
7547            } else {
7548                MathKind::Close
7549            }
7550        }
7551        Math::ChangeColor(_, inner) | Math::ChangeCharClass(_, inner) => {
7552            boundary_math_kind(ctx, inner, left)
7553        }
7554        // V0_1 only (`read-math`): the boundary class is a property of the
7555        // wrapped content, not of which context laid it out under, so
7556        // recurse into `inner` with the SAME probing `ctx` (mirrors the
7557        // `ChangeColor`/`ChangeCharClass` arms above, which also recurse
7558        // with the ambient `ctx` rather than switching to their own stored
7559        // state).
7560        Math::WithContext(_, inner) => boundary_math_kind(ctx, inner, left),
7561    }
7562}
7563
7564fn left_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7565    boundary_math_kind(ctx, ms, true)
7566}
7567
7568fn right_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7569    boundary_math_kind(ctx, ms, false)
7570}
7571
7572/// `math-class option` — `MathKind::End` (the empty-list sentinel) becomes
7573/// `None`; every real class becomes `Some(<ctor>)`, round-tripping exactly
7574/// with `as_math_kind`'s ctor names.
7575fn make_math_class_option_value(mk: MathKind) -> Value {
7576    let name = match mk {
7577        MathKind::Ord => "MathOrd",
7578        MathKind::Bin => "MathBin",
7579        MathKind::Rel => "MathRel",
7580        MathKind::Op => "MathOp",
7581        MathKind::Punct => "MathPunct",
7582        MathKind::Open => "MathOpen",
7583        MathKind::Close => "MathClose",
7584        MathKind::Prefix => "MathPrefix",
7585        MathKind::Inner => "MathInner",
7586        MathKind::End => return Value::Ctor("None".to_string(), None),
7587    };
7588    Value::Ctor(
7589        "Some".to_string(),
7590        Some(Box::new(Value::Ctor(name.to_string(), None))),
7591    )
7592}
7593
7594/// `get-left-math-class : context -> math -> math-class option`.
7595fn prim_get_left_math_class_v006(
7596    interp: &mut Interp,
7597    mut args: Vec<Value>,
7598) -> Result<Value, EvalError> {
7599    let m = as_math(interp, args.pop().unwrap())?;
7600    let ctx = as_context(args.pop().unwrap())?;
7601    Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7602}
7603
7604/// `get-left-math-class : math-boxes -> math-class option` (vminst.ml:128)
7605/// — ctx DROPPED (matches upstream, which takes no context at all here).
7606/// The boundary-class probe still needs SOME `Context` to resolve an
7607/// unresolved `VariantCharPending` token's whole-token class map
7608/// (`math_element_kind`) — this port's own deferred-resolution design, not
7609/// upstream's, since upstream's `math` atoms already carry a resolved
7610/// class — so a bare default context stands in.
7611fn prim_get_left_math_class_v01(
7612    _interp: &mut Interp,
7613    mut args: Vec<Value>,
7614) -> Result<Value, EvalError> {
7615    let m = as_math_boxes(args.pop().unwrap())?;
7616    let ctx = Context::initial(Length::ZERO);
7617    Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7618}
7619
7620/// `get-right-math-class : context -> math -> math-class option`.
7621fn prim_get_right_math_class_v006(
7622    interp: &mut Interp,
7623    mut args: Vec<Value>,
7624) -> Result<Value, EvalError> {
7625    let m = as_math(interp, args.pop().unwrap())?;
7626    let ctx = as_context(args.pop().unwrap())?;
7627    Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7628}
7629
7630/// `get-right-math-class : math-boxes -> math-class option` (vminst.ml:146)
7631/// — same fork as `get-left-math-class`.
7632fn prim_get_right_math_class_v01(
7633    _interp: &mut Interp,
7634    mut args: Vec<Value>,
7635) -> Result<Value, EvalError> {
7636    let m = as_math_boxes(args.pop().unwrap())?;
7637    let ctx = Context::initial(Length::ZERO);
7638    Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7639}
7640
7641/// `set-math-command : [math] inline-cmd -> context -> context`
7642/// FAITHFUL: installs the command `read_inline`'s `EmbedMath` arm applies
7643/// to bare `${…}`.
7644fn prim_set_math_command(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7645    let mut ctx = as_context(args.pop().unwrap())?;
7646    let cmd = args.pop().unwrap();
7647    ctx.math_command = Some(interp.register_math_command(cmd));
7648    Ok(Value::Context(Box::new(ctx)))
7649}
7650
7651/// Resolve a font abbrev to one of the 3 base faces by name heuristic — the
7652/// only font-name resolution this port has. Shared by set-font/set-math-font.
7653fn resolve_font_abbrev(abbrev: &str) -> FontKey {
7654    let lower = abbrev.to_ascii_lowercase();
7655    if lower.contains("bold") {
7656        FONT_BOLD
7657    } else if lower.contains("it") || lower.contains("obl") || lower.contains("slant") {
7658        FONT_OBLIQUE
7659    } else {
7660        FONT_REGULAR
7661    }
7662}
7663
7664/// `set-math-font : string -> context -> context` (0.0.6
7665/// `vminstdef.yaml:1364`) — `abbrev` resolves through the font metrics
7666/// provider's registry first (the same upgrade as `set-font`), falling back
7667/// to the 3-face name heuristic, so a math OTF configured under
7668/// any abbrev (not just the CLI regular face) can be selected.
7669fn prim_set_math_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7670    let ctx = as_context(args.pop().unwrap())?;
7671    let abbrev = as_str(args.pop().unwrap())?;
7672    let math_font = interp
7673        .metrics
7674        .resolve_font_abbrev(&abbrev)
7675        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7676    Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7677}
7678
7679/// `set-math-font : font -> context -> context` (saphe-split
7680/// `tools/gencode/vminst.ml:1462`, whose body is
7681/// `ctx with math_font_key = Some(mathkey)`) — the 0.1 arm takes the opaque
7682/// handle, so there is no abbrev left to resolve. The bundled 0.1 corpus
7683/// already calls it that way (`std-ja.satyh`'s `set-math-font
7684/// FontLatinModernMath.main`).
7685fn prim_set_math_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7686    let ctx = as_context(args.pop().unwrap())?;
7687    let math_font = as_font_key(args.pop().unwrap())?;
7688    Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7689}
7690
7691/// `load-single-font : string -> font` — LOCAL, non-upstream, V0_1-only.
7692///
7693/// Upstream has no surface name for this: `envelopeChecker.ml`'s
7694/// `check_font_envelope` synthesizes one binding per `files[]` row of a font
7695/// ENVELOPE, typed `BaseType(FontType)`, whose right-hand side is the
7696/// internal `LoadSingleFont{ path; used_as_math_font }` node — and
7697/// `evaluator.cppo.ml:427-434` evaluates that to `BaseConstant(BCFontKey
7698/// (FontInfo.add_single path))`. This port's bundled 0.1 font envelopes are
7699/// ordinary `.satyh` stand-ins (`dist-v01/packages/font-*.satyh`) rather
7700/// than envelopes the loader synthesizes bindings from, so they need a
7701/// spelling for the same step; this is it. Same LOCAL-primitive precedent as
7702/// `set-font-key`.
7703///
7704/// The argument stands in for upstream's font-file PATH: it is the port's
7705/// font-store key, resolved here through exactly the ladder `set-font` used
7706/// to run per call — the metrics provider's registry
7707/// (`FontMetrics::resolve_font_abbrev`, a real `TtfFontStore` built from
7708/// `fonts.satysfi-hash`), falling back to the 3-face name heuristic. Doing
7709/// it HERE rather than at `set-font` time is what makes the resulting `font`
7710/// a genuine handle: resolution is a pure function of the abbrev and the
7711/// provider (`&self`, no interior mutation), so moving it earlier is
7712/// observationally identical.
7713fn prim_load_single_font(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7714    let abbrev = as_str(args.pop().unwrap())?;
7715    let key = interp
7716        .metrics
7717        .resolve_font_abbrev(&abbrev)
7718        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7719    Ok(Value::Font(key))
7720}
7721
7722/// `space-between-maths : context -> math -> math -> inline-boxes option`
7723/// (vminst.ml:173) — STAND-IN: the real inter-atom glue is the full
7724/// `space_between_math_kinds` table (`math.ml:319-410`); always returns
7725/// `None` (no extra glue), used by `math.satyh`'s
7726/// `+align` — never invoked eagerly (that binding is a `let-block` closure).
7727fn prim_space_between_maths_v006(
7728    _interp: &mut Interp,
7729    mut args: Vec<Value>,
7730) -> Result<Value, EvalError> {
7731    let _m2 = args.pop().unwrap();
7732    let _m1 = args.pop().unwrap();
7733    let _ctx = as_context(args.pop().unwrap())?;
7734    Ok(Value::Ctor("None".to_string(), None))
7735}
7736
7737/// `space-between-maths : context -> math-boxes -> math-boxes -> inline-
7738/// boxes option` (vminst.ml:164) — shared STAND-IN body, only the extractor
7739/// forks (`as_math_boxes` vs `as_math`).
7740fn prim_space_between_maths_v01(
7741    _interp: &mut Interp,
7742    mut args: Vec<Value>,
7743) -> Result<Value, EvalError> {
7744    let _m2 = as_math_boxes(args.pop().unwrap())?;
7745    let _m1 = as_math_boxes(args.pop().unwrap())?;
7746    let _ctx = as_context(args.pop().unwrap())?;
7747    Ok(Value::Ctor("None".to_string(), None))
7748}
7749
7750/// `raise-inline : length -> inline-boxes -> inline-boxes` — STAND-IN: the
7751/// line model has no per-box vertical-offset wrapper outside
7752/// `PureHorzBox::Math`'s own per-glyph `dy` ("structural difference"
7753/// note); returns the boxes unshifted (used by `math.satyh`'s `\cases`,
7754/// never invoked eagerly).
7755fn prim_raise_inline(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7756    let ib = as_inline_boxes(args.pop().unwrap())?;
7757    let _len = as_length(args.pop().unwrap())?;
7758    Ok(Value::InlineBoxes(ib))
7759}
7760
7761/// `embed-block-breakable : context -> block-boxes -> inline-boxes`
7762/// (vminst.ml:973; upstream `HorzEmbeddedVertBreakable`) — a MANDATORY
7763/// break on both sides: upstream's `LBEmbeddedVertBreakable` resets the
7764/// width map to this breakpoint alone (`lineBreak.ml:1076-1087`), flushes
7765/// the accumulated line, emits the block as its own vertical item, then
7766/// starts a fresh line (`lineBreak.ml:809-818`).
7767///
7768/// Modelled here as a forced `Discretionary` either side of the block —
7769/// without them the block was just an inline box, so latexcmds'
7770/// `\linebreak` (`inline-fil ++ embed-block-breakable ctx (block-skip
7771/// gap)`, `latexcmds.satyh:150`) never broke: the `inline-fil` swallowed
7772/// the line's whole slack and shoved everything after it off the page
7773/// edge, silently losing it (`このように`/`使い`/`すぎると`/`読みにくく`
7774/// all vanished from the render).
7775fn prim_embed_block_breakable(
7776    _interp: &mut Interp,
7777    mut args: Vec<Value>,
7778) -> Result<Value, EvalError> {
7779    let bb = as_block_boxes(args.pop().unwrap())?;
7780    let ctx = as_context(args.pop().unwrap())?;
7781    // Embed the block inline, top-anchored (the block's FIRST line sits on the
7782    // surrounding text baseline — same as `embed-block-top`).
7783    // `make_embedded_block` splits the box's height/depth around the first line
7784    // so the pager accounts for the embedded figure's extent.
7785    let block = match make_embedded_block(ctx.paragraph_width, bb, false, true) {
7786        Value::InlineBoxes(boxes) => boxes,
7787        other => return Ok(other),
7788    };
7789    let forced = || {
7790        HorzBox::Pure(PureHorzBox::Discretionary {
7791            penalty: FORCED_BREAK_PENALTY,
7792            pre_break: Vec::new(),
7793            post_break: Vec::new(),
7794            no_break: Vec::new(),
7795        })
7796    };
7797    let mut out = vec![forced()];
7798    out.extend(block);
7799    out.push(forced());
7800    Ok(Value::InlineBoxes(out))
7801}
7802
7803/// `unite-path : path -> path -> path` — FAITHFUL: `path` is upstream's
7804/// `path list` (a list of independently-closed subpaths — see
7805/// `graphics.rs`'s `Path` doc comment), so uniting two is a plain
7806/// subpath-list append. Used by `math.satyh`'s `\norm` (two parallel bars).
7807fn prim_unite_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7808    let p2 = as_path(args.pop().unwrap())?;
7809    let p1 = as_path(args.pop().unwrap())?;
7810    let mut subpaths = p1.subpaths;
7811    subpaths.extend(p2.subpaths);
7812    Ok(Value::Path(Path { subpaths }))
7813}
7814
7815/// `set-min-gap-of-lines : length -> context -> context` (vminst.ml:1291) —
7816/// STAND-IN: no separate `min_gap_of_lines` field on `Context` yet (see
7817/// `set-leading`'s own comment on why IT, not this, is the baseline-distance
7818/// setter); accepted and dropped. Used by `math.satyh`'s `+math-list`, never
7819/// invoked eagerly.
7820fn prim_set_min_gap_of_lines(
7821    _interp: &mut Interp,
7822    mut args: Vec<Value>,
7823) -> Result<Value, EvalError> {
7824    let ctx = as_context(args.pop().unwrap())?;
7825    let _len = as_length(args.pop().unwrap())?;
7826    Ok(Value::Context(Box::new(ctx)))
7827}
7828
7829/// `embed-math : context -> math -> inline-boxes` (vminst.ml:520) — the
7830/// bridge to the page: the faithful, primitive-driven analog of `read_math`,
7831/// operating on a `Value::Math` tree instead. FAITHFUL for the atoms
7832/// `read_math` already draws (plain/kerned/variant chars, groups, sup/sub);
7833/// the structural forms (fraction/radical/paren/limits/pull-in-scripts/
7834/// embedded-text) get a deliberately cheap, documented stand-in rendering
7835/// rather than an error, so `${…}`-shaped math built through these
7836/// primitives is never *unusable*, just not yet typographically faithful.
7837fn prim_embed_math_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7838    let m = args.pop().unwrap();
7839    let ctx = as_context(args.pop().unwrap())?;
7840    let elems = as_math(interp, m)?;
7841    let boxed = layout_math_value(interp, &ctx, &elems)?;
7842    Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7843}
7844
7845/// `embed-math : context -> math-boxes -> inline-boxes` (vminst.ml:472) —
7846/// `as_math_boxes` then the SAME `layout_math_value` (:5165 below) — the
7847/// whole MATH-engine reuse in one primitive.
7848fn prim_embed_math_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7849    let m = args.pop().unwrap();
7850    let ctx = as_context(args.pop().unwrap())?;
7851    let elems = as_math_boxes(m)?;
7852    let boxed = layout_math_value(interp, &ctx, &elems)?;
7853    Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7854}
7855
7856/// Lay out a faithful `&[Math]` run into one `PureHorzBox::Math`, mirroring
7857/// `read_math`'s glyph-emission shape (fixed-constant super/subscript
7858/// shift/scale, the same minimal `Bin`/`Rel` spacer) but keyed on each
7859/// atom's own EXPLICIT class (from `math-char`/`math-group`/…) rather than
7860/// `ascii_math_kind`'s inference.
7861fn layout_math_value(
7862    interp: &mut Interp,
7863    ctx: &Context,
7864    elems: &[Math],
7865) -> Result<PureHorzBox, EvalError> {
7866    let (glyphs, rules, width, _left, _right) =
7867        layout_math_list(interp, ctx, elems, ctx.font_size)?;
7868    let mut height = Length::ZERO;
7869    let mut depth = Length::ZERO;
7870    for g in &glyphs {
7871        height = height.max(g.dy + g.height);
7872        depth = depth.max(g.depth - g.dy);
7873    }
7874    // A fraction bar/radical sign is a `Fill` with no `MathGlyph` backing it
7875    // at all, so the glyph-only aggregation above would silently undercount
7876    // a run whose bar/sign extends above every glyph's own ink (e.g.
7877    // `${\sqrt{2}}`'s `l_extra` ascender). Fold every rule's own (y-up,
7878    // box-local — same frame as `MathGlyph::dy`) bounding box in too.
7879    for r in &rules {
7880        // `graphics_bbox` -> `Option`; a `None` rule (unreachable here
7881        // under 0.0.6 math rules) contributes nothing.
7882        if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
7883            height = height.max(max_y);
7884            depth = depth.max(-min_y);
7885        }
7886    }
7887    Ok(PureHorzBox::Math {
7888        width,
7889        height,
7890        depth,
7891        glyphs,
7892        rules,
7893    })
7894}
7895
7896/// Lay out a flat `&[Math]` list at `size`, threading inter-atom spacing
7897/// (`space_before`, a minimal spacer) and returning the glyphs (at
7898/// LOCAL coordinates starting at `x = 0`), any graphics `rules` an atom
7899/// pushed (shifted horizontally by the same running `x` a glyph gets
7900/// — `layout_math_list` never shifts an atom vertically, only
7901/// `shift_and_append`'s callers do), the total width, and the boundary
7902/// classes on either end (needed by a `Group` ancestor, which can present
7903/// different left/right classes — see `Math::Group`'s doc comment).
7904fn layout_math_list(
7905    interp: &mut Interp,
7906    ctx: &Context,
7907    elems: &[Math],
7908    size: Length,
7909) -> Result<
7910    (
7911        Vec<MathGlyph>,
7912        Vec<GraphicsElem>,
7913        Length,
7914        MathKind,
7915        MathKind,
7916    ),
7917    EvalError,
7918> {
7919    // Lay every atom out FIRST, because `normalize_math_kind` below needs each
7920    // one's NEIGHBOURS' raw classes — upstream's `convert_to_low` passes
7921    // `mkprev`/`mknext` into `convert_to_low_single` for exactly this
7922    // (`math.ml:753-765`, via `get_right_math_kind`/`get_left_math_kind`).
7923    // The layout of an atom does not depend on its class, only the SPACING
7924    // between atoms does, so splitting the walk in two moves no glyph.
7925    let mut laid: Vec<(
7926        Vec<MathGlyph>,
7927        Vec<GraphicsElem>,
7928        Length,
7929        MathKind,
7930        MathKind,
7931    )> = Vec::with_capacity(elems.len());
7932    for atom in elems {
7933        laid.push(layout_math_atom(interp, ctx, atom, size)?);
7934    }
7935
7936    let mut glyphs = Vec::new();
7937    let mut rules = Vec::new();
7938    let mut x = Length::ZERO;
7939    let mut last_kind: Option<MathKind> = None;
7940    let mut first_kind: Option<MathKind> = None;
7941    let in_script = math_in_script(ctx, size);
7942    for (i, (atom_glyphs, atom_rules, atom_width, left_raw, right_raw)) in
7943        laid.iter().cloned().enumerate()
7944    {
7945        // `mkprev`/`mknext` are the neighbours' RAW classes (upstream never
7946        // feeds a normalized class back in), and the ends of the list are
7947        // `MathEnd` — `math.ml:1270`'s `convert_to_low mathctx MathEnd MathEnd`.
7948        let prev_raw = if i == 0 { MathKind::End } else { laid[i - 1].4 };
7949        let next_raw = laid.get(i + 1).map_or(MathKind::End, |a| a.3);
7950        let left = normalize_math_kind(prev_raw, next_raw, left_raw);
7951        let right = normalize_math_kind(prev_raw, next_raw, right_raw);
7952        if let Some(prev) = last_kind {
7953            x += space_before(prev, left, in_script, size);
7954        }
7955        first_kind.get_or_insert(left);
7956        let base_x = x;
7957        for mut g in atom_glyphs {
7958            g.dx = base_x + g.dx;
7959            glyphs.push(g);
7960        }
7961        for r in &atom_rules {
7962            rules.push(shift_graphics((base_x, Length::ZERO), r));
7963        }
7964        x = base_x + atom_width;
7965        last_kind = Some(right);
7966    }
7967    let left = first_kind.unwrap_or(MathKind::Ord);
7968    let right = last_kind.unwrap_or(MathKind::Ord);
7969    Ok((glyphs, rules, x, left, right))
7970}
7971
7972/// Upstream `check_subscript` (math.ml:682-699): if a superscript base's
7973/// LAST element is itself a `Sub`, strip it — returning `(subscript script,
7974/// new base)` where the new base is the preceding elements followed by the
7975/// inner `Sub`'s own base, so `{x_1}^2` becomes one base carrying both a
7976/// sub and a sup. Recurses through `ChangeColor`/`ChangeCharClass`.
7977fn check_subscript(base: &[Math]) -> Option<(Vec<Math>, Vec<Math>)> {
7978    let (last, head) = base.split_last()?;
7979    match last {
7980        Math::Sub(inner_base, sub_script) => {
7981            let mut new_base = head.to_vec();
7982            new_base.extend(inner_base.iter().cloned());
7983            Some((sub_script.clone(), new_base))
7984        }
7985        Math::ChangeColor(color, inner) => {
7986            let (sub_script, inner_new) = check_subscript(inner)?;
7987            let mut new_base = head.to_vec();
7988            new_base.push(Math::ChangeColor(color.clone(), inner_new));
7989            Some((vec![Math::ChangeColor(color.clone(), sub_script)], new_base))
7990        }
7991        Math::ChangeCharClass(cls, inner) => {
7992            let (sub_script, inner_new) = check_subscript(inner)?;
7993            let mut new_base = head.to_vec();
7994            new_base.push(Math::ChangeCharClass(cls.clone(), inner_new));
7995            Some((
7996                vec![Math::ChangeCharClass(cls.clone(), sub_script)],
7997                new_base,
7998            ))
7999        }
8000        _ => None,
8001    }
8002}
8003
8004/// Upstream `invoke_pull_in_scripts` (math.ml:957-966): call a
8005/// `math-pull-in-scripts` resolver with the actual pulled-in scripts —
8006/// `resolver : math option -> math option -> math`, SUBSCRIPT option first,
8007/// SUPERSCRIPT second — then splice the returned math after the remaining
8008/// base as ONE `Group(cls1, cls2, …)` atom and lay the whole list out.
8009#[allow(clippy::too_many_arguments)]
8010fn layout_pull_in_scripts(
8011    interp: &mut Interp,
8012    ctx: &Context,
8013    head: &[Math],
8014    cls1: MathKind,
8015    cls2: MathKind,
8016    resolver: &Value,
8017    sub: Option<&[Math]>,
8018    sup: Option<&[Math]>,
8019    size: Length,
8020) -> Result<
8021    (
8022        Vec<MathGlyph>,
8023        Vec<GraphicsElem>,
8024        Length,
8025        MathKind,
8026        MathKind,
8027    ),
8028    EvalError,
8029> {
8030    let opt_math = |o: Option<&[Math]>| match o {
8031        Some(m) => Value::Ctor(
8032            "Some".to_string(),
8033            Some(Box::new(Value::Math(Rc::new(m.to_vec())))),
8034        ),
8035        None => Value::Ctor("None".to_string(), None),
8036    };
8037    let partial = interp.apply(resolver.clone(), opt_math(sub))?;
8038    let result = interp.apply(partial, opt_math(sup))?;
8039    let resolved = as_math(interp, result)?;
8040    let mut items: Vec<Math> = head.to_vec();
8041    items.push(Math::Group(cls1, cls2, (*resolved).clone()));
8042    layout_math_list(interp, ctx, &items, size)
8043}
8044
8045/// The metrics-probe fallback policy: resolve `c` under `ctx`'s current
8046/// `math_char_class` (checking the runtime override map first, then the
8047/// built-in `default_math_variant_char` table), but only actually EMIT the
8048/// remapped codepoint if the current font can render it
8049/// (`interp.metrics.advance` returns `Some`) — otherwise fall back to the
8050/// source char `c` (its class, from `Context::math_class_map`/
8051/// `ascii_math_kind`-style inference, is kept regardless). This is what
8052/// keeps base-14/WinAnsi documents byte-identical (`Base14Metrics` returns
8053/// `None` outside ASCII 32-126) while a math-capable TTF, or a permissive
8054/// test stub, gets the real Mathematical-Alphanumeric glyph automatically.
8055fn resolve_variant_char(interp: &Interp, ctx: &Context, c: char, size: Length) -> char {
8056    let mapped = ctx
8057        .math_variant_char_map
8058        .get(&(c, ctx.math_char_class))
8059        .copied()
8060        .or_else(|| default_math_variant_char(ctx.math_char_class, c));
8061    match mapped {
8062        Some(m) if math_char_available(interp, ctx, m, size) => m,
8063        _ => c,
8064    }
8065}
8066
8067/// Invoke ONE `paren` closure (`math.satyh`'s `paren-left`/
8068/// `paren-right`/`abs-left`/`brace-left`/…) exactly the way upstream's
8069/// `make_paren` does (`math.ml:644-649`): 5 CURRIED args in order — inner
8070/// height `h_in` (≥0), inner depth SIGNED (≤0, hence `-d_in` — this port
8071/// carries depths as non-negative magnitudes, see this function's `d_in`
8072/// param doc below), the axis height at the local size, the local
8073/// (script-scaled) size, and the current text color — then unpack the
8074/// returned `(inline-boxes, length -> length)` 2-tuple and harvest the
8075/// boxes' glyphs/rules/width via `math_boxes_of_inline_boxes` (the
8076/// graphics-harvesting sibling of `math_glyphs_of_inline_boxes`, since a
8077/// closure's delimiter is drawn `Fill`/`Stroke` ink via `inline-graphics`,
8078/// not a font glyph). The kernf itself is returned un-invoked (callers
8079/// re-derive/discard it as `math.ml:923` does for `ParenWithMiddle`'s own
8080/// middle).
8081///
8082/// `d_in`: this port's non-negative ink-depth MAGNITUDE (`inner_ink_extent`'s
8083/// second component). Upstream's own box depths are non-positive internally
8084/// (`convert_to_low`'s `dC` folds via `Length.min`, always ≤ `Length.zero`),
8085/// and `half-length` (`math.satyh:1023-1026`) computes the below-axis need
8086/// as `hgtaxis +' dpt` on that SIGNED value — so passing the magnitude
8087/// directly would OVERSIZE every delimiter below the axis (double-counts
8088/// the depth on the wrong side). Negating here is what keeps the closure's
8089/// own arithmetic faithful without changing this port's magnitude
8090/// convention everywhere else.
8091fn make_paren_run(
8092    interp: &mut Interp,
8093    ctx: &Context,
8094    paren: &Value,
8095    h_in: Length,
8096    d_in: Length,
8097    axis: Length,
8098    size: Length,
8099) -> Result<(Vec<MathGlyph>, Vec<GraphicsElem>, Length, Value), EvalError> {
8100    let mut v = paren.clone();
8101    if interp.version.math_is_split() {
8102        // 0.1 protocol (math.ml:640-642): `paren h d ictx` — (height, SIGNED
8103        // depth, context). The closure extracts fontsize / axis-ratio (via
8104        // `get-math-axis-height-ratio`) / color FROM the context instead of
8105        // receiving them as separate explicit arguments (the 0.0.6→0.1
8106        // delta, `t_paren`'s doc comment). Upstream's `ictx` is already
8107        // scaled to the local (script-level) size at this call site; this
8108        // port threads `size` as a separate parameter, so clone-and-set —
8109        // BIGGEST RISK: forgetting this silently
8110        // oversizes script-level delimiters (the closure would read the
8111        // OUTER context's font_size instead of the local scaled one).
8112        let mut c2 = ctx.clone();
8113        c2.font_size = size;
8114        let args = [
8115            Value::Length(h_in),
8116            Value::Length(-d_in),
8117            Value::Context(Box::new(c2)),
8118        ];
8119        for a in args {
8120            v = interp.apply(v, a)?;
8121        }
8122    } else {
8123        // 0.0.6 protocol.
8124        let args = [
8125            Value::Length(h_in),
8126            Value::Length(-d_in),
8127            Value::Length(axis),
8128            Value::Length(size),
8129            make_color_value(ctx.text_color),
8130        ];
8131        for a in args {
8132            v = interp.apply(v, a)?;
8133        }
8134    }
8135    let (boxes_v, kernf) = match v {
8136        Value::Tuple(mut items) if items.len() == 2 => {
8137            let kernf = items.pop().unwrap();
8138            (items.pop().unwrap(), kernf)
8139        }
8140        other => {
8141            return eval_error(format!(
8142                "math-paren: a paren closure must return (inline-boxes, length -> length), got {}",
8143                other.type_name()
8144            ))
8145        }
8146    };
8147    let boxes = as_inline_boxes(boxes_v)?;
8148    let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
8149    Ok((glyphs, rules, width, kernf))
8150}
8151
8152/// The original MATH-native stretchy-delimiter body, extracted verbatim as
8153/// the fallback `Math::Paren`/`Math::ParenWithMiddle` now take when the
8154/// closure route (`make_paren_run`, primary — upstream-faithful) errors:
8155/// every delimiter renders as a correctly-SIZED `(`/`)`/`|` regardless of
8156/// the requested paren kind (identity-wrong, but usable, for any closure
8157/// that can't be run — a synthetic/ill-shaped test closure, or a real error
8158/// from a malformed user-supplied one).
8159fn paren_variant_fallback(
8160    interp: &mut Interp,
8161    ctx: &Context,
8162    parts: Vec<(Vec<MathGlyph>, Vec<GraphicsElem>, Length)>,
8163    h_in: Length,
8164    d_in: Length,
8165    axis: Length,
8166    size: Length,
8167) -> Result<
8168    (
8169        Vec<MathGlyph>,
8170        Vec<GraphicsElem>,
8171        Length,
8172        MathKind,
8173        MathKind,
8174    ),
8175    EvalError,
8176> {
8177    let target = (h_in - axis).max(axis + d_in) * 2.0;
8178    let mut glyphs = Vec::new();
8179    let mut rules = Vec::new();
8180    let mut x = Length::ZERO;
8181    push_delimiter_glyph(interp, ctx, '(', size, target, axis, &mut glyphs, &mut x)?;
8182    for (i, (pg, pr, pw)) in parts.into_iter().enumerate() {
8183        if i > 0 {
8184            push_delimiter_glyph(interp, ctx, '|', size, target, axis, &mut glyphs, &mut x)?;
8185        }
8186        append_at(&mut glyphs, &mut rules, &mut x, pg, pr, pw);
8187    }
8188    push_delimiter_glyph(interp, ctx, ')', size, target, axis, &mut glyphs, &mut x)?;
8189    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8190}
8191
8192/// Re-derive a paren base's TRAILING (right) delimiter's dense math
8193/// kern function by re-invoking its closure at the script-attachment site
8194/// (`superscript_kern`'s glyph-corner sampling doesn't apply to a paren
8195/// base — it has no single "last glyph" to sample italic-correction/corner
8196/// kerns off; the closure itself IS the source of truth for how much a
8197/// script should tuck into it, exactly upstream's `lp_math_kern_scheme`,
8198/// `math.ml:906`/`922`). Closures are pure (`math.satyh`'s bundled ones
8199/// have no side effects), so re-invoking with the SAME `(h_in, d_in, axis,
8200/// size)` the original `Math::Paren`/`ParenWithMiddle` layout used yields
8201/// the identical `kernf` value. Returns `None` when `base`'s last atom
8202/// isn't a paren, or when re-running its closure(s) errors (the delimiter
8203/// fallback path carries no math-kern scheme at all — `dense_kern`'s
8204/// caller then falls back to zero, matching that stand-in's own
8205/// `kerninfo _ = 0pt` shape).
8206fn paren_trailing_kernf(
8207    interp: &mut Interp,
8208    ctx: &Context,
8209    base: &[Math],
8210    size: Length,
8211) -> Option<Value> {
8212    let (r, h_in, d_in) = match base.last()? {
8213        Math::Paren(_, r, inner) => {
8214            let (g, ru, ..) = layout_math_list(interp, ctx, inner, size).ok()?;
8215            let (h, d) = inner_ink_extent(&g, &ru);
8216            (r, h, d)
8217        }
8218        Math::ParenWithMiddle(_, r, _, parts) => {
8219            let mut h = Length::ZERO;
8220            let mut d = Length::ZERO;
8221            for p in parts {
8222                let (g, ru, ..) = layout_math_list(interp, ctx, p, size).ok()?;
8223                let (ph, pd) = inner_ink_extent(&g, &ru);
8224                h = h.max(ph);
8225                d = d.max(pd);
8226            }
8227            (r, h, d)
8228        }
8229        _ => return None,
8230    };
8231    let mc = MathC::of(interp, ctx);
8232    let axis = mc.axis(size);
8233    let (_, _, _, kernf) = make_paren_run(interp, ctx, r, h_in, d_in, axis, size).ok()?;
8234    Some(kernf)
8235}
8236
8237/// `fontInfo.ml:361`'s `DenseMathKern` branch: `Length.negate (kernf
8238/// corrhgt)` — the closure returns a POSITIVE tuck amount (how far to slide
8239/// the script INTO the delimiter's hollow), and the engine negates it into
8240/// a kern (negative = closer to the previous glyph, `get_math_kern`'s own
8241/// doc comment). Any failure (wrong-shaped return, closure error) collapses
8242/// to `Length::ZERO` — no kern, not a layout error; matches
8243/// `paren_trailing_kernf`'s own `None`-on-error contract.
8244fn dense_kern(interp: &mut Interp, kernf: &Value, corrhgt: Length) -> Length {
8245    match interp.apply(kernf.clone(), Value::Length(corrhgt)) {
8246        Ok(Value::Length(l)) => -l,
8247        _ => Length::ZERO,
8248    }
8249}
8250
8251/// Lay out one `Math` atom at `size` (LOCAL coordinates, `x` starting at
8252/// 0), returning its glyphs, any graphics `rules` it pushed (only the
8253/// `Fraction`/`Radical` arms produce any; every other arm forwards its
8254/// children's), width, and left/right boundary class.
8255fn layout_math_atom(
8256    interp: &mut Interp,
8257    ctx: &Context,
8258    atom: &Math,
8259    size: Length,
8260) -> Result<
8261    (
8262        Vec<MathGlyph>,
8263        Vec<GraphicsElem>,
8264        Length,
8265        MathKind,
8266        MathKind,
8267    ),
8268    EvalError,
8269> {
8270    match atom {
8271        Math::Pure(MathElement::Char { class, big, chars })
8272        | Math::Pure(MathElement::CharWithKern {
8273            class, big, chars, ..
8274        }) => {
8275            let mut glyphs = Vec::new();
8276            let mut x = Length::ZERO;
8277            for c in chars.chars() {
8278                if *big {
8279                    push_big_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8280                } else {
8281                    push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8282                }
8283            }
8284            Ok((glyphs, Vec::new(), x, *class, *class))
8285        }
8286        Math::Pure(MathElement::VariantChar { class, style, .. }) => {
8287            // Select the target codepoints by the CURRENT restyling
8288            // (`Context::math_char_class`, set by `ChangeCharClass`'s
8289            // layout arm below) rather than always `style.italic` — these
8290            // are explicit per-style codepoints the caller built
8291            // (`math-variant-char`), so no metrics-probe fallback (unlike
8292            // `resolve_variant_char`): `push_char_glyph` errors like any
8293            // other explicit-codepoint atom if the font can't render it.
8294            let text = match ctx.math_char_class {
8295                MathCharClass::Italic => &style.italic,
8296                MathCharClass::BoldItalic => &style.bold_italic,
8297                MathCharClass::Roman => &style.roman,
8298                MathCharClass::BoldRoman => &style.bold_roman,
8299                MathCharClass::Script => &style.script,
8300                MathCharClass::BoldScript => &style.bold_script,
8301                MathCharClass::Fraktur => &style.fraktur,
8302                MathCharClass::BoldFraktur => &style.bold_fraktur,
8303                MathCharClass::DoubleStruck => &style.double_struck,
8304                // `MathVariantStyle` (this
8305                // 9-field record) is deliberately NOT widened to 14 fields
8306                // — it models the 0.0.6 `math-variant-char` prim's record
8307                // shape, which upstream itself never grew sans-
8308                // serif/typewriter fields for either (only `math-char-class`
8309                // itself widened, `horzBox.ml:98-113`). This arm is
8310                // unreachable in practice: `math-variant-char`/
8311                // `MathElement::VariantChar` is a V0_0-only prim
8312                // (registered `v006` only, `primitives.rs`'s prim table),
8313                // and the 5 new `MathCharClass` ctors are V0_1-only
8314                // (`prim_types.rs::math_char_class_decl`) — the two can
8315                // never co-occur. Closest-analog fallback, purely to keep
8316                // the match exhaustive.
8317                MathCharClass::SansSerif | MathCharClass::Typewriter => &style.roman,
8318                MathCharClass::ItalicSansSerif => &style.italic,
8319                MathCharClass::BoldSansSerif => &style.bold_roman,
8320                MathCharClass::BoldItalicSansSerif => &style.bold_italic,
8321            };
8322            let mut glyphs = Vec::new();
8323            let mut x = Length::ZERO;
8324            for c in text.chars() {
8325                push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8326            }
8327            Ok((glyphs, Vec::new(), x, *class, *class))
8328        }
8329        Math::Pure(MathElement::VariantCharPending(s)) => {
8330            // One MATHCHAR token, resolved now that `ctx` (font +
8331            // math_char_class + both override maps) is available: first
8332            // try the whole-TOKEN class map (`=`, `-`, `,`, … ->
8333            // (replacement, MathKind)); if the token isn't there, fall back
8334            // to a per-char variant remap (the metrics-probe policy)
8335            // with `MathKind::Ord`.
8336            let mut glyphs = Vec::new();
8337            let mut x = Length::ZERO;
8338            if let Some((target, kind)) = ctx.math_class_map.get(s.as_str()) {
8339                let kind = *kind;
8340                let all_renderable = target
8341                    .chars()
8342                    .all(|c| math_char_available(interp, ctx, c, size));
8343                let chosen = if all_renderable {
8344                    target.clone()
8345                } else {
8346                    s.clone()
8347                };
8348                for c in chosen.chars() {
8349                    push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8350                }
8351                return Ok((glyphs, Vec::new(), x, kind, kind));
8352            }
8353            for c in s.chars() {
8354                let chosen = resolve_variant_char(interp, ctx, c, size);
8355                push_char_glyph(interp, ctx, chosen, size, &mut glyphs, &mut x)?;
8356            }
8357            Ok((glyphs, Vec::new(), x, MathKind::Ord, MathKind::Ord))
8358        }
8359        Math::Pure(MathElement::EmbeddedText { class, body }) => {
8360            let v = interp.apply((**body).clone(), Value::Context(Box::new(ctx.clone())))?;
8361            let boxes = as_inline_boxes(v)?;
8362            // `math_boxes_of_inline_boxes`, not the glyphs-only walk: embedded
8363            // inline content can carry its ink as GRAPHICS rather than glyphs.
8364            // latexcmds' `\underset`/`\overset` are exactly that — they reduce
8365            // to `text-in-math (… \normal-underset …)`, which draws through
8366            // `inline-graphics` + `draw-text`. Harvesting glyphs alone kept the
8367            // box's WIDTH and threw the drawing away, so the Schrödinger-equation
8368            // example rendered as `[−     + V(x)]Ψ`: a correctly-sized hole where
8369            // the fraction and its under-text should be.
8370            let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
8371            Ok((glyphs, rules, width, *class, *class))
8372        }
8373        Math::Pure(MathElement::EmbeddedBoxes { class, boxes }) => {
8374            // V0_1 `embed-inline-to-math`: eager, already-materialized
8375            // boxes, so no closure application (contrast `EmbeddedText`
8376            // above) — but the same graphics-bearing content is possible.
8377            let (glyphs, rules, width) = math_boxes_of_inline_boxes(boxes);
8378            Ok((glyphs, rules, width, *class, *class))
8379        }
8380        Math::Group(cls1, cls2, inner) => {
8381            let (glyphs, rules, width, _, _) = layout_math_list(interp, ctx, inner, size)?;
8382            Ok((glyphs, rules, width, *cls1, *cls2))
8383        }
8384        Math::Sup(base, script) => {
8385            // Upstream MathSuperscript: (1) check_subscript merges a
8386            // base-tail `Sub` into one base + (sub, sup) pair;
8387            // (2) check_pull_in hands the script(s) to a base-tail
8388            // `PullInScripts` resolver.
8389            if let Some((sub_script, new_base)) = check_subscript(base) {
8390                if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) =
8391                    new_base.split_last()
8392                {
8393                    return layout_pull_in_scripts(
8394                        interp,
8395                        ctx,
8396                        head,
8397                        *cls1,
8398                        *cls2,
8399                        resolver,
8400                        Some(&sub_script),
8401                        Some(script),
8402                        size,
8403                    );
8404                }
8405                // No pull-in (`{x_1}^2`): one sub+sup pair on the same base.
8406                let (mut glyphs, mut rules, base_width, left, _) =
8407                    layout_math_list(interp, ctx, &new_base, size)?;
8408                let mc = MathC::of(interp, ctx);
8409                let script_size = size * mc.script_scale();
8410                // Re-derive ONCE (a paren base's dense math
8411                // kern function, if `new_base`'s trailing atom is a paren —
8412                // `paren_trailing_kernf`'s doc comment) and reuse it for
8413                // BOTH the sup and sub kerns below, mirroring
8414                // `lp_math_kern_scheme`'s single scheme feeding both corner
8415                // attachments upstream.
8416                let paren_kernf = paren_trailing_kernf(interp, ctx, &new_base, size);
8417                // Subscripts are always cramped; the superscript inherits
8418                // the ambient cramped state unchanged (do NOT flip/reset it
8419                // here).
8420                let sub_ctx = Context {
8421                    math_cramped: true,
8422                    ..ctx.clone()
8423                };
8424                let (sub_glyphs, sub_rules, sub_width, _, _) =
8425                    layout_math_list(interp, &sub_ctx, &sub_script, script_size)?;
8426                let (sup_glyphs, sup_rules, sup_width, _, _) =
8427                    layout_math_list(interp, ctx, script, script_size)?;
8428                let (h_base, d_base) = inner_ink_extent(&glyphs, &rules);
8429                let (_, d_sup) = inner_ink_extent(&sup_glyphs, &sup_rules);
8430                let (h_sub, _) = inner_ink_extent(&sub_glyphs, &sub_rules);
8431                let sup_shift_raw = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
8432                let sub_shift_raw = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
8433                let (sup_shift, sub_shift) = mc.correct_script_gap(
8434                    ctx.font_size,
8435                    d_sup,
8436                    h_sub,
8437                    sup_shift_raw,
8438                    sub_shift_raw,
8439                );
8440                let kern = match &paren_kernf {
8441                    Some(kf) => dense_kern(interp, kf, sup_shift - d_sup),
8442                    None => superscript_kern(
8443                        interp,
8444                        ctx,
8445                        size,
8446                        script_size,
8447                        &glyphs,
8448                        &sup_glyphs,
8449                        sup_shift,
8450                        h_base,
8451                        d_sup,
8452                    ),
8453                };
8454                let sub_kern = paren_kernf
8455                    .as_ref()
8456                    .map(|kf| dense_kern(interp, kf, h_sub - d_base))
8457                    .unwrap_or(Length::ZERO);
8458                shift_and_append(
8459                    &mut glyphs,
8460                    &mut rules,
8461                    sub_glyphs,
8462                    sub_rules,
8463                    base_width + sub_kern,
8464                    -sub_shift,
8465                );
8466                shift_and_append(
8467                    &mut glyphs,
8468                    &mut rules,
8469                    sup_glyphs,
8470                    sup_rules,
8471                    base_width + kern,
8472                    sup_shift,
8473                );
8474                return Ok((
8475                    glyphs,
8476                    rules,
8477                    base_width + (sub_kern + sub_width).max(kern + sup_width),
8478                    left,
8479                    MathKind::Ord,
8480                ));
8481            }
8482            if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
8483                return layout_pull_in_scripts(
8484                    interp,
8485                    ctx,
8486                    head,
8487                    *cls1,
8488                    *cls2,
8489                    resolver,
8490                    None,
8491                    Some(script),
8492                    size,
8493                );
8494            }
8495            let (mut glyphs, mut rules, base_width, left, _) =
8496                layout_math_list(interp, ctx, base, size)?;
8497            let mc = MathC::of(interp, ctx);
8498            let script_size = size * mc.script_scale();
8499            let (script_glyphs, script_rules, script_width, _, _) =
8500                layout_math_list(interp, ctx, script, script_size)?;
8501            let (h_base, _) = inner_ink_extent(&glyphs, &rules);
8502            let (_, d_sup) = inner_ink_extent(&script_glyphs, &script_rules);
8503            let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
8504            // A paren base has no italic correction / glyph
8505            // corner kern to sample (`superscript_kern`'s own last-glyph
8506            // sampling would hit the INNER run's last glyph, not the
8507            // delimiter) — its closure's dense kern REPLACES
8508            // `superscript_kern` outright rather than adding to it.
8509            let kern = match paren_trailing_kernf(interp, ctx, base, size) {
8510                Some(kf) => dense_kern(interp, &kf, sup_shift - d_sup),
8511                None => superscript_kern(
8512                    interp,
8513                    ctx,
8514                    size,
8515                    script_size,
8516                    &glyphs,
8517                    &script_glyphs,
8518                    sup_shift,
8519                    h_base,
8520                    d_sup,
8521                ),
8522            };
8523            shift_and_append(
8524                &mut glyphs,
8525                &mut rules,
8526                script_glyphs,
8527                script_rules,
8528                base_width + kern,
8529                sup_shift,
8530            );
8531            Ok((
8532                glyphs,
8533                rules,
8534                base_width + kern + script_width,
8535                left,
8536                MathKind::Ord,
8537            ))
8538        }
8539        Math::Sub(base, script) => {
8540            // Upstream MathSubscript: a `PullInScripts` at the base list's
8541            // TAIL receives the subscript itself instead of a corner script.
8542            if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
8543                return layout_pull_in_scripts(
8544                    interp,
8545                    ctx,
8546                    head,
8547                    *cls1,
8548                    *cls2,
8549                    resolver,
8550                    Some(script),
8551                    None,
8552                    size,
8553                );
8554            }
8555            let (mut glyphs, mut rules, base_width, left, _) =
8556                layout_math_list(interp, ctx, base, size)?;
8557            let mc = MathC::of(interp, ctx);
8558            let script_size = size * mc.script_scale();
8559            // Subscripts are always cramped.
8560            let sub_ctx = Context {
8561                math_cramped: true,
8562                ..ctx.clone()
8563            };
8564            let (script_glyphs, script_rules, script_width, _, _) =
8565                layout_math_list(interp, &sub_ctx, script, script_size)?;
8566            let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8567            let (h_sub, _) = inner_ink_extent(&script_glyphs, &script_rules);
8568            let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
8569            // Non-paren subscripts carry no kern (`kern = Length::ZERO`); a
8570            // paren base's closure supplies one via `paren_trailing_kernf`'s
8571            // `Some` arm.
8572            let kern = match paren_trailing_kernf(interp, ctx, base, size) {
8573                Some(kf) => dense_kern(interp, &kf, h_sub - d_base),
8574                None => Length::ZERO,
8575            };
8576            shift_and_append(
8577                &mut glyphs,
8578                &mut rules,
8579                script_glyphs,
8580                script_rules,
8581                base_width + kern,
8582                -sub_shift,
8583            );
8584            Ok((
8585                glyphs,
8586                rules,
8587                base_width + kern + script_width,
8588                left,
8589                MathKind::Ord,
8590            ))
8591        }
8592        Math::ChangeColor(_, inner) => {
8593            // STAND-IN: color restyling doesn't affect glyph rendering yet
8594            // — just render the content.
8595            let (glyphs, rules, width, left, right) = layout_math_list(interp, ctx, inner, size)?;
8596            Ok((glyphs, rules, width, left, right))
8597        }
8598        Math::ChangeCharClass(cls, inner) => {
8599            // Lay `inner` out under a
8600            // context with `math_char_class` set to `cls`, which is what
8601            // `VariantCharPending`/`VariantChar`'s arms above consult.
8602            let ctx2 = Context {
8603                math_char_class: *cls,
8604                ..ctx.clone()
8605            };
8606            let (glyphs, rules, width, left, right) = layout_math_list(interp, &ctx2, inner, size)?;
8607            Ok((glyphs, rules, width, left, right))
8608        }
8609        Math::Fraction(num, den) => {
8610            // Real numerator/denominator placement (`math.ml:574-594`
8611            // `numerator_baseline_height`/ `denominator_baseline_depth`)
8612            // plus a bar `Fill` — replaces the ASCII "num / den" stand-in.
8613            // `num`/`den` are laid out at the SAME `size` as this atom (no
8614            // script-scale reduction — a fraction's own
8615            // numerator/denominator aren't scripts, matching upstream's
8616            // `convert_to_low` call with the ambient `mathctx` unchanged).
8617            let (num_glyphs, num_rules, num_w, ..) = layout_math_list(interp, ctx, num, size)?;
8618            // The denominator is always cramped; the numerator inherits the
8619            // ambient cramped state unchanged.
8620            let den_ctx = Context {
8621                math_cramped: true,
8622                ..ctx.clone()
8623            };
8624            let (den_glyphs, den_rules, den_w, ..) = layout_math_list(interp, &den_ctx, den, size)?;
8625            let w = num_w.max(den_w);
8626            // Center the narrower of the two over/under the wider
8627            // (`math.ml:1140-1155`'s symmetric padding).
8628            let num_dx = (w - num_w) * 0.5;
8629            let den_dx = (w - den_w) * 0.5;
8630            let (_, d_numer) = inner_ink_extent(&num_glyphs, &num_rules);
8631            let (h_denom, _) = inner_ink_extent(&den_glyphs, &den_rules);
8632            let mc = MathC::of(interp, ctx);
8633            let numer_shift = mc.frac_numer_shift(size, d_numer);
8634            let denom_shift = mc.frac_denom_shift(size, h_denom);
8635            let axis = mc.axis(size);
8636            let rule = mc.frac_rule(size);
8637            let mut glyphs = Vec::new();
8638            let mut rules = Vec::new();
8639            // `num dy>0` (raised above the axis), `den dy<0` (`frac_denom_
8640            // shift` is already signed negative — see that method's doc
8641            // comment) — both applied via the SAME up-positive `dy_shift`
8642            // `shift_and_append` uses for Sup/Sub.
8643            shift_and_append(
8644                &mut glyphs,
8645                &mut rules,
8646                num_glyphs,
8647                num_rules,
8648                num_dx,
8649                numer_shift,
8650            );
8651            shift_and_append(
8652                &mut glyphs,
8653                &mut rules,
8654                den_glyphs,
8655                den_rules,
8656                den_dx,
8657                denom_shift,
8658            );
8659            // The bar itself: `rect x∈[0,w], y∈[axis·s, axis·s+rule·s]`
8660            // (a deliberate simplification of
8661            // upstream's own `Rectangle((xpos, ypos+h_bar+t_bar/2), (wid,
8662            // t_bar))`, which centers the rule on its OWN half-thickness
8663            // rather than sitting flush on the axis; this port picks the
8664            // simpler flush-on-axis placement instead).
8665            rules.push(GraphicsElem::Fill(
8666                ctx.text_color,
8667                rect_path((Length::ZERO, axis), (w, rule)),
8668            ));
8669            Ok((glyphs, rules, w, MathKind::Inner, MathKind::Inner))
8670        }
8671        Math::Radical(_degree, inner) => {
8672            // Real bar metrics (`math.ml:620-626` `radical_bar_
8673            // metrics`) plus a ported `default_radical` checkmark `Fill`
8674            // (`primitives.cppo.ml:311-355`) and an overbar rect `Fill` —
8675            // replaces the U+221A stand-in. `RadicalWithDegree` (`_degree =
8676            // Some(..)`, `\sqrt[n]{..}`) stays unimplemented — the degree is
8677            // carried faithfully in the
8678            // `Math` value but silently NOT drawn, matching upstream's own
8679            // parity note (`math.ml:886-899`'s `failwith "unsupported"` is
8680            // upstream's harder failure mode; this port's own stand-in
8681            // policy already chose "render the radicand
8682            // without the degree" over erroring, unchanged since).
8683            // The radicand is always cramped.
8684            let radicand_ctx = Context {
8685                math_cramped: true,
8686                ..ctx.clone()
8687            };
8688            let (inner_glyphs, inner_rules, inner_w, ..) =
8689                layout_math_list(interp, &radicand_ctx, inner, size)?;
8690            let (h_cont, d_cont) = inner_ink_extent(&inner_glyphs, &inner_rules);
8691            let mc = MathC::of(interp, ctx);
8692            let (h_bar, t_bar, l_extra) = mc.radical_bar_metrics(size, h_cont);
8693            // `_nonnegdpt` (the sign's own, slightly deeper, ink extent —
8694            // `default_radical`'s downward checkmark stroke pads `d_cont` by
8695            // `size*0.1`, upstream's own `nonnegdpt`) isn't threaded into
8696            // this atom's reported `depth` directly: unlike upstream's own
8697            // `d_whole = d_cont` (`math.ml:884`, a "temporary" simplification
8698            // per its own comment there), this port's `layout_math_value`
8699            // folds every rule's `graphics_bbox` into the OUTER box's
8700            // height/depth (a correctness fix, `PureHorzBox::Math`'s doc
8701            // comment), so the sign's real ink depth reaches the top-level
8702            // box automatically THROUGH the drawn `Fill` — no separate
8703            // manual accounting needed here.
8704            let (sign_path, sign_w, _nonnegdpt) = radical_sign_geometry(size, h_bar, t_bar, d_cont);
8705            let mut rules = vec![GraphicsElem::Fill(ctx.text_color, sign_path)];
8706            // Overbar + radicand share the same x-range right after the
8707            // sign (`math.ml:1163-1176`'s `hbbar`/`hbback`/`hblstC`); the
8708            // radicand itself stays at `dy = 0` (its own baseline), exactly
8709            // upstream — `h_bar` already clears it via the vertical-gap add
8710            // in `radical_bar_metrics`, so no raise is needed here.
8711            rules.push(GraphicsElem::Fill(
8712                ctx.text_color,
8713                rect_path((sign_w, h_bar), (inner_w, t_bar)),
8714            ));
8715            // `l_extra`: the extra ascender ABOVE the bar this run reports
8716            // to its container (upstream `h_whole = h_rad +% l_extra`,
8717            // `math.ml:882`) — no ink of its own, just headroom, so there's
8718            // no glyph/fill shape to naturally carry it. A single-point
8719            // "extent marker" `Fill` (a subpath with a `move_to` and no
8720            // further segments paints nothing — PDF's `f` on a degenerate
8721            // zero-length path is a no-op) reports it through the SAME
8722            // `graphics_bbox` fold `layout_math_value` already does for
8723            // every rule, without adding a new return channel just for this
8724            // one field.
8725            rules.push(GraphicsElem::Fill(
8726                ctx.text_color,
8727                Path {
8728                    subpaths: vec![Subpath {
8729                        start: (Length::ZERO, h_bar + t_bar + l_extra),
8730                        segs: Vec::new(),
8731                        closing: Closing::Open,
8732                    }],
8733                },
8734            ));
8735            let mut glyphs = Vec::new();
8736            for mut g in inner_glyphs {
8737                g.dx = sign_w + g.dx;
8738                glyphs.push(g);
8739            }
8740            for r in &inner_rules {
8741                rules.push(shift_graphics((sign_w, Length::ZERO), r));
8742            }
8743            Ok((
8744                glyphs,
8745                rules,
8746                sign_w + inner_w,
8747                MathKind::Inner,
8748                MathKind::Inner,
8749            ))
8750        }
8751        Math::Paren(l, r, inner) => {
8752            // PRIMARY route is upstream's own `make_paren` closure
8753            // invocation (`math.ml:644-649`, `make_paren_run` above) —
8754            // identity (a `\paren` drawing round parens vs. an `\abs`
8755            // drawing vertical bars, etc.) lives ENTIRELY in the `l`/`r`
8756            // closures (`math.satyh`'s `paren-left`/`abs-left`/…), so
8757            // running them is what makes different delimiter kinds actually
8758            // look different. Falls back to the MATH-native
8759            // stretchy-variant stand-in (`paren_variant_fallback`) only if
8760            // either closure errors (synthetic/ill-shaped test closures, or
8761            // a real user error) — that fallback's own delimiter kind is
8762            // always `(`/`)` regardless of what was requested. Inner is laid
8763            // out OUTSIDE the closure
8764            // route so an inner layout error still propagates normally
8765            // (only closure-route errors trigger the fallback); splice
8766            // order `lg ++ inner ++ rg` matches upstream's own
8767            // `LowMathParen(lpL, lpR, lmC)` (`math.ml:909`).
8768            let (inner_glyphs, inner_rules, inner_w, ..) =
8769                layout_math_list(interp, ctx, inner, size)?;
8770            let (h_in, d_in) = inner_ink_extent(&inner_glyphs, &inner_rules);
8771            let mc = MathC::of(interp, ctx);
8772            let axis = mc.axis(size);
8773            let closure_route =
8774                make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8775                    let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8776                    Ok((left, right))
8777                });
8778            match closure_route {
8779                Ok(((lg, lr, lw, _), (rg, rr, rw, _))) => {
8780                    let mut glyphs = Vec::new();
8781                    let mut rules = Vec::new();
8782                    let mut x = Length::ZERO;
8783                    append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8784                    append_at(
8785                        &mut glyphs,
8786                        &mut rules,
8787                        &mut x,
8788                        inner_glyphs,
8789                        inner_rules,
8790                        inner_w,
8791                    );
8792                    append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8793                    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8794                }
8795                Err(_) => paren_variant_fallback(
8796                    interp,
8797                    ctx,
8798                    vec![(inner_glyphs, inner_rules, inner_w)],
8799                    h_in,
8800                    d_in,
8801                    axis,
8802                    size,
8803                ),
8804            }
8805        }
8806        Math::ParenWithMiddle(l, r, m, mlstlst) => {
8807            // Same closure-primary/fallback policy as `Math::Paren`,
8808            // but ONE shared `(h_in, d_in)` over every part (the tallest
8809            // part's ink drives the size of every delimiter, including the
8810            // middle separator(s)) — mirrors upstream's own
8811            // `MathParenWithMiddle` fold (`math.ml:912-916`). The middle
8812            // closure's own kernf is DISCARDED (`math.ml:923`: `let
8813            // (hblstmiddle, _) = make_paren mathctx middle hC dC in ...`) —
8814            // a separator never tucks a script into itself.
8815            let mut parts = Vec::with_capacity(mlstlst.len());
8816            let mut h_in = Length::ZERO;
8817            let mut d_in = Length::ZERO;
8818            for part in mlstlst {
8819                let (part_glyphs, part_rules, part_w, ..) =
8820                    layout_math_list(interp, ctx, part, size)?;
8821                let (h, d) = inner_ink_extent(&part_glyphs, &part_rules);
8822                h_in = h_in.max(h);
8823                d_in = d_in.max(d);
8824                parts.push((part_glyphs, part_rules, part_w));
8825            }
8826            let mc = MathC::of(interp, ctx);
8827            let axis = mc.axis(size);
8828            let closure_route =
8829                make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8830                    let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8831                    let middle = make_paren_run(interp, ctx, m, h_in, d_in, axis, size)?;
8832                    Ok((left, right, middle))
8833                });
8834            match closure_route {
8835                Ok(((lg, lr, lw, _), (rg, rr, rw, _), (mg, mr, mw, _))) => {
8836                    let mut glyphs = Vec::new();
8837                    let mut rules = Vec::new();
8838                    let mut x = Length::ZERO;
8839                    append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8840                    for (i, (part_glyphs, part_rules, part_w)) in parts.into_iter().enumerate() {
8841                        if i > 0 {
8842                            append_at(&mut glyphs, &mut rules, &mut x, mg.clone(), mr.clone(), mw);
8843                        }
8844                        append_at(
8845                            &mut glyphs,
8846                            &mut rules,
8847                            &mut x,
8848                            part_glyphs,
8849                            part_rules,
8850                            part_w,
8851                        );
8852                    }
8853                    append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8854                    Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8855                }
8856                Err(_) => paren_variant_fallback(interp, ctx, parts, h_in, d_in, axis, size),
8857            }
8858        }
8859        Math::UpperLimit(base, upper) => {
8860            let (mut glyphs, mut rules, base_width, left, right) =
8861                layout_math_list(interp, ctx, base, size)?;
8862            let mc = MathC::of(interp, ctx);
8863            let script_size = size * mc.script_scale();
8864            let (script_glyphs, script_rules, script_width, _, _) =
8865                layout_math_list(interp, ctx, upper, script_size)?;
8866            let (h_base, _) = inner_ink_extent(&glyphs, &rules);
8867            let (_, d_up) = inner_ink_extent(&script_glyphs, &script_rules);
8868            let up_shift = mc.upper_limit_shift(ctx.font_size, h_base, d_up);
8869            // A LIMIT is CENTERED over its base, not set beside it
8870            // (`math.ml:1219-1231`: upstream pads the narrower of the two with
8871            // half the difference on each side, so the whole is
8872            // `max(w_base, w_up)` wide). Placing it at `base_width` — i.e. to
8873            // the right, widening the box to the SUM — set `\sum_a^b`'s limits
8874            // off the operator's shoulder instead of above and below it.
8875            let (base_dx, script_dx) = center_offsets(base_width, script_width);
8876            shift_existing(&mut glyphs, &mut rules, base_dx);
8877            shift_and_append(
8878                &mut glyphs,
8879                &mut rules,
8880                script_glyphs,
8881                script_rules,
8882                script_dx,
8883                up_shift,
8884            );
8885            Ok((glyphs, rules, base_width.max(script_width), left, right))
8886        }
8887        Math::LowerLimit(base, lower) => {
8888            let (mut glyphs, mut rules, base_width, left, right) =
8889                layout_math_list(interp, ctx, base, size)?;
8890            let mc = MathC::of(interp, ctx);
8891            let script_size = size * mc.script_scale();
8892            let (script_glyphs, script_rules, script_width, _, _) =
8893                layout_math_list(interp, ctx, lower, script_size)?;
8894            let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8895            let (h_low, _) = inner_ink_extent(&script_glyphs, &script_rules);
8896            let low_shift = mc.lower_limit_shift(ctx.font_size, d_base, h_low);
8897            // Centered under the base — see the `UpperLimit` arm above.
8898            let (base_dx, script_dx) = center_offsets(base_width, script_width);
8899            shift_existing(&mut glyphs, &mut rules, base_dx);
8900            shift_and_append(
8901                &mut glyphs,
8902                &mut rules,
8903                script_glyphs,
8904                script_rules,
8905                script_dx,
8906                -low_shift,
8907            );
8908            Ok((glyphs, rules, base_width.max(script_width), left, right))
8909        }
8910        Math::PullInScripts(cls1, cls2, resolver) => {
8911            // Not consumed by an enclosing Sub/Sup (bare `\sum` with no
8912            // scripts): resolver gets (None, None).
8913            layout_pull_in_scripts(interp, ctx, &[], *cls1, *cls2, resolver, None, None, size)
8914        }
8915        // V0_1 only (`read-math`): lay `inner` out
8916        // with ambient context = the STORED context, and size = the
8917        // stored context's OWN `font_size` — an ABSOLUTE override, not a
8918        // further multiply of the caller's `size`. This is deliberate: a
8919        // `WithContext` built under an `enter_script`-shrunk context
8920        // already carries the script-shrunk `font_size` in `stored`, so
8921        // laying it out at `stored.font_size` (rather than at this call's
8922        // `size`) means the engine's own Sup/Sub shrink is never applied a
8923        // second time on top of it.
8924        Math::WithContext(stored, inner) => {
8925            layout_math_list(interp, stored, inner, stored.font_size)
8926        }
8927    }
8928}
8929
8930/// Append `glyphs`/`rules` (already at LOCAL coordinates relative to their
8931/// own run) onto `out_glyphs`/`out_rules` at the running `*x`, advancing `*x`
8932/// past them — the no-spacing-adjustment sibling of `layout_math_list`'s
8933/// per-atom loop, used by the structural stand-ins above (paren) that
8934/// concatenate sub-runs directly rather than through the spacing table.
8935/// `rules` shifts horizontally only (`shift_graphics` with a zero `dy` —
8936/// `append_at`'s callers never raise/lower a sub-run, only `dx`-place it;
8937/// contrast `shift_and_append` below, which does both).
8938fn append_at(
8939    out_glyphs: &mut Vec<MathGlyph>,
8940    out_rules: &mut Vec<GraphicsElem>,
8941    x: &mut Length,
8942    glyphs: Vec<MathGlyph>,
8943    rules: Vec<GraphicsElem>,
8944    width: Length,
8945) {
8946    let base_x = *x;
8947    for mut g in glyphs {
8948        g.dx = base_x + g.dx;
8949        out_glyphs.push(g);
8950    }
8951    for r in &rules {
8952        out_rules.push(shift_graphics((base_x, Length::ZERO), r));
8953    }
8954    *x = base_x + width;
8955}
8956
8957/// Horizontal offsets that CENTER a limit against its base: half the width
8958/// difference goes to whichever of the two is narrower, so the pair occupies
8959/// `max(base, script)` (upstream `math.ml:1219-1231`).
8960fn center_offsets(base_width: Length, script_width: Length) -> (Length, Length) {
8961    if base_width < script_width {
8962        ((script_width - base_width) * 0.5, Length::ZERO)
8963    } else {
8964        (Length::ZERO, (base_width - script_width) * 0.5)
8965    }
8966}
8967
8968/// Slide already-emitted glyphs/rules right by `dx` — used when a limit is
8969/// WIDER than its base, so the base itself has to move to stay centered.
8970fn shift_existing(glyphs: &mut [MathGlyph], rules: &mut [GraphicsElem], dx: Length) {
8971    if dx == Length::ZERO {
8972        return;
8973    }
8974    for g in glyphs.iter_mut() {
8975        g.dx = g.dx + dx;
8976    }
8977    for r in rules.iter_mut() {
8978        *r = shift_graphics((dx, Length::ZERO), r);
8979    }
8980}
8981
8982/// Append `glyphs`/`rules` (LOCAL coordinates, from an isolated
8983/// `layout_math_list` call) onto `out_glyphs`/`out_rules`, shifting every
8984/// glyph/rule right by `dx_shift` (its base's own width — placing the
8985/// script/numerator/denominator/radicand right after the preceding content)
8986/// and up/down by `dy_shift` (`> 0` raises, `< 0` lowers) — the `Math`-atom
8987/// analog of `place_script`, which instead threads a single
8988/// running `x` across a flat `MathElem` list. `rules` go through the SAME
8989/// `shift_graphics` a standalone `inline-graphics` box's `shift-graphics`
8990/// primitive uses — box-local, y-**up** coordinates, exactly
8991/// `MathGlyph::dy`'s sign convention (a critical correctness note: get
8992/// this sign wrong and a fraction bar/ radical mirrors instead of landing
8993/// at the axis).
8994fn shift_and_append(
8995    out_glyphs: &mut Vec<MathGlyph>,
8996    out_rules: &mut Vec<GraphicsElem>,
8997    glyphs: Vec<MathGlyph>,
8998    rules: Vec<GraphicsElem>,
8999    dx_shift: Length,
9000    dy_shift: Length,
9001) {
9002    for mut g in glyphs {
9003        g.dx = dx_shift + g.dx;
9004        g.dy = g.dy + dy_shift;
9005        out_glyphs.push(g);
9006    }
9007    for r in &rules {
9008        out_rules.push(shift_graphics((dx_shift, dy_shift), r));
9009    }
9010}
9011
9012/// An axis-aligned rectangle `Fill` path, box-local (y-**up**): bottom-left
9013/// corner `origin`, extending `size.0` right and `size.1` up. Shared by the
9014/// fraction bar and the radical overbar — both are exactly this
9015/// shape, just at different `y`/width.
9016fn rect_path(origin: Point, size: (Length, Length)) -> Path {
9017    let (x, y) = origin;
9018    let (w, h) = size;
9019    Path {
9020        subpaths: vec![Subpath {
9021            start: (x, y),
9022            segs: vec![
9023                PathSeg::Line((x + w, y)),
9024                PathSeg::Line((x + w, y + h)),
9025                PathSeg::Line((x, y + h)),
9026            ],
9027            closing: Closing::Line,
9028        }],
9029    }
9030}
9031
9032/// Port of `default_radical` (`primitives.cppo.ml:311-355`): the radical
9033/// checkmark's `GeneralPath`, plus its own natural advance (`wid`, upstream's
9034/// `PHGFixedGraphics`'s declared width) and `nonnegdpt` (its own depth
9035/// extent, upstream's declared `depth` — returned for completeness though
9036/// The overall `Math::Radical` depth uses `d_cont` directly, matching
9037/// upstream's own "temporary" simplification, see that arm's call site).
9038/// `size` is the ambient LOCAL nesting size (upstream `fontsize`); `hgt_bar`/
9039/// `t_bar` come from `MathC::radical_bar_metrics`; `dpt` is the radicand's
9040/// own depth (a NON-NEGATIVE magnitude, this port's convention — see
9041/// `sup_shift_clamped`'s doc comment; upstream's signed `Length.negate dpt`
9042/// becomes a plain ADD of `dpt` here).
9043///
9044/// Box-local origin `(0, 0)` = this atom's own baseline-left corner (where
9045/// upstream's `graphics (xpos, ypos)` closure is finally called with the
9046/// box's placed anchor — every point below is relative to that same origin,
9047/// matching `PathSeg`/`Subpath`'s y-**up** convention).
9048fn radical_sign_geometry(
9049    size: Length,
9050    hgt_bar: Length,
9051    t_bar: Length,
9052    dpt: Length,
9053) -> (Path, Length, Length) {
9054    let w_m = size * 0.02;
9055    let w1 = size * 0.1;
9056    let w2 = size * 0.15;
9057    let w3 = size * 0.4;
9058    let w_a = size * 0.18;
9059    let h1 = size * 0.3;
9060    let h2 = size * 0.375;
9061
9062    let nonnegdpt = dpt + size * 0.1;
9063    let l_r = hgt_bar + nonnegdpt;
9064
9065    let wid = w_m + w1 + w2 + w3;
9066    let a1 = (h2 - h1) / w1;
9067    let a2 = h2 / w2;
9068    let a3 = l_r / w3;
9069    let t1 = t_bar * (1.0 + a1 * a1).sqrt();
9070    let t3 = t_bar * (((1.0 + a3 * a3).sqrt() - 1.0) / a3);
9071    let h_a = h1 + t1 + w_a * a1;
9072    let w_b = (l_r + t_bar - h_a - (w1 + w2 + w3 - t3 - w_a) * a3) * (-1.0 / (a2 + a3));
9073    let h_b = h_a - w_b * a2;
9074
9075    let path = Path {
9076        subpaths: vec![Subpath {
9077            start: (wid, hgt_bar),
9078            segs: vec![
9079                PathSeg::Line((w_m + w1 + w2, -nonnegdpt)),
9080                PathSeg::Line((w_m + w1, -nonnegdpt + h2)),
9081                PathSeg::Line((w_m, -nonnegdpt + h1)),
9082                PathSeg::Line((w_m, -nonnegdpt + h1 + t1)),
9083                PathSeg::Line((w_m + w_a, -nonnegdpt + h_a)),
9084                PathSeg::Line((w_m + w_a + w_b, -nonnegdpt + h_b)),
9085                PathSeg::Line((wid - t3, hgt_bar + t_bar)),
9086                PathSeg::Line((wid, hgt_bar + t_bar)),
9087            ],
9088            closing: Closing::Line,
9089        }],
9090    };
9091    (path, wid, nonnegdpt)
9092}
9093
9094/// Flatten `text-in-math`'s embedded `inline-boxes` (already laid out
9095/// by `read_inline` against the math atom's own context) into `MathGlyph`s
9096/// nestable in a math run — the box-in-math bridge `layout_math_atom`'s
9097/// `EmbeddedText` arm needs. Mirrors `linebreak.rs`'s `natural_metrics`
9098/// exhaustive `PureHorzBox` walk EXACTLY (same variant list, same "what
9099/// advances `x`" choice per variant) so an added/renamed `PureHorzBox`
9100/// variant can't silently drop content here without also breaking that
9101/// walk. Caveats (faithful to what's actually renderable here): only
9102/// `InnerString`/nested `Math` boxes contribute real glyphs (hence height/
9103/// depth, computed by the caller from the returned glyphs); every other
9104/// box kind (`Image`/`Graphics`/`Tabular`/`EmbeddedBlock`/…) keeps its
9105/// horizontal space but contributes no ink; text run at full (non-script)
9106/// size regardless of the math run's own `size` (upstream-faithful — a
9107/// `text-in-math` body is laid out once, by `read_inline`, before this
9108/// function ever sees it).
9109// UNWIRED. `layout_script` builds the same `(Vec<MathGlyph>, Length)` on the
9110// live path, so nothing calls this. Kept rather than deleted because
9111// `math_boxes_of_inline_boxes` below is documented as its sibling, and
9112// because it is the upstream-faithful flattening a `text-in-math` body needs
9113// if that path is ever wired back up.
9114#[allow(dead_code)]
9115fn math_glyphs_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Length) {
9116    fn go(pure: &PureHorzBox, out: &mut Vec<MathGlyph>, x: &mut Length) {
9117        match pure {
9118            PureHorzBox::InnerString {
9119                info,
9120                text,
9121                width,
9122                height,
9123                depth,
9124            } => {
9125                out.push(MathGlyph {
9126                    info: info.clone(),
9127                    text: text.clone(),
9128                    gid: None,
9129                    dx: *x,
9130                    dy: Length::ZERO,
9131                    width: *width,
9132                    height: *height,
9133                    depth: *depth,
9134                });
9135                *x += *width;
9136            }
9137            PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
9138            PureHorzBox::OuterFil => {}
9139            PureHorzBox::FixedEmpty { width } => *x += *width,
9140            PureHorzBox::Image { width, .. } => *x += *width,
9141            PureHorzBox::Discretionary { no_break, .. } => {
9142                for p in no_break {
9143                    go(p, out, x);
9144                }
9145            }
9146            PureHorzBox::Graphics { width, .. } => *x += *width,
9147            // An unresolved `inline-graphics-outer` marker has zero width
9148            // (fil semantics, see the variant's doc comment) and no glyph
9149            // representation this walk can extract — advance past it like
9150            // `Image`/`Tabular` (a resolved one is an ordinary `Graphics`,
9151            // handled by the arm above).
9152            PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
9153            PureHorzBox::Math { width, glyphs, .. } => {
9154                for g in glyphs {
9155                    let mut g = g.clone();
9156                    g.dx = *x + g.dx;
9157                    out.push(g);
9158                }
9159                *x += *width;
9160            }
9161            PureHorzBox::HookPageBreak { .. } => {}
9162            PureHorzBox::Tabular(tab) => *x += tab.width,
9163            PureHorzBox::EmbeddedBlock { width, .. } => *x += *width,
9164            // A frame in a math context has no glyph representation this
9165            // walk can extract — advance past it like `Image`/`Tabular`.
9166            PureHorzBox::Frame { width, .. } => *x += *width,
9167            PureHorzBox::FrameMarker { .. } => {}
9168            // Zero-width bracket; its contents are spliced siblings, already
9169            // walked by this same loop.
9170            PureHorzBox::InlineFrameMarker { .. } => {}
9171            // Zero-width marker; no glyph representation. Same treatment
9172            // as `HookPageBreak`.
9173            PureHorzBox::Footnote { .. } => {}
9174            // inert reflow marker, no glyph representation — same
9175            // treatment as `HookPageBreak`/`FrameMarker`/`Footnote`
9176            // above.
9177            PureHorzBox::InlineMark(_) => {}
9178        }
9179    }
9180    let mut glyphs = Vec::new();
9181    let mut x = Length::ZERO;
9182    for HorzBox::Pure(p) in boxes {
9183        go(p, &mut glyphs, &mut x);
9184    }
9185    (glyphs, x)
9186}
9187
9188/// `math_glyphs_of_inline_boxes`'s graphics-harvesting sibling — the
9189/// shape a `make_paren` closure's result needs, since a delimiter drawn via
9190/// `inline-graphics` (`math.satyh`'s `paren-left`/`abs-left`/…, `fill`/
9191/// `stroke` a path) carries its ink as a `PureHorzBox::Graphics` box, not a
9192/// `MathGlyph`. The same exhaustive `PureHorzBox` variant list as
9193/// `math_glyphs_of_inline_boxes` (do NOT modify that function — every OTHER
9194/// caller still wants glyphs-only, e.g. `EmbeddedText`), but additionally
9195/// harvests `Graphics::elems` (`dx`-shifted via `shift_graphics`, the box's
9196/// own local-origin convention — see `PureHorzBox::Graphics`'s doc comment)
9197/// and forwards BOTH the glyphs AND `rules` out of any nested
9198/// `PureHorzBox::Math` box (a paren closure could, in principle, embed one
9199/// via `text-in-math`/`embed-math`).
9200///
9201/// ONE arm diverges from that sibling and makes this walk VERTICAL too: `dy`
9202/// (y-up, box-local — `MathGlyph::dy`'s own frame) carries the offset of the
9203/// stacked line a nested `EmbeddedBlock`'s content sits on. `Frame` and
9204/// `Tabular` still contribute width alone; they could be descended into the
9205/// same way, but nothing in the corpus puts either inside a `text-in-math`
9206/// body, so neither has a measured shape to be faithful to.
9207fn math_boxes_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Vec<GraphicsElem>, Length) {
9208    fn go(
9209        pure: &PureHorzBox,
9210        out: &mut Vec<MathGlyph>,
9211        rules: &mut Vec<GraphicsElem>,
9212        x: &mut Length,
9213        dy: Length,
9214    ) {
9215        match pure {
9216            PureHorzBox::InnerString {
9217                info,
9218                text,
9219                width,
9220                height,
9221                depth,
9222            } => {
9223                out.push(MathGlyph {
9224                    info: info.clone(),
9225                    text: text.clone(),
9226                    gid: None,
9227                    dx: *x,
9228                    dy,
9229                    width: *width,
9230                    height: *height,
9231                    depth: *depth,
9232                });
9233                *x += *width;
9234            }
9235            PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
9236            PureHorzBox::OuterFil => {}
9237            PureHorzBox::FixedEmpty { width } => *x += *width,
9238            PureHorzBox::Image { width, .. } => *x += *width,
9239            PureHorzBox::Discretionary { no_break, .. } => {
9240                for p in no_break {
9241                    go(p, out, rules, x, dy);
9242                }
9243            }
9244            PureHorzBox::Graphics { width, elems, .. } => {
9245                for e in elems {
9246                    rules.push(shift_graphics((*x, dy), e));
9247                }
9248                *x += *width;
9249            }
9250            // See `math_glyphs_of_inline_boxes`'s matching arm.
9251            PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
9252            PureHorzBox::Math {
9253                width,
9254                glyphs,
9255                rules: inner_rules,
9256                ..
9257            } => {
9258                for g in glyphs {
9259                    let mut g = g.clone();
9260                    g.dx = *x + g.dx;
9261                    g.dy += dy;
9262                    out.push(g);
9263                }
9264                for r in inner_rules {
9265                    rules.push(shift_graphics((*x, dy), r));
9266                }
9267                *x += *width;
9268            }
9269            PureHorzBox::HookPageBreak { .. } => {}
9270            PureHorzBox::Tabular(tab) => *x += tab.width,
9271            // A `line-stack-top`/`-bottom` (or `embed-block-top`/`-bottom`) box
9272            // handed BACK to math through `text-in-math` — azmath's
9273            // `\overbrace`/`\underbrace` (`parens.satyh:533`/`:561`) stack the
9274            // brace over the braced formula this way. Placed with
9275            // `place_embedded_block`'s (rustyfi-pdf) geometry but in this walk's
9276            // y-UP frame: `place_block_at` seats the stack at a page-y-DOWN
9277            // origin, the anchored line lands on the math baseline
9278            // (`anchor_last`: the LAST for `-bottom`, the FIRST for `-top` —
9279            // upstream's `adjust_to_last_line`/`adjust_to_first_line`), and
9280            // every other line is offset by the NEGATED difference of their
9281            // placed baselines. That is the same split `make_embedded_block`
9282            // measured the box's own height/depth from, so what
9283            // `layout_math_value` folds back up agrees with the box metrics the
9284            // rest of the pipeline already saw.
9285            PureHorzBox::EmbeddedBlock {
9286                width,
9287                block,
9288                anchor_last,
9289                ..
9290            } => {
9291                let placed = place_block_at((Length::ZERO, Length::ZERO), block.clone());
9292                let anchor = if *anchor_last {
9293                    placed.last()
9294                } else {
9295                    placed.first()
9296                };
9297                if let Some(anchor) = anchor {
9298                    let anchor_y = anchor.baseline_y;
9299                    for line in &placed {
9300                        let line_dy = dy - (line.baseline_y - anchor_y);
9301                        for (cdx, cbx) in &line.contents {
9302                            // Each stacked line has its own horizontal origin,
9303                            // and must not advance the OUTER run's pen — the
9304                            // block's own `width` accounts for that once below.
9305                            let mut cx = *x + line.x + *cdx;
9306                            go(cbx, out, rules, &mut cx, line_dy);
9307                        }
9308                    }
9309                }
9310                *x += *width;
9311            }
9312            // See `math_glyphs_of_inline_boxes`'s matching arm.
9313            PureHorzBox::Frame { width, .. } => *x += *width,
9314            PureHorzBox::FrameMarker { .. } => {}
9315            // See `math_glyphs_of_inline_boxes`'s matching arm.
9316            PureHorzBox::InlineFrameMarker { .. } => {}
9317            // See `math_glyphs_of_inline_boxes`'s matching arm.
9318            PureHorzBox::Footnote { .. } => {}
9319            // See `math_glyphs_of_inline_boxes`'s matching arm.
9320            PureHorzBox::InlineMark(_) => {}
9321        }
9322    }
9323    let mut glyphs = Vec::new();
9324    let mut rules = Vec::new();
9325    let mut x = Length::ZERO;
9326    for HorzBox::Pure(p) in boxes {
9327        go(p, &mut glyphs, &mut rules, &mut x, Length::ZERO);
9328    }
9329    (glyphs, rules, x)
9330}
9331
9332// ============================================================================
9333// ---- context-setter + box-combinator prims `code.satyh`/`itemize.satyh`
9334// need. ------------------------------------------------------------------
9335// ============================================================================
9336
9337/// The inverse of `as_color` (mirrors `evalUtil.ml:124`'s `get_color` the
9338/// other way) — `get-text-color`'s result, which `itemize.satyh` feeds
9339/// straight into `fill`, so the tag/payload shape must match `as_color`
9340/// exactly (see that primitive's doc comment).
9341fn make_color_value(c: Color) -> Value {
9342    match c {
9343        Color::Gray(g) => Value::Ctor("Gray".to_string(), Some(Box::new(Value::Float(g)))),
9344        Color::Rgb(r, g, b) => Value::Ctor(
9345            "RGB".to_string(),
9346            Some(Box::new(Value::Tuple(vec![
9347                Value::Float(r),
9348                Value::Float(g),
9349                Value::Float(b),
9350            ]))),
9351        ),
9352        Color::Cmyk(c, m, y, k) => Value::Ctor(
9353            "CMYK".to_string(),
9354            Some(Box::new(Value::Tuple(vec![
9355                Value::Float(c),
9356                Value::Float(m),
9357                Value::Float(y),
9358                Value::Float(k),
9359            ]))),
9360        ),
9361    }
9362}
9363
9364/// `font` = `Value::Tuple([string, float, float])` in `(abbrev, size_ratio,
9365/// rising_ratio)` order (vminst.ml's `tFONT`) — `set-font`'s second argument.
9366fn as_font(v: Value) -> Result<(String, f64, f64), EvalError> {
9367    match v {
9368        Value::Tuple(vs) if vs.len() == 3 => {
9369            let mut it = vs.into_iter();
9370            let abbrev = as_str(it.next().unwrap())?;
9371            let size_ratio = as_float(it.next().unwrap())?;
9372            let rising_ratio = as_float(it.next().unwrap())?;
9373            Ok((abbrev, size_ratio, rising_ratio))
9374        }
9375        other => eval_error(format!(
9376            "expected a font (string * float * float), got {}",
9377            other.type_name()
9378        )),
9379    }
9380}
9381
9382/// [`as_font`]'s V0_1 twin — saphe-split's `tFONTWR = font * float * float`,
9383/// whose head is the opaque handle rather than an abbrev.
9384fn as_font_with_ratio(v: Value) -> Result<(FontKey, f64, f64), EvalError> {
9385    match v {
9386        Value::Tuple(vs) if vs.len() == 3 => {
9387            let mut it = vs.into_iter();
9388            let key = as_font_key(it.next().unwrap())?;
9389            let size_ratio = as_float(it.next().unwrap())?;
9390            let rising_ratio = as_float(it.next().unwrap())?;
9391            Ok((key, size_ratio, rising_ratio))
9392        }
9393        other => eval_error(format!(
9394            "expected a font (font * float * float), got {}",
9395            other.type_name()
9396        )),
9397    }
9398}
9399
9400/// The opaque V0_1 `font` handle (upstream's `BCFontKey of FontKey.t`).
9401fn as_font_key(v: Value) -> Result<FontKey, EvalError> {
9402    match v {
9403        Value::Font(key) => Ok(key),
9404        other => eval_error(format!("expected a font, got {}", other.type_name())),
9405    }
9406}
9407
9408/// `set-text-color : color -> context -> context` (vminst.ml:1603) —
9409/// FAITHFUL store (`Context::text_color`, the `set-font-size` shape); it
9410/// rides on every `HorzStringInfo` and both PDF writers emit `rg`/`g`
9411/// before `Tj` for a non-black run.
9412fn prim_set_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9413    let ctx = as_context(args.pop().unwrap())?;
9414    let color = as_color(args.pop().unwrap())?;
9415    Ok(Value::Context(Box::new(Context {
9416        text_color: color,
9417        ..ctx
9418    })))
9419}
9420
9421/// `get-text-color : context -> color` (vminst.ml:1618) — FAITHFUL and
9422/// load-bearing: `itemize.satyh`'s `make-bullet` feeds this straight into
9423/// `fill color (Gr.circle …)`, so it must round-trip exactly what
9424/// `set-text-color` stored (see `make_color_value`'s doc comment).
9425fn prim_get_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9426    let ctx = as_context(args.pop().unwrap())?;
9427    Ok(make_color_value(ctx.text_color))
9428}
9429
9430/// `set-hyphen-penalty : int -> context -> context` (vminst.ml:1692) —
9431/// FAITHFUL store (`Context::hyphen_badness`), now a real consumer:
9432/// `text_to_boxes`'s `flush_word` uses this as each injected
9433/// `Discretionary`'s `penalty`, but only when a dictionary is installed via
9434/// `set-hyphenation-dictionary` — with no dictionary installed (the
9435/// default), this is stored but has no layout effect, same as before.
9436/// `code.satyh`'s `set-hyphen-penalty 100000` still works as "disable
9437/// hyphenation" (huge positive penalty, DP avoids it).
9438fn prim_set_hyphen_penalty(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9439    let ctx = as_context(args.pop().unwrap())?;
9440    let n = as_int(args.pop().unwrap())?;
9441    Ok(Value::Context(Box::new(Context {
9442        hyphen_badness: n,
9443        ..ctx
9444    })))
9445}
9446
9447/// `set-hyphen-min : int -> int -> context -> context` (upstream
9448/// `vminstdef.yaml:1163-1177`) — writes
9449/// `Context::left_hyphen_min`/`right_hyphen_min`, each clamped to `>= 0`
9450/// (mirrors `set-space-ratio`'s `.max(0.0)` clamping style; a negative
9451/// minimum would be meaningless to the min-fragment filter in
9452/// `crate::hyphenation::hyphenate_word`).
9453fn prim_set_hyphen_min(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9454    let ctx = as_context(args.pop().unwrap())?;
9455    let right = as_int(args.pop().unwrap())?.max(0);
9456    let left = as_int(args.pop().unwrap())?.max(0);
9457    Ok(Value::Context(Box::new(Context {
9458        left_hyphen_min: left,
9459        right_hyphen_min: right,
9460        ..ctx
9461    })))
9462}
9463
9464/// `set-space-ratio : float -> float -> float -> context -> context`
9465/// (vminst.ml:1309), params `(natural, shrink, stretch)` — FAITHFUL store
9466/// (`Context::space_natural`/`space_shrink`/`space_stretch`, clamped to
9467/// `>= 0.0` like upstream), read by `text_to_boxes`'s interword-glue
9468/// computation.
9469fn prim_set_space_ratio(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9470    let ctx = as_context(args.pop().unwrap())?;
9471    let stretch = as_float(args.pop().unwrap())?.max(0.0);
9472    let shrink = as_float(args.pop().unwrap())?.max(0.0);
9473    let natural = as_float(args.pop().unwrap())?.max(0.0);
9474    Ok(Value::Context(Box::new(Context {
9475        space_natural: natural,
9476        space_shrink: shrink,
9477        space_stretch: stretch,
9478        ..ctx
9479    })))
9480}
9481
9482/// `set-space-ratio-between-scripts : float -> float -> float -> script ->
9483/// script -> context -> context` (`vminstdef.yaml:1230-1250`) — writes one
9484/// ordered script pair's entry of `ctx.script_space_map`
9485/// (`convertText.ml:34-50` reads it back).
9486///
9487/// Only the NATURAL ratio is stored. The other two arguments are accepted and
9488/// dropped, because upstream drops them too: `pure_space_between_scripts`
9489/// misplaces them into `LBAtom`'s height and depth slots, so no
9490/// `set-space-ratio-between-scripts` call in any document has ever been able
9491/// to give this glue stretch or shrink. See [`interscript_glue`].
9492///
9493/// This was a STAND-IN that ignored all three, on the reasoning that slydifi
9494/// only ever calls it with `0. 0. 0.` to SUPPRESS the spacing and the port
9495/// inserted none anyway. The port has inserted Latin↔CJK glue for some time
9496/// now, so ignoring the call left slydifi with a 0.24em space at every
9497/// Japanese/Latin junction that upstream does not set.
9498fn prim_set_space_ratio_between_scripts(
9499    _interp: &mut Interp,
9500    mut args: Vec<Value>,
9501) -> Result<Value, EvalError> {
9502    let ctx = as_context(args.pop().unwrap())?;
9503    let script2 = as_script(args.pop().unwrap())?;
9504    let script1 = as_script(args.pop().unwrap())?;
9505    let _stretch = as_float(args.pop().unwrap())?;
9506    let _shrink = as_float(args.pop().unwrap())?;
9507    // `max 0.`, as `vminstdef.yaml`'s own `set_space_ratio_between_scripts`
9508    // clamps each ratio before storing it.
9509    let natural = as_float(args.pop().unwrap())?.max(0.0);
9510    let mut script_space_map = ctx.script_space_map;
9511    script_space_map[script1 as usize][script2 as usize] = natural;
9512    Ok(Value::Context(Box::new(Context {
9513        script_space_map,
9514        ..ctx
9515    })))
9516}
9517
9518/// `split-into-lines : string -> (int * string) list` (vminst.ml:2269) —
9519/// FAITHFUL: splits on `'\n'` and, per line, counts the leading ASCII spaces
9520/// `i` and returns `(i, rest_after_indent)` — exactly `evalUtil.ml:36`'s
9521/// `chop_space_indent`. Pure string op: no context, no box, no new type.
9522fn prim_split_into_lines(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9523    let s = as_str(args.pop().unwrap())?;
9524    let mut out = Vec::new();
9525    for line in s.split('\n') {
9526        let indent = line.chars().take_while(|c| *c == ' ').count();
9527        let rest: String = line.chars().skip(indent).collect();
9528        out.push(Value::Tuple(vec![
9529            Value::Int(indent as i64),
9530            Value::Str(rest),
9531        ]));
9532    }
9533    Ok(Value::List(out))
9534}
9535
9536/// Shift every content box in `block`'s `Line`s right by `pad_l`
9537/// (`block-frame-breakable`'s left-indent, point 4) — the simplest of the
9538/// two options that section names: adjusting each box's own `x` offset
9539/// directly rather than prepending an extra `FixedEmpty` box (`Skip`s carry
9540/// no `x` offsets to shift, so they pass through unchanged).
9541fn indent_left(block: Vec<VertBox>, pad_l: Length) -> Vec<VertBox> {
9542    block
9543        .into_iter()
9544        .map(|vb| match vb {
9545            VertBox::Line {
9546                height,
9547                depth,
9548                leading,
9549                contents,
9550            } => VertBox::Line {
9551                height,
9552                depth,
9553                leading,
9554                contents: contents
9555                    .into_iter()
9556                    .map(|(x, bx)| (x + pad_l, bx))
9557                    .collect(),
9558            },
9559            // `Skip`/`ClearPage`/`HookPageBreak` carry no `x` offsets to shift.
9560            other => other,
9561        })
9562        .collect()
9563}
9564
9565/// `block-frame-breakable : context -> paddings -> deco-set -> (context ->
9566/// block-boxes) -> block-boxes` (vminst.ml:1090) — the `inline-frame-outer`
9567/// playbook, one dimension up:
9568/// `paddingL`/`paddingR` shrink the inner `reducef` closure's context width,
9569/// and the result is indented and top/bottom-padded with plain `Skip`s,
9570/// bracketed by a `FrameStart(id)`/`FrameEnd(id)` marker pair — the frame's
9571/// pads/width/deco-set are interned into `interp.decos` under `id`
9572/// (`DecoEntry::Block`), and `fire_hooks`'s block-fragment pass fires
9573/// `decoS` once the frame's whole single-page fragment is placed (the first
9574/// cut: multi-page fragments/`decoH`/`decoM`/`decoT` are a documented
9575/// follow-up, see `fire_hooks`'s doc comment).
9576/// Drop the margin boxes at either END of a `block-frame-breakable`'s body —
9577/// the first inner block's top margin and the last inner block's bottom
9578/// margin, which upstream's `normalize` never produces for a frame's contents
9579/// (`pageBreak.ml:664` and `:582-585`; see the call site). Margins in the
9580/// MIDDLE of the body are untouched: those are real inter-block gaps, squashed
9581/// there exactly as they are outside a frame.
9582fn strip_outer_margins(body: &mut Vec<VertBox>) {
9583    let is_margin = |vb: &VertBox| matches!(vb, VertBox::Skip(_) | VertBox::ParagTop(_));
9584    if body.first().is_some_and(is_margin) {
9585        body.remove(0);
9586    }
9587    if body.last().is_some_and(is_margin) {
9588        body.pop();
9589    }
9590}
9591
9592fn prim_block_frame_breakable(
9593    interp: &mut Interp,
9594    version: RustyfiVersion,
9595    mut args: Vec<Value>,
9596) -> Result<Value, EvalError> {
9597    let k = args.pop().unwrap();
9598    let decoset = as_decoset(args.pop().unwrap())?;
9599    let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
9600    let ctx = as_context(args.pop().unwrap())?;
9601    let id = DecoId(interp.decos.len());
9602    interp.decos.push(DecoEntry::Block {
9603        pads: Paddings {
9604            l: pad_l,
9605            r: pad_r,
9606            t: pad_t,
9607            b: pad_b,
9608        },
9609        width: ctx.paragraph_width,
9610        decoset,
9611        // See `make_inline_frame`'s identical capture.
9612        version,
9613    });
9614    let inner_ctx = Context {
9615        paragraph_width: ctx.paragraph_width - pad_l - pad_r,
9616        ..ctx
9617    };
9618    let inner = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9619    let mut indented = indent_left(inner, pad_l);
9620    // THE FRAME CARRIES THE MARGINS, ITS BODY DOES NOT. Upstream normalizes a
9621    // frame's contents STANDALONE — `aux None TopMarginProhibited Alist.empty
9622    // vblstsub` (`pageBreak.ml:664`) — so the first inner block's `margin_top`
9623    // is never appended, and the last inner block's `margin_bottom` goes
9624    // through `squash_margins _ []`, whose empty-list arm (`:582-585`) emits
9625    // no skip at all. What surrounds the frame instead is the frame's OWN
9626    // `margins`, taken from the OUTER context (`vminstdef.yaml`'s
9627    // `BackendVertFrame`: `margin_top = ctx.paragraph_top`, `margin_bottom =
9628    // ctx.paragraph_bottom`), which `squash_margins` max-collapses against the
9629    // neighbouring blocks' margins exactly as `chop_page` collapses adjacent
9630    // `Skip`s.
9631    //
9632    // The distinction is not cosmetic: the body's `ParagTop` carries
9633    // `min_first_line_ascender` folded in (`prim_line_break`,
9634    // `lineBreak.ml:855-857`), and the frame's margin does NOT. Keeping the
9635    // body's made the advance INTO a frame a constant — `max(0, 9pt - hgt)`
9636    // cancels the first line's own height — where upstream's tracks the ink:
9637    // measured on `layout-tests/probes/code_line_height.saty` against real
9638    // SATySFi 0.0.11, `+code(`ooo`)` / `lll` / `ggg` advance 29.114 / 31.166 /
9639    // 29.138pt upstream and a flat 32.835pt here.
9640    strip_outer_margins(&mut indented);
9641    let mut out = Vec::with_capacity(indented.len() + 6);
9642    out.push(VertBox::Skip(ctx.paragraph_top));
9643    out.push(VertBox::FrameStart(id));
9644    out.push(VertBox::FramePad(pad_t));
9645    out.extend(indented);
9646    out.push(VertBox::FramePad(pad_b));
9647    out.push(VertBox::FrameEnd(id));
9648    out.push(VertBox::Skip(ctx.paragraph_bottom));
9649    Ok(Value::BlockBoxes(out))
9650}
9651
9652/// Build the `PureHorzBox::EmbeddedBlock` shared by `embed-block-top`
9653/// (vminst.ml:1145) and `embed-block-bottom` (vminst.ml:1185). FAITHFUL:
9654/// `anchor_last` selects which of `block`'s lines lands on the surrounding
9655/// text baseline — the FIRST for top (upstream's `adjust_to_first_line`) or
9656/// the LAST for bottom (`adjust_to_last_line`) — computed by placing the
9657/// block once (`place_block_at`) to find where each line's baseline falls,
9658/// then splitting the box's total vertical extent around the anchored line:
9659/// around the first line for TOP, so the box hangs DOWN from the baseline;
9660/// around the last line for BOTTOM, so it hangs UP. A degenerate line-less
9661/// block (only skips, no baseline to anchor) falls back to
9662/// `measure_block`'s skip-as-height sum for both.
9663fn make_embedded_block(
9664    width: Length,
9665    block: Vec<VertBox>,
9666    anchor_last: bool,
9667    breakable: bool,
9668) -> Value {
9669    let first_line_height = block.iter().find_map(|vb| match vb {
9670        VertBox::Line { height, .. } => Some(*height),
9671        _ => None,
9672    });
9673    let last_line_depth = block.iter().rev().find_map(|vb| match vb {
9674        VertBox::Line { depth, .. } => Some(*depth),
9675        _ => None,
9676    });
9677    let (height, depth) = match (first_line_height, last_line_depth) {
9678        // Place once to learn each line's baseline, then split the box's
9679        // total vertical extent around the anchored line: `place_block_at`
9680        // seats the first baseline at `first_h` (origin 0), so the block
9681        // spans `[0, last_baseline + last_d]`.
9682        (Some(first_h), Some(last_d)) => {
9683            let placed = place_block_at((Length::ZERO, Length::ZERO), block.clone());
9684            let last_baseline = placed.last().map(|l| l.baseline_y).unwrap_or(first_h);
9685            let bottom_edge = last_baseline + last_d;
9686            if anchor_last {
9687                (last_baseline, last_d)
9688            } else {
9689                (first_h, bottom_edge - first_h)
9690            }
9691        }
9692        // A degenerate line-less block (only skips — no baseline to anchor):
9693        // keep `measure_block` (its skip-as-height fallback is right there).
9694        _ => measure_block(&block),
9695    };
9696    Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::EmbeddedBlock {
9697        width,
9698        height,
9699        depth,
9700        block,
9701        anchor_last,
9702        breakable,
9703    })])
9704}
9705
9706/// `embed-block-top : context -> length -> (context -> block-boxes) ->
9707/// inline-boxes` (vminst.ml:1145) — see [`make_embedded_block`].
9708fn prim_embed_block_top(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9709    let k = args.pop().unwrap();
9710    let wid = as_length(args.pop().unwrap())?;
9711    let ctx = as_context(args.pop().unwrap())?;
9712    let inner_ctx = Context {
9713        paragraph_width: wid,
9714        ..ctx
9715    };
9716    let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9717    Ok(make_embedded_block(wid, block, false, false))
9718}
9719
9720/// `embed-block-bottom : context -> length -> (context -> block-boxes) ->
9721/// inline-boxes` (vminst.ml:1185) — see [`make_embedded_block`]; anchors the
9722/// LAST line, used by latexcmds' `\parbox?:(Bottom)`.
9723fn prim_embed_block_bottom(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9724    let k = args.pop().unwrap();
9725    let wid = as_length(args.pop().unwrap())?;
9726    let ctx = as_context(args.pop().unwrap())?;
9727    let inner_ctx = Context {
9728        paragraph_width: wid,
9729        ..ctx
9730    };
9731    let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9732    Ok(make_embedded_block(wid, block, true, false))
9733}
9734
9735/// `line-stack-bottom : inline-boxes list -> inline-boxes` (vminst.ml:1229,
9736/// `evalUtil.ml`'s `make_line_stack`) — FAITHFUL: each `inline-boxes` in the
9737/// list becomes exactly one line, fit (not broken) to the widest line's
9738/// natural width via `fit_cell` (this port's `LineBreak.fit`, already used
9739/// by the tabular grid solver — same "no `Context`, `natural_metrics`
9740/// height/depth" fallback upstream's `make_line_stack` needs since it too
9741/// has no context to lean on). Lines are stacked with zero extra margin
9742/// (upstream's `VertParagraph`s all have `margin_top`/`margin_bottom =
9743/// None`): each line's `leading` is set to the previous line's depth plus
9744/// this line's height, so consecutive baselines sit exactly
9745/// `prev_depth + this_height` apart (see `pagebreak.rs`'s
9746/// `leading.max(height)` placement formula — this choice makes that `max`
9747/// always resolve to our computed `leading`). See `line_stack` for the
9748/// shared body and [`prim_line_stack_top`] for the other half.
9749fn prim_line_stack_bottom(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9750    line_stack(args.pop().unwrap(), true)
9751}
9752
9753/// `line-stack-top : inline-boxes list -> inline-boxes`
9754/// (vminstdef.yaml:1109 `BackendLineStackTop`) — FAITHFUL: the same
9755/// `make_line_stack` construction as [`prim_line_stack_bottom`], differing
9756/// only in which stacked line's baseline becomes the result's — one shared
9757/// body and one flag rather than two copies that could drift.
9758///
9759/// `ruby` calls this to sit its annotation above the base run.
9760fn prim_line_stack_top(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9761    line_stack(args.pop().unwrap(), false)
9762}
9763
9764/// `evalUtil.ml`'s `make_line_stack` — shared body of the two `line-stack-*`
9765/// prims; `anchor_last` picks upstream's `adjust_to_last_line` (`true`) or
9766/// `adjust_to_first_line` (`false`).
9767fn line_stack(arg: Value, anchor_last: bool) -> Result<Value, EvalError> {
9768    let hblstlst = as_list(arg)?
9769        .into_iter()
9770        .map(as_inline_boxes)
9771        .collect::<Result<Vec<_>, _>>()?;
9772    let wid = hblstlst
9773        .iter()
9774        .map(|hbs| natural_metrics(hbs).0)
9775        .fold(Length::ZERO, |acc, w| if w > acc { w } else { acc });
9776    let mut block = Vec::with_capacity(hblstlst.len());
9777    let mut prev_depth = Length::ZERO;
9778    for (idx, hbs) in hblstlst.into_iter().enumerate() {
9779        let (contents, height, depth) = fit_cell(hbs, wid);
9780        let leading = if idx == 0 {
9781            height + depth
9782        } else {
9783            prev_depth + height
9784        };
9785        block.push(VertBox::Line {
9786            height,
9787            depth,
9788            leading,
9789            contents,
9790        });
9791        prev_depth = depth;
9792    }
9793    // `anchor_last` IS upstream's `adjust_to_last_line`/`adjust_to_first_line`
9794    // choice: `line-stack-bottom` is BOTTOM-anchored (SATySFi vminst.ml:1229 —
9795    // the result baseline is the LAST stacked line's baseline), so the box's
9796    // height spans everything above that last line and its depth is the last
9797    // line's depth. Top-anchoring it instead put the baseline at the FIRST
9798    // line, which dropped the whole stack below the baseline — e.g. figbox's
9799    // `margin`/`hvmargin` (a `line-stack-bottom` of [top-mgn; content;
9800    // bot-mgn]) had its content rendered below its frame (the E=mc² bug). For
9801    // `line-stack-top` the first line IS the right anchor, which is the whole
9802    // difference between the two prims.
9803    Ok(make_embedded_block(wid, block, anchor_last, false))
9804}
9805
9806/// `add-footnote : block-boxes -> inline-boxes` (vminst.ml:1130
9807/// `BackendAddFootnote`) — FAITHFUL: wraps the block in a zero-metric
9808/// `PureHorzBox::Footnote` marker (upstream `PHGFootnote`,
9809/// vminstdef.yaml:1034-1044; the upstream body's `PageBreak.solidify` is a
9810/// no-op here because this port's block-boxes are already solid
9811/// `Vec<VertBox>`). `chop_page` (rustyfi-backend) extracts the marker when
9812/// its line is committed to a page, reserves the stack's height at the
9813/// column bottom, and places the block bottom-aligned there — see that
9814/// function's doc comment. The cross-trial `changed`-flag protocol
9815/// `footnote-scheme.satyh` layers on top rides the crossref fixpoint.
9816fn prim_add_footnote(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9817    let block = as_block_boxes(args.pop().unwrap())?;
9818    Ok(Value::InlineBoxes(vec![HorzBox::Pure(
9819        PureHorzBox::Footnote { block },
9820    )]))
9821}
9822
9823/// `set-font : script -> string * float * float -> context -> context`
9824/// (0.0.6 `vminstdef.yaml:1335`, `tFONT` head) — real per-script wiring.
9825/// `abbrev` resolves through the font metrics
9826/// provider's registry first (`FontMetrics::resolve_font_abbrev` — a real
9827/// `TtfFontStore` built from `fonts.satysfi-hash`), falling back to
9828/// the 3-face name heuristic (`resolve_font_abbrev` free fn)
9829/// when the provider has no registry entry for it (an abbrev the config
9830/// doesn't name) — never an error, matching this
9831/// port's existing accept-and-degrade stance on unresolvable font names.
9832///
9833/// **Resolution rule (back-compat critical).** `Latin`-script text keeps
9834/// reading `Context::font` directly rather than `font_scheme[Latin]` (see
9835/// that field's doc comment) — `set-font Latin f` therefore writes BOTH so
9836/// the two stay in sync, but `set-font` on any OTHER script only touches
9837/// `font_scheme`, leaving `ctx.font` (and hence `set-font-key`/`\bold`/
9838/// `\emph`, which only ever read `ctx.font`) untouched.
9839fn prim_set_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9840    let mut ctx = as_context(args.pop().unwrap())?;
9841    let (abbrev, size_ratio, rising_ratio) = as_font(args.pop().unwrap())?;
9842    let script = as_script(args.pop().unwrap())?;
9843    let font = interp
9844        .metrics
9845        .resolve_font_abbrev(&abbrev)
9846        .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
9847    install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9848    Ok(Value::Context(Box::new(ctx)))
9849}
9850
9851/// `set-font : script -> font * float * float -> context -> context`
9852/// (saphe-split `tools/gencode/vminst.ml:1433`, `tFONTWR` head). Identical
9853/// to [`prim_set_font_v006`] except that the triple's head is ALREADY a
9854/// resolved handle — 0.1 has no abbrev at this point and nothing to resolve;
9855/// `load-single-font` did that when the font envelope's member was minted.
9856fn prim_set_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9857    let mut ctx = as_context(args.pop().unwrap())?;
9858    let (font, size_ratio, rising_ratio) = as_font_with_ratio(args.pop().unwrap())?;
9859    let script = as_script(args.pop().unwrap())?;
9860    install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9861    Ok(Value::Context(Box::new(ctx)))
9862}
9863
9864/// `get-font : script -> context -> string * float * float`
9865/// (vminstdef.yaml:1350 `PrimitiveGetFont`) — FAITHFUL: `script_font` IS
9866/// upstream's `get_font_with_ratio` (normalize the script, then read the
9867/// scheme slot), and the triple is `evalUtil.ml:196`'s `make_font_value`.
9868///
9869/// The head is a font ABBREV. Upstream's `font_scheme` stores abbrevs and
9870/// resolves them to files only at render time; this port resolves eagerly in
9871/// [`prim_set_font_v006`] and stores a `FontKey`, so the name comes back from
9872/// the store that minted it (`FontMetrics::font_abbrev`) and is `""` when the
9873/// key was never named by a registry — see that method for exactly when, and
9874/// why the corpus does not care (every caller in it, and upstream's own
9875/// `convertText.ml:78`, writes `let (_, ratio, _) =` and uses the RATIO,
9876/// which is exact).
9877///
9878/// This is what `ruby` and `quotation` need: the CJK face's size ratio, so a
9879/// ruby annotation or a two-em Japanese indent scales with the face rather
9880/// than with the Latin `get-font-size`.
9881fn prim_get_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9882    let ctx = as_context(args.pop().unwrap())?;
9883    let script = as_script(args.pop().unwrap())?;
9884    let sf = script_font(&ctx, script);
9885    let abbrev = interp.metrics.font_abbrev(sf.font).unwrap_or_default();
9886    Ok(Value::Tuple(vec![
9887        Value::Str(abbrev),
9888        Value::Float(sf.ratio),
9889        Value::Float(sf.rising),
9890    ]))
9891}
9892
9893/// `get-font : script -> context -> font * float * float` — the 0.1 arm,
9894/// mirroring [`prim_set_font_v006`]/[`prim_set_font_v01`]'s split. 0.1's
9895/// `font` IS the opaque handle this port already stores, so unlike the 0.0.6
9896/// arm there is nothing to recover: the value round-trips exactly.
9897fn prim_get_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9898    let ctx = as_context(args.pop().unwrap())?;
9899    let script = as_script(args.pop().unwrap())?;
9900    let sf = script_font(&ctx, script);
9901    Ok(Value::Tuple(vec![
9902        Value::Font(sf.font),
9903        Value::Float(sf.ratio),
9904        Value::Float(sf.rising),
9905    ]))
9906}
9907
9908/// The half of `set-font` that is NOT version-forked — the `Context` write
9909/// both arms end at, kept in one place so the 0.0.6 behaviour cannot drift
9910/// when the 0.1 one changes. See `prim_set_font_v006`'s "Resolution rule".
9911fn install_script_font(ctx: &mut Context, script: Script, font: FontKey, ratio: f64, rising: f64) {
9912    ctx.font_scheme[script as usize] = ScriptFont {
9913        font,
9914        ratio,
9915        rising,
9916    };
9917    if script == Script::Latin {
9918        ctx.font = font;
9919    }
9920}
9921
9922/// `set-code-text-command : [string] inline-cmd -> context -> context`
9923/// (`stdja:116`; no vminst.ml entry to cite). STAND-IN, same
9924/// shape as `set-math-command`/`set-math-font` above: `(command \cmd)`
9925/// means a real program CAN build a `[string]
9926/// inline-cmd` value to pass here — but `Context` (`rustyfi-backend`) still
9927/// cannot hold an arbitrary lang-side `Value` without a reverse crate
9928/// dependency, and the one seam this codebase uses for that indirection
9929/// (`Interp::hooks`'s ID-table, `eval.rs`) sits outside this file's
9930/// boundary — so the command argument is accepted (to keep the
9931/// arity/signature faithful) and dropped.
9932fn prim_set_code_text_command(
9933    interp: &mut Interp,
9934    mut args: Vec<Value>,
9935) -> Result<Value, EvalError> {
9936    let mut ctx = as_context(args.pop().unwrap())?;
9937    let cmd = args.pop().unwrap();
9938    ctx.code_text_command = Some(interp.register_math_command(cmd));
9939    Ok(Value::Context(Box::new(ctx)))
9940}
9941
9942/// `get-natural-length : block-boxes -> length` (vminst.ml:2040) —
9943/// FAITHFUL: `get-natural-width`'s block sibling (`get-natural-width` itself
9944/// is a `pervasives.satyh` wrapper over `get-natural-metrics`, not a
9945/// primitive). A block's own "natural length" is its total vertical extent
9946/// — `measure_block`'s two components (height above the nominal top, depth
9947/// of the last line) summed into one length.
9948fn prim_get_natural_length(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9949    let bb = as_block_boxes(args.pop().unwrap())?;
9950    let (height, depth) = measure_block(&bb);
9951    Ok(Value::Length(height + depth))
9952}
9953
9954/// `set-dominant-wide-script : script -> context -> context`
9955/// (vminst.ml:1511 `PrimitiveSetDominantWideScript`) — FAITHFUL store,
9956/// consumed by `get-dominant-wide-script` now, by CJK script normalization
9957/// later.
9958fn prim_set_dominant_wide_script(
9959    _interp: &mut Interp,
9960    mut args: Vec<Value>,
9961) -> Result<Value, EvalError> {
9962    let ctx = as_context(args.pop().unwrap())?;
9963    let dominant_wide_script = as_script(args.pop().unwrap())?;
9964    Ok(Value::Context(Box::new(Context {
9965        dominant_wide_script,
9966        ..ctx
9967    })))
9968}
9969
9970/// `set-dominant-narrow-script : script -> context -> context`
9971/// (vminst.ml:1539) — FAITHFUL store, mirror of the wide setter.
9972fn prim_set_dominant_narrow_script(
9973    _interp: &mut Interp,
9974    mut args: Vec<Value>,
9975) -> Result<Value, EvalError> {
9976    let ctx = as_context(args.pop().unwrap())?;
9977    let dominant_narrow_script = as_script(args.pop().unwrap())?;
9978    Ok(Value::Context(Box::new(Context {
9979        dominant_narrow_script,
9980        ..ctx
9981    })))
9982}
9983
9984/// `set-language : script -> language -> context -> context`
9985/// (vminst.ml:1568 `PrimitiveSetLangSys`) — FAITHFUL per-script map insert
9986/// (`langsys_scheme |> ScriptSchemeMap.add script langsys` upstream; a
9987/// 4-slot array write here).
9988fn prim_set_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9989    let ctx = as_context(args.pop().unwrap())?;
9990    let langsys = as_language(args.pop().unwrap())?;
9991    let script = as_script(args.pop().unwrap())?;
9992    let mut langsys_scheme = ctx.langsys_scheme;
9993    langsys_scheme[script as usize] = langsys;
9994    Ok(Value::Context(Box::new(Context {
9995        langsys_scheme,
9996        ..ctx
9997    })))
9998}
9999
10000/// `get-dominant-wide-script : context -> script` (vminst.ml:1526) — FAITHFUL.
10001fn prim_get_dominant_wide_script(
10002    _interp: &mut Interp,
10003    mut args: Vec<Value>,
10004) -> Result<Value, EvalError> {
10005    let ctx = as_context(args.pop().unwrap())?;
10006    Ok(make_script_value(ctx.dominant_wide_script))
10007}
10008
10009/// `get-dominant-narrow-script : context -> script` (vminst.ml:1555) — FAITHFUL.
10010fn prim_get_dominant_narrow_script(
10011    _interp: &mut Interp,
10012    mut args: Vec<Value>,
10013) -> Result<Value, EvalError> {
10014    let ctx = as_context(args.pop().unwrap())?;
10015    Ok(make_script_value(ctx.dominant_narrow_script))
10016}
10017
10018/// `get-language : script -> context -> language` (vminst.ml:1587
10019/// `PrimitiveGetLangSys`) — FAITHFUL. Upstream routes through
10020/// `get_language_system`, whose `normalize_script` step is the identity on
10021/// every script a VALUE can carry (only the char-decoder-internal
10022/// CommonNarrow/CommonWide/Inherited normalize, horzBox.ml:470-479), so
10023/// this is a plain indexed read; absent-entry default `NoLanguageSystem`
10024/// is baked into the array's initial value.
10025fn prim_get_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
10026    let ctx = as_context(args.pop().unwrap())?;
10027    let script = as_script(args.pop().unwrap())?;
10028    Ok(make_language_value(ctx.langsys_scheme[script as usize]))
10029}
10030
10031/// `set-every-word-break : inline-boxes -> inline-boxes -> context -> context`
10032/// (vminst.ml:3007 `PrimitiveSetEveryWordBreak`) — sets the inline-boxes
10033/// inserted before/after every inter-word break (mdja.satyh uses it for a
10034/// CJK word-break strut). STAND-IN: accepted and dropped (no per-context
10035/// every-word-break state yet), same pattern as `prim_set_language` above.
10036fn prim_set_every_word_break(
10037    _interp: &mut Interp,
10038    mut args: Vec<Value>,
10039) -> Result<Value, EvalError> {
10040    let ctx = as_context(args.pop().unwrap())?;
10041    let _after = args.pop().unwrap();
10042    let _before = args.pop().unwrap();
10043    Ok(Value::Context(Box::new(ctx)))
10044}
10045
10046/// `register-outline : (int * string * string * bool) list -> unit`
10047/// (vminstdef.yaml:2794 `BackendRegisterOutline`) — FAITHFUL: upstream
10048/// REPLACES the whole registered list (`outline.ml`: `registered_outline :=
10049/// ol`), it does not append; and it is callable anywhere (no
10050/// during-page-break gate — upstream's `Outline.register` has no `State`
10051/// check). Keys resolve through [`Interp::dest_name`] (upstream
10052/// `make_entry`'s `NamedDest.get key`).
10053fn prim_register_outline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
10054    let entries = as_list(args.pop().unwrap())?;
10055    let mut out = Vec::with_capacity(entries.len());
10056    for e in entries {
10057        let Value::Tuple(vs) = e else {
10058            return eval_error("register-outline expects a list of (int * string * string * bool)");
10059        };
10060        if vs.len() != 4 {
10061            return eval_error("register-outline expects 4-tuples (level, text, key, is-open)");
10062        }
10063        let mut it = vs.into_iter();
10064        let level = as_int(it.next().unwrap())?;
10065        let text = as_str(it.next().unwrap())?;
10066        let key = as_str(it.next().unwrap())?;
10067        let is_open = as_bool(it.next().unwrap())?;
10068        let dest_name = interp.dest_name(&key);
10069        out.push(OutlineEntry {
10070            level,
10071            text,
10072            dest_name,
10073            is_open,
10074        });
10075    }
10076    interp.outline = out; // replace, not extend
10077    Ok(Value::Unit)
10078}
10079
10080/// Recursive `extract_one` helper for [`prim_extract_string`] — mirrors
10081/// `horzBox.ml`'s `extract_string`'s `extract_one`: an `InnerString`
10082/// contributes its own text, a `Discretionary` recurses into `no_break`
10083/// (the "not yet broken" reading), every other box contributes nothing.
10084/// This port's box vocabulary has no separate Rising/Frame/ScriptGuard
10085/// wrapper (`inline-frame-breakable` et al. already flatten their padding
10086/// into the same flat `Vec<HorzBox>` — see `prim_inline_frame_breakable`),
10087/// so there is nothing else to recurse into.
10088fn extract_string_pure_one(phb: &PureHorzBox) -> String {
10089    match phb {
10090        PureHorzBox::InnerString { text, .. } => text.clone(),
10091        PureHorzBox::Discretionary { no_break, .. } => {
10092            no_break.iter().map(extract_string_pure_one).collect()
10093        }
10094        // Upstream `extract_string` recurses into frames.
10095        PureHorzBox::Frame { contents, .. } => contents
10096            .iter()
10097            .map(|(_, b)| extract_string_pure_one(b))
10098            .collect(),
10099        _ => String::new(),
10100    }
10101}
10102
10103fn extract_string_one(hb: &HorzBox) -> String {
10104    match hb {
10105        HorzBox::Pure(phb) => extract_string_pure_one(phb),
10106    }
10107}
10108
10109/// `extract-string : inline-boxes -> string` (vminstdef.yaml:1565
10110/// `PrimitiveExtract`) — FAITHFUL (see [`extract_string_one`]).
10111fn prim_extract_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
10112    let boxes = as_inline_boxes(args.pop().unwrap())?;
10113    let s: String = boxes.iter().map(extract_string_one).collect();
10114    Ok(Value::Str(s))
10115}
10116
10117/// `get-initial-text-info : unit -> text-info` (v0.0.6 vminst.ml:953
10118/// `TextGetInitialTextModeContext`) — FAITHFUL:
10119/// `TextBackend.get_initial_text_mode_context` is `{ indent = 0;
10120/// escape_list = [] }` (textBackend.ml:9-12); escape_list is omitted from
10121/// the port's `TextInfo` (see its doc comment). The v0.0.6 fork side.
10122fn prim_get_initial_text_info_v006(
10123    _interp: &mut Interp,
10124    mut args: Vec<Value>,
10125) -> Result<Value, EvalError> {
10126    let _unit = args.pop().unwrap();
10127    Ok(Value::TextInfo(TextInfo { indent: 0 }))
10128}
10129
10130/// `get-initial-text-info : inline [math-text] -> (string -> option string
10131/// -> option string -> string) -> text-info` (dev-0-1-0 vminst.ml:904-925)
10132/// — the v0.1 fork side. STAND-IN: pops and
10133/// drops both new arguments (the text-mode default math command and the
10134/// math-scripts stringifier) — this port's `TextInfo` carries no text-mode
10135/// command state, same degenerate policy as `stringify-math`. Returns the
10136/// same `TextInfo{indent: 0}` as the v0.0.6 side.
10137fn prim_get_initial_text_info_v01(
10138    _interp: &mut Interp,
10139    mut args: Vec<Value>,
10140) -> Result<Value, EvalError> {
10141    let _stringifier = args.pop().unwrap();
10142    let _default_math_cmd = args.pop().unwrap();
10143    Ok(Value::TextInfo(TextInfo { indent: 0 }))
10144}
10145
10146/// `deepen-indent : int -> text-info -> text-info` (vminst.ml:921
10147/// `TextDeepenIndent`) — FAITHFUL: `indent + max i 0`
10148/// (`TextBackend.deepen_indent`, textBackend.ml:15-16 — the INCREMENT is
10149/// clamped, not the total).
10150fn prim_deepen_indent(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
10151    let tinfo = as_text_info(args.pop().unwrap())?;
10152    let i = as_int(args.pop().unwrap())?;
10153    Ok(Value::TextInfo(TextInfo {
10154        indent: tinfo.indent + i.max(0),
10155    }))
10156}
10157
10158/// `break : text-info -> string` (vminst.ml:935 `TextBreak`) — FAITHFUL:
10159/// `"\n" ^ String.make indent ' '` (`TextBackend.get_indent`).
10160fn prim_break(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
10161    let tinfo = as_text_info(args.pop().unwrap())?;
10162    let mut s = String::with_capacity(1 + tinfo.indent as usize);
10163    s.push('\n');
10164    for _ in 0..tinfo.indent {
10165        s.push(' ');
10166    }
10167    Ok(Value::Str(s))
10168}
10169
10170// ============================================================================
10171// unit tests: `as_page` (every paper-size ctor),
10172// `read_content_scheme`/`read_parts_scheme` (field extraction +
10173// missing-field errors). These extractors are private, so the tests live
10174// in-module rather than in `tests/`, same pattern as `crossref.rs`'s own
10175// `#[cfg(test)] mod tests`.
10176// ============================================================================
10177#[cfg(test)]
10178mod page_model_tests {
10179    use super::*;
10180
10181    #[test]
10182    fn as_page_maps_every_nullary_ctor_to_the_right_paper_size() {
10183        let cases: &[(&str, PaperSize)] = &[
10184            ("A0Paper", PaperSize::A0),
10185            ("A1Paper", PaperSize::A1),
10186            ("A2Paper", PaperSize::A2),
10187            ("A3Paper", PaperSize::A3),
10188            ("A4Paper", PaperSize::A4),
10189            ("A5Paper", PaperSize::A5),
10190            ("USLetter", PaperSize::USLetter),
10191            ("USLegal", PaperSize::USLegal),
10192        ];
10193        for (name, expected) in cases {
10194            let v = Value::Ctor((*name).to_string(), None);
10195            assert_eq!(as_page(v).unwrap(), *expected, "ctor {name}");
10196        }
10197    }
10198
10199    #[test]
10200    fn as_page_unwraps_user_defined_papers_tuple_payload() {
10201        let v = Value::Ctor(
10202            "UserDefinedPaper".to_string(),
10203            Some(Box::new(Value::Tuple(vec![
10204                Value::Length(Length::pt(100.0)),
10205                Value::Length(Length::pt(200.0)),
10206            ]))),
10207        );
10208        assert_eq!(
10209            as_page(v).unwrap(),
10210            PaperSize::UserDefined(Length::pt(100.0), Length::pt(200.0))
10211        );
10212    }
10213
10214    #[test]
10215    fn a4_paper_dims_are_595_by_842_points() {
10216        let (w, h) = PaperSize::A4.dims();
10217        assert!((w.0 - 595.0).abs() < 1.0, "width: {}", w.0);
10218        assert!((h.0 - 842.0).abs() < 1.0, "height: {}", h.0);
10219    }
10220
10221    #[test]
10222    fn read_content_scheme_extracts_origin_and_height() {
10223        let mut fields = BTreeMap::new();
10224        fields.insert(
10225            "text-origin".to_string(),
10226            Value::Tuple(vec![
10227                Value::Length(Length::pt(10.0)),
10228                Value::Length(Length::pt(20.0)),
10229            ]),
10230        );
10231        fields.insert("text-height".to_string(), Value::Length(Length::pt(300.0)));
10232        let (origin, height) = read_content_scheme(Value::Record(fields)).unwrap();
10233        assert_eq!(origin, (Length::pt(10.0), Length::pt(20.0)));
10234        assert_eq!(height, Length::pt(300.0));
10235    }
10236
10237    #[test]
10238    fn read_content_scheme_errors_on_a_missing_field() {
10239        let mut fields = BTreeMap::new();
10240        fields.insert(
10241            "text-origin".to_string(),
10242            Value::Tuple(vec![
10243                Value::Length(Length::ZERO),
10244                Value::Length(Length::ZERO),
10245            ]),
10246        );
10247        let err = read_content_scheme(Value::Record(fields)).unwrap_err();
10248        assert!(
10249            err.msg.contains("text-height"),
10250            "error should name the missing field: {}",
10251            err.msg
10252        );
10253    }
10254
10255    #[test]
10256    fn read_parts_scheme_extracts_all_four_fields() {
10257        let mut fields = BTreeMap::new();
10258        fields.insert(
10259            "header-origin".to_string(),
10260            Value::Tuple(vec![
10261                Value::Length(Length::ZERO),
10262                Value::Length(Length::ZERO),
10263            ]),
10264        );
10265        fields.insert("header-content".to_string(), Value::BlockBoxes(Vec::new()));
10266        fields.insert(
10267            "footer-origin".to_string(),
10268            Value::Tuple(vec![
10269                Value::Length(Length::pt(1.0)),
10270                Value::Length(Length::pt(2.0)),
10271            ]),
10272        );
10273        fields.insert("footer-content".to_string(), Value::BlockBoxes(Vec::new()));
10274        let (horg, hbb, forg, fbb) = read_parts_scheme(Value::Record(fields)).unwrap();
10275        assert_eq!(horg, (Length::ZERO, Length::ZERO));
10276        assert!(hbb.is_empty());
10277        assert_eq!(forg, (Length::pt(1.0), Length::pt(2.0)));
10278        assert!(fbb.is_empty());
10279    }
10280
10281    #[test]
10282    fn read_parts_scheme_errors_on_a_missing_field() {
10283        let err = read_parts_scheme(Value::Record(BTreeMap::new())).unwrap_err();
10284        assert!(
10285            err.msg.contains("header-origin"),
10286            "error should name the missing field: {}",
10287            err.msg
10288        );
10289    }
10290}
10291
10292/// `enter_script_scales_and_saturates`:
10293/// `enter_script` is crate-private, so this lives here rather than in the
10294/// external `tests/v01_math.rs` integration suite, which can only reach
10295/// `pub` items.
10296#[cfg(test)]
10297mod math_split_tests {
10298    use super::*;
10299    use rustyfi_backend::FontMetrics;
10300
10301    /// A `FontMetrics` stub with NO MATH table (`math_constants` defaults
10302    /// to `None`) — exercises `enter_script`'s documented fallback
10303    /// constants (`0.7`, `5.0/7.0`), the shape every other base-14 fixture
10304    /// in this crate already relies on.
10305    struct NoMath;
10306    impl FontMetrics for NoMath {
10307        fn advance(&self, _f: FontKey, c: char, size: Length) -> Option<Length> {
10308            if c.is_ascii() {
10309                Some(size * 0.5)
10310            } else {
10311                None
10312            }
10313        }
10314        fn ascender(&self, _f: FontKey, size: Length) -> Length {
10315            size * 0.75
10316        }
10317        fn descender(&self, _f: FontKey, size: Length) -> Length {
10318            size * 0.25
10319        }
10320    }
10321
10322    #[test]
10323    fn enter_script_scales_and_saturates() {
10324        let metrics = NoMath;
10325        let interp = Interp::new(&metrics);
10326        let ctx = Context::initial(Length::pt(400.0));
10327        assert_eq!(ctx.math_script_level, MathScriptLevel::Base);
10328        assert_eq!(ctx.font_size, Length::pt(12.0));
10329
10330        // Base -> Script: font_size * script_scale_down (fallback 0.7).
10331        let s1 = enter_script(&interp, &ctx);
10332        assert_eq!(s1.math_script_level, MathScriptLevel::Script);
10333        assert!(
10334            (s1.font_size.0 - ctx.font_size.0 * 0.7).abs() < 1e-9,
10335            "expected {} * 0.7, got {}",
10336            ctx.font_size.0,
10337            s1.font_size.0
10338        );
10339
10340        // Script -> ScriptScript: font_size * (script_script_scale_down /
10341        // script_scale_down) (fallback 5.0/7.0).
10342        let s2 = enter_script(&interp, &s1);
10343        assert_eq!(s2.math_script_level, MathScriptLevel::ScriptScript);
10344        assert!(
10345            (s2.font_size.0 - s1.font_size.0 * (5.0 / 7.0)).abs() < 1e-9,
10346            "expected {} * 5/7, got {}",
10347            s1.font_size.0,
10348            s2.font_size.0
10349        );
10350
10351        // ScriptScript saturates: no further shrink, level stays put.
10352        let s3 = enter_script(&interp, &s2);
10353        assert_eq!(s3.math_script_level, MathScriptLevel::ScriptScript);
10354        assert_eq!(s3.font_size, s2.font_size);
10355    }
10356}