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 Ok(match re.match_at(&chars, 0) {
855 Some(end) => {
856 let matched: String = chars[..end].iter().collect();
857 let rest: String = chars[end..].iter().collect();
858 Value::Ctor(
859 "Some".to_string(),
860 Some(Box::new(Value::Tuple(vec![
861 Value::Str(matched),
862 Value::Str(rest),
863 ]))),
864 )
865 }
866 None => Value::Ctor("None".to_string(), None),
867 })
868}
869
870fn regexp_full_match(pattern: &str, input: &str) -> bool {
871 let p: Vec<char> = pattern.chars().collect();
872 if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
873 // A character class matches exactly one character.
874 let mut chars = input.chars();
875 match (chars.next(), chars.next()) {
876 (Some(c), None) => char_in_class(&p[1..p.len() - 1], c),
877 _ => false,
878 }
879 } else {
880 input == pattern
881 }
882}
883
884// `split-on-regexp : regexp -> string -> (int * string) list` — split `input`
885// at every character matching the (single-character) pattern, pairing each
886// resulting segment with its starting code-point offset. Handles the pattern
887// forms base uses: a `[…]` class, an escaped literal (`\.`), or a bare
888// literal character; anything else never matches (one segment = the whole
889// string).
890fn prim_split_on_regexp(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
891 let input = as_str(args.pop().unwrap())?;
892 let pattern = as_str(args.pop().unwrap())?;
893 let is_delim = single_char_matcher(&pattern);
894 let mut segments: Vec<Value> = Vec::new();
895 let mut seg_start = 0usize;
896 let mut cur = String::new();
897 for (idx, c) in input.chars().enumerate() {
898 if is_delim(c) {
899 segments.push(Value::Tuple(vec![
900 Value::Int(seg_start as i64),
901 Value::Str(std::mem::take(&mut cur)),
902 ]));
903 seg_start = idx + 1;
904 } else {
905 cur.push(c);
906 }
907 }
908 segments.push(Value::Tuple(vec![
909 Value::Int(seg_start as i64),
910 Value::Str(cur),
911 ]));
912 Ok(Value::List(segments))
913}
914
915/// A predicate matching one character against a `regexp` pattern's single-char
916/// forms (a `[…]` class, an escaped literal `\X`, or a bare literal char).
917fn single_char_matcher(pattern: &str) -> Box<dyn Fn(char) -> bool> {
918 let p: Vec<char> = pattern.chars().collect();
919 if p.len() >= 2 && p[0] == '[' && p[p.len() - 1] == ']' {
920 let cls: Vec<char> = p[1..p.len() - 1].to_vec();
921 Box::new(move |c| char_in_class(&cls, c))
922 } else if p.len() == 2 && p[0] == '\\' {
923 let lit = p[1];
924 Box::new(move |c| c == lit)
925 } else if p.len() == 1 {
926 let lit = p[0];
927 Box::new(move |c| c == lit)
928 } else {
929 Box::new(|_| false)
930 }
931}
932
933fn char_in_class(cls: &[char], c: char) -> bool {
934 let (neg, cls) = match cls.first() {
935 Some('^') => (true, &cls[1..]),
936 _ => (false, cls),
937 };
938 let mut i = 0;
939 let mut found = false;
940 while i < cls.len() {
941 if i + 2 < cls.len() && cls[i + 1] == '-' {
942 if cls[i] <= c && c <= cls[i + 2] {
943 found = true;
944 }
945 i += 3;
946 } else {
947 if cls[i] == c {
948 found = true;
949 }
950 i += 1;
951 }
952 }
953 found ^ neg
954}
955
956fn as_length(v: Value) -> Result<Length, EvalError> {
957 match v {
958 Value::Length(l) => Ok(l),
959 other => eval_error(format!("expected length, got {}", other.type_name())),
960 }
961}
962
963fn as_list(v: Value) -> Result<Vec<Value>, EvalError> {
964 match v {
965 Value::List(items) => Ok(items),
966 other => eval_error(format!("expected list, got {}", other.type_name())),
967 }
968}
969
970fn as_image(v: Value) -> Result<ImageId, EvalError> {
971 match v {
972 Value::Image(id) => Ok(id),
973 other => eval_error(format!("expected image, got {}", other.type_name())),
974 }
975}
976
977// ---- graphics argument extractors ------------------------------------------
978
979/// `point` = `Value::Tuple([Length, Length])` (mirrors `evalUtil.ml:228`'s
980/// point extraction).
981fn as_point(v: Value) -> Result<Point, EvalError> {
982 match v {
983 Value::Tuple(vs) if vs.len() == 2 => {
984 let mut it = vs.into_iter();
985 let x = as_length(it.next().unwrap())?;
986 let y = as_length(it.next().unwrap())?;
987 Ok((x, y))
988 }
989 other => eval_error(format!(
990 "expected a point (length * length), got {}",
991 other.type_name()
992 )),
993 }
994}
995
996/// `color` = `Value::Ctor("Gray"|"RGB"|"CMYK", ..)` (mirrors
997/// `evalUtil.ml:124`'s `get_color` exactly — a wrong shape here would
998/// surface only at draw time).
999fn as_color(v: Value) -> Result<Color, EvalError> {
1000 match v {
1001 Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1002 ("Gray", Some(p)) => Ok(Color::Gray(as_float(p)?)),
1003 ("RGB", Some(Value::Tuple(vs))) if vs.len() == 3 => {
1004 let mut it = vs.into_iter();
1005 let r = as_float(it.next().unwrap())?;
1006 let g = as_float(it.next().unwrap())?;
1007 let b = as_float(it.next().unwrap())?;
1008 Ok(Color::Rgb(r, g, b))
1009 }
1010 ("CMYK", Some(Value::Tuple(vs))) if vs.len() == 4 => {
1011 let mut it = vs.into_iter();
1012 let c = as_float(it.next().unwrap())?;
1013 let m = as_float(it.next().unwrap())?;
1014 let y = as_float(it.next().unwrap())?;
1015 let k = as_float(it.next().unwrap())?;
1016 Ok(Color::Cmyk(c, m, y, k))
1017 }
1018 (other, _) => eval_error(format!(
1019 "expected a color (Gray/RGB/CMYK), got variant '{other}'"
1020 )),
1021 },
1022 other => eval_error(format!("expected a color, got {}", other.type_name())),
1023 }
1024}
1025
1026/// `script` = nullary `Value::Ctor` (prim_types.rs `script_decl`); mirrors
1027/// upstream `get_script` (evalUtil.ml:235-241).
1028fn as_script(v: Value) -> Result<Script, EvalError> {
1029 match v {
1030 Value::Ctor(name, None) => match name.as_str() {
1031 "HanIdeographic" => Ok(Script::HanIdeographic),
1032 "Kana" => Ok(Script::Kana),
1033 "Latin" => Ok(Script::Latin),
1034 "OtherScript" => Ok(Script::OtherScript),
1035 other => eval_error(format!("expected a script, got variant '{other}'")),
1036 },
1037 other => eval_error(format!("expected a script, got {}", other.type_name())),
1038 }
1039}
1040
1041/// Inverse of [`as_script`] (upstream `make_script_value`, evalUtil.ml:244).
1042fn make_script_value(s: Script) -> Value {
1043 let name = match s {
1044 Script::HanIdeographic => "HanIdeographic",
1045 Script::Kana => "Kana",
1046 Script::Latin => "Latin",
1047 Script::OtherScript => "OtherScript",
1048 };
1049 Value::Ctor(name.to_string(), None)
1050}
1051
1052/// `language` = nullary `Value::Ctor` (prim_types.rs `language_decl`);
1053/// mirrors upstream `get_language_system` (evalUtil.ml:262).
1054fn as_language(v: Value) -> Result<Language, EvalError> {
1055 match v {
1056 Value::Ctor(name, None) => match name.as_str() {
1057 "Japanese" => Ok(Language::Japanese),
1058 "English" => Ok(Language::English),
1059 "NoLanguageSystem" => Ok(Language::NoLanguageSystem),
1060 other => eval_error(format!("expected a language, got variant '{other}'")),
1061 },
1062 other => eval_error(format!("expected a language, got {}", other.type_name())),
1063 }
1064}
1065
1066/// Inverse of [`as_language`] (upstream `make_language_system_value`).
1067fn make_language_value(l: Language) -> Value {
1068 let name = match l {
1069 Language::Japanese => "Japanese",
1070 Language::English => "English",
1071 Language::NoLanguageSystem => "NoLanguageSystem",
1072 };
1073 Value::Ctor(name.to_string(), None)
1074}
1075
1076/// `page` = `Value::Ctor("A4Paper"|.., None | Some(Tuple[Length;2]))`
1077/// — `page-break`'s first argument, mapped to the backend's
1078/// `PaperSize`.
1079fn as_page(v: Value) -> Result<PaperSize, EvalError> {
1080 match v {
1081 Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1082 ("A0Paper", None) => Ok(PaperSize::A0),
1083 ("A1Paper", None) => Ok(PaperSize::A1),
1084 ("A2Paper", None) => Ok(PaperSize::A2),
1085 ("A3Paper", None) => Ok(PaperSize::A3),
1086 ("A4Paper", None) => Ok(PaperSize::A4),
1087 ("A5Paper", None) => Ok(PaperSize::A5),
1088 ("USLetter", None) => Ok(PaperSize::USLetter),
1089 ("USLegal", None) => Ok(PaperSize::USLegal),
1090 ("UserDefinedPaper", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1091 let mut it = vs.into_iter();
1092 let w = as_length(it.next().unwrap())?;
1093 let h = as_length(it.next().unwrap())?;
1094 Ok(PaperSize::UserDefined(w, h))
1095 }
1096 (other, _) => eval_error(format!(
1097 "expected a page (A4Paper/.../UserDefinedPaper), got variant '{other}'"
1098 )),
1099 },
1100 other => eval_error(format!("expected a page, got {}", other.type_name())),
1101 }
1102}
1103
1104/// v0.1's `page-break`'s first argument: a plain `(length * length)` tuple
1105/// — the `page` ADT (`as_page` above) no longer exists upstream in 0.1.
1106/// Maps straight into `PaperSize::UserDefined`, the exact same backend
1107/// value `as_page`'s own `UserDefinedPaper` arm produces: the retype drops
1108/// the ADT wrapper without changing what geometry `page-break` can
1109/// express, so only the source `Value` shape differs.
1110fn as_page_v01(v: Value) -> Result<PaperSize, EvalError> {
1111 match v {
1112 Value::Tuple(vs) if vs.len() == 2 => {
1113 let mut it = vs.into_iter();
1114 let w = as_length(it.next().unwrap())?;
1115 let h = as_length(it.next().unwrap())?;
1116 Ok(PaperSize::UserDefined(w, h))
1117 }
1118 other => eval_error(format!(
1119 "expected a page as (length * length), got {}",
1120 other.type_name()
1121 )),
1122 }
1123}
1124
1125/// `paddings` = `Value::Tuple([Length; 4])` in `(paddingL, paddingR,
1126/// paddingT, paddingB)` order (mirrors `evalUtil.ml`'s `get_paddings`).
1127/// `inline-frame-outer`'s first argument.
1128fn as_paddings(v: Value) -> Result<(Length, Length, Length, Length), EvalError> {
1129 match v {
1130 Value::Tuple(vs) if vs.len() == 4 => {
1131 let mut it = vs.into_iter();
1132 let l = as_length(it.next().unwrap())?;
1133 let r = as_length(it.next().unwrap())?;
1134 let t = as_length(it.next().unwrap())?;
1135 let b = as_length(it.next().unwrap())?;
1136 Ok((l, r, t, b))
1137 }
1138 other => eval_error(format!(
1139 "expected paddings (length * length * length * length), got {}",
1140 other.type_name()
1141 )),
1142 }
1143}
1144
1145/// `cell` = `Value::Ctor("NormalCell"|"EmptyCell"|"MultiCell", ..)` (mirrors
1146/// `evalUtil.ml:102`'s `get_cell`) — `tabular`'s grid entries;
1147fn as_cell(v: Value) -> Result<Cell, EvalError> {
1148 match v {
1149 Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
1150 ("NormalCell", Some(Value::Tuple(vs))) if vs.len() == 2 => {
1151 let mut it = vs.into_iter();
1152 let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1153 let ib = as_inline_boxes(it.next().unwrap())?;
1154 Ok(Cell::Normal(Paddings { l, r, t, b }, ib))
1155 }
1156 ("EmptyCell", None) => Ok(Cell::Empty),
1157 ("MultiCell", Some(Value::Tuple(vs))) if vs.len() == 4 => {
1158 let mut it = vs.into_iter();
1159 let numrow = as_int(it.next().unwrap())?;
1160 let numcol = as_int(it.next().unwrap())?;
1161 let (l, r, t, b) = as_paddings(it.next().unwrap())?;
1162 let ib = as_inline_boxes(it.next().unwrap())?;
1163 Ok(Cell::Multi(
1164 numrow.max(0) as usize,
1165 numcol.max(0) as usize,
1166 Paddings { l, r, t, b },
1167 ib,
1168 ))
1169 }
1170 (other, _) => eval_error(format!(
1171 "expected a cell (NormalCell/EmptyCell/MultiCell), got variant '{other}'"
1172 )),
1173 },
1174 other => eval_error(format!("expected a cell, got {}", other.type_name())),
1175 }
1176}
1177
1178/// `(cell list) list` — `tabular`'s first argument.
1179fn as_cell_grid(v: Value) -> Result<Vec<Vec<Cell>>, EvalError> {
1180 as_list(v)?
1181 .into_iter()
1182 .map(|row| -> Result<Vec<Cell>, EvalError> {
1183 as_list(row)?.into_iter().map(as_cell).collect()
1184 })
1185 .collect()
1186}
1187
1188fn as_prepath(v: Value) -> Result<PrePath, EvalError> {
1189 match v {
1190 Value::PrePath(p) => Ok(p),
1191 other => eval_error(format!("expected pre-path, got {}", other.type_name())),
1192 }
1193}
1194
1195fn as_path(v: Value) -> Result<Path, EvalError> {
1196 match v {
1197 Value::Path(p) => Ok(p),
1198 other => eval_error(format!("expected path, got {}", other.type_name())),
1199 }
1200}
1201
1202fn as_graphics(v: Value) -> Result<GraphicsElem, EvalError> {
1203 match v {
1204 Value::Graphics(g) => Ok(g),
1205 other => eval_error(format!("expected graphics, got {}", other.type_name())),
1206 }
1207}
1208
1209/// `dash` = `length * length * length` (mirrors `evalUtil.ml`'s `get_tuple3
1210/// get_length`) — `dashed-stroke`'s 2nd argument, `(d1, d2, d0)` = on-length,
1211/// off-length, phase.
1212fn as_dash(v: Value) -> Result<Dash, EvalError> {
1213 match v {
1214 Value::Tuple(vs) if vs.len() == 3 => {
1215 let mut it = vs.into_iter();
1216 let d1 = as_length(it.next().unwrap())?;
1217 let d2 = as_length(it.next().unwrap())?;
1218 let d0 = as_length(it.next().unwrap())?;
1219 Ok((d1, d2, d0))
1220 }
1221 other => eval_error(format!(
1222 "expected a dash pattern (length * length * length), got {}",
1223 other.type_name()
1224 )),
1225 }
1226}
1227
1228/// The inverse of `as_point` (mirrors `evalUtil.ml:228`'s point
1229/// construction) — used by `inline-graphics` to build the `(0pt, 0pt)`
1230/// origin its callback is (eagerly) invoked with; see that primitive's doc
1231/// comment for the shift-covariance caveat this stands in for.
1232fn make_point_value(pt: Point) -> Value {
1233 Value::Tuple(vec![Value::Length(pt.0), Value::Length(pt.1)])
1234}
1235
1236/// `length list` construction (mirrors `evalUtil.ml:709`) — builds the
1237/// box-local grid-line coordinates `tabular`'s rule callback is (eagerly)
1238/// invoked with; see `prim_tabular`'s doc comment.
1239fn make_length_list(lens: &[Length]) -> Value {
1240 Value::List(lens.iter().map(|l| Value::Length(*l)).collect())
1241}
1242
1243// ---- primitive-body macros ----------------------------------------------------
1244//
1245// The arithmetic, comparison, boolean, and unary-conversion primitives all
1246// share one strict-call shape: the (already-evaluated) operands are popped
1247// right-to-left through a type extractor, then the result is re-wrapped as a
1248// `Value`. These macros capture that shape so each primitive is a single line.
1249// The vminst.ml citations for each stay on the `prims!` registration table
1250// above; per-primitive notes ride along on the invocations below.
1251
1252/// A strict binary primitive. Pops `b` then `a` (i.e. rightmost argument
1253/// first, matching application order) through the given extractor(s) and wraps
1254/// `body` as `Value::$ctor`. Accepts either one extractor for both operands or
1255/// a `(as_a, as_b)` pair when the operands have different types.
1256macro_rules! binop_prim {
1257 ($name:ident, ($as_a:path, $as_b:path), $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1258 fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1259 let $b = $as_b(args.pop().unwrap())?;
1260 let $a = $as_a(args.pop().unwrap())?;
1261 Ok(Value::$ctor($body))
1262 }
1263 };
1264 ($name:ident, $as:path, $ctor:ident, |$a:ident, $b:ident| $body:expr) => {
1265 binop_prim!($name, ($as, $as), $ctor, |$a, $b| $body);
1266 };
1267}
1268
1269/// A strict binary comparison: like `binop_prim!` but always wraps as
1270/// `Value::Bool`.
1271macro_rules! cmp_prim {
1272 ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1273 binop_prim!($name, ($as, $as), Bool, |$a, $b| $body);
1274 };
1275}
1276
1277/// A strict unary primitive: pops one operand through `as` and wraps `body`
1278/// as `Value::$ctor`.
1279macro_rules! unop_prim {
1280 ($name:ident, $as:path, $ctor:ident, |$a:ident| $body:expr) => {
1281 fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1282 let $a = $as(args.pop().unwrap())?;
1283 Ok(Value::$ctor($body))
1284 }
1285 };
1286}
1287
1288/// A strict binary primitive with a fallible body: `body` is the function's
1289/// tail expression and must itself yield `Result<Value, EvalError>`, so it can
1290/// guard cases like division by zero.
1291macro_rules! binop_prim_try {
1292 ($name:ident, $as:path, |$a:ident, $b:ident| $body:expr) => {
1293 fn $name(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
1294 let $b = $as(args.pop().unwrap())?;
1295 let $a = $as(args.pop().unwrap())?;
1296 $body
1297 }
1298 };
1299}
1300
1301// ---- text conversion ----------------------------------------------------------
1302
1303/// Convert quoted inline text to boxes under `ctx` (the core of
1304/// `read-inline`): words become measured `InnerString`s, whitespace becomes
1305/// glue, embedded commands are applied to `ctx` and their arguments.
1306pub fn read_inline(
1307 interp: &mut Interp,
1308 ctx: &Context,
1309 elems: &[IText],
1310 env: &Env,
1311) -> Result<Vec<HorzBox>, EvalError> {
1312 let mut out = Vec::new();
1313 for elem in elems {
1314 match elem {
1315 IText::Text(text) => text_to_boxes(interp, ctx, text, &mut out)?,
1316 // `ImInputHorzEmbeddedCodeText` (`evaluator.cppo.ml:768-779`): hand
1317 // the literal to the context's code-text command if one is
1318 // installed, else set it as ordinary text
1319 // (`DefaultCodeTextCommand`).
1320 IText::CodeText(text) => match ctx.code_text_command {
1321 Some(id) => {
1322 let cmd = interp.math_commands[id.0].clone();
1323 let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1324 let v = interp.apply(v, Value::Str(text.clone()))?;
1325 out.extend(as_inline_boxes(v)?);
1326 }
1327 None => text_to_boxes(interp, ctx, text, &mut out)?,
1328 },
1329 IText::Cmd { cmd, args } => {
1330 // Resolved at compile time (`crate::quoted`); running it can
1331 // still raise the same "unbound inline command" error for the
1332 // defensive case the compiler could not resolve.
1333 let cmd = cmd.run(env, interp)?;
1334 let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1335 for arg in args {
1336 let mut opt_vals = Vec::with_capacity(arg.opts.len());
1337 for (label, e) in &arg.opts {
1338 opt_vals.push((label.clone(), e.run(env, interp)?));
1339 }
1340 let arg_v = arg.arg.run(env, interp)?;
1341 v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1342 }
1343 out.extend(as_inline_boxes(v)?);
1344 }
1345 IText::Embed { expr, span } => {
1346 let v = expr.run(env, interp)?;
1347 match v {
1348 Value::InlineText {
1349 elems: sub_elems,
1350 env: cap_env,
1351 } => {
1352 out.extend(read_inline(interp, ctx, &sub_elems, &cap_env)?);
1353 }
1354 other => {
1355 return Err(EvalError {
1356 span: Some(*span),
1357 msg: format!(
1358 "expected inline-text in '#…;' embed, got {}",
1359 other.type_name()
1360 ),
1361 });
1362 }
1363 }
1364 }
1365 IText::EmbedMath { elems, .. } => {
1366 // Upstream: a bare `${…}` in inline text evaluates by
1367 // applying the context's installed `[math] inline-cmd` to
1368 // (ctx, the math value) — `apply(cmd, ctx)` then
1369 // `apply(_, math)`, exactly like `IText::Cmd` above.
1370 let installed = ctx
1371 .math_command
1372 .and_then(|id| interp.math_commands.get(id.0).cloned());
1373 match installed {
1374 Some(cmd) => {
1375 let v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1376 let v = interp.apply(
1377 v,
1378 Value::MathText {
1379 elems: Rc::clone(elems),
1380 env: env.clone(),
1381 },
1382 )?;
1383 out.extend(as_inline_boxes(v)?);
1384 }
1385 None => {
1386 // No installed command (contexts built by
1387 // `Context::initial` directly, i.e. unit tests):
1388 // reflect + lay out through the faithful engine so
1389 // `\cmd`/`#var` still evaluate — the same machinery
1390 // `+math(${…})` uses via `as_math`. This fallback
1391 // dispatches on `interp.version`
1392 // — the installed-command path above is version-
1393 // blind already (an ordinary `[math-text] inline-
1394 // cmd` applied to `(ctx, math-text)`).
1395 let mut atoms = Vec::new();
1396 if interp.version.math_is_split() {
1397 for e in elems.iter() {
1398 reflect_math_elem_v01(interp, ctx, e, env, &mut atoms)?;
1399 }
1400 } else {
1401 for e in elems.iter() {
1402 reflect_math_elem(interp, e, env, &mut atoms)?;
1403 }
1404 }
1405 out.push(HorzBox::Pure(layout_math_value(interp, ctx, &atoms)?));
1406 }
1407 }
1408 }
1409 }
1410 }
1411 // Space inline `\code(…)`/`${…}` boxes against adjacent CJK prose the way
1412 // SATySFi does (the text-run glue in `text_to_boxes` can't see these
1413 // cross-element boundaries). Idempotent — a boundary already carrying glue
1414 // is skipped.
1415 Ok(insert_box_interscript_glue(out, ctx))
1416}
1417
1418/// Convert quoted block text to vertical boxes (the core of `read-block`).
1419fn read_block(
1420 interp: &mut Interp,
1421 ctx: &Context,
1422 elems: &[BText],
1423 env: &Env,
1424) -> Result<Vec<VertBox>, EvalError> {
1425 let mut out = Vec::new();
1426 for elem in elems {
1427 match elem {
1428 BText::Cmd { cmd, args } => {
1429 let cmd = cmd.run(env, interp)?;
1430 let mut v = interp.apply(cmd, Value::Context(Box::new(ctx.clone())))?;
1431 for arg in args {
1432 let mut opt_vals = Vec::with_capacity(arg.opts.len());
1433 for (label, e) in &arg.opts {
1434 opt_vals.push((label.clone(), e.run(env, interp)?));
1435 }
1436 let arg_v = arg.arg.run(env, interp)?;
1437 v = interp.apply_with_opts(v, opt_vals, arg_v)?;
1438 }
1439 out.extend(as_block_boxes(v)?);
1440 }
1441 BText::Embed { expr, span } => {
1442 let v = expr.run(env, interp)?;
1443 match v {
1444 Value::BlockText {
1445 elems: sub_elems,
1446 env: cap_env,
1447 } => {
1448 out.extend(read_block(interp, ctx, &sub_elems, &cap_env)?);
1449 }
1450 other => {
1451 return Err(EvalError {
1452 span: Some(*span),
1453 msg: format!(
1454 "expected block-text in '#…;' embed, got {}",
1455 other.type_name()
1456 ),
1457 });
1458 }
1459 }
1460 }
1461 }
1462 }
1463 Ok(out)
1464}
1465
1466/// UAX#14 byte offsets in `text` that are a real, content-driven break
1467/// candidate: every `break_opportunities` boundary except the one always
1468/// reported at `text.len()` (the segmenter's "always break at the end of
1469/// text" convention — an artifact of segmenting this one run in isolation,
1470/// not a signal about what follows it in the paragraph, since
1471/// `text_to_boxes` is called once per `IText::Text` leaf and more content
1472/// may follow via a sibling `Cmd`, `Embed`, or `EmbedMath`).
1473fn uax14_boundaries(text: &str) -> Vec<Option<BreakKind>> {
1474 let mut boundary = vec![None; text.len() + 1];
1475 for (offset, kind) in break_opportunities(text) {
1476 if offset < text.len() {
1477 boundary[offset] = Some(kind);
1478 }
1479 }
1480 boundary
1481}
1482
1483/// This run's `(font, size, rising)` for `script` (see `Context::font_scheme`'s
1484/// doc comment): `Latin` reads `ctx.font` itself (NOT `font_scheme[Latin].font`)
1485/// so `set-font-key`/`\bold`/`\emph` keep working unchanged, while still
1486/// picking up `font_scheme[Latin]`'s ratio/rising (written in lockstep by
1487/// `set-font Latin ..`).
1488///
1489/// `OtherScript` first goes through `normalize_script` (`horzBox.ml:472`):
1490/// upstream's `CommonNarrow`/`Inherited` resolve to `ctx.dominant_narrow_script`
1491/// rather than to a scheme slot of their own; this port's `char_script` has no
1492/// separate Common bucket, so everything outside Latin-1..Latin-Ext-B and the
1493/// CJK ranges lands in `OtherScript` and gets the same treatment — the only
1494/// WIDE Common chars (`U+3000` fullwidth forms) already fall in
1495/// `char_script`'s `HanIdeographic` range, so this costs nothing there.
1496///
1497/// Real effect, not a niceness: without `set-dominant-narrow-script Kana`, a
1498/// document's `□`/`✓` both resolve to a Latin face with NEITHER glyph, degrade
1499/// to the same `.notdef` glyph id and `ToUnicode` entry, and one of the two
1500/// simply vanishes from the extracted text — enumitem's three missing `✓`,
1501/// each overprinted onto a `□` by the document's own `ooalign`.
1502///
1503/// Defaults to `OtherScript` (`Context::initial`, matching upstream), so a
1504/// document that never calls the primitive is unaffected and the recursion is
1505/// one step deep at most.
1506fn script_font(ctx: &Context, script: Script) -> ScriptFont {
1507 if script == Script::OtherScript && ctx.dominant_narrow_script != Script::OtherScript {
1508 return script_font(ctx, ctx.dominant_narrow_script);
1509 }
1510 if script == Script::Latin {
1511 ScriptFont {
1512 font: ctx.font,
1513 ..ctx.font_scheme[Script::Latin as usize]
1514 }
1515 } else {
1516 ctx.font_scheme[script as usize]
1517 }
1518}
1519
1520/// Measure `text` (already known to be one script run) at `size` under
1521/// `font`, falling back per-glyph to `fallback_font` (`ctx.font`) when
1522/// `font` has no glyph for a character — the "CJK per-glyph metrics path
1523/// stubbed" case: a character within a script-run's bucket
1524/// that its assigned font happens to lack (e.g. a fullwidth-form character
1525/// absent from a narrow CJK face) still measures via the Latin default
1526/// rather than failing the whole run. Errors (both fonts lack the glyph)
1527/// name the offending character and font key.
1528///
1529/// **Known limitation** (documented, not fixed): the
1530/// measurement here can fall back per-glyph, but `PureHorzBox::InnerString`
1531/// carries one `HorzStringInfo::font` for its WHOLE text run — so if a
1532/// fallback glyph is actually used, the PDF writer's `emit_box` still tries
1533/// to look it up in `font`'s face at render time and fails there instead.
1534/// Splitting a run into sub-boxes at the source-font-only/fallback boundary
1535/// (a faithful fix) is future work; every stdja default face configuration
1536/// covers its script's whole repertoire, so this path is not expected to
1537/// trigger in practice.
1538fn measure_run(
1539 interp: &Interp,
1540 font: FontKey,
1541 fallback_font: FontKey,
1542 text: &str,
1543 size: Length,
1544) -> Result<Length, EvalError> {
1545 let mut width = Length::ZERO;
1546 for c in text.chars() {
1547 // A character absent from BOTH the run font and the fallback degrades
1548 // to a `.notdef`-style box (half-em advance) rather than aborting the
1549 // whole document — the way real typesetters render an uncovered glyph.
1550 // (satysfi-base's `enumitem`/the SATySFi Book use a few glyphs — `□`,
1551 // `〚` — that the bundled Latin face lacks; a faithful per-glyph
1552 // font-fallback via run-splitting is the documented follow-up.) This
1553 // only ever changes behavior for a glyph that would otherwise be a
1554 // hard error, so covered-glyph documents are byte-identical.
1555 let advance = interp
1556 .metrics
1557 .advance(font, c, size)
1558 .or_else(|| interp.metrics.advance(fallback_font, c, size))
1559 .unwrap_or(size * 0.5);
1560 width += advance;
1561 }
1562 Ok(width)
1563}
1564
1565/// Build one `InnerString` box for `text`, measured through [`measure_run`]
1566/// with `sf`'s font/size/rising — the single construction site shared by
1567/// `text_to_boxes`'s `flush_word` for both the plain (no-hyphenation) path
1568/// and each hyphenated fragment / hyphen glyph. Factored out so both paths
1569/// measure/build identically — this is part of what makes the width-identity
1570/// argument hold: `measure_run` is purely additive per char (no
1571/// kerning/ligatures), so concatenating the fragments this produces
1572/// reconstructs exactly the box a single un-split call would have produced.
1573fn make_inner_string_pure_box(
1574 interp: &Interp,
1575 ctx: &Context,
1576 sf: ScriptFont,
1577 size: Length,
1578 rising: Length,
1579 text: String,
1580) -> Result<PureHorzBox, EvalError> {
1581 let width = measure_run(interp, sf.font, ctx.font, &text, size)?;
1582 // SATySFi measures a run's height/depth from the ACTUAL per-glyph bounding
1583 // boxes (fontInfo.ml `get_metrics_of_word`), not the font-level
1584 // ascender/descender — so a no-descender run (CJK, digits, TOC dots) is
1585 // shorter and packs tighter at block boundaries.
1586 let (height, depth) = interp.metrics.run_vextent(sf.font, &text, size);
1587 Ok(PureHorzBox::InnerString {
1588 info: HorzStringInfo {
1589 font: sf.font,
1590 size,
1591 rising,
1592 color: ctx.text_color,
1593 },
1594 height,
1595 depth,
1596 text,
1597 width,
1598 })
1599}
1600
1601/// Whether two adjacent runs' scripts form a Latin↔CJK boundary that gets
1602/// SATySFi's default inter-script glue (`primitives.ml:517-524`: entries for
1603/// `(Latin, Kana)`, `(Kana, Latin)`, `(Latin, Han)`, `(Han, Latin)` only —
1604/// NOT Kana↔Han, and not same-script).
1605fn is_latin_cjk_boundary(a: Script, b: Script) -> bool {
1606 let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
1607 (a == Script::Latin && is_cjk(b)) || (is_cjk(a) && b == Script::Latin)
1608}
1609
1610/// Upstream `is_open_punctuation` (`charBasis.ml:133`: `OP | QU | JLOP`) —
1611/// opening brackets and quotes. Consulted for the LEFT edge of a script
1612/// boundary only.
1613fn is_open_punct(c: char) -> bool {
1614 matches!(
1615 c,
1616 '(' | '['
1617 | '{'
1618 | '"'
1619 | '\''
1620 | '('
1621 | '「'
1622 | '『'
1623 | '【'
1624 | '〔'
1625 | '〈'
1626 | '《'
1627 | '['
1628 | '{'
1629 | '〖'
1630 | '〘'
1631 | '〚'
1632 | '“'
1633 | '‘'
1634 )
1635}
1636
1637/// Upstream `is_close_punctuation` (`charBasis.ml:139`: `CL | CP | QU | NS |
1638/// JLCP | JLNS | JLCM | JLFS`) — closing brackets, quotes, and the kuten/touten
1639/// family. Consulted for the RIGHT edge of a script boundary only.
1640///
1641/// Two families the port used to list are NOT in that set, and their absence is
1642/// upstream's own, not an oversight:
1643/// - `!` `?` `!` `?` are line-break class `EX` (`LineBreak.txt:2566,2582` for
1644/// the fullwidth pair), which appears in no arm of `is_close_punctuation`;
1645/// - `,` `.` `;` `:` are `IS`, likewise absent.
1646///
1647/// Their FULLWIDTH cousins are a different matter and stay: `,`/`.` are
1648/// overridden to `JLCM`/`JLFS` (`lineBreakDataMap.ml:95-96`) and `:`/`;` are
1649/// `NS` (`LineBreak.txt:2580`).
1650///
1651/// Listing the six suppressed the 0.24em inter-script glue — and, since that
1652/// glue is the boundary's only break candidate, the break opportunity with it —
1653/// before every sentence-final mark that is not a kuten.
1654fn is_close_punct(c: char) -> bool {
1655 matches!(
1656 c,
1657 ')' | ']'
1658 | '}'
1659 | '"'
1660 | '\''
1661 | ')'
1662 | '」'
1663 | '』'
1664 | '】'
1665 | '〕'
1666 | '〉'
1667 | '》'
1668 | ']'
1669 | '}'
1670 | '〗'
1671 | '〙'
1672 | '〛'
1673 | '”'
1674 | '’'
1675 | '、'
1676 | '。'
1677 | ','
1678 | '.'
1679 | '・'
1680 | ':'
1681 | ';'
1682 )
1683}
1684
1685/// Whether SATySFi's default inter-script glue is suppressed between a
1686/// left-hand character `l` and a right-hand `r`.
1687///
1688/// `pure_space_between_scripts` (`convertText.ml:31`) drops the glue when
1689/// `is_open_punctuation lbc1 || is_close_punctuation lbc2` — the LEFT edge being
1690/// OPENING punctuation, or the RIGHT edge being CLOSING punctuation. The aki
1691/// that would otherwise sit there is supplied by the separate JLreq
1692/// class-spacing layer.
1693///
1694/// The port used to test one symmetric "is punctuation" predicate against BOTH
1695/// edges, which suppressed far more than upstream: `、` before a Latin/math run
1696/// is a *closing* mark on the LEFT, which upstream does not suppress. Since this
1697/// glue is also the only break opportunity at such a boundary, suppressing it
1698/// left the breaker with nowhere to break — latexcmds ran
1699/// `…、${dropcolor}` 32pt past the margin because there was no legal break
1700/// between the touten and the math box.
1701fn interscript_glue_suppressed(l: char, r: char) -> bool {
1702 is_open_punct(l) || is_close_punct(r)
1703}
1704
1705/// JLreq character classes SATySFi's inter-CJK spacing distinguishes
1706/// (`charBasis.ml:116-122`). Only the classes that actually change spacing are
1707/// modelled; every other CJK character is `None` ("ordinary").
1708#[derive(Clone, Copy, PartialEq, Eq)]
1709enum JlClass {
1710 /// cl-01, fullwidth OPEN punctuation — carries a leading half-width kern.
1711 Open,
1712 /// cl-02, fullwidth CLOSE punctuation — trailing half-width kern.
1713 Close,
1714 /// cl-06, kuten (fullwidth full stop) — trailing half-width kern.
1715 FullStop,
1716 /// cl-07, touten (fullwidth comma) — trailing half-width kern.
1717 Comma,
1718 /// cl-05, nakaten (fullwidth middle dot) — quarter-width kern BOTH sides.
1719 MiddleDot,
1720}
1721
1722fn jl_class(c: char) -> Option<JlClass> {
1723 match c {
1724 '(' | '「' | '『' | '【' | '〔' | '〈' | '《' | '[' | '{' | '〖' | '〘' | '〚' => {
1725 Some(JlClass::Open)
1726 }
1727 ')' | '」' | '』' | '】' | '〕' | '〉' | '》' | ']' | '}' | '〗' | '〙' | '〛' => {
1728 Some(JlClass::Close)
1729 }
1730 '。' | '.' => Some(JlClass::FullStop),
1731 '、' | ',' => Some(JlClass::Comma),
1732 '・' | ':' | ';' => Some(JlClass::MiddleDot),
1733 _ => None,
1734 }
1735}
1736
1737/// `ideographic_single`'s TRAILING kern for `c` (`convertText.ml:266-283`), as a
1738/// negative ratio of `font_size`: JLCP/JLFS/JLCM are `[glyph; hwkern]`, JLMD is
1739/// `[qwkern; glyph; qwkern]`.
1740///
1741/// A kern belongs to the CHARACTER, not to the boundary — `ideographic_single`
1742/// runs per chunk and never consults its neighbours. Hence one char per
1743/// function, even though the only caller today is the pair-shaped
1744/// [`cjk_pair_space`]: at a CJK↔Latin boundary the CJK side still carries its own
1745/// kern upstream, and this port does not yet emit it there (see the
1746/// `PreventBreak` arm of `text_to_boxes` for what that costs and what unblocking
1747/// it needs).
1748fn cjk_trailing_kern(c: char) -> f64 {
1749 match jl_class(c) {
1750 Some(JlClass::Close) | Some(JlClass::FullStop) | Some(JlClass::Comma) => -0.5,
1751 Some(JlClass::MiddleDot) => -0.25,
1752 _ => 0.0,
1753 }
1754}
1755
1756/// `ideographic_single`'s LEADING kern for `c` — JLOP is `[hwkern; glyph]`,
1757/// JLMD `[qwkern; glyph; qwkern]`. See [`cjk_trailing_kern`].
1758fn cjk_leading_kern(c: char) -> f64 {
1759 match jl_class(c) {
1760 Some(JlClass::Open) => -0.5,
1761 Some(JlClass::MiddleDot) => -0.25,
1762 _ => 0.0,
1763 }
1764}
1765
1766/// The glue SATySFi puts between two directly adjacent CJK characters, as an
1767/// absolute `(natural, shrink, stretch)` — `space_between_chunks`
1768/// (`convertText.ml:220`) with `ideographic_single`'s compensating kerns
1769/// (`convertText.ml:266`) folded in.
1770///
1771/// Upstream renders CJK punctuation at its full em and kerns it back:
1772/// `。`/`、`/`)` carry a trailing −0.5em kern, `(` a leading one, `・` −0.25em
1773/// on both sides. `pure_space_between_classes` (`convertText.ml:194`) then adds
1774/// a half-width space back — natural 0.5em, stretch 0.25em, shrink 0.25em
1775/// unless the pair is "hard" (after a full stop). Net natural width is
1776/// unchanged, but each punctuation mark contributes **0.25em of stretch** — ten
1777/// times the 0.025em `adjacent_stretch` between ordinary characters, and the
1778/// bulk of a Japanese line's elasticity. Two punctuation marks in a row
1779/// (`」、`, `」。`) get NO space back, so the pair sets 0.5em tighter.
1780///
1781/// **Which font size each part scales against is not uniform, and that is the
1782/// point of taking three sizes rather than one.** Upstream applies the kerns
1783/// (`halfwidth_kern`/`quarterwidth_kern`, `convertText.ml:110-118`) and all
1784/// four `pure_halfwidth_space_*` sizes to `get_corrected_font_size ctx script`
1785/// (`convertText.ml:76-79`) — the font size TIMES the script's own ratio, 0.88
1786/// for stdja's CJK face, so a half-width kern at 12pt is −5.28pt and not −6pt.
1787/// Only `adjacent_space` (`:101-106`) takes the RAW `ctx.font_size`. Scaling
1788/// everything by the raw size made every JLreq class space 13.6% too elastic
1789/// (0.25 × 12 = 3pt of stretch where upstream has 0.25 × 10.56 = 2.64pt);
1790/// since punctuation carries ten times the stretch of an ordinary
1791/// inter-character gap, that error set the stretch budget of a whole Japanese
1792/// line and so its justified glyph positions.
1793///
1794/// `size_a`/`size_b` are the corrected sizes of the LEFT and RIGHT characters,
1795/// upstream's `size1`/`size2` (`convertText.ml:196-198`); the `hwsoftM`/
1796/// `hwhardM` arms take `Length::max` of the two, exactly as `sizeM` does. They
1797/// differ only when the two characters resolve to different `font_scheme` slots
1798/// (`Kana` vs `HanIdeographic`) carrying different ratios.
1799fn cjk_pair_space(
1800 a: char,
1801 size_a: Length,
1802 b: char,
1803 size_b: Length,
1804 raw_size: Length,
1805 adjacent_stretch: f64,
1806) -> (Length, Length, Length) {
1807 use JlClass::*;
1808 let (ca, cb) = (jl_class(a), jl_class(b));
1809 // Kerns from `ideographic_single`, each a NEGATIVE ratio of its OWN
1810 // character's corrected size. Between two CJK characters the pair's kern is
1811 // exactly `a`'s trailing plus `b`'s leading one, which is what makes the
1812 // pair form equivalent to upstream's per-character one here.
1813 let kern = size_a * cjk_trailing_kern(a) + size_b * cjk_leading_kern(b);
1814 let size_m = Length::max(size_a, size_b);
1815 // `pure_space_between_classes`, in its own match order. The third component
1816 // of each arm is the size that arm's space scales against.
1817 let hwsoft = |s: Length| (s * 0.5, s * 0.25, s * 0.25);
1818 let hwhard = |s: Length| (s * 0.5, Length::ZERO, s * 0.25);
1819 let cls = match (ca, cb) {
1820 (Some(Close), Some(Open)) | (Some(Comma), Some(Open)) => Some(hwsoft(size_m)),
1821 (Some(FullStop), Some(Open)) => Some(hwhard(size_m)),
1822 (_, Some(Open)) => Some(hwsoft(size_b)),
1823 (Some(Close), Some(Comma)) | (Some(Close), Some(FullStop)) => None,
1824 (Some(Close), _) | (Some(Comma), _) => Some(hwsoft(size_a)),
1825 (Some(FullStop), _) => Some(hwhard(size_a)),
1826 _ => None,
1827 };
1828 match cls {
1829 Some((n, sh, st)) => (kern + n, sh, st),
1830 // No class space: `adjacent_space` (natural 0, shrink 0, stretch
1831 // `adjacent_stretch` × the RAW size), plus whatever kern the pair
1832 // carries.
1833 None => (kern, Length::ZERO, raw_size * adjacent_stretch),
1834 }
1835}
1836
1837/// A box's LEADING glyph for inter-script spacing, or `None` for
1838/// glue/discretionary/skip/image (a "transparent" separator — an inter-script
1839/// space is never inserted adjacent to one) and for math (reported as a Latin
1840/// `'x'`, matching SATySFi where a `${…}` chunk spaces against CJK like Western
1841/// text). The char lets the caller apply the `is_interscript_punct` guard.
1842fn box_leading_char(b: &HorzBox) -> Option<char> {
1843 match b {
1844 HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().next(),
1845 HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1846 _ => None,
1847 }
1848}
1849
1850/// A box's TRAILING glyph (see `box_leading_char`).
1851fn box_trailing_char(b: &HorzBox) -> Option<char> {
1852 match b {
1853 HorzBox::Pure(PureHorzBox::InnerString { text, .. }) => text.chars().last(),
1854 HorzBox::Pure(PureHorzBox::Math { .. }) => Some('x'),
1855 _ => None,
1856 }
1857}
1858
1859/// Insert SATySFi's inter-script glue (`default_script_space_map`) between two
1860/// DIRECTLY-adjacent boxes whose touching edges are Latin↔CJK — the boundary
1861/// that `text_to_boxes` can't see because it spans separate inline elements: a
1862/// `\code(…)`/`${…}` box against surrounding CJK prose ("cellfmt 型", "𝑛 番目").
1863/// A boundary already carrying a glue/discretionary reads as `None` on one edge
1864/// and is skipped, so this is idempotent and never doubles the text-run glue.
1865/// The Latin↔CJK inter-script space, `pure_space_between_scripts`
1866/// (`convertText.ml:29-50`).
1867///
1868/// **RIGID — natural `0.24 * font_size`, no shrink, no stretch** — and that is
1869/// upstream's behaviour, not a simplification. `default_script_space_map`
1870/// (`primitives.cppo.ml:488`) really does carry the triple
1871/// `(0.24, 0.08, 0.16)`, but `pure_space_between_scripts` spends it like this:
1872///
1873/// ```ocaml
1874/// Some(LBAtom((natural (size *% r0), size *% r1, size *% r2), EvHorzEmpty))
1875/// ```
1876///
1877/// `LBAtom`'s first field is `metrics = length_info * length * length`, i.e.
1878/// *(width info, HEIGHT, DEPTH)* (`lineBreakBox.ml:7`), and `natural wid`
1879/// builds `{natural = wid; shrinkable = zero; stretchable = zero}`
1880/// (`lineBreakBox.ml:54-59`). So `r1` and `r2` land in the height and depth
1881/// slots; only `r0` reaches the width. Contrast the sibling
1882/// `pure_halfwidth_space_soft` (`convertText.ml:83-85`), which builds its
1883/// elasticity with `make_width_info` and passes `Length.zero, Length.zero` for
1884/// height and depth — the correct shape, right next door. The commented-out
1885/// predecessor at `convertText.ml:58` has the same misplacement, so v0.0.6 has
1886/// never had an elastic inter-script space.
1887///
1888/// The stray height (`0.08 * size`) and depth (`0.16 * size`) are swallowed by
1889/// `get_total_metrics`'s `max hacc h` / `min dacc d` (`lineBreak.ml:55-59`) —
1890/// a 0.96pt height never exceeds a real glyph's and a POSITIVE depth never
1891/// wins a `min` against a descender — so rigidity is the only observable
1892/// consequence, and it is the one that matters: this glue sits at every
1893/// Japanese/Latin junction, and giving it 0.16em of stretch let the port soak
1894/// up justification slack that upstream is forced to push into the
1895/// inter-character `adjacent_space` inside the CJK runs themselves.
1896///
1897/// Still a break point: upstream wraps this box in `discretionary_if_breakable`
1898/// (`convertText.ml:228`) exactly as it does the elastic glues, and an
1899/// `OuterEmpty` with zero shrink and stretch is still `is_glue()` — which is
1900/// also why a ratio of 0.0 emits a zero-width box rather than nothing.
1901///
1902/// The ratio comes from `ctx.script_space_map`, so
1903/// `set-space-ratio-between-scripts` reaches it (slydifi's arctic theme zeroes
1904/// all four Latin↔CJK directions).
1905fn interscript_glue(ctx: &Context, left: Script, right: Script) -> PureHorzBox {
1906 PureHorzBox::OuterEmpty {
1907 natural: ctx.font_size * ctx.script_space_map[left as usize][right as usize],
1908 shrinkable: Length::ZERO,
1909 stretchable: Length::ZERO,
1910 }
1911}
1912
1913fn insert_box_interscript_glue(boxes: Vec<HorzBox>, ctx: &Context) -> Vec<HorzBox> {
1914 if boxes.len() < 2 {
1915 return boxes;
1916 }
1917 let mut out: Vec<HorzBox> = Vec::with_capacity(boxes.len());
1918 for b in boxes {
1919 if let (Some(pc), Some(cc)) = (out.last().and_then(box_trailing_char), box_leading_char(&b))
1920 {
1921 let (ls, rs) = (char_script(pc), char_script(cc));
1922 if is_latin_cjk_boundary(ls, rs) && !interscript_glue_suppressed(pc, cc) {
1923 out.push(HorzBox::Pure(interscript_glue(ctx, ls, rs)));
1924 }
1925 }
1926 out.push(b);
1927 }
1928 out
1929}
1930
1931fn text_to_boxes(
1932 interp: &mut Interp,
1933 ctx: &Context,
1934 text: &str,
1935 out: &mut Vec<HorzBox>,
1936) -> Result<(), EvalError> {
1937 // Interword glue is upstream's
1938 // `context_main.space_natural`/`space_shrink`/`space_stretch`
1939 // (`set-space-ratio`), each a ratio of `font_size` — NOT a measured
1940 // glyph-advance of the space character and NOT a fraction of the natural
1941 // width. `ctx.space_*` always carries a value (defaults 0.33/0.08/0.16,
1942 // matching `Context::initial`'s own upstream-faithful defaults), so this
1943 // is a plain formula, no fallback needed.
1944 let space_width = ctx.font_size * ctx.space_natural;
1945 let boundary = uax14_boundaries(text);
1946 let mut word = String::new();
1947 let flush_word =
1948 |word: &mut String, script: Script, out: &mut Vec<HorzBox>| -> Result<(), EvalError> {
1949 if word.is_empty() {
1950 return Ok(());
1951 }
1952 let sf = script_font(ctx, script);
1953 let size = ctx.font_size * sf.ratio;
1954 // The script-font's own baseline raise (a ratio of font_size) PLUS the
1955 // manual raise from `set-manual-rising` (`ctx.manual_rising`, an
1956 // absolute Length). Both feed `HorzStringInfo.rising`, which every
1957 // render path adds to the baseline before `Tj`. `manual_rising`
1958 // defaults to `Length::ZERO` (`Context::initial`), so a document that
1959 // never calls `set-manual-rising` is byte-identical. Real effect: the
1960 // `\SATySFi`/`\LaTeX`/`\TeX` logo kerning.
1961 let rising = ctx.font_size * sf.rising + ctx.manual_rising;
1962
1963 // Knuth-Liang hyphenation opt-in injection: fires ONLY when a
1964 // dictionary has been installed (`ctx.hyphen_dictionary ==
1965 // Some(tag)`) and the run's script is Latin. With `hyphen_dictionary
1966 // == None` (the `Context::initial` default), `breaks` is always
1967 // empty and the code below falls straight through to the
1968 // single-`InnerString` path.
1969 let breaks = match ctx.hyphen_dictionary {
1970 Some(tag) if script == Script::Latin => {
1971 // An explicit soft hyphen (U+00AD) authored in the word
1972 // takes priority over dictionary-derived breaks (matches the
1973 // `hyphenation` crate's own `Standard::hyphenate` priority
1974 // rule). Only reachable here with a soft hyphen still
1975 // embedded in `word` because the tokenizer above
1976 // (`text_to_boxes`'s per-char loop) defers to this branch
1977 // instead of splitting on it as an ordinary UAX#14 boundary
1978 // — gated on this same `Some(tag) && Latin` condition, so
1979 // `hyphen_dictionary == None` never reaches
1980 // `strip_soft_hyphens` and reproduces exactly today's
1981 // split-at-soft-hyphen behavior.
1982 let (clean, shy_breaks) = crate::hyphenation::strip_soft_hyphens(word);
1983 if !shy_breaks.is_empty() {
1984 *word = clean;
1985 shy_breaks
1986 } else {
1987 crate::hyphenation::hyphenate_word(
1988 tag,
1989 word,
1990 ctx.left_hyphen_min.max(0) as usize,
1991 ctx.right_hyphen_min.max(0) as usize,
1992 )
1993 }
1994 }
1995 _ => Vec::new(),
1996 };
1997
1998 if breaks.is_empty() {
1999 out.push(HorzBox::Pure(make_inner_string_pure_box(
2000 interp,
2001 ctx,
2002 sf,
2003 size,
2004 rising,
2005 std::mem::take(word),
2006 )?));
2007 return Ok(());
2008 }
2009
2010 // Width-identity invariant (also see
2011 // `make_inner_string_pure_box`'s doc comment): `measure_run` is
2012 // purely additive per char (no
2013 // kerning/ligatures), so splitting `word` into fragments here and
2014 // rejoining them via empty-slot `Discretionary`s (taken only at a
2015 // chosen line break) reproduces the exact width/height/depth of the
2016 // un-split box when no break is actually taken — only words the DP
2017 // *does* break render differently, which is the intended new
2018 // behavior, confined to documents that opt in.
2019 let chars: Vec<char> = word.chars().collect();
2020 let penalty = ctx.hyphen_badness.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
2021 let mut prev = 0usize;
2022 for &b in &breaks {
2023 let fragment: String = chars[prev..b].iter().collect();
2024 out.push(HorzBox::Pure(make_inner_string_pure_box(
2025 interp, ctx, sf, size, rising, fragment,
2026 )?));
2027 let hyphen_box =
2028 make_inner_string_pure_box(interp, ctx, sf, size, rising, "-".to_string())?;
2029 out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2030 penalty,
2031 pre_break: vec![hyphen_box],
2032 post_break: Vec::new(),
2033 no_break: Vec::new(),
2034 }));
2035 prev = b;
2036 }
2037 let tail: String = chars[prev..].iter().collect();
2038 out.push(HorzBox::Pure(make_inner_string_pure_box(
2039 interp, ctx, sf, size, rising, tail,
2040 )?));
2041 word.clear();
2042 Ok(())
2043 };
2044 // `Some(s)` exactly when `word` is non-empty — the script of the run
2045 // currently being accumulated (a run also breaks on a script
2046 // change, not just on whitespace/UAX#14, see `char_script`).
2047 let mut word_script: Option<Script> = None;
2048 // The script of the immediately-preceding *typeset* character (persists
2049 // across the UAX#14 discretionary flushing that resets `word_script`), so
2050 // an inter-script boundary can be detected even between two single-char
2051 // CJK/Latin runs. Reset by an explicit space (no auto inter-script glue is
2052 // added adjacent to a real space). See the `is_latin_cjk_boundary` insert.
2053 let mut prev_script: Option<Script> = None;
2054 // The preceding typeset char itself, for the `is_interscript_punct` guard.
2055 let mut prev_char: Option<char> = None;
2056 for (i, c) in text.char_indices() {
2057 // Whitespace normalization around CJK — upstream's rewrite table
2058 // (`lineBreakDataMap.ml:143-157`, applied before any box is built):
2059 //
2060 // CJK + (SP|BR) + Latin -> deleted Latin + (SP|BR) + CJK -> deleted
2061 // CJK + BR + CJK -> deleted CJK + SP + CJK -> KEPT
2062 // any remaining (SP|BR) touching CJK -> deleted; a leftover BR -> space
2063 //
2064 // Every space/line break adjacent to CJK is dropped EXCEPT a single
2065 // literal space between two CJK characters — the Latin/CJK boundary's
2066 // spacing is supplied by the inter-script glue below (0.24em), not by
2067 // the author's whitespace, so keeping it both double-counted that
2068 // boundary and turned every source line break into a space (the port
2069 // set `あります。 1 つは`/`これは 指定した` where SATySFi sets both
2070 // tight — figbox `manual.saty:116-120`). Deleting is a plain
2071 // `continue`: the characters either side still space against each
2072 // other through the inter-script rule, as if the whitespace had never
2073 // been written.
2074 if c == ' ' || c == '\n' {
2075 let is_cjk_script = |s| matches!(s, Script::HanIdeographic | Script::Kana);
2076 let prev_cjk = prev_script.is_some_and(is_cjk_script);
2077 let rest = &text[i + c.len_utf8()..];
2078 // Whether more whitespace follows: upstream's rules only ever match
2079 // ONE space between the two CJK characters (a longer run falls
2080 // through to the delete-everything rules), so a run collapses away.
2081 let run_continues = rest
2082 .chars()
2083 .next()
2084 .is_some_and(|ch| matches!(ch, ' ' | '\n'));
2085 let next_cjk = rest
2086 .chars()
2087 .find(|ch| !matches!(ch, ' ' | '\n'))
2088 .is_some_and(|ch| is_cjk_script(char_script(ch)));
2089 if prev_cjk || next_cjk {
2090 let keep = c == ' ' && !run_continues && prev_cjk && next_cjk;
2091 if !keep {
2092 continue;
2093 }
2094 }
2095 }
2096 if c == ' ' || c == '\n' {
2097 if let Some(s) = word_script.take() {
2098 flush_word(&mut word, s, out)?;
2099 }
2100 prev_script = None;
2101 prev_char = None;
2102 // Avoid piling up doubled glue at text-run boundaries — but ONLY
2103 // for elastic (prose) spaces. A RIGID space (shrink == stretch == 0,
2104 // i.e. `code.satyh`'s `set-space-ratio (charwid/fs) 0. 0.`) is a
2105 // fixed-width verbatim column: SATySFi never collapses consecutive
2106 // ones, so the aligned source in a `+code` block keeps its spacing
2107 // (`| How | I`, not the collapsed `| How | I`). Collapsing them
2108 // shortened code lines and let the port pack code blocks too tight.
2109 let rigid_space = ctx.space_shrink == 0.0 && ctx.space_stretch == 0.0;
2110 if rigid_space
2111 || !matches!(
2112 out.last(),
2113 Some(HorzBox::Pure(PureHorzBox::OuterEmpty { .. }))
2114 )
2115 {
2116 out.push(HorzBox::Pure(PureHorzBox::OuterEmpty {
2117 natural: space_width,
2118 // Upstream derives shrink/stretch directly as a ratio
2119 // of `font_size` (`ctx.space_shrink`/`space_stretch`),
2120 // NOT as a fraction of `space_width` — the previous
2121 // `space_width * 0.25`/`* 0.5` was a port-invented
2122 // approximation.
2123 shrinkable: ctx.font_size * ctx.space_shrink,
2124 stretchable: ctx.font_size * ctx.space_stretch,
2125 }));
2126 }
2127 continue;
2128 }
2129 let script = char_script(c);
2130 // Inter-script glue (`primitives.ml:517-524` `default_script_space_map`,
2131 // applied in `convertText.ml` `pure_space_between_scripts`): SATySFi's
2132 // default context inserts a `0.24 * size` space between a Latin run and
2133 // an adjacent CJK (Kana/Han) run — the space visible as "2 つ" /
2134 // "+easytable は" that the port otherwise packs tight ("2つ"). Emitted
2135 // at the boundary using `prev_script` (a CJK char resets `word_script`
2136 // via its UAX#14 discretionary, so this can't rely on `word_script`
2137 // alone). The glue is also an `is_break_point`, matching upstream (the
2138 // boundary is a legal break). See [`interscript_glue`] for why it is
2139 // RIGID even though `default_script_space_map` carries a triple.
2140 if let (Some(prev), Some(pc)) = (prev_script, prev_char) {
2141 if is_latin_cjk_boundary(prev, script) && !interscript_glue_suppressed(pc, c) {
2142 if let Some(s) = word_script.take() {
2143 if !word.is_empty() {
2144 flush_word(&mut word, s, out)?;
2145 }
2146 }
2147 out.push(HorzBox::Pure(interscript_glue(ctx, prev, script)));
2148 }
2149 }
2150 if let Some(cur) = word_script {
2151 if cur != script {
2152 flush_word(&mut word, cur, out)?;
2153 }
2154 }
2155 word_script = Some(script);
2156 prev_script = Some(script);
2157 prev_char = Some(c);
2158 word.push(c);
2159 // Only non-ASCII text gets UAX#14 discretionaries: plain ASCII stays
2160 // on exactly today's space/newline-only splitter, so existing Latin
2161 // fixtures wrap identically (a real, tested divergence otherwise —
2162 // UAX#14 allows a break after a hyphen, which would fragment e.g.
2163 // "SATySFi-in-Rust" into three `InnerString`s instead of one,
2164 // changing the PDF content stream even though the zero-width
2165 // discretionaries between them render no differently when unchosen).
2166 // CJK and other non-ASCII scripts have no such existing behavior to
2167 // preserve, and are exactly where UAX#14 breaking is the whole point
2168 // (no interword glue at all otherwise, see `is_break_point`'s doc).
2169 // A soft hyphen (U+00AD) inside a run that the Knuth-Liang injection
2170 // above will consume (dictionary installed, Latin script) must NOT
2171 // be split here as an ordinary UAX#14 break-after point — doing so
2172 // would flush/fragment the word right at the soft hyphen before
2173 // `flush_word`'s hyphenation branch ever sees the whole word,
2174 // pre-empting `strip_soft_hyphens`'s explicit-break handling.
2175 // Instead let it accumulate into `word` like any other Latin letter.
2176 // Gated on the exact same `Some(_) && Latin` condition as that
2177 // branch, so `hyphen_dictionary == None` (or a non-Latin run)
2178 // reproduces exactly today's split-at-soft-hyphen behavior.
2179 let is_gated_soft_hyphen =
2180 c == '\u{ad}' && script == Script::Latin && ctx.hyphen_dictionary.is_some();
2181 // UAX#14 break opportunities apply to ALL text, ASCII included — that
2182 // is simply what upstream's line-break engine does (it runs over the
2183 // whole run with no script gate). Do NOT narrow this to non-ASCII, or
2184 // to the explicit hyphen — both approximations leave a load-bearing
2185 // gap:
2186 //
2187 // - `+fig-center` (54.7pt, unbreakable) made the candidate widths jump
2188 // clean over the feasible window — 400.32pt (ratio 2.72, dropped) to
2189 // 455.00pt (overfull) with nothing between — so the breaker fell back
2190 // to a degenerate one-character line.
2191 // - a `+code` line `…?:(drop) ?:(dropcolor)` ran 80pt past the column
2192 // and clean off the paper, because the only break the port allowed
2193 // was at a space, and breaking there left a rigid line 7.7pt short
2194 // (dropped). UAX#14 grants a break between `:` and `(` — offset 2 of
2195 // `?:(drop)…` — which is exactly where SATySFi breaks it.
2196 //
2197 // Cost: an ASCII run is now split into one `InnerString` per break
2198 // opportunity. Widths are unaffected (`measure_run` is purely additive,
2199 // see `make_inner_string_pure_box`), so this only changes how the text
2200 // is CHUNKED, not where any glyph lands.
2201 if !is_gated_soft_hyphen {
2202 let after = i + c.len_utf8();
2203 // The inter-chunk spacing between two DIRECTLY ADJACENT CJK
2204 // characters: `cjk_pair_space` folds `pure_space_between_classes` /
2205 // `adjacent_space` (`convertText.ml:101/194`) together with
2206 // `ideographic_single`'s compensating kerns (`convertText.ml:266`).
2207 //
2208 // The elastic part is the give a Japanese line justifies with.
2209 // Without it a CJK line's only give was whatever incidental Latin
2210 // spaces it happened to contain — a handful of points across a whole
2211 // line — so the breaker could neither fill to the column nor accept a
2212 // break that needed a hair of stretch.
2213 //
2214 // Only between two CJK characters: a CJK/Latin boundary is
2215 // `pure_space_between_scripts`'s job (the inter-script glue
2216 // above), and upstream falls through to `adjacent_space` only
2217 // once that has returned `None` (`space_between_chunks`,
2218 // `convertText.ml:220`).
2219 let is_cjk = |s| matches!(s, Script::HanIdeographic | Script::Kana);
2220 let next_char = text[after..].chars().next();
2221 let next_is_cjk = next_char.is_some_and(|nc| is_cjk(char_script(nc)));
2222 let pair = if is_cjk(script) && next_is_cjk {
2223 let nc = next_char.expect("checked");
2224 // `get_corrected_font_size` per SIDE (`convertText.ml:76-79`):
2225 // font size times the script's own `font_scheme` ratio. The
2226 // kerns and the JLreq class spaces scale against these; only
2227 // `adjacent_space` takes the raw size. See `cjk_pair_space`.
2228 let size_a = ctx.font_size * script_font(ctx, script).ratio;
2229 let size_b = ctx.font_size * script_font(ctx, char_script(nc)).ratio;
2230 Some(cjk_pair_space(
2231 c,
2232 size_a,
2233 nc,
2234 size_b,
2235 ctx.font_size,
2236 ctx.adjacent_stretch,
2237 ))
2238 } else {
2239 None
2240 };
2241 // `discretionary_if_breakable alw badns lphb`
2242 // (`convertText.ml:183-190`) — the ONE decision upstream makes at a
2243 // chunk boundary. The spacing is computed the same way either way;
2244 // only its container depends on whether UAX#14 grants a break:
2245 //
2246 // AllowBreak -> LBDiscretionary(badns, id, [glue], [], [])
2247 // PreventBreak -> LBPure(glue)
2248 //
2249 // The port used to emit the `AllowBreak` arm and *nothing* for
2250 // `PreventBreak`, so at every prohibited boundary — and in Japanese
2251 // prose that is one boundary in several, since LB13 forbids a break
2252 // before `、`/`。`/`」`/`)` and LB14 after `(`/`「` — a CJK line
2253 // carried give only at the subset of its boundaries that happened to
2254 // be breakable. A line with no give has to FILL its measure with
2255 // characters, which is part of why the port packs more per line than
2256 // SATySFi.
2257 match boundary[after] {
2258 Some(kind) => {
2259 flush_word(&mut word, script, out)?;
2260 word_script = None;
2261 let mut no_break = Vec::new();
2262 if let Some((n, sh, st)) = pair {
2263 // The kern part is RIGID and must never be a break point,
2264 // so it rides as a `FixedEmpty` rather than as glue.
2265 if n != Length::ZERO {
2266 no_break.push(PureHorzBox::FixedEmpty { width: n });
2267 }
2268 no_break.push(PureHorzBox::OuterEmpty {
2269 natural: Length::ZERO,
2270 shrinkable: sh,
2271 stretchable: st,
2272 });
2273 }
2274 out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2275 penalty: match kind {
2276 BreakKind::Allowed => 0,
2277 BreakKind::Mandatory => FORCED_BREAK_PENALTY,
2278 },
2279 pre_break: Vec::new(),
2280 post_break: Vec::new(),
2281 no_break,
2282 }));
2283 }
2284 // The `PreventBreak` arm: `LBPure(glue)`, spelled as a
2285 // `Discretionary` whose every break slot is empty and whose
2286 // penalty is `NO_BREAK_PENALTY` (a bare `OuterEmpty` IS a
2287 // breakpoint in this box model, so a pure elastic box has no
2288 // other spelling).
2289 //
2290 // Only the ELASTIC half, deliberately — the one place this
2291 // port knowingly diverges from `discretionary_if_breakable`.
2292 // Landing the RIGID half too was written and MEASURED: it makes
2293 // the kern model ASYMMETRIC, since `cjk_pair_space`'s kern is a
2294 // property of the PAIR while upstream's is a property of the
2295 // CHARACTER, and the two agree only when BOTH neighbours are
2296 // CJK — `生成・変換` would get the nakaten's kern on both sides
2297 // while `(例:textbox` gets only its leading one (the trailing
2298 // side faces Latin, unreached by `cjk_pair_space`) — figbox's
2299 // largest intra-line divergence from upstream (mean |dx| on
2300 // that line 2.5pt -> 6.5pt).
2301 //
2302 // Completing it needs the per-character kerns at CJK<->Latin
2303 // boundaries and run edges too, which needs the source-
2304 // whitespace rewrite applied BEFORE `uax14_boundaries` rather
2305 // than during this loop (upstream's own order). Without that a
2306 // `。` before a deleted source newline gets its trailing kern
2307 // while the class space that pays it back is skipped (the
2308 // lookahead sees the newline, not the character after it), and
2309 // the layout-fidelity gate fails 12 ways. So the natural-width
2310 // bug the rigid half would fix — `」。`/`」、` a half-em too
2311 // wide, `末・雲` a quarter — stays exactly as open as before.
2312 None => {
2313 if let Some((_, sh, st)) = pair {
2314 if sh != Length::ZERO || st != Length::ZERO {
2315 flush_word(&mut word, script, out)?;
2316 word_script = None;
2317 out.push(HorzBox::Pure(PureHorzBox::Discretionary {
2318 penalty: NO_BREAK_PENALTY,
2319 pre_break: Vec::new(),
2320 post_break: Vec::new(),
2321 no_break: vec![PureHorzBox::OuterEmpty {
2322 natural: Length::ZERO,
2323 shrinkable: sh,
2324 stretchable: st,
2325 }],
2326 }));
2327 }
2328 }
2329 }
2330 }
2331 }
2332 }
2333 match word_script {
2334 Some(s) => flush_word(&mut word, s, out),
2335 None => Ok(()),
2336 }
2337}
2338
2339// ---- math conversion ----------------------
2340//
2341// Walks the already-elaborated `MathElem` tree straight into one
2342// `PureHorzBox::Math`, fixed-constant shift/scale (no MATH table).
2343
2344/// Superscript/subscript size ratio, used ONLY as `MathC`'s fallback when
2345/// the current math font has no OpenType MATH table (`script_percent_scale_down
2346/// / 100`). Not read anywhere outside `MathC` — every layout site goes
2347/// through `MathC::script_scale`/`sup_shift_clamped`/etc. so a MATH-table
2348/// font gets the real per-font ratio instead.
2349const SCRIPT_SCALE: f64 = 0.7;
2350/// Superscript raise, as a fraction of `ctx.font_size` — `MathC`'s
2351/// no-MATH-table fallback (`superscript_shift_up` clamped per
2352/// `math.ml:527`). Not read outside `MathC`.
2353const SUP_SHIFT: f64 = 0.5;
2354/// Cramped-style superscript raise fallback — the no-MATH-table fallback
2355/// `sup_shift_clamped` uses in place of `SuperscriptShiftUpCramped` when there
2356/// is no real MATH table to read. Deliberately set EQUAL to `SUP_SHIFT`: every
2357/// checked-in fixture font has no MATH table, so cramped and uncramped
2358/// superscripts get the identical fallback shift there. Only a real MATH
2359/// font (host-installed, test-guarded) makes cramped/uncramped diverge.
2360const SUP_SHIFT_CRAMPED: f64 = SUP_SHIFT;
2361/// Subscript drop, as a fraction of `ctx.font_size` — `MathC`'s
2362/// no-MATH-table fallback (`subscript_shift_down` per
2363/// `math.ml:545`). Not read outside `MathC`.
2364const SUB_SHIFT: f64 = 0.25;
2365/// `MathC::frac_numer_shift`'s no-MATH-table fallback: a flat,
2366/// content-independent numerator raise, as a fraction of the fraction's own
2367/// LOCAL size (mirrors `sup_shift_clamped`'s None-branch style, which
2368/// also ignores ink extent with no MATH table). Not read outside `MathC`.
2369const FRAC_NUMER_SHIFT_FALLBACK: f64 = 0.33;
2370/// `MathC::frac_denom_shift`'s no-MATH-table fallback (mirrors
2371/// `FRAC_NUMER_SHIFT_FALLBACK`; applied as a downward, i.e. negative, shift
2372/// by the caller). Not read outside `MathC`.
2373const FRAC_DENOM_SHIFT_FALLBACK: f64 = 0.33;
2374
2375/// MATH-table resolver: one query of `interp.metrics.math_constants(font)`
2376/// per laid-out math run, memoized here so every shift/scale/kern site in
2377/// that run reads the SAME `Option` instead of re-querying — and so a font
2378/// with no MATH table (every `Base14Metrics` call, and any TTF that lacks
2379/// one) transparently falls back to the flat pre-MATH-table constants
2380/// above. Fields are ratios of
2381/// the font size; callers multiply by whichever size is in scope
2382/// (`ctx.font_size` for the shift magnitudes — matching the pre-existing
2383/// "shift doesn't shrink with nesting" behavior these constants always had
2384/// — or the atom's own local `size` for glyph-relative queries like
2385/// `script_scale`/kerning).
2386struct MathC {
2387 c: Option<MathConstants>,
2388 /// `ctx.math_cramped` at the point this `MathC` was built — whether the
2389 /// current math sub-formula is laid out cramped. Consulted only by
2390 /// `sup_shift`/`sup_shift_clamped`, the sole positioning formula cramped
2391 /// changes in this port's feature set.
2392 cramped: bool,
2393}
2394
2395impl MathC {
2396 fn of(interp: &Interp, ctx: &Context) -> Self {
2397 Self {
2398 c: interp.metrics.math_constants(ctx.math_font),
2399 cramped: ctx.math_cramped,
2400 }
2401 }
2402
2403 /// Flat, unclamped superscript raise (`math.ml:527`'s `h_supstd` alone,
2404 /// no `math.ml:524-533` clamp) — the shape `layout_math_atom`'s callers
2405 /// need when no base/script ink extent is at hand yet.
2406 fn sup_shift(&self, s: Length) -> Length {
2407 match self.c {
2408 None => {
2409 s * if self.cramped {
2410 SUP_SHIFT_CRAMPED
2411 } else {
2412 SUP_SHIFT
2413 }
2414 }
2415 Some(c) => {
2416 s * if self.cramped {
2417 c.superscript_shift_up_cramped
2418 } else {
2419 c.superscript_shift_up
2420 }
2421 }
2422 }
2423 }
2424
2425 /// Flat, unclamped subscript drop (mirrors `sup_shift`).
2426 fn sub_shift(&self, s: Length) -> Length {
2427 self.c
2428 .map(|c| s * c.subscript_shift_down)
2429 .unwrap_or(s * SUB_SHIFT)
2430 }
2431
2432 /// `script_percent_scale_down / 100`, or the fixed `SCRIPT_SCALE`
2433 /// fallback. Nesting-level scale gap: upstream
2434 /// switches to `script_script_percent_scale_down` one level deeper;
2435 /// this port applies `script_scale_down` uniformly at every depth.
2436 fn script_scale(&self) -> f64 {
2437 self.c.map(|c| c.script_scale_down).unwrap_or(SCRIPT_SCALE)
2438 }
2439
2440 /// `math.ml`'s `h_bar` (axis height): the vertical center math content
2441 /// (fraction bars, `get-axis-height`) aligns to. Falls back to a fixed
2442 /// `0.25` ratio with no MATH table.
2443 fn axis(&self, s: Length) -> Length {
2444 self.c.map(|c| s * c.axis_height).unwrap_or(s * 0.25)
2445 }
2446
2447 /// `math.ml:524-533` `superscript_baseline_height`, clamped: the
2448 /// MAGNITUDE of the upward shift a superscript needs given the base's
2449 /// own ink height (`h_base`, a positive extent above ITS baseline) and
2450 /// the superscript's own ink depth (`d_sup`, a positive extent below
2451 /// ITS baseline — i.e. `MathGlyph.height`/`.depth`, not upstream's
2452 /// signed `Length.negate`d fields). Falls back to the flat `sup_shift`
2453 /// (ignoring `h_base`/`d_sup`) when there's no MATH table, so base-14
2454 /// output is untouched by this clamp.
2455 fn sup_shift_clamped(&self, s: Length, h_base: Length, d_sup: Length) -> Length {
2456 match self.c {
2457 None => self.sup_shift(s),
2458 Some(c) => {
2459 let shift_up = if self.cramped {
2460 c.superscript_shift_up_cramped
2461 } else {
2462 c.superscript_shift_up
2463 };
2464 let cand1 = s * shift_up;
2465 let cand2 = h_base - s * c.superscript_baseline_drop_max;
2466 let cand3 = s * c.superscript_bottom_min + d_sup;
2467 cand1.max(cand2).max(cand3)
2468 }
2469 }
2470 }
2471
2472 /// `math.ml:545-553` `subscript_baseline_depth`, clamped: the MAGNITUDE
2473 /// of the downward shift, given the base's own ink depth (`d_base`) and
2474 /// the subscript's own ink height (`h_sub`). Mirrors
2475 /// `sup_shift_clamped`'s fallback behavior.
2476 fn sub_shift_clamped(&self, s: Length, d_base: Length, h_sub: Length) -> Length {
2477 match self.c {
2478 None => self.sub_shift(s),
2479 Some(c) => {
2480 let cand1 = s * c.subscript_shift_down;
2481 let cand2 = d_base + s * c.subscript_baseline_drop_min;
2482 let cand3 = h_sub - s * c.subscript_top_max;
2483 cand1.max(cand2).max(cand3)
2484 }
2485 }
2486 }
2487
2488 /// `math.ml:562-573` `correct_script_baseline_heights`: when a base
2489 /// carries BOTH a subscript and a superscript, nudge the two
2490 /// already-clamped shift magnitudes apart so their ink keeps at least
2491 /// `sub_superscript_gap_min` clearance. `d_sup`/`h_sub` are the same ink
2492 /// extents `sup_shift_clamped`/`sub_shift_clamped` took; `sup`/`sub` are
2493 /// their (already clamped) outputs. A no-op when there's no MATH table
2494 /// — the flat fallback shifts are never additionally corrected, so
2495 /// base-14 output stays exactly `(sup, sub)`.
2496 fn correct_script_gap(
2497 &self,
2498 s: Length,
2499 d_sup: Length,
2500 h_sub: Length,
2501 sup: Length,
2502 sub: Length,
2503 ) -> (Length, Length) {
2504 let Some(c) = self.c else {
2505 return (sup, sub);
2506 };
2507 let gap_min = s * c.sub_superscript_gap_min;
2508 let gap = (sup - d_sup) - (h_sub - sub);
2509 if gap < gap_min {
2510 let corr = (gap_min - gap) * 0.5;
2511 (sup + corr, sub + corr)
2512 } else {
2513 (sup, sub)
2514 }
2515 }
2516
2517 /// `math.ml:596-602` `upper_limit_baseline_height`, clamped: the
2518 /// MAGNITUDE of the upward shift for an `\overset`-like upper limit,
2519 /// given the base's own ink height (`h_base`) and the limit content's
2520 /// own ink depth (`d_up`). Falls back to the flat `sup_shift` (same
2521 /// shape upstream's superscript raise uses) with no MATH table.
2522 fn upper_limit_shift(&self, s: Length, h_base: Length, d_up: Length) -> Length {
2523 match self.c {
2524 None => self.sup_shift(s),
2525 Some(c) => {
2526 let cand1 = h_base + s * c.upper_limit_baseline_rise_min;
2527 let cand2 = h_base + s * c.upper_limit_gap_min + d_up;
2528 cand1.max(cand2)
2529 }
2530 }
2531 }
2532
2533 /// `math.ml:605-611` `lower_limit_baseline_depth`, clamped: mirrors
2534 /// `upper_limit_shift` for a lower limit, given the base's own ink
2535 /// depth (`d_base`) and the limit content's own ink height (`h_low`).
2536 fn lower_limit_shift(&self, s: Length, d_base: Length, h_low: Length) -> Length {
2537 match self.c {
2538 None => self.sub_shift(s),
2539 Some(c) => {
2540 let cand1 = d_base + s * c.lower_limit_baseline_drop_min;
2541 let cand2 = d_base + s * c.lower_limit_gap_min + h_low;
2542 cand1.max(cand2)
2543 }
2544 }
2545 }
2546
2547 /// `math.ml:982-991` `horz_fraction_bar`'s rule thickness (also
2548 /// `radical_bar_metrics`'s `t_bar` — both are "the same generic rule
2549 /// ratio" in the pre-MATH-table fixed-constant world):
2550 /// `fraction_rule_thickness`, or the fixed `0.04` fallback. Multiplied
2551 /// by the ambient LOCAL nesting `size` (not `ctx.font_size` — a
2552 /// fraction/radical's own metrics DO shrink with nesting, matching
2553 /// upstream's `FontInfo.actual_math_font_size`, unlike the sup/sub shift
2554 /// constants' documented `ctx.font_size` simplification above).
2555 fn frac_rule(&self, s: Length) -> Length {
2556 self.c
2557 .map(|c| s * c.fraction_rule_thickness)
2558 .unwrap_or(s * 0.04)
2559 }
2560
2561 /// `math.ml:574-583` `numerator_baseline_height`, clamped: the
2562 /// MAGNITUDE of the upward shift a numerator needs given its own ink
2563 /// depth (`d_numer`, a positive extent below ITS baseline — this port's
2564 /// convention, see `sup_shift_clamped`'s doc comment; upstream's
2565 /// `Length.negate d_numer` becomes a plain ADD of `d_numer` here, not a
2566 /// subtract — getting this sign wrong would shrink the raise for a
2567 /// deeper numerator instead of growing it, overlapping the bar). Falls
2568 /// back to a flat, content-independent ratio with no MATH table
2569 /// (mirrors `sup_shift_clamped`'s None-branch style).
2570 fn frac_numer_shift(&self, s: Length, d_numer: Length) -> Length {
2571 match self.c {
2572 None => s * FRAC_NUMER_SHIFT_FALLBACK,
2573 Some(c) => {
2574 let std = s * c.fraction_numer_shift_up;
2575 let gap =
2576 self.axis(s) + self.frac_rule(s) * 0.5 + s * c.fraction_numer_gap_min + d_numer;
2577 std.max(gap)
2578 }
2579 }
2580 }
2581
2582 /// `math.ml:585-594` `denominator_baseline_depth`, clamped: mirrors
2583 /// `frac_numer_shift`. Returns the SIGNED (already-negative) drop the
2584 /// caller applies straight to `dy` — unlike the sup/sub methods'
2585 /// positive-magnitude-then-caller-negates convention — because
2586 /// upstream's own `d_denombl` is signed too, so there's no sign flip to
2587 /// make here (and `h_denom`, a HEIGHT not a depth, is subtracted
2588 /// directly, matching upstream's un-negated use of it).
2589 fn frac_denom_shift(&self, s: Length, h_denom: Length) -> Length {
2590 match self.c {
2591 None => -(s * FRAC_DENOM_SHIFT_FALLBACK),
2592 Some(c) => {
2593 let std = -(s * c.fraction_denom_shift_down);
2594 let gap =
2595 self.axis(s) - self.frac_rule(s) * 0.5 - s * c.fraction_denom_gap_min - h_denom;
2596 std.min(gap)
2597 }
2598 }
2599 }
2600
2601 /// `math.ml:620-626` `radical_bar_metrics`: `(h_bar, t_bar, l_extra)` —
2602 /// the bar's height above baseline (radicand height + gap, so the bar
2603 /// always clears the radicand with no separate raise needed), its rule
2604 /// thickness, and the extra ascender the WHOLE radical run reports
2605 /// above the bar. Fallback ratios (no MATH table):
2606 /// vertical_gap=0.06, rule=0.04 (same fixed ratio `frac_rule` falls back
2607 /// to), extra_ascender=0.06.
2608 fn radical_bar_metrics(&self, s: Length, h_cont: Length) -> (Length, Length, Length) {
2609 match self.c {
2610 Some(c) => (
2611 h_cont + s * c.radical_vertical_gap,
2612 s * c.radical_rule_thickness,
2613 s * c.radical_extra_ascender,
2614 ),
2615 None => (h_cont + s * 0.06, s * 0.04, s * 0.06),
2616 }
2617 }
2618}
2619
2620/// The ink height/depth of an already-laid-out run, as positive magnitudes
2621/// (`MathGlyph.dy` is signed, up-positive; `.height`/`.depth` are always
2622/// non-negative extents from EACH glyph's own local baseline) — the same
2623/// aggregate `read_math`/`layout_math_value` compute for a whole
2624/// `PureHorzBox::Math`, reused here per sub-run so `MathC`'s clamp formulas
2625/// have an `h_base`/`d_sup`/etc to clamp against. Empty input -> `(ZERO,
2626/// ZERO)` (an empty base/script contributes no clamp pressure).
2627fn glyphs_extent(glyphs: &[MathGlyph]) -> (Length, Length) {
2628 let mut height = Length::ZERO;
2629 let mut depth = Length::ZERO;
2630 for g in glyphs {
2631 height = height.max(g.dy + g.height);
2632 depth = depth.max(g.depth - g.dy);
2633 }
2634 (height, depth)
2635}
2636
2637/// `glyphs_extent` plus `rules`' own bounding boxes folded in — exactly the
2638/// aggregate `layout_math_value` computes for a whole `PureHorzBox::Math`
2639/// (see that function's doc comment on why a bare `Fill`, e.g. a fraction
2640/// bar/radical sign, needs its own bbox folded in rather than being silently
2641/// undercounted). Also reused to size a stretchy delimiter to its
2642/// enclosed run's REAL ink (glyphs + any drawn rules), not just its glyphs.
2643///
2644/// This — NOT bare `glyphs_extent` — is what every `layout_math_value` arm
2645/// must use for a sub-run's `h_base`/`d_base`/`d_sup`/`h_sub`/`d_numer`/…,
2646/// because it is upstream's `convert_to_low` return value: each arm's
2647/// `(_, h, d, _, _)` is the whole sub-run's `h_whole`/`d_whole`, and a
2648/// `MathParen`'s is `max(hC, hL, hR)` / `min(dC, dL, dR)` over the
2649/// DELIMITER boxes too (`math.ml:908-909`). A `math.satyh` delimiter is
2650/// `inline-graphics` ink, so it lands in `rules` and in nothing else: with
2651/// `glyphs_extent` a `\paren{…}` base reported only its CONTENT's height,
2652/// which is smaller than the delimiter it just sized, and
2653/// `sup_shift_clamped`'s `h_base - SuperscriptBaselineDropMax` candidate
2654/// therefore lost when upstream's wins. `${\paren{\frac{1}{1-v}}^{2}}` at
2655/// 12pt: `h_base` 16.116pt (content) vs upstream's 17.316pt (`hgtaxis +
2656/// halflen`, the paren's own declared box), i.e. a 1.2pt-too-low superscript
2657/// — `layout-tests/probes/math_box_extent.saty` row 4. The bbox of
2658/// `math.satyh`'s `paren-left`/`angle-left`/… path is exactly that declared
2659/// box (its extreme points ARE `ycenter ± halflen`), so folding the rule in
2660/// reproduces upstream's number rather than approximating it.
2661fn inner_ink_extent(glyphs: &[MathGlyph], rules: &[GraphicsElem]) -> (Length, Length) {
2662 let (mut height, mut depth) = glyphs_extent(glyphs);
2663 for r in rules {
2664 // `graphics_bbox` is now `Option` (`None` for an empty `Group`
2665 // — unreachable here under 0.0.6 math rules, but the fold is
2666 // version-blind and correct either way: a `None` rule contributes
2667 // nothing to the ink extent).
2668 if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
2669 height = height.max(max_y);
2670 depth = depth.max(-min_y);
2671 }
2672 }
2673 (height, depth)
2674}
2675
2676/// `math.ml:1040-1075`'s superscript kern tuck: the italic correction of
2677/// the base's TRAILING glyph plus the two corner kerns — the base's
2678/// top-right sampled at the height the raised superscript's ink starts
2679/// (`l_base = sup_shift - d_sup`, `superscript_correction_heights`'s first
2680/// component), and the superscript's own bottom-left sampled (at the
2681/// superscript's OWN size) at the height the base's ink ends (`l_sup =
2682/// h_base - sup_shift`, that function's second component) — the extra
2683/// horizontal gap upstream inserts between a base and a raised superscript
2684/// so slanted glyphs (an italic integral, say) don't collide with what's
2685/// stacked above them. `size`/`script_size` are the local sizes the base/
2686/// script glyphs were actually measured at (NOT `ctx.font_size`, unlike the
2687/// shift magnitude — these feed a design-units conversion that must match
2688/// each glyph's own em square). Every lookup misses to `Length::ZERO` (no
2689/// MATH table, no glyph, no kern data, ...), so base-14 output is
2690/// untouched: this returns exactly `Length::ZERO` whenever `ctx.math_font`
2691/// has no MATH table.
2692#[allow(clippy::too_many_arguments)]
2693fn superscript_kern(
2694 interp: &Interp,
2695 ctx: &Context,
2696 size: Length,
2697 script_size: Length,
2698 base_glyphs: &[MathGlyph],
2699 script_glyphs: &[MathGlyph],
2700 sup_shift: Length,
2701 h_base: Length,
2702 d_sup: Length,
2703) -> Length {
2704 let font = ctx.math_font;
2705 let last_base = base_glyphs.last().and_then(|g| g.text.chars().last());
2706 let first_script = script_glyphs.first().and_then(|g| g.text.chars().next());
2707 let l_italic = last_base
2708 .and_then(|c| interp.metrics.italic_correction(font, c, size))
2709 .unwrap_or(Length::ZERO);
2710 let l_base = sup_shift - d_sup;
2711 let l_sup = h_base - sup_shift;
2712 let l_kernbase = last_base
2713 .and_then(|c| {
2714 interp
2715 .metrics
2716 .math_kern(font, c, size, MathCorner::TopRight, l_base)
2717 })
2718 .unwrap_or(Length::ZERO);
2719 let l_kernsup = first_script
2720 .and_then(|c| {
2721 interp
2722 .metrics
2723 .math_kern(font, c, script_size, MathCorner::BottomLeft, l_sup)
2724 })
2725 .unwrap_or(Length::ZERO);
2726 l_italic + l_kernbase + l_kernsup
2727}
2728
2729/// A minimal stand-in for v0.0.6's per-codepoint math-class table
2730/// (`primitives.cppo.ml`) + `normalize_math_kind` (`math.ml:240`) — just
2731/// enough for `${a+b}` to get binary-operator spacing. Letters/digits/
2732/// everything else default to `Ord`.
2733fn ascii_math_kind(c: char) -> MathKind {
2734 match c {
2735 '+' | '-' | '*' | '/' => MathKind::Bin,
2736 '=' | '<' | '>' => MathKind::Rel,
2737 ',' | ';' | ':' | '.' => MathKind::Punct,
2738 _ => MathKind::Ord,
2739 }
2740}
2741
2742/// `normalize_math_kind` (`math.ml:238-277`): a BINARY atom whose neighbours
2743/// make it unary is really an ORDINARY one, and gets none of `Bin`'s spacing.
2744/// Upstream demotes on `mkprev in {Op, Bin, Rel, Open, Punct}` or `mknext in
2745/// {Rel, Close, Punct}`; `MathEnd` — the sentinel `math.ml:1270` passes for
2746/// both ends of a formula — is included here on the LEFT, which is what makes
2747/// `${-------}` set seven tight glyphs rather than a leading binary minus
2748/// followed by six ordinaries, and what keeps `${-N, -N + 1}`'s minus signs
2749/// tight against their operands the way the reference sets them. Every other
2750/// class passes through unchanged.
2751///
2752/// Reachable at all only since the math lexer stopped gluing a run of symbols
2753/// into one token: before that a `--` was a single `Ord` atom and there was no
2754/// adjacent pair to normalize.
2755fn normalize_math_kind(prev: MathKind, next: MathKind, raw: MathKind) -> MathKind {
2756 if raw != MathKind::Bin {
2757 return raw;
2758 }
2759 let unary_left = matches!(
2760 prev,
2761 MathKind::Op
2762 | MathKind::Bin
2763 | MathKind::Rel
2764 | MathKind::Open
2765 | MathKind::Punct
2766 | MathKind::End
2767 );
2768 let unary_right = matches!(next, MathKind::Rel | MathKind::Close | MathKind::Punct);
2769 if unary_left || unary_right {
2770 MathKind::Ord
2771 } else {
2772 MathKind::Bin
2773 }
2774}
2775
2776/// The six inter-atom space ratios `space_between_math_kinds` multiplies the
2777/// math font size by — `primitives.cppo.ml:528-533`'s `space_math_bin`, `_rel`,
2778/// `_op`, `_punct`, `_inner`, `_prefix`. Upstream keeps them as a
2779/// natural/shrink/stretch triple on `HorzBox.context_main` that no v0.0.6
2780/// primitive writes, and this port's math spacer emits a fixed kern rather than
2781/// glue, so only the natural component is needed.
2782const SPACE_MATH_BIN: f64 = 0.25;
2783const SPACE_MATH_REL: f64 = 0.375;
2784const SPACE_MATH_OP: f64 = 0.125;
2785const SPACE_MATH_PUNCT: f64 = 0.125;
2786const SPACE_MATH_INNER: f64 = 0.125;
2787const SPACE_MATH_PREFIX: f64 = 0.125;
2788
2789/// `space_between_math_kinds` (`math.ml:319-410`), arm for arm and in the same
2790/// ORDER: OCaml's `match` is first-match, so `(Punct, Close)` must reach the
2791/// `(Punct, _)` arm and not `(_, Close)`. Every ratio is
2792/// `primitives.cppo.ml:528-533`'s.
2793///
2794/// `in_script` is `not (MathContext.is_in_base_level mathctx)` — inside a
2795/// sub/superscript upstream suppresses the whole table except the five operator
2796/// pairs. `font_size` is `FontInfo.actual_math_font_size mathctx`, the size of
2797/// the LEVEL being laid out and not the ambient base size, which is why callers
2798/// pass their local `size`.
2799///
2800/// Upstream's `space_correction` channel is pinned at `NoSpace` here, so the
2801/// three arms that read it — `(_, Close)`, `(Ord|Prefix, Open)` and the
2802/// fallthrough — all yield nothing: exact for `NoSpace`, and the conservative
2803/// reading of a trailing italics correction and the MATH table's
2804/// `space_after_script`.
2805fn space_before(prev: MathKind, cur: MathKind, in_script: bool, font_size: Length) -> Length {
2806 use MathKind::*;
2807 let ratio = if in_script {
2808 match (prev, cur) {
2809 (Op, Ord) | (Ord, Op) | (Op, Op) | (Close, Op) | (Inner, Op) => SPACE_MATH_OP,
2810 _ => return Length::ZERO,
2811 }
2812 } else {
2813 match (prev, cur) {
2814 (Punct, _) => SPACE_MATH_PUNCT,
2815
2816 (Inner, Ord) | (Inner, Open) | (Inner, Punct) | (Inner, Inner) | (Ord, Inner)
2817 | (Prefix, Inner) | (Close, Inner) => SPACE_MATH_INNER,
2818
2819 // `corr = NoSpace`: no italics correction to append.
2820 (_, Close) => return Length::ZERO,
2821
2822 // `corr = NoSpace`: no `space_after_script` either.
2823 (Ord, Open) | (Prefix, Open) => return Length::ZERO,
2824
2825 (Bin, Ord) | (Bin, Prefix) | (Bin, Op) | (Bin, Open) | (Bin, Inner) | (Ord, Bin)
2826 | (Close, Bin) | (Inner, Bin) => SPACE_MATH_BIN,
2827
2828 (Rel, Ord) | (Rel, Op) | (Rel, Inner) | (Rel, Open) | (Rel, Prefix) | (Ord, Rel)
2829 | (Op, Rel) | (Inner, Rel) | (Close, Rel) => SPACE_MATH_REL,
2830
2831 (Op, Ord) | (Op, Op) | (Op, Inner) | (Op, Prefix) | (Ord, Op) | (Close, Op)
2832 | (Inner, Op) => SPACE_MATH_OP,
2833
2834 (Ord, Prefix) | (Inner, Prefix) => SPACE_MATH_PREFIX,
2835
2836 (_, End) | (End, _) => return Length::ZERO,
2837
2838 _ => return Length::ZERO,
2839 }
2840 };
2841 font_size * ratio
2842}
2843
2844/// `not (MathContext.is_in_base_level mathctx)` for the `(ctx, size)` pair this
2845/// port threads instead of upstream's `math_context`. BOTH witnesses are
2846/// needed: `layout_math_atom`'s script arms shrink only the local `size` and
2847/// pass the ambient `ctx` down, while `enter_script` (`attach_scripts`,
2848/// `read-math`'s `Math::WithContext`) advances `Context::math_script_level` and
2849/// scales `font_size` together — so a `WithContext` captured under a script
2850/// arrives with `size == ctx.font_size` and is recognisable only by its level.
2851fn math_in_script(ctx: &Context, size: Length) -> bool {
2852 size != ctx.font_size || ctx.math_script_level != MathScriptLevel::Base
2853}
2854
2855/// FontKey a math glyph c@size should measure/emit in: dedicated ctx.math_font
2856/// when it can render c, else text ctx.font. The one place math diverges from
2857/// text font; the MATH-table slice keys lookups on the same returned FontKey.
2858fn math_glyph_font(interp: &Interp, ctx: &Context, c: char, size: Length) -> FontKey {
2859 if interp.metrics.advance(ctx.math_font, c, size).is_some() {
2860 ctx.math_font
2861 } else {
2862 ctx.font
2863 }
2864}
2865
2866/// gap-5 metrics-probe predicate, now math-font-aware.
2867fn math_char_available(interp: &Interp, ctx: &Context, c: char, size: Length) -> bool {
2868 interp.metrics.advance(ctx.math_font, c, size).is_some()
2869 || interp.metrics.advance(ctx.font, c, size).is_some()
2870}
2871
2872/// Measure one math character at `size` under `math_glyph_font(ctx, c)` and
2873/// push it as a `MathGlyph` at the running `*x` (`dy = 0`; callers shift
2874/// scripts afterward), advancing `*x` past it.
2875fn push_char_glyph(
2876 interp: &mut Interp,
2877 ctx: &Context,
2878 c: char,
2879 size: Length,
2880 out: &mut Vec<MathGlyph>,
2881 x: &mut Length,
2882) -> Result<(), EvalError> {
2883 let font = math_glyph_font(interp, ctx, c, size);
2884 // `fontInfo.ml:379-383`: a math glyph below base level is set in the font's
2885 // `ssty` variant — a purpose-drawn form with its own advance, not the base
2886 // glyph shrunk (see `FontMetrics::math_script_variant`). Any miss falls
2887 // through to the base glyph unchanged.
2888 if math_in_script(ctx, size) {
2889 if let Some(v) = interp.metrics.math_script_variant(font, c, size) {
2890 out.push(MathGlyph {
2891 info: HorzStringInfo {
2892 font,
2893 size,
2894 rising: Length::ZERO,
2895 color: ctx.text_color,
2896 },
2897 text: c.to_string(),
2898 gid: Some(v.gid),
2899 dx: *x,
2900 dy: Length::ZERO,
2901 width: v.advance,
2902 height: v.height,
2903 depth: v.depth,
2904 });
2905 *x += v.advance;
2906 return Ok(());
2907 }
2908 }
2909 // Graceful degradation for a math character neither the math font nor the
2910 // text font can render (e.g. `⋯` U+22EF under the bundled faces): fall back
2911 // to a half-em advance and let the glyph degrade to `.notdef` at render
2912 // time (`gid: None`, resolved by `cid::encode_glyph_run`), exactly as the
2913 // text path does in `measure_run` — a missing glyph must not abort the whole
2914 // document. This only ever changes behavior for a glyph that would otherwise
2915 // be a hard error, so covered-glyph documents stay byte-identical.
2916 let advance = interp.metrics.advance(font, c, size).unwrap_or(size * 0.5);
2917 let (height, depth) = math_glyph_vextent(interp, font, c, size);
2918 out.push(MathGlyph {
2919 info: HorzStringInfo {
2920 font,
2921 size,
2922 rising: Length::ZERO,
2923 color: ctx.text_color,
2924 },
2925 text: c.to_string(),
2926 gid: None,
2927 dx: *x,
2928 dy: Length::ZERO,
2929 width: advance,
2930 height,
2931 depth,
2932 });
2933 *x += advance;
2934 Ok(())
2935}
2936
2937/// One math glyph's vertical ink extent, the way upstream measures it:
2938/// `FontFormat.get_math_glyph_metrics` (`fontFormat.ml:2257-2264`) takes the
2939/// glyph's OWN bounding box and truncates each side towards the baseline —
2940/// `hgt = truncate_negative ymax` (so a wholly-subscripted glyph reports
2941/// height 0, never a negative height) and `dpt = truncate_positive ymin` (so a
2942/// glyph entirely above the baseline, `*` at ymin=+320, reports depth 0).
2943///
2944/// This is NOT the font-level ascender/descender:
2945/// `MathC::sub_shift_clamped`/`sup_shift_clamped` clamp against these extents
2946/// (`math.ml:527-552`), so feeding them latinmodern-math's hhea ascender
2947/// (806/1000 em) and descender (194/1000 em) instead of `m`'s real ink box
2948/// (ymax 442, ymin 0) made the `superscript_baseline_drop_max` /
2949/// `subscript_baseline_drop_min` candidate win every time. At 12pt that is
2950/// `9.672 - 3.0 = 6.672pt` of superscript rise where upstream's own clamp
2951/// picks `SuperscriptShiftUp = 4.356pt` (plus a gap correction, 4.525pt), and
2952/// `2.328 + 2.4 = 4.728pt` of subscript drop where upstream picks
2953/// `SubscriptShiftDown = 2.964pt` — measured by `layout-tests/probes/
2954/// math_script_drop.saty`.
2955///
2956/// Falls back to `ascender`/`descender` when the provider exposes no per-glyph
2957/// bbox (base-14 metrics, test stubs).
2958fn math_glyph_vextent(interp: &Interp, font: FontKey, c: char, size: Length) -> (Length, Length) {
2959 match interp.metrics.glyph_vextent(font, c, size) {
2960 Some((h, d)) => (h.max(Length::ZERO), d.max(Length::ZERO)),
2961 None => (
2962 interp.metrics.ascender(font, size),
2963 interp.metrics.descender(font, size),
2964 ),
2965 }
2966}
2967
2968/// `push_char_glyph`'s big-operator sibling: try the v0.0.6 `BigOp`
2969/// vertical variant (`fontInfo.ml:386-401` — the 2nd `MathVariants` record if
2970/// present, else the 1st) unconditionally. Upstream's own guard is
2971/// `is_in_display && is_big`, but `math.ml`'s `convert_math_char` hardcodes
2972/// `is_in_display = true`, so it reduces to just `is_big` — a big operator
2973/// grows even inline, even at script size, exactly like upstream; the port
2974/// tracks no display/inline distinction and needs none here. On any miss (no
2975/// MATH table, no vertical construction for `c`, or a variant/hmtx/bbox
2976/// lookup failure — every base-14 call, always) falls back to
2977/// `push_char_glyph`, byte-identical to the base output.
2978fn push_big_char_glyph(
2979 interp: &mut Interp,
2980 ctx: &Context,
2981 c: char,
2982 size: Length,
2983 out: &mut Vec<MathGlyph>,
2984 x: &mut Length,
2985) -> Result<(), EvalError> {
2986 let font = math_glyph_font(interp, ctx, c, size);
2987 match interp
2988 .metrics
2989 .math_vertical_variant(font, c, size, VertVariantPolicy::BigOp)
2990 {
2991 Some(v) => {
2992 out.push(MathGlyph {
2993 info: HorzStringInfo {
2994 font,
2995 size,
2996 rising: Length::ZERO,
2997 color: ctx.text_color,
2998 },
2999 text: c.to_string(),
3000 gid: Some(v.gid),
3001 dx: *x,
3002 dy: Length::ZERO,
3003 width: v.advance,
3004 height: v.height,
3005 depth: v.depth,
3006 });
3007 *x += v.advance;
3008 Ok(())
3009 }
3010 None => push_char_glyph(interp, ctx, c, size, out, x),
3011 }
3012}
3013
3014/// One stretchy-delimiter glyph: the smallest `MathVariants`
3015/// record whose `advance_measurement` covers `target` (else the largest
3016/// record — `VertVariantPolicy::AtLeast`), centered on the math axis
3017/// (`dy = axis - (h - d) / 2`; y-**up**, same sign convention as
3018/// `shift_and_append`'s `dy_shift` — see that function's doc comment on the
3019/// mirroring trap a flipped sign causes). Falls back to the baseline
3020/// base glyph (`push_char_glyph`) when there's no vertical construction.
3021fn push_delimiter_glyph(
3022 interp: &mut Interp,
3023 ctx: &Context,
3024 c: char,
3025 size: Length,
3026 target: Length,
3027 axis: Length,
3028 out: &mut Vec<MathGlyph>,
3029 x: &mut Length,
3030) -> Result<(), EvalError> {
3031 let font = math_glyph_font(interp, ctx, c, size);
3032 let variant =
3033 interp
3034 .metrics
3035 .math_vertical_variant(font, c, size, VertVariantPolicy::AtLeast(target));
3036 // `GlyphAssembly`: if even the largest discrete variant's own ink
3037 // extent (`height + depth`) still doesn't span `target` — a delimiter
3038 // taller than anything the font enumerates as a prepared variant — grow
3039 // it from the assembly parts instead (stack top + repeated extenders +
3040 // bottom). `None` (no MATH table / no assembly / base-14) leaves the
3041 // discrete/base path below byte-identical.
3042 let discrete_covers = variant
3043 .map(|v| (v.height + v.depth).0 >= target.0)
3044 .unwrap_or(false);
3045 if !discrete_covers {
3046 if let Some(parts) = interp.metrics.math_vertical_assembly(font, c, size, target) {
3047 if !parts.is_empty() {
3048 // Horizontal advance of the delimiter column: the largest
3049 // discrete variant's own hmtx advance when we have one (the
3050 // parts share the same nominal delimiter width), else the base
3051 // glyph's advance.
3052 let hadv = match variant {
3053 Some(v) => v.advance,
3054 None => interp
3055 .metrics
3056 .advance(font, c, size)
3057 .unwrap_or(Length::ZERO),
3058 };
3059 // Total vertical extent of the stacked assembly (local, from
3060 // the bottom part's baseline at 0), then center it on the math
3061 // axis exactly like the discrete path centers a variant's ink.
3062 let total = parts
3063 .last()
3064 .map(|(_, dy, adv)| *dy + *adv)
3065 .unwrap_or(Length::ZERO);
3066 let base_off = axis - total * 0.5;
3067 for (i, (gid, dy_local, adv)) in parts.iter().enumerate() {
3068 out.push(MathGlyph {
3069 info: HorzStringInfo {
3070 font,
3071 size,
3072 rising: Length::ZERO,
3073 color: ctx.text_color,
3074 },
3075 text: c.to_string(),
3076 gid: Some(*gid),
3077 dx: *x,
3078 dy: base_off + *dy_local,
3079 // Only the first part carries the column's horizontal
3080 // width (all parts are stacked in the SAME x column);
3081 // its baseline-relative extent is the part's vertical
3082 // advance (up), so `glyphs_extent` folds the whole
3083 // stacked column into the box's height/depth.
3084 width: if i == 0 { hadv } else { Length::ZERO },
3085 height: *adv,
3086 depth: Length::ZERO,
3087 });
3088 }
3089 *x += hadv;
3090 return Ok(());
3091 }
3092 }
3093 }
3094 match variant {
3095 Some(v) => {
3096 let dy = axis - (v.height - v.depth) * 0.5;
3097 out.push(MathGlyph {
3098 info: HorzStringInfo {
3099 font,
3100 size,
3101 rising: Length::ZERO,
3102 color: ctx.text_color,
3103 },
3104 text: c.to_string(),
3105 gid: Some(v.gid),
3106 dx: *x,
3107 dy,
3108 width: v.advance,
3109 height: v.height,
3110 depth: v.depth,
3111 });
3112 *x += v.advance;
3113 Ok(())
3114 }
3115 None => push_char_glyph(interp, ctx, c, size, out, x),
3116 }
3117}
3118
3119/// Lay out `elems` in isolation (its own local `x` starting at 0, its own
3120/// spacing state) at `size` — the shape a `Sup`/`Sub`/`Primes` script needs
3121/// before its glyphs get re-anchored onto the base's running `x` and
3122/// shifted by the caller. `size` is the caller's `MathC::script_scale`-
3123/// derived script size (real MATH-table ratio when available, `SCRIPT_SCALE`
3124/// otherwise). Returns the glyphs (still at local coordinates) and
3125/// the script's total width.
3126fn layout_script(
3127 interp: &mut Interp,
3128 ctx: &Context,
3129 elems: &[MathElem],
3130 size: Length,
3131) -> Result<(Vec<MathGlyph>, Length), EvalError> {
3132 let mut glyphs = Vec::new();
3133 let mut x = Length::ZERO;
3134 let mut last_kind: Option<MathKind> = None;
3135 for e in elems {
3136 layout_math_elem(interp, ctx, e, size, &mut glyphs, &mut x, &mut last_kind)?;
3137 }
3138 Ok((glyphs, x))
3139}
3140
3141/// Re-anchor an isolated script's glyphs (`layout_script`'s output) onto the
3142/// base's running `*x`, adding `dy_shift` to every glyph's vertical offset —
3143/// `dy_shift > 0` raises (superscript), `< 0` lowers (subscript). Advances
3144/// `*x` past the whole script.
3145fn place_script(
3146 out: &mut Vec<MathGlyph>,
3147 x: &mut Length,
3148 script_glyphs: Vec<MathGlyph>,
3149 script_width: Length,
3150 dy_shift: Length,
3151) {
3152 let base_x = *x;
3153 for mut g in script_glyphs {
3154 g.dx = base_x + g.dx;
3155 g.dy = g.dy + dy_shift;
3156 out.push(g);
3157 }
3158 *x = base_x + script_width;
3159}
3160
3161/// The recursive core of `read_math`: lays out one `MathElem` into `out`,
3162/// advancing `*x` and threading `*last_kind` (the trailing `MathKind` of
3163/// whatever was laid out immediately before, for `space_before`) through
3164/// siblings — the analog of `convert_to_low` + `horz_of_low_math`
3165/// (`math.ml:753`/`:1016`), fused and with fixed constants.
3166fn layout_math_elem(
3167 interp: &mut Interp,
3168 ctx: &Context,
3169 elem: &MathElem,
3170 size: Length,
3171 out: &mut Vec<MathGlyph>,
3172 x: &mut Length,
3173 last_kind: &mut Option<MathKind>,
3174) -> Result<(), EvalError> {
3175 match elem {
3176 MathElem::Chars(s) => {
3177 for c in s.chars() {
3178 let kind = ascii_math_kind(c);
3179 if let Some(prev) = *last_kind {
3180 *x += space_before(prev, kind, math_in_script(ctx, size), size);
3181 }
3182 push_char_glyph(interp, ctx, c, size, out, x)?;
3183 *last_kind = Some(kind);
3184 }
3185 Ok(())
3186 }
3187 MathElem::Group(elems) => {
3188 for e in elems {
3189 layout_math_elem(interp, ctx, e, size, out, x, last_kind)?;
3190 }
3191 Ok(())
3192 }
3193 MathElem::Sup(base, script) => {
3194 let base_start = out.len();
3195 layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3196 let mc = MathC::of(interp, ctx);
3197 let script_size = ctx.font_size * mc.script_scale();
3198 let (h_base, _) = glyphs_extent(&out[base_start..]);
3199 let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3200 let (_, d_sup) = glyphs_extent(&script_glyphs);
3201 let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3202 let kern = superscript_kern(
3203 interp,
3204 ctx,
3205 size,
3206 script_size,
3207 &out[base_start..],
3208 &script_glyphs,
3209 sup_shift,
3210 h_base,
3211 d_sup,
3212 );
3213 *x += kern;
3214 place_script(out, x, script_glyphs, script_width, sup_shift);
3215 Ok(())
3216 }
3217 MathElem::Sub(base, script) => {
3218 let base_start = out.len();
3219 layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3220 let mc = MathC::of(interp, ctx);
3221 let script_size = ctx.font_size * mc.script_scale();
3222 let (_, d_base) = glyphs_extent(&out[base_start..]);
3223 let (script_glyphs, script_width) = layout_script(interp, ctx, script, script_size)?;
3224 let (h_sub, _) = glyphs_extent(&script_glyphs);
3225 let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
3226 place_script(out, x, script_glyphs, script_width, -sub_shift);
3227 Ok(())
3228 }
3229 MathElem::Primes(base, n) => {
3230 let base_start = out.len();
3231 layout_math_elem(interp, ctx, base, size, out, x, last_kind)?;
3232 let mc = MathC::of(interp, ctx);
3233 let script_size = ctx.font_size * mc.script_scale();
3234 let (h_base, _) = glyphs_extent(&out[base_start..]);
3235 // Upstream desugars primes to exactly this: a superscript of `n`
3236 // U+2032 `′` chars (`parser.mly:1082`).
3237 let primes = vec![MathElem::Chars("\u{2032}".repeat(*n))];
3238 let (script_glyphs, script_width) = layout_script(interp, ctx, &primes, script_size)?;
3239 let (_, d_sup) = glyphs_extent(&script_glyphs);
3240 let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
3241 let kern = superscript_kern(
3242 interp,
3243 ctx,
3244 size,
3245 script_size,
3246 &out[base_start..],
3247 &script_glyphs,
3248 sup_shift,
3249 h_base,
3250 d_sup,
3251 );
3252 *x += kern;
3253 place_script(out, x, script_glyphs, script_width, sup_shift);
3254 Ok(())
3255 }
3256 MathElem::Cmd { name, span, .. } => Err(EvalError {
3257 span: Some(*span),
3258 msg: format!("math command `{name}` needs the math package (phase 7 roadmap A)"),
3259 }),
3260 MathElem::Embed { span, .. } => Err(EvalError {
3261 span: Some(*span),
3262 msg: "embedding a program value in math needs the math package \
3263 (phase 7 roadmap A)"
3264 .into(),
3265 }),
3266 }
3267}
3268
3269/// Walk an elaborated `${…}` tree (`read_inline`'s `EmbedMath` arm) into one
3270/// `PureHorzBox::Math`, measuring every glyph through `interp.metrics` at
3271/// `ctx.font`/`ctx.font_size` — the same `FontMetrics` seam `text_to_boxes`
3272/// uses. Box-model rationale: a math run carries its own pre-shifted
3273/// sub-glyphs, since the line model has no per-box vertical slot.
3274pub fn read_math(
3275 interp: &mut Interp,
3276 ctx: &Context,
3277 elems: &[MathElem],
3278) -> Result<PureHorzBox, EvalError> {
3279 let mut glyphs: Vec<MathGlyph> = Vec::new();
3280 let mut x = Length::ZERO;
3281 let mut last_kind: Option<MathKind> = None;
3282 for e in elems {
3283 layout_math_elem(
3284 interp,
3285 ctx,
3286 e,
3287 ctx.font_size,
3288 &mut glyphs,
3289 &mut x,
3290 &mut last_kind,
3291 )?;
3292 }
3293 let width = x;
3294 let mut height = Length::ZERO;
3295 let mut depth = Length::ZERO;
3296 for g in &glyphs {
3297 height = height.max(g.dy + g.height);
3298 depth = depth.max(g.depth - g.dy);
3299 }
3300 Ok(PureHorzBox::Math {
3301 width,
3302 height,
3303 depth,
3304 glyphs,
3305 rules: Vec::new(),
3306 })
3307}
3308
3309// ---- primitive bodies ----------------------------------------------------------
3310
3311fn prim_read_inline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3312 let it = args.pop().unwrap();
3313 let ctx = as_context(args.pop().unwrap())?;
3314 let (elems, env) = as_inline_text(it)?;
3315 Ok(Value::InlineBoxes(read_inline(interp, &ctx, &elems, &env)?))
3316}
3317
3318fn prim_read_block(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3319 let bt = args.pop().unwrap();
3320 let ctx = as_context(args.pop().unwrap())?;
3321 let (elems, env) = as_block_text(bt)?;
3322 Ok(Value::BlockBoxes(read_block(interp, &ctx, &elems, &env)?))
3323}
3324
3325/// `line-break : bool -> bool -> context -> inline-boxes -> block-boxes`
3326/// (vminst.ml `BackendLineBreaking`). The two leading bools tell the real
3327/// line breaker whether the paragraph's top/bottom edge may break across a
3328/// page; this port's `break_into_lines` does not yet model breakability
3329/// at all, so both are accepted (to keep the arity/signature faithful to
3330/// v0.0.6) and ignored for now.
3331///
3332/// This is upstream's `form_paragraph` seam — every stdlib caller
3333/// (`form-paragraph = line-break true true`, and every direct `line-break _
3334/// _ (ctx |> set-paragraph-margin …)` call for headings/itemize/footnotes)
3335/// relies on `line-break` itself to apply
3336/// `ctx.paragraph_top`/`paragraph_bottom` around the formed lines,
3337/// unconditionally of the two breakability bools (those only ever gate
3338/// page-break eligibility upstream, never whether the margin applies).
3339/// Prepending/appending `VertBox::Skip` here is a no-op in extent for a
3340/// caller that already zeroed the margin (e.g. `footnote-scheme.satyh`'s
3341/// `set-paragraph-margin 0pt 0pt`), and the leading skip specifically is
3342/// further discarded by `chop_page` when it lands at the very top of a
3343/// page/column (see that function's `pending_skip` handling) — mirroring
3344/// upstream's page-top glue suppression so a page's first paragraph does not
3345/// get a spurious gap above it.
3346fn prim_line_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3347 let ib = as_inline_boxes(args.pop().unwrap())?;
3348 let ctx = as_context(args.pop().unwrap())?;
3349 let _is_breakable_bottom = as_bool(args.pop().unwrap())?;
3350 let _is_breakable_top = as_bool(args.pop().unwrap())?;
3351 let lines = break_into_lines(&ctx, ib);
3352 // No lines were actually formed (empty inline content, `break_into_lines`'s
3353 // own `n == 0` early return) — don't manufacture a margin around nothing.
3354 let mut out = Vec::with_capacity(lines.len() + 2);
3355 if !lines.is_empty() {
3356 // `min_first_line_ascender` (9pt, `primitives.cppo.ml:516`) is folded
3357 // into the paragraph's OWN top margin, exactly as `lineBreak.ml:855-857`
3358 // does — `margin_top = paragraph_margin_top + max(0, 9pt - hgt)` over
3359 // the FIRST formed line's height. That padded value is what
3360 // `pageBreak.ml`'s `squash_margins` (`:596-601`) then max-collapses
3361 // against the previous block's bottom margin, so a larger predecessor
3362 // ABSORBS the pad instead of stacking it on top. Applying the floor
3363 // downstream to the line HEIGHT, after the collapse, is a different
3364 // function: it over-spaced every block whose predecessor had the larger
3365 // bottom margin — 5pt at every stdjabook section heading, whose 4pt
3366 // rule lines are shorter than the floor.
3367 //
3368 // The BOTTOM margin takes no pad: `min_last_descender` is assigned at
3369 // `lineBreak.ml:1144` and never read.
3370 let first_height = lines
3371 .iter()
3372 .find_map(|vb| match vb {
3373 VertBox::Line { height, .. } => Some(*height),
3374 _ => None,
3375 })
3376 .unwrap_or(Length::ZERO);
3377 let pad = (MIN_FIRST_ASCENDER - first_height).max(Length::ZERO);
3378 out.push(VertBox::ParagTop(ctx.paragraph_top + pad));
3379 out.extend(lines);
3380 out.push(VertBox::Skip(ctx.paragraph_bottom));
3381 }
3382 for vb in &mut out {
3383 if let VertBox::Line { contents, .. } = vb {
3384 resolve_outer_graphics_in_contents(interp, contents)?;
3385 }
3386 }
3387 Ok(Value::BlockBoxes(out))
3388}
3389
3390/// Look up `field` in a scheme record's fields, erroring with the
3391/// available-fields hint (mirrors `evalUtil.ml`'s `report_bug_value` arms
3392/// for a missing/mistyped scheme field) if it's absent.
3393fn record_field(
3394 fields: &BTreeMap<String, Value>,
3395 record_name: &str,
3396 field: &str,
3397) -> Result<Value, EvalError> {
3398 match fields.get(field) {
3399 Some(v) => Ok(v.clone()),
3400 None => eval_error(format!(
3401 "{record_name} record is missing field '{field}' (available fields: {})",
3402 available_fields(fields)
3403 )),
3404 }
3405}
3406
3407/// Extract `(text-origin, text-height)` from a `page-content-scheme`
3408/// record (`{| text-origin : point; text-height : length |}`) — the direct
3409/// port of `make_page_content_scheme_func`'s field pull (`evalUtil.ml:558-
3410/// 565`).
3411fn read_content_scheme(v: Value) -> Result<(Point, Length), EvalError> {
3412 let fields = match v {
3413 Value::Record(m) => m,
3414 other => {
3415 return eval_error(format!(
3416 "a page-content-scheme closure must return a record, got {}",
3417 other.type_name()
3418 ))
3419 }
3420 };
3421 let origin = as_point(record_field(&fields, "page-content-scheme", "text-origin")?)?;
3422 let height = as_length(record_field(&fields, "page-content-scheme", "text-height")?)?;
3423 Ok((origin, height))
3424}
3425
3426/// Extract `(header-origin, header-content, footer-origin, footer-content)`
3427/// from a `page-parts` record — the direct port of
3428/// `make_page_parts_scheme_func`'s field pull (`evalUtil.ml:576-595`).
3429fn read_parts_scheme(v: Value) -> Result<(Point, Vec<VertBox>, Point, Vec<VertBox>), EvalError> {
3430 let fields = match v {
3431 Value::Record(m) => m,
3432 other => {
3433 return eval_error(format!(
3434 "a page-parts closure must return a record, got {}",
3435 other.type_name()
3436 ))
3437 }
3438 };
3439 let header_origin = as_point(record_field(&fields, "page-parts", "header-origin")?)?;
3440 let header_content = as_block_boxes(record_field(&fields, "page-parts", "header-content")?)?;
3441 let footer_origin = as_point(record_field(&fields, "page-parts", "footer-origin")?)?;
3442 let footer_content = as_block_boxes(record_field(&fields, "page-parts", "footer-content")?)?;
3443 Ok((header_origin, header_content, footer_origin, footer_content))
3444}
3445
3446/// Upstream's `--page-number-limit` default (main.ml:1029). v0.0.6 guards
3447/// only the multicolumn loop (pageBreak.ml:765, `PageNumberLimitExceeded`);
3448/// the port guards the shared loop unconditionally — a hook-less run is
3449/// already bounded by the vbox count (`chop_page`'s progress guarantee), so
3450/// the guard can only fire when column hooks inject content, exactly the
3451/// case upstream added it for.
3452const PAGE_NUMBER_LIMIT: i64 = 10_000;
3453
3454/// The real 4-arg `page-break`, v0.0.6 arm — upstream `BCDocument(pagesize,
3455/// SingleColumn, (fun () -> []), (fun () -> []), …)` (vminst.ml:1039): one
3456/// zero-shift column, no hooks. Forked from the v0.1 arm below ONLY in its
3457/// first-argument extraction (`as_page` vs `as_page_v01`) — deliberately two
3458/// separate functions per tag rather than one branching on a `version`
3459/// parameter, so that a "shared" function is genuinely shared code.
3460/// `page_break_core` below IS that shared code.
3461fn prim_page_break_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3462 let bb = as_block_boxes(args.pop().unwrap())?;
3463 let pagepartsf = args.pop().unwrap();
3464 let pagecontf = args.pop().unwrap();
3465 let paper = as_page(args.pop().unwrap())?;
3466 page_break_core(
3467 interp,
3468 paper,
3469 vec![Length::ZERO],
3470 None,
3471 None,
3472 pagecontf,
3473 pagepartsf,
3474 bb,
3475 )
3476}
3477
3478/// v0.1 arm of `page-break`. Identical to `prim_page_break_v006` above
3479/// except `as_page_v01` in place of `as_page`; everything downstream
3480/// (`page_break_core`, `chop_page`, `place_block_at`, `DocumentValue`
3481/// assembly) is the SAME shared code both arms call, unedited by this fork.
3482fn prim_page_break_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3483 let bb = as_block_boxes(args.pop().unwrap())?;
3484 let pagepartsf = args.pop().unwrap();
3485 let pagecontf = args.pop().unwrap();
3486 let paper = as_page_v01(args.pop().unwrap())?;
3487 page_break_core(
3488 interp,
3489 paper,
3490 vec![Length::ZERO],
3491 None,
3492 None,
3493 pagecontf,
3494 pagepartsf,
3495 bb,
3496 )
3497}
3498
3499/// `page-break-two-column : page -> length -> (unit -> block-boxes) ->
3500/// (pbinfo -> page-content-scheme) -> (pbinfo -> page-parts) ->
3501/// block-boxes -> document` (vminst.ml:1041 `BackendPageBreakingTwoColumn`),
3502/// v0.0.6 arm — upstream builds `MultiColumn([origin_shift])` with the
3503/// user's column hook and a trivial column-end hook (vminst.ml:1062); the
3504/// `length` is the x-shift of the SECOND column's origin. See
3505/// `prim_page_break_v006`'s doc comment for the fork rationale.
3506fn prim_page_break_two_column_v006(
3507 interp: &mut Interp,
3508 mut args: Vec<Value>,
3509) -> Result<Value, EvalError> {
3510 let bb = as_block_boxes(args.pop().unwrap())?;
3511 let pagepartsf = args.pop().unwrap();
3512 let pagecontf = args.pop().unwrap();
3513 let columnhookf = args.pop().unwrap();
3514 let origin_shift = as_length(args.pop().unwrap())?;
3515 let paper = as_page(args.pop().unwrap())?;
3516 page_break_core(
3517 interp,
3518 paper,
3519 vec![Length::ZERO, origin_shift],
3520 Some(columnhookf),
3521 None,
3522 pagecontf,
3523 pagepartsf,
3524 bb,
3525 )
3526}
3527
3528/// v0.1 arm of `page-break-two-column`, using `as_page_v01` in place of
3529/// `as_page`.
3530fn prim_page_break_two_column_v01(
3531 interp: &mut Interp,
3532 mut args: Vec<Value>,
3533) -> Result<Value, EvalError> {
3534 let bb = as_block_boxes(args.pop().unwrap())?;
3535 let pagepartsf = args.pop().unwrap();
3536 let pagecontf = args.pop().unwrap();
3537 let columnhookf = args.pop().unwrap();
3538 let origin_shift = as_length(args.pop().unwrap())?;
3539 let paper = as_page_v01(args.pop().unwrap())?;
3540 page_break_core(
3541 interp,
3542 paper,
3543 vec![Length::ZERO, origin_shift],
3544 Some(columnhookf),
3545 None,
3546 pagecontf,
3547 pagepartsf,
3548 bb,
3549 )
3550}
3551
3552/// `page-break-multicolumn : page -> length list -> (unit -> block-boxes)
3553/// -> (unit -> block-boxes) -> (pbinfo -> page-content-scheme) -> (pbinfo
3554/// -> page-parts) -> block-boxes -> document` (vminst.ml:1065
3555/// `BackendPageBreakingMultiColumn`), v0.0.6 arm — FAITHFUL: the shift list
3556/// gives columns 2..N's x-origin shifts; upstream prepends `Length.zero` for
3557/// column 1 (pageBreak.ml:762), so `stdjareport.satyh:403`'s `[]` is a
3558/// one-column layout whose hooks still fire per column/page.
3559fn prim_page_break_multicolumn_v006(
3560 interp: &mut Interp,
3561 mut args: Vec<Value>,
3562) -> Result<Value, EvalError> {
3563 let bb = as_block_boxes(args.pop().unwrap())?;
3564 let pagepartsf = args.pop().unwrap();
3565 let pagecontf = args.pop().unwrap();
3566 let columnendhookf = args.pop().unwrap();
3567 let columnhookf = args.pop().unwrap();
3568 let mut origin_shifts = vec![Length::ZERO];
3569 for v in as_list(args.pop().unwrap())? {
3570 origin_shifts.push(as_length(v)?);
3571 }
3572 let paper = as_page(args.pop().unwrap())?;
3573 page_break_core(
3574 interp,
3575 paper,
3576 origin_shifts,
3577 Some(columnhookf),
3578 Some(columnendhookf),
3579 pagecontf,
3580 pagepartsf,
3581 bb,
3582 )
3583}
3584
3585/// v0.1 arm of `page-break-multicolumn`, using `as_page_v01` in place of
3586/// `as_page`.
3587fn prim_page_break_multicolumn_v01(
3588 interp: &mut Interp,
3589 mut args: Vec<Value>,
3590) -> Result<Value, EvalError> {
3591 let bb = as_block_boxes(args.pop().unwrap())?;
3592 let pagepartsf = args.pop().unwrap();
3593 let pagecontf = args.pop().unwrap();
3594 let columnendhookf = args.pop().unwrap();
3595 let columnhookf = args.pop().unwrap();
3596 let mut origin_shifts = vec![Length::ZERO];
3597 for v in as_list(args.pop().unwrap())? {
3598 origin_shifts.push(as_length(v)?);
3599 }
3600 let paper = as_page_v01(args.pop().unwrap())?;
3601 page_break_core(
3602 interp,
3603 paper,
3604 origin_shifts,
3605 Some(columnhookf),
3606 Some(columnendhookf),
3607 pagecontf,
3608 pagepartsf,
3609 bb,
3610 )
3611}
3612
3613/// Apply a `unit -> block-boxes` column hook and PREPEND its result to the
3614/// remaining content — the port of `chop_single_column_with_insertion`
3615/// (pageBreak.ml:699-702; the upstream `normalize` is a no-op here because
3616/// block-boxes are already solid `Vec<VertBox>`).
3617///
3618/// Reports whether the hook actually inserted anything: for the COLUMN-END
3619/// hook that is the difference between a remainder upstream would normalize
3620/// away and one it would turn into a real page — see `page_break_core`'s
3621/// blank-page suppression.
3622fn apply_column_hook(
3623 interp: &mut Interp,
3624 hook: &Value,
3625 remaining: &mut Vec<VertBox>,
3626) -> Result<bool, EvalError> {
3627 let inserted = as_block_boxes(interp.apply(hook.clone(), Value::Unit)?)?;
3628 let any = !inserted.is_empty();
3629 remaining.splice(0..0, inserted);
3630 Ok(any)
3631}
3632
3633/// Walk one run of a page's just-placed lines with the hooks-only half of
3634/// [`crate::PlacedWalk`].
3635///
3636/// Both flags are set for the duration of the walk and cleared after it, so
3637/// nothing ELSE the page loop evaluates — the content scheme, the parts
3638/// scheme, the column hooks — inherits either: `fire_pass` because those are
3639/// not walks, and `current_page` because it is upstream's
3640/// `State.during_page_break` window, which a `register-destination` outside a
3641/// fired callback must still be refused by.
3642fn walk_hooks(
3643 interp: &mut Interp,
3644 walk: &mut crate::PlacedWalk,
3645 paper_height: Length,
3646 page: usize,
3647 lines: &[rustyfi_backend::PlacedLine],
3648 body: bool,
3649) -> Result<(), EvalError> {
3650 interp.fire_pass = crate::eval::FirePass::HooksOnly;
3651 interp.current_page = Some(page);
3652 let r = walk.lines(interp, paper_height, page, lines, body);
3653 interp.current_page = None;
3654 interp.fire_pass = crate::eval::FirePass::All;
3655 r
3656}
3657
3658/// [`walk_hooks`]' page-closing twin. Fires nothing in this pass — every frame
3659/// fragment is a decoration — but it is what advances the cross-page frame
3660/// state, so the two passes stay in step about which fragment is which.
3661fn end_page_hooks(
3662 interp: &mut Interp,
3663 walk: &mut crate::PlacedWalk,
3664 paper_height: Length,
3665 page: usize,
3666) -> Result<(), EvalError> {
3667 interp.fire_pass = crate::eval::FirePass::HooksOnly;
3668 interp.current_page = Some(page);
3669 let r = walk.end_page(interp, paper_height, page);
3670 interp.current_page = None;
3671 interp.fire_pass = crate::eval::FirePass::All;
3672 r
3673}
3674
3675/// The shared per-page loop backing `page-break`, `page-break-two-column`,
3676/// and `page-break-multicolumn` — the port of `PageBreak.main` /
3677/// `main_multicolumn` (pageBreak.ml:705-781). Lang-side because it is the
3678/// one place that legally holds `&mut Interp` to apply the scheme/hook
3679/// closures (the `fire_hooks` seam). `origin_shifts` is the FULL column
3680/// list (leading zero included by the callers); `None` hooks are upstream's
3681/// `(fun () -> [])`.
3682///
3683/// Per page: apply `pagecontf` once; per column: fire `columnhookf`
3684/// (start of EVERY column, pageBreak.ml:700), chop one column at
3685/// `(x0 + shift, y0)` (footnotes bottom-place per column inside
3686/// `chop_page`), stop early when content runs out; then fire
3687/// `columnendhookf` exactly once (both upstream arms — exhausted
3688/// mid-columns `:751` and shifts-exhausted `:736` — reduce to "prepend its
3689/// output to the remainder"); then apply `pagepartsf` and place the parts.
3690#[allow(clippy::too_many_arguments)]
3691fn page_break_core(
3692 interp: &mut Interp,
3693 paper: PaperSize,
3694 origin_shifts: Vec<Length>,
3695 columnhookf: Option<Value>,
3696 columnendhookf: Option<Value>,
3697 pagecontf: Value,
3698 pagepartsf: Value,
3699 bb: Vec<VertBox>,
3700) -> Result<Value, EvalError> {
3701 let (paper_w, paper_h) = paper.dims();
3702
3703 // Capture the flat pre-page-break `Vec<VertBox>` BEFORE
3704 // `chop_page`/`apply_column_hook` below start draining/mutating
3705 // `remaining` — this clone is the document's natural linear flow exactly
3706 // as `bb` arrived here (no pages, no injected headers/footers, no
3707 // column-hook-inserted content). Unconditional (not gated on which
3708 // output format was requested — see `DocumentValue::reflow_source`'s doc
3709 // comment): PDF and the faithful HTML backend never read the field, so
3710 // this costs them only the clone itself, never a byte of their rendered
3711 // output.
3712 let reflow_source = bb.clone();
3713
3714 let mut remaining = bb;
3715 let mut pages: Vec<Page> = Vec::new();
3716 let mut pageno: i64 = 1;
3717 // The per-page `hook-page-break` pass, threaded through the whole loop so
3718 // that a `block-frame-breakable` straddling a page break keeps its state —
3719 // see `crate::PlacedWalk` for what the two passes divide between them, and
3720 // `eval::FirePass` for why there are two.
3721 let mut walk = crate::PlacedWalk::default();
3722 // Did the PREVIOUS page's column-end hook inject the content this page is
3723 // being made out of? See the blank-page suppression below.
3724 let mut columnend_injected = false;
3725 loop {
3726 if pageno > PAGE_NUMBER_LIMIT {
3727 return eval_error(format!(
3728 "page number limit exceeded ({PAGE_NUMBER_LIMIT}); a column hook keeps injecting content"
3729 ));
3730 }
3731 let page_index = (pageno - 1) as usize;
3732 let mut pb_fields = BTreeMap::new();
3733 pb_fields.insert("page-number".to_string(), Value::Int(pageno));
3734 let pbinfo = Value::Record(pb_fields);
3735
3736 // ---- content scheme: this page's text area (applied ONCE per page, shared by all its columns — pageBreak.ml:769) ----
3737 let sch = interp.apply(pagecontf.clone(), pbinfo.clone())?;
3738 let (origin, height) = read_content_scheme(sch)?;
3739 let (x0, y0) = origin;
3740
3741 // ---- columns ----
3742 let mut lines: Vec<rustyfi_backend::PlacedLine> = Vec::new();
3743 walk.begin_page();
3744 for shift in &origin_shifts {
3745 if let Some(hook) = &columnhookf {
3746 apply_column_hook(interp, hook, &mut remaining)?;
3747 }
3748 let placed_before = lines.len();
3749 lines.extend(chop_page((x0 + *shift, y0), height, &mut remaining));
3750 // Upstream fires this column's hooks HERE, and says so:
3751 // `pageBreak.ml:747` is commented "Adds the column to the page and
3752 // invokes hook functions". `add_column_to_page` (`:748`) builds
3753 // the column's PDF ops eagerly and `EvVertHookPageBreak` invokes
3754 // the closure as they are built (`handlePdf.ml:336`) — after
3755 // `chop_single_column_with_insertion` (`:744`) and before BOTH
3756 // `columnendhookf` (`:752`) and `write_page`'s `pagepartsf`
3757 // (`:775` -> `handlePdf.ml:464`). Single-column `main` is the same
3758 // three steps at `:715-717`.
3759 //
3760 // That order is the whole mechanism behind a floating figure:
3761 // `stdjareport`'s `\figure` is a `hook-page-break` that pushes the
3762 // figure onto `ref-float-boxes`, and the page-parts callback drains
3763 // that list onto a page whose number EXCEEDS the one it was pushed
3764 // on. Firing after the page loop (which is what this port used to
3765 // do) left every page-parts callback reading an empty list, and no
3766 // figure was ever emitted. `columnendhookf` reads hook state too:
3767 // `does-page-breaking-reach-last`, set by the
3768 // `hook-page-break-block` at the very end of the document.
3769 walk_hooks(
3770 interp,
3771 &mut walk,
3772 paper_h,
3773 page_index,
3774 &lines[placed_before..],
3775 true,
3776 )?;
3777 if remaining.is_empty() {
3778 break; // content exhausted: remaining columns are skipped
3779 }
3780 }
3781 let injected_now = match &columnendhookf {
3782 Some(hook) => apply_column_hook(interp, hook, &mut remaining)?,
3783 None => false,
3784 };
3785
3786 // A trailing pure-skip/glue (e.g. the last block's `paragraph_bottom`)
3787 // can roll past the previous page's bottom into a final `chop_page`
3788 // that places NO real line — `chop_page` discards it as a page-top
3789 // skip, leaving an empty body. SATySFi never emits such a trailing
3790 // blank page (glue at the end of the vertical list is dropped), so when
3791 // the body is empty AND content is now exhausted, stop before turning
3792 // that leftover into a spurious blank page (header/footer included).
3793 //
3794 // UNLESS the previous page's COLUMN-END HOOK is what put the content
3795 // here, which is a page the document deliberately asked for. Upstream
3796 // draws exactly that line: leftovers from the chop are normalized
3797 // (`normalize_after_break`, pageBreak.ml:101-121, maps `[]` and a lone
3798 // trailing breakable skip to `NormalizedEmpty`, so `restopt` is `None`
3799 // and no further page is created), but the column-end hook's insertion
3800 // bypasses it — `iter_on_column` returns it as the remainder (`:737`,
3801 // `:752`) and `iter_on_page` only tests that remainder for emptiness
3802 // (`:776-778`).
3803 // `stdjareport`'s `columnendhookf` buys the extra page its TRAILING
3804 // figures float onto with precisely that `block-skip 0pt`, and a
3805 // blanket suppression deleted the page and the figures with it.
3806 if !columnend_injected
3807 && remaining.is_empty()
3808 && !lines.iter().any(|l| placed_line_extent(l).is_some())
3809 {
3810 break;
3811 }
3812 columnend_injected = injected_now;
3813
3814 // ---- parts scheme: this page's header + footer ----
3815 // Everything placed so far is body/column content; the header and
3816 // footer append AFTER it (see `Page::body_lines`).
3817 let body_lines = lines.len();
3818 let parts = interp.apply(pagepartsf.clone(), pbinfo)?;
3819 let (header_origin, header_content, footer_origin, footer_content) =
3820 read_parts_scheme(parts)?;
3821 lines.extend(place_block_at(header_origin, header_content));
3822 lines.extend(place_block_at(footer_origin, footer_content));
3823 // The header's and footer's own hooks, in upstream's position too:
3824 // `write_page` runs `pagepartsf` (handlePdf.ml:464) and then walks the
3825 // parts' boxes through the same `ops_of_evaled_vert_box_list` (`:467`,
3826 // `:471`) that fired the body's.
3827 walk_hooks(
3828 interp,
3829 &mut walk,
3830 paper_h,
3831 page_index,
3832 &lines[body_lines..],
3833 false,
3834 )?;
3835 end_page_hooks(interp, &mut walk, paper_h, page_index)?;
3836
3837 pages.push(Page { lines, body_lines });
3838 if remaining.is_empty() {
3839 break;
3840 }
3841 pageno += 1;
3842 }
3843 // Tells the `fire_hooks` that runs once this document is returned to fire
3844 // the DECORATIONS only: every `hook-page-break` above has already run.
3845 interp.page_break_hooks_fired = true;
3846
3847 // Every image `load-image` decoded while evaluating this document (see
3848 // `Interp::images`'s doc comment) rides along in the packaged
3849 // `DocumentValue` so the PDF writer can emit XObjects for the ones
3850 // actually placed on a page.
3851 let images = interp.images.clone();
3852 Ok(Value::Document(Rc::new(DocumentValue {
3853 geometry: PageGeometry::for_paper(paper_w, paper_h),
3854 pages,
3855 images,
3856 // Filled in by `compile_document_cst_with_trials` once `fire_hooks`
3857 // has walked the final trial's placed geometry (see
3858 // `DocumentValue::extras`'s doc comment) — hooks/decos haven't fired
3859 // yet at this point in `page-break`'s own evaluation.
3860 extras: DocExtras::default(),
3861 reflow_source: Some(reflow_source),
3862 // Filled in alongside `extras` once `fire_hooks` has run — see
3863 // `DocumentValue::reflow_links`'s doc comment.
3864 reflow_links: Vec::new(),
3865 reflow_dests: Vec::new(),
3866 reflow_frame_decos: Vec::new(),
3867 })))
3868}
3869
3870// ---- int arithmetic -------------------------------------------------------
3871
3872// Wrapping arithmetic to match OCaml's native `int` (SATySFi's `int` is an
3873// OCaml int, which wraps on overflow) — and, decisively, so a debug build does
3874// not panic on the large intermediate products base's float bit-twiddling
3875// (`exp2i`, `ldexp`, `frexp`) computes.
3876binop_prim!(prim_int_add, as_int, Int, |a, b| a.wrapping_add(b));
3877binop_prim!(prim_int_sub, as_int, Int, |a, b| a.wrapping_sub(b));
3878binop_prim!(prim_int_mul, as_int, Int, |a, b| a.wrapping_mul(b));
3879
3880// OCaml catches `Division_by_zero` and reports `"division by zero"`; `mod`
3881// (see `Mod` in vminst.ml) shares that behavior.
3882binop_prim_try!(prim_int_div, as_int, |a, b| if b == 0 {
3883 eval_error("division by zero")
3884} else {
3885 Ok(Value::Int(a / b))
3886});
3887binop_prim_try!(prim_int_mod, as_int, |a, b| if b == 0 {
3888 eval_error("division by zero")
3889} else {
3890 Ok(Value::Int(a % b))
3891});
3892
3893// ---- int comparisons -------------------------------------------------------
3894
3895cmp_prim!(prim_int_eq, as_int, |a, b| a == b);
3896cmp_prim!(prim_int_ne, as_int, |a, b| a != b);
3897cmp_prim!(prim_int_lt, as_int, |a, b| a < b);
3898cmp_prim!(prim_int_gt, as_int, |a, b| a > b);
3899cmp_prim!(prim_int_le, as_int, |a, b| a <= b);
3900cmp_prim!(prim_int_ge, as_int, |a, b| a >= b);
3901
3902// ---- 0.1 bitwise ops -------------------------------------------------------
3903//
3904// `band`/`bor`/`bxor` mirror OCaml's `land`/`lor`/`lxor`; `bnot` mirrors
3905// `lnot` (bitwise complement). DOCUMENTED DEVIATION: this port's `int` is a
3906// 64-bit two's-complement `i64`, vs upstream's 63-bit boxed OCaml `int` — a
3907// value that actually uses bit 62 (the port's sign-adjacent bit upstream
3908// doesn't have) will complement/shift differently than upstream on that
3909// platform; upstream's own results are themselves platform-width-dependent
3910// there, and no bundled package relies on it.
3911binop_prim!(prim_band, as_int, Int, |a, b| a & b);
3912binop_prim!(prim_bor, as_int, Int, |a, b| a | b);
3913binop_prim!(prim_bxor, as_int, Int, |a, b| a ^ b);
3914unop_prim!(prim_bnot, as_int, Int, |a| !a);
3915
3916// `<<`/`>>` (dev-0-1-0 vminst.ml :2495/:2477): logical shifts (OCaml's
3917// `lsl`/`lsr`, NOT arithmetic — `>>` on a negative int does NOT sign-extend,
3918// see the `-16 >> 2` witness in the test suite), with upstream's exact
3919// dynamic-error message when the shift amount is out of `0..=63`.
3920binop_prim_try!(
3921 prim_bit_shift_left,
3922 as_int,
3923 |a, b| if !(0..=63).contains(&b) {
3924 eval_error("Bit offset out of bounds for '<<'")
3925 } else {
3926 Ok(Value::Int(((a as u64) << b) as i64))
3927 }
3928);
3929binop_prim_try!(
3930 prim_bit_shift_right,
3931 as_int,
3932 |a, b| if !(0..=63).contains(&b) {
3933 eval_error("Bit offset out of bounds for '>>'")
3934 } else {
3935 Ok(Value::Int(((a as u64) >> b) as i64))
3936 }
3937);
3938
3939// ---- bool -------------------------------------------------------------------
3940
3941// Strict (both arguments already evaluated by the caller before these natives
3942// run): real SATySFi source-level `&&`/`||` short-circuit via elaboration into
3943// `if`, which is out of scope here.
3944binop_prim!(prim_bool_and, as_bool, Bool, |a, b| a && b);
3945binop_prim!(prim_bool_or, as_bool, Bool, |a, b| a || b);
3946unop_prim!(prim_bool_not, as_bool, Bool, |a| !a);
3947
3948// ---- float --------------------------------------------------------------------
3949
3950binop_prim!(prim_float_add, as_float, Float, |a, b| a + b);
3951binop_prim!(prim_float_sub, as_float, Float, |a, b| a - b);
3952binop_prim!(prim_float_mul, as_float, Float, |a, b| a * b);
3953binop_prim!(prim_float_div, as_float, Float, |a, b| a / b);
3954unop_prim!(prim_float_of_int, as_int, Float, |n| n as f64);
3955
3956// `PrimitiveRound` in vminst.ml is, despite the name, `int_of_float`
3957// (truncation toward zero), not rounding to nearest.
3958unop_prim!(prim_round, as_float, Int, |x| x as i64);
3959
3960// ---- 0.1 float comparisons (saphe-split vminst.ml:2679-2740) ----
3961cmp_prim!(prim_float_gt, as_float, |a, b| a > b);
3962cmp_prim!(prim_float_lt, as_float, |a, b| a < b);
3963cmp_prim!(prim_float_ge, as_float, |a, b| a >= b);
3964cmp_prim!(prim_float_le, as_float, |a, b| a <= b);
3965
3966// ---- length ---------------------------------------------------------------------
3967
3968binop_prim!(prim_length_add, as_length, Length, |a, b| a + b);
3969binop_prim!(prim_length_sub, as_length, Length, |a, b| a - b);
3970binop_prim!(prim_length_scale, (as_length, as_float), Length, |a, b| a
3971 * b);
3972binop_prim!(prim_length_div, as_length, Float, |a, b| a / b);
3973cmp_prim!(prim_length_lt, as_length, |a, b| a < b);
3974
3975// `LengthGreaterThan` in vminst.ml is implemented as `len2 <% len1`, i.e.
3976// `a >' b` iff `b <' a` — the same ordering, just flipped operands.
3977cmp_prim!(prim_length_gt, as_length, |a, b| b < a);
3978
3979// ---- string -----------------------------------------------------------------------
3980
3981binop_prim!(prim_string_concat, as_str, Str, |a, b| a + &b);
3982unop_prim!(prim_arabic, as_int, Str, |n| n.to_string());
3983cmp_prim!(prim_string_same, as_str, |a, b| a == b);
3984
3985// ---- list -----------------------------------------------------------------
3986
3987/// `x :: xs` — prepend `x` onto the list `xs`.
3988fn prim_list_cons(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
3989 let tail = args.pop().unwrap();
3990 let head = args.pop().unwrap();
3991 let mut list = match tail {
3992 Value::List(v) => v,
3993 other => return eval_error(format!("expected list, got {}", other.type_name())),
3994 };
3995 list.insert(0, head);
3996 Ok(Value::List(list))
3997}
3998
3999// ---- mutable-cell dereference ----------------------------------------------
4000
4001/// `!` — read the current contents of a mutable cell (see the `prims!`
4002/// registration above for how this differs structurally, not semantically,
4003/// from v0.0.6's `Dereference`/`Location` handling).
4004fn prim_deref(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4005 let v = args.pop().unwrap();
4006 match v {
4007 Value::Ref(cell) => Ok(cell.borrow().clone()),
4008 other => eval_error(format!(
4009 "expected a mutable cell for '!', got {}",
4010 other.type_name()
4011 )),
4012 }
4013}
4014
4015// ---- string, continued -----------------------------------------------------
4016
4017// `string-length : string -> int` (vminst.ml `PrimitiveStringLength`) —
4018// counts Unicode scalar values (`BatUTF8.length`), not UTF-8 bytes.
4019unop_prim!(prim_string_length, as_str, Int, |s| s.chars().count()
4020 as i64);
4021
4022/// `string-sub : string -> int -> int -> string` (vminst.ml
4023/// `PrimitiveStringSub`) — a substring addressed by Unicode-scalar-value
4024/// offset/width (`BatUTF8.sub`), not byte offset. Upstream raises a dynamic
4025/// error ("illegal index for string-sub") on an out-of-range index; we do
4026/// the same.
4027fn prim_string_sub(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4028 let wid = as_int(args.pop().unwrap())?;
4029 let pos = as_int(args.pop().unwrap())?;
4030 let s = as_str(args.pop().unwrap())?;
4031 if wid < 0 || pos < 0 {
4032 return eval_error("illegal index for string-sub");
4033 }
4034 let chars: Vec<char> = s.chars().collect();
4035 let pos = pos as usize;
4036 let wid = wid as usize;
4037 match pos.checked_add(wid) {
4038 Some(end) if end <= chars.len() => Ok(Value::Str(chars[pos..end].iter().collect())),
4039 _ => eval_error("illegal index for string-sub"),
4040 }
4041}
4042
4043// `string-explode : string -> int list` (vminst.ml `PrimitiveStringExplode`)
4044// — the string's Unicode scalar values (code points) in order, not its bytes.
4045unop_prim!(prim_string_explode, as_str, List, |s| s
4046 .chars()
4047 .map(|c| Value::Int(c as i64))
4048 .collect());
4049
4050fn prim_embed_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4051 let s = as_str(args.pop().unwrap())?;
4052 Ok(Value::InlineText {
4053 elems: Rc::new(vec![IText::Text(s)]),
4054 env: Env::root(),
4055 })
4056}
4057
4058// ---- context ops ------------------------------------------------------------
4059
4060/// `set-font-size : length -> context -> context` (vminst.ml
4061/// `PrimitiveSetFontSize`).
4062fn prim_set_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4063 let ctx = as_context(args.pop().unwrap())?;
4064 let size = as_length(args.pop().unwrap())?;
4065 Ok(Value::Context(Box::new(Context {
4066 font_size: size,
4067 ..ctx
4068 })))
4069}
4070
4071/// `get-font-size : context -> length` (vminst.ml `PrimitiveGetFontSize`).
4072fn prim_get_font_size(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4073 let ctx = as_context(args.pop().unwrap())?;
4074 Ok(Value::Length(ctx.font_size))
4075}
4076
4077/// `set-leading : length -> context -> context` (vminst.ml
4078/// `PrimitiveSetLeading`; see the `prims!` table comment for why this is
4079/// the baseline-distance setter and not `set-min-gap-of-lines`).
4080fn prim_set_leading(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4081 let ctx = as_context(args.pop().unwrap())?;
4082 let leading = as_length(args.pop().unwrap())?;
4083 Ok(Value::Context(Box::new(Context { leading, ..ctx })))
4084}
4085
4086/// `set-paragraph-margin : length -> length -> context -> context`
4087/// (vminst.ml `PrimitiveSetParagraphMargin`).
4088fn prim_set_paragraph_margin(
4089 _interp: &mut Interp,
4090 mut args: Vec<Value>,
4091) -> Result<Value, EvalError> {
4092 let ctx = as_context(args.pop().unwrap())?;
4093 let bottom = as_length(args.pop().unwrap())?;
4094 let top = as_length(args.pop().unwrap())?;
4095 Ok(Value::Context(Box::new(Context {
4096 paragraph_top: top,
4097 paragraph_bottom: bottom,
4098 ..ctx
4099 })))
4100}
4101
4102/// `get-text-width : context -> length` (vminst.ml `PrimitiveGetTextWidth`).
4103fn prim_get_text_width(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4104 let ctx = as_context(args.pop().unwrap())?;
4105 Ok(Value::Length(ctx.paragraph_width))
4106}
4107
4108/// `get-initial-context : length -> [math] inline-cmd -> context`
4109/// (vminst.ml `PrimitiveGetInitialContext`) — the second argument is the
4110/// default math command a bare `${…}` in inline text dispatches to (v0.0.6
4111/// `context_main.math_command`); interned via
4112/// `Interp::register_math_command`, carried as `Context::math_command`.
4113fn prim_get_initial_context(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4114 let cmd = args.pop().unwrap();
4115 let width = as_length(args.pop().unwrap())?;
4116 let mut ctx = Context::initial(width);
4117 ctx.math_command = Some(interp.register_math_command(cmd));
4118 // Overlay the configured `default-font.satysfi-hash` `scripts`
4119 // block, if any, so a bare document with a configured font root renders
4120 // CJK/etc. with zero `set-font` calls (`interp.metrics.
4121 // default_script_font` is `None` for every script on a provider with no
4122 // such config — `Base14Metrics` and a bare `TtfFontStore::load` both —
4123 // so this loop is a no-op there).
4124 for (idx, script) in [
4125 Script::HanIdeographic,
4126 Script::Kana,
4127 Script::Latin,
4128 Script::OtherScript,
4129 ]
4130 .into_iter()
4131 .enumerate()
4132 {
4133 if let Some((font, ratio, rising)) = interp.metrics.default_script_font(script) {
4134 ctx.font_scheme[idx] = ScriptFont {
4135 font,
4136 ratio,
4137 rising,
4138 };
4139 if script == Script::Latin {
4140 ctx.font = font;
4141 }
4142 }
4143 }
4144 // Overlay the configured `default-font.satysfi-hash` `"math"`
4145 // abbrev, if any, so a document with a bundled MATH-table font renders
4146 // real cramped/uncramped math metrics with zero `set-math-font` calls.
4147 // `interp.metrics.default_math_font` is `None` on a provider with no such
4148 // config, the same no-op-by-default shape as the `scripts` overlay above.
4149 if let Some(font) = interp.metrics.default_math_font() {
4150 ctx.math_font = font;
4151 }
4152 Ok(Value::Context(Box::new(ctx)))
4153}
4154
4155/// `set-font-key : int -> context -> context` — LOCAL, non-upstream
4156/// primitive; see the `prims!` table comment on `"set-font-key"` for why it
4157/// exists. Sets `Context::font` directly to `FontKey(n)`.
4158fn prim_set_font_key(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4159 let ctx = as_context(args.pop().unwrap())?;
4160 let key = as_int(args.pop().unwrap())?;
4161 if key < 0 || key > i64::from(u16::MAX) {
4162 return eval_error(format!("set-font-key: font key {key} is out of range"));
4163 }
4164 Ok(Value::Context(Box::new(Context {
4165 font: FontKey(key as u16),
4166 ..ctx
4167 })))
4168}
4169
4170// ---- box combinators ---------------------------------------------------------
4171
4172/// `++ : inline-boxes -> inline-boxes -> inline-boxes` (vminst.ml
4173/// `HorzConcat`).
4174fn prim_inline_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4175 let mut b = as_inline_boxes(args.pop().unwrap())?;
4176 let mut a = as_inline_boxes(args.pop().unwrap())?;
4177 a.append(&mut b);
4178 Ok(Value::InlineBoxes(a))
4179}
4180
4181/// `+++ : block-boxes -> block-boxes -> block-boxes` (vminst.ml
4182/// `VertConcat`).
4183fn prim_block_concat(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4184 let mut b = as_block_boxes(args.pop().unwrap())?;
4185 let mut a = as_block_boxes(args.pop().unwrap())?;
4186 a.append(&mut b);
4187 Ok(Value::BlockBoxes(a))
4188}
4189
4190/// `inline-skip : length -> inline-boxes` (vminst.ml `BackendFixedEmpty`).
4191fn prim_inline_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4192 let width = as_length(args.pop().unwrap())?;
4193 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4194 PureHorzBox::FixedEmpty { width },
4195 )]))
4196}
4197
4198/// `inline-glue : length -> length -> length -> inline-boxes` (vminst.ml
4199/// `BackendOuterEmpty`; params `(widnat, widshrink, widstretch)`, i.e.
4200/// natural, then shrink, then stretch — the same order `OuterEmpty`'s
4201/// fields are already declared in).
4202fn prim_inline_glue(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4203 let stretchable = as_length(args.pop().unwrap())?;
4204 let shrinkable = as_length(args.pop().unwrap())?;
4205 let natural = as_length(args.pop().unwrap())?;
4206 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4207 PureHorzBox::OuterEmpty {
4208 natural,
4209 shrinkable,
4210 stretchable,
4211 },
4212 )]))
4213}
4214
4215/// `block-skip : length -> block-boxes` (vminst.ml `BackendVertSkip`).
4216fn prim_block_skip(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4217 let len = as_length(args.pop().unwrap())?;
4218 Ok(Value::BlockBoxes(vec![VertBox::Skip(len)]))
4219}
4220
4221/// `list-mark : int -> block-boxes` — the block-level reflow marker
4222/// constructor `itemize.satyh`'s `listing`/`listing-item`/`listing-item-
4223/// breakable`/`enumerate`/`enumerate-item` call to fence list/item
4224/// boundaries. Returns a single-element `block-boxes` carrying an INERT
4225/// `VertBox::ListMark` — zero height/depth, stripped with zero contribution
4226/// by `chop_page`/`place_block_at`/`measure_block` before it can ever reach a
4227/// `PlacedLine`, so PDF and faithful HTML render identically whether or not
4228/// a document's stdlib calls this. Only `page_break_core`'s `reflow_source`
4229/// clone (taken BEFORE `chop_page` drains its input) retains it, for the
4230/// `html-support` branch's reflow HTML walker to read back.
4231///
4232/// `tag` encoding (the only "int tag" scheme any caller needs to know,
4233/// since this primitive is never reflected through the type system beyond
4234/// `int -> block-boxes`):
4235/// - `0` = `ListStart { ordered: false }` (opens a `<ul>`)
4236/// - `1` = `ListStart { ordered: true }` (opens an `<ol>`)
4237/// - `2` = `ListEnd`
4238/// - `3` = `ItemStart`
4239/// - `4` = `ItemEnd`
4240fn prim_list_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4241 let tag = as_int(args.pop().unwrap())?;
4242 let kind = match tag {
4243 0 => ListMarkKind::ListStart { ordered: false },
4244 1 => ListMarkKind::ListStart { ordered: true },
4245 2 => ListMarkKind::ListEnd,
4246 3 => ListMarkKind::ItemStart,
4247 4 => ListMarkKind::ItemEnd,
4248 other => return eval_error(format!("list-mark: unknown tag {other}")),
4249 };
4250 Ok(Value::BlockBoxes(vec![VertBox::ListMark(kind)]))
4251}
4252
4253/// `inline-mark : int -> inline-boxes` — the inline-level reflow marker
4254/// constructor: `itemize.satyh`'s `make-bullet`/`enumerate-item` fence the
4255/// drawn bullet/number glyph run with `BulletStart`/`BulletEnd`, and the
4256/// repo-controlled `\emph`/`\bold` definitions (an opt-in, per-command wrap)
4257/// fence their body with `EmphStart`/`EmphEnd`. Same INERT-marker contract as
4258/// `list-mark` above — a zero-size `PureHorzBox::InlineMark`, ignored by
4259/// `measure`/`natural_metrics`/`justify_line`
4260/// (rustyfi-backend's `linebreak.rs`),
4261/// `math_glyphs_of_inline_boxes`/`math_boxes_of_inline_boxes` below, and both
4262/// the PDF and faithful HTML writers; read only by the `html-support`
4263/// branch's reflow HTML walker.
4264///
4265/// `tag` encoding:
4266/// - `0` = `EmphStart { strong: false }` (opens `<em>`)
4267/// - `1` = `EmphStart { strong: true }` (opens `<strong>`)
4268/// - `2` = `EmphEnd`
4269/// - `3` = `BulletStart`
4270/// - `4` = `BulletEnd`
4271fn prim_inline_mark(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4272 let tag = as_int(args.pop().unwrap())?;
4273 let kind = match tag {
4274 0 => InlineMarkKind::EmphStart { strong: false },
4275 1 => InlineMarkKind::EmphStart { strong: true },
4276 2 => InlineMarkKind::EmphEnd,
4277 3 => InlineMarkKind::BulletStart,
4278 4 => InlineMarkKind::BulletEnd,
4279 other => return eval_error(format!("inline-mark: unknown tag {other}")),
4280 };
4281 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4282 PureHorzBox::InlineMark(kind),
4283 )]))
4284}
4285
4286// ---- pure float primitives --------------------------------------------------
4287//
4288// All bodies below are `float -> float` (or `float -> float -> float`)
4289// straight wraps of the matching `f64` method — vminst.ml's OCaml bodies
4290// (`make_float (sin flt1)`, etc.) are themselves direct wraps of the same
4291// IEEE-754 libm functions, so there is no behavioral daylight here.
4292
4293binop_prim!(prim_atan2, as_float, Float, |a, b| a.atan2(b));
4294unop_prim!(prim_sin, as_float, Float, |x| x.sin());
4295unop_prim!(prim_asin, as_float, Float, |x| x.asin());
4296unop_prim!(prim_cos, as_float, Float, |x| x.cos());
4297unop_prim!(prim_acos, as_float, Float, |x| x.acos());
4298unop_prim!(prim_tan, as_float, Float, |x| x.tan());
4299unop_prim!(prim_atan, as_float, Float, |x| x.atan());
4300// vminst.ml:2834 `FloatLogarithm`: OCaml's `log` is the NATURAL logarithm
4301// (`ln`), not `log10`.
4302unop_prim!(prim_log, as_float, Float, |x| x.ln());
4303unop_prim!(prim_exp, as_float, Float, |x| x.exp());
4304// `ceil`/`floor` return `float`, unlike `round` (above), which returns
4305// `int` — see this file's `prims!` table comment on `"ceil"`/`"floor"`.
4306unop_prim!(prim_ceil, as_float, Float, |x| x.ceil());
4307unop_prim!(prim_floor, as_float, Float, |x| x.floor());
4308
4309// `show-float : float -> string` (vminst.ml:2319 `PrimitiveShowFloat`) —
4310// OCaml's `string_of_float`. See `ocaml_show_float`'s doc comment (below)
4311// for the emulation and its known fidelity limits.
4312unop_prim!(prim_show_float, as_float, Str, |x| ocaml_show_float(x));
4313
4314/// A from-scratch emulation of OCaml's `Stdlib.string_of_float`: format via
4315/// a C `%.12g` equivalent (12 significant digits; fixed-point when the
4316/// decimal exponent falls in `-4..12`, scientific otherwise; trailing
4317/// fractional zeros trimmed), then apply `valid_float_lexem`'s post-pass —
4318/// append a trailing `.` when the result would otherwise print as a bare
4319/// integer (`"1."`, never `"1"`, so a `float`'s printed form always reads
4320/// as a float, not an `int`). Known limitation: this is a Rust
4321/// reimplementation of the same specification (OCaml itself defers to the
4322/// platform C library's `%.12g`), so it may disagree with OCaml in obscure
4323/// corner cases, though it agrees on ordinary values (verified by hand
4324/// against real OCaml output for `0.`, `-0.`, `1.`, `100.`, `0.0025`,
4325/// `1e+20`, `1e-05`).
4326fn ocaml_show_float(x: f64) -> String {
4327 if x.is_nan() {
4328 return "nan".to_string();
4329 }
4330 if x.is_infinite() {
4331 return if x < 0.0 { "-infinity" } else { "infinity" }.to_string();
4332 }
4333 const PREC: i32 = 12;
4334 // Style-E rendering at precision PREC-1 recovers the correctly-rounded
4335 // decimal exponent (a naive `log10().floor()` can be off by one right
4336 // at a power of ten, because of binary/decimal rounding).
4337 let sci = format!("{:.*e}", (PREC - 1) as usize, x);
4338 let epos = sci
4339 .find('e')
4340 .expect("scientific formatting always emits 'e'");
4341 let exp: i32 = sci[epos + 1..].parse().expect("well-formed exponent");
4342 let body = if exp < -4 || exp >= PREC {
4343 let mantissa = trim_trailing_fractional_zeros(&sci[..epos]);
4344 format!(
4345 "{mantissa}e{}{:02}",
4346 if exp < 0 { "-" } else { "+" },
4347 exp.abs()
4348 )
4349 } else {
4350 let decimals = (PREC - 1 - exp).max(0) as usize;
4351 trim_trailing_fractional_zeros(&format!("{:.*}", decimals, x)).to_string()
4352 };
4353 if body.bytes().all(|b| b.is_ascii_digit() || b == b'-') {
4354 format!("{body}.")
4355 } else {
4356 body
4357 }
4358}
4359
4360/// Strip trailing zeros after a decimal point, then the point itself if
4361/// nothing remains after it (`"3.140" -> "3.14"`, `"5.000" -> "5"`);
4362/// already-integer-shaped strings (no `.`) pass through unchanged.
4363fn trim_trailing_fractional_zeros(s: &str) -> &str {
4364 if !s.contains('.') {
4365 return s;
4366 }
4367 s.trim_end_matches('0').trim_end_matches('.')
4368}
4369
4370// `string-byte-length : string -> int` (vminst.ml:2159
4371// `PrimitiveStringByteLength`) — UTF-8 BYTE count (`String.length` in
4372// OCaml, whose native strings are raw byte sequences), unlike
4373// `string-length`'s Unicode-scalar-value count.
4374unop_prim!(prim_string_byte_length, as_str, Int, |s| s.len() as i64);
4375
4376/// `string-sub-bytes : string -> int -> int -> string` (vminst.ml:2123
4377/// `PrimitiveStringSubBytes`) — byte-indexed substring (OCaml's
4378/// `String.sub`), unlike `string-sub`'s Unicode-scalar-value indexing.
4379/// Guards an out-of-range span exactly like `prim_string_sub`'s "illegal
4380/// index" dynamic error, AND a split landing inside a multi-byte UTF-8
4381/// sequence — impossible for OCaml's byte-oriented strings, but a
4382/// `Value::Str` here is a Rust `String`, which must stay valid UTF-8.
4383fn prim_string_sub_bytes(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4384 let wid = as_int(args.pop().unwrap())?;
4385 let pos = as_int(args.pop().unwrap())?;
4386 let s = as_str(args.pop().unwrap())?;
4387 if wid < 0 || pos < 0 {
4388 return eval_error("illegal index for string-sub-bytes");
4389 }
4390 let (pos, wid) = (pos as usize, wid as usize);
4391 match pos.checked_add(wid) {
4392 Some(end) if end <= s.len() && s.is_char_boundary(pos) && s.is_char_boundary(end) => {
4393 Ok(Value::Str(s[pos..end].to_string()))
4394 }
4395 _ => eval_error("illegal index for string-sub-bytes"),
4396 }
4397}
4398
4399/// `string-unexplode : int list -> string` (vminst.ml:2196
4400/// `PrimitiveStringUnexplode`) — the inverse of `string-explode` (above):
4401/// each int is a Unicode scalar value (code point), concatenated into one
4402/// UTF-8 string. Upstream's `Uchar.of_int` raises on an int that isn't a
4403/// valid Unicode scalar value (a surrogate, or out of range); reported here
4404/// as the same kind of dynamic error rather than panicking.
4405fn prim_string_unexplode(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4406 let items = as_list(args.pop().unwrap())?;
4407 let mut s = String::new();
4408 for v in items {
4409 let n = as_int(v)?;
4410 match u32::try_from(n).ok().and_then(char::from_u32) {
4411 Some(c) => s.push(c),
4412 None => {
4413 return eval_error(format!(
4414 "string-unexplode: {n} is not a valid Unicode scalar value"
4415 ))
4416 }
4417 }
4418 }
4419 Ok(Value::Str(s))
4420}
4421
4422/// `normalize-string-to-nfc : string -> string` (dev-0-1-0 vminst.ml:2050
4423/// `NormalizeStringToNFC`) — REAL: UAX #15 Normalization Form C, via the
4424/// `unicode-normalization` crate (`UnicodeNormalization::nfc`), a pure-Rust
4425/// stand-in for upstream's uunf-backed `NormalizeString.of_utf8_nfc`.
4426/// DOCUMENTED NON-RISK: this crate's embedded Unicode table version may lag
4427/// or lead upstream's uunf pin — both track recent Unicode, and no bundled
4428/// package/test relies on a normalization pair that changed between
4429/// versions.
4430fn prim_normalize_string_to_nfc(
4431 _interp: &mut Interp,
4432 mut args: Vec<Value>,
4433) -> Result<Value, EvalError> {
4434 let s = as_str(args.pop().unwrap())?;
4435 Ok(Value::Str(s.nfc().collect()))
4436}
4437
4438/// `normalize-string-to-nfd : string -> string` (dev-0-1-0 vminst.ml:2066
4439/// `NormalizeStringToNFD`) — REAL: UAX #15 Normalization Form D, same
4440/// crate/caveats as [`prim_normalize_string_to_nfc`] above.
4441fn prim_normalize_string_to_nfd(
4442 _interp: &mut Interp,
4443 mut args: Vec<Value>,
4444) -> Result<Value, EvalError> {
4445 let s = as_str(args.pop().unwrap())?;
4446 Ok(Value::Str(s.nfd().collect()))
4447}
4448
4449/// `split-grapheme-cluster : string -> list string` (dev-0-1-0 vminst.ml:
4450/// 2082 `SplitOnGraphemeCluster` / `GraphemeCluster.split_utf8`) — REAL: UAX
4451/// #29 EXTENDED grapheme clusters, via the `unicode-segmentation` crate's
4452/// `graphemes(s, true)` (`true` selects the extended, not legacy, cluster
4453/// rules — what upstream's uuseg default segmenter produces).
4454fn prim_split_grapheme_cluster(
4455 _interp: &mut Interp,
4456 mut args: Vec<Value>,
4457) -> Result<Value, EvalError> {
4458 let s = as_str(args.pop().unwrap())?;
4459 let clusters: Vec<Value> = s
4460 .graphemes(true)
4461 .map(|g| Value::Str(g.to_string()))
4462 .collect();
4463 Ok(Value::List(clusters))
4464}
4465
4466/// `display-message : string -> unit` (vminst.ml:2056
4467/// `PrimitiveDisplayMessage`) — upstream prints via `print_endline`
4468/// (STDOUT); this port deliberately prints to STDERR instead (`eprintln!`),
4469/// keeping stdout reserved for actual document output. This matches the
4470/// existing house convention: the CLI's own "output written" status line
4471/// (`rustyfi`'s `main.rs`) is likewise stderr-only, never stdout — a
4472/// documented deviation, not an oversight.
4473fn prim_display_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4474 let msg = as_str(args.pop().unwrap())?;
4475 eprintln!("{msg}");
4476 Ok(Value::Unit)
4477}
4478
4479/// `abort-with-message : string -> 'a` (vminst.ml:3133 `AbortWithMessage`)
4480/// — raises a dynamic error carrying `msg` verbatim. The polymorphic result
4481/// type (`prim_types.rs`'s `poly1`) is vacuously satisfiable: this always
4482/// evaluates to `Err`, never actually producing a value of whatever type
4483/// the call site expected.
4484fn prim_abort_with_message(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4485 let msg = as_str(args.pop().unwrap())?;
4486 eval_error(msg)
4487}
4488
4489// ---- images (raster images) -----------
4490
4491/// `load-image : string -> image` (v0.0.6 vminstdef.yaml:540). Resolves
4492/// `path` against the process's current working directory — this port
4493/// has no "job directory" threaded through `Interp` yet, so this is a
4494/// deliberately simple stand-in for v0.0.6's real job-directory-relative
4495/// resolution, good enough for a CLI invoked from the document's own
4496/// directory and for this crate's fixture-driven tests (which pass an
4497/// absolute path).
4498///
4499/// Decoding is eager (via the `image` crate, to 8-bit `DeviceRGB` — see
4500/// `ImageResource`'s doc comment for the alpha-dropping/format caveats),
4501/// matching v0.0.6's `ImageInfo.add_image` (imageInfo.ml): a missing or
4502/// undecodable file is a clean `EvalError` here, not deferred to the PDF
4503/// writer.
4504///
4505/// JPEG DCTDecode passthrough: in addition to the eager RGB8 decode above
4506/// (still needed for `use-image-by-width`'s aspect ratio and the HTML
4507/// backend's `<img>` data URI), this re-reads the same path's raw bytes and
4508/// sniffs them for a baseline JPEG (`ImageResource::sniff_baseline_jpeg_dct`)
4509/// so the PDF writer can embed the ORIGINAL DCT-encoded bytes instead of
4510/// re-encoding the flattened samples. The second read is best-effort: a
4511/// failure just leaves `jpeg_dct` as `None` and falls back to flat-RGB8
4512/// embedding, since the file already decoded fine above.
4513fn prim_load_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4514 let path = as_str(args.pop().unwrap())?;
4515 let decoded = image::open(&path).map_err(|e| EvalError {
4516 span: None,
4517 msg: format!("load-image: cannot decode '{path}': {e}"),
4518 })?;
4519 let rgb = decoded.to_rgb8();
4520 let (px_w, px_h) = rgb.dimensions();
4521 let jpeg_dct = std::fs::read(&path)
4522 .ok()
4523 .and_then(ImageResource::sniff_baseline_jpeg_dct);
4524 let id = ImageId(interp.images.len());
4525 interp.images.push(ImageResource {
4526 samples: rgb.into_raw(),
4527 px_w,
4528 px_h,
4529 jpeg_dct,
4530 pdf: None,
4531 });
4532 Ok(Value::Image(id))
4533}
4534
4535/// `use-image-by-width : image -> length -> inline-boxes` (v0.0.6
4536/// vminstdef.yaml:554). Computes the on-page height from the source
4537/// image's own pixel aspect ratio (v0.0.6
4538/// `ImageInfo.get_height_from_width`, imageInfo.ml:44): `height = width *
4539/// px_h / px_w`.
4540fn prim_use_image_by_width(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4541 let width = as_length(args.pop().unwrap())?;
4542 let image = as_image(args.pop().unwrap())?;
4543 let resource = interp.images.get(image.0).ok_or_else(|| EvalError {
4544 span: None,
4545 msg: format!("internal error: image id {} out of range", image.0),
4546 })?;
4547 let (iw, ih) = resource.intrinsic_dims_pt();
4548 if iw == 0.0 {
4549 return eval_error("use-image-by-width: image has zero width, cannot scale");
4550 }
4551 let height = width * (ih / iw);
4552 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
4553 PureHorzBox::Image {
4554 width,
4555 height,
4556 image,
4557 },
4558 )]))
4559}
4560
4561/// `load-pdf-image : string -> int -> image` (v0.0.6 vminstdef.yaml:525-538
4562/// `BackendRegisterPdfImage`; dev-0-1-0 renames it `PrimitiveLoadPdfImage`
4563/// with the identical type/body). Loads page `pageno` (1-based) of the PDF
4564/// at `path`, parsed eagerly with `lopdf`, and stores a `PdfPageResource` —
4565/// the page's `/MediaBox` (for `use-image-by-width`'s aspect ratio), its
4566/// content stream(s) (already inflated/concatenated by
4567/// `lopdf::Document::get_page_content`), and its imported `/Resources`
4568/// object subtree (for the PDF writer's Form XObject).
4569///
4570/// Path resolution is cwd-relative, the same documented deviation as
4571/// `prim_load_image`/`prim_read_file` (no job-directory threaded through
4572/// `Interp` yet).
4573///
4574/// Errors (all clean `EvalError`, no panics):
4575/// - file missing/unreadable → "cannot open '<path>': <e>";
4576/// - malformed/unparseable PDF → "cannot parse PDF '<path>': <e>";
4577/// - `pageno < 1` → "page number must be >= 1 (got <n>)";
4578/// - `pageno` beyond the page count → "'<path>' has no page <n>";
4579/// - `/Encrypt` present in the trailer → "'<path>' is encrypted; not
4580/// supported" (decryption is never attempted);
4581/// - no usable `/MediaBox` (missing at every level of the inherited page
4582/// tree, wrong array length, or non-numeric entries) → "page <n> of
4583/// '<path>' has no usable MediaBox".
4584#[cfg(feature = "pdf-image")]
4585fn prim_load_pdf_image(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4586 let pageno = as_int(args.pop().unwrap())?;
4587 let path = as_str(args.pop().unwrap())?;
4588 if pageno < 1 {
4589 return eval_error(format!(
4590 "load-pdf-image: page number must be >= 1 (got {pageno})"
4591 ));
4592 }
4593 let doc = lopdf::Document::load(&path).map_err(|e| {
4594 let msg = match &e {
4595 lopdf::Error::IO(io_e) => format!("load-pdf-image: cannot open '{path}': {io_e}"),
4596 other => format!("load-pdf-image: cannot parse PDF '{path}': {other}"),
4597 };
4598 EvalError { span: None, msg }
4599 })?;
4600 if doc.is_encrypted() {
4601 return eval_error(format!(
4602 "load-pdf-image: '{path}' is encrypted; not supported"
4603 ));
4604 }
4605 let pages = doc.get_pages();
4606 let page_id = *pages.get(&(pageno as u32)).ok_or_else(|| EvalError {
4607 span: None,
4608 msg: format!("load-pdf-image: '{path}' has no page {pageno}"),
4609 })?;
4610 let page_dict = doc.get_dictionary(page_id).map_err(|e| EvalError {
4611 span: None,
4612 msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4613 })?;
4614 let media_box = resolve_pdf_media_box(&doc, page_dict).ok_or_else(|| EvalError {
4615 span: None,
4616 msg: format!("load-pdf-image: page {pageno} of '{path}' has no usable MediaBox"),
4617 })?;
4618 let content = doc.get_page_content(page_id).map_err(|e| EvalError {
4619 span: None,
4620 msg: format!("load-pdf-image: cannot parse PDF '{path}': {e}"),
4621 })?;
4622 let resources = import_pdf_resources(&doc, page_dict);
4623 let id = ImageId(interp.images.len());
4624 interp.images.push(ImageResource {
4625 samples: Vec::new(),
4626 px_w: 0,
4627 px_h: 0,
4628 jpeg_dct: None,
4629 pdf: Some(PdfPageResource {
4630 media_box,
4631 content,
4632 resources,
4633 }),
4634 });
4635 Ok(Value::Image(id))
4636}
4637
4638/// Without the `pdf-image` feature no PDF reader is linked in, so the
4639/// primitive can only fail — which it does at the call site, naming why.
4640///
4641/// The name stays REGISTERED rather than being gated out of the primitive
4642/// tables: `typecheck::PRIMITIVE_NAMES`, `prim_types::primitive_type` and this
4643/// table are cross-checked against each other (`tests/typecheck.rs`), and a
4644/// document that reaches for `load-pdf-image` is better told that this build
4645/// cannot read PDFs than that the name does not exist.
4646#[cfg(not(feature = "pdf-image"))]
4647fn prim_load_pdf_image(_interp: &mut Interp, _args: Vec<Value>) -> Result<Value, EvalError> {
4648 eval_error(
4649 "load-pdf-image: this build has no PDF reader (the `pdf-image` feature \
4650 is off). It is off for WebAssembly, where the primitive could not work \
4651 regardless: it takes a filesystem path."
4652 .to_string(),
4653 )
4654}
4655
4656/// `/MediaBox` lookup with page-tree inheritance (`lopdf` does not resolve
4657/// this automatically, unlike upstream camlpdf's `Pdfpage` helpers): walk
4658/// `page_dict`, then its `/Parent` chain, returning the first `/MediaBox`
4659/// found as `(x0, y0, x1, y1)` in raw PDF points. `None`
4660/// if no ancestor carries a well-formed 4-element numeric array, or if a
4661/// `/Parent` cycle is detected.
4662#[cfg(feature = "pdf-image")]
4663fn resolve_pdf_media_box(
4664 doc: &lopdf::Document,
4665 page_dict: &lopdf::Dictionary,
4666) -> Option<(f64, f64, f64, f64)> {
4667 let mut cur = page_dict;
4668 let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4669 loop {
4670 if let Ok(obj) = cur.get(b"MediaBox") {
4671 if let Ok(arr) = obj.as_array() {
4672 if arr.len() == 4 {
4673 let mut v = [0f64; 4];
4674 let mut ok = true;
4675 for (slot, item) in v.iter_mut().zip(arr.iter()) {
4676 match item.as_float() {
4677 Ok(f) => *slot = f as f64,
4678 Err(_) => {
4679 ok = false;
4680 break;
4681 }
4682 }
4683 }
4684 if ok {
4685 return Some((v[0], v[1], v[2], v[3]));
4686 }
4687 }
4688 }
4689 }
4690 match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4691 Ok(parent_id) => {
4692 if !seen.insert(parent_id) {
4693 return None; // cycle
4694 }
4695 cur = doc.get_dictionary(parent_id).ok()?;
4696 }
4697 Err(_) => return None,
4698 }
4699 }
4700}
4701
4702/// Import the page's `/Resources` subtree (walking page-tree inheritance
4703/// like `resolve_pdf_media_box`) into a neutral `ImportedObjects` table for
4704/// the PDF writer. Local id `0` always holds the
4705/// (possibly inline) `/Resources` dictionary itself; every other entry is a
4706/// real source PDF object number, keyed by `convert_pdf_object`'s
4707/// transitive walk of every `Reference` reachable from it.
4708#[cfg(feature = "pdf-image")]
4709fn import_pdf_resources(doc: &lopdf::Document, page_dict: &lopdf::Dictionary) -> ImportedObjects {
4710 let mut out: Vec<(u32, ObjRepr)> = Vec::new();
4711 let mut seen: BTreeSet<u32> = BTreeSet::new();
4712 let root_repr = match resolve_pdf_resources_object(doc, page_dict) {
4713 Some(obj) => convert_pdf_object(doc, obj, &mut out, &mut seen),
4714 None => ObjRepr::Dict(Vec::new()),
4715 };
4716 out.insert(0, (0, root_repr));
4717 ImportedObjects(out)
4718}
4719
4720/// `/Resources` lookup with page-tree inheritance, mirroring
4721/// `resolve_pdf_media_box` but returning the raw (possibly-inline)
4722/// `&lopdf::Object` rather than a decoded value, since `/Resources` may
4723/// legally be either a direct dictionary or an indirect reference.
4724#[cfg(feature = "pdf-image")]
4725fn resolve_pdf_resources_object<'a>(
4726 doc: &'a lopdf::Document,
4727 page_dict: &'a lopdf::Dictionary,
4728) -> Option<&'a lopdf::Object> {
4729 let mut cur = page_dict;
4730 let mut seen: BTreeSet<(u32, u16)> = BTreeSet::new();
4731 loop {
4732 if let Ok(obj) = cur.get(b"Resources") {
4733 return Some(obj);
4734 }
4735 match cur.get(b"Parent").and_then(|o| o.as_reference()) {
4736 Ok(parent_id) => {
4737 if !seen.insert(parent_id) {
4738 return None;
4739 }
4740 cur = doc.get_dictionary(parent_id).ok()?;
4741 }
4742 Err(_) => return None,
4743 }
4744 }
4745}
4746
4747/// Recursively convert one `lopdf::Object` into the neutral `ObjRepr`
4748/// grammar, following every `Reference` transitively and
4749/// appending newly-visited indirect objects to `out` keyed by their source
4750/// object number (`seen` guards against re-visiting/cycles — a shared
4751/// object referenced from multiple places is emitted once and pointed at by
4752/// `ObjRepr::Ref` from every occurrence). Stream objects are copied
4753/// **verbatim** (still-filtered bytes, `/Filter`/`/DecodeParms` kept as-is;
4754/// only `/Length` is dropped since the writer derives it) — unlike the
4755/// page's own content stream (`Document::get_page_content`, inflated
4756/// separately in `prim_load_pdf_image`), a resource stream (font program,
4757/// embedded image XObject, ICC profile, ...) is re-emitted byte-for-byte,
4758/// so no decode/re-encode risk is taken on data this importer doesn't need
4759/// to understand.
4760#[cfg(feature = "pdf-image")]
4761fn convert_pdf_object(
4762 doc: &lopdf::Document,
4763 obj: &lopdf::Object,
4764 out: &mut Vec<(u32, ObjRepr)>,
4765 seen: &mut BTreeSet<u32>,
4766) -> ObjRepr {
4767 use lopdf::Object as LObj;
4768 match obj {
4769 LObj::Null => ObjRepr::Null,
4770 LObj::Boolean(b) => ObjRepr::Bool(*b),
4771 LObj::Integer(n) => ObjRepr::Int(*n),
4772 LObj::Real(r) => ObjRepr::Real(*r as f64),
4773 LObj::Name(n) => ObjRepr::Name(n.clone()),
4774 LObj::String(s, _) => ObjRepr::String(s.clone()),
4775 LObj::Array(items) => ObjRepr::Array(
4776 items
4777 .iter()
4778 .map(|it| convert_pdf_object(doc, it, out, seen))
4779 .collect(),
4780 ),
4781 LObj::Dictionary(d) => ObjRepr::Dict(convert_pdf_dict(doc, d, out, seen)),
4782 LObj::Stream(s) => {
4783 let dict_entries = convert_pdf_dict(doc, &s.dict, out, seen)
4784 .into_iter()
4785 .filter(|(k, _)| k.as_slice() != b"Length")
4786 .collect();
4787 ObjRepr::Stream(dict_entries, s.content.clone())
4788 }
4789 LObj::Reference((obj_num, gen)) => {
4790 let (obj_num, gen) = (*obj_num, *gen);
4791 if obj_num != 0 && seen.insert(obj_num) {
4792 if let Ok(target) = doc.get_object((obj_num, gen)) {
4793 let repr = convert_pdf_object(doc, target, out, seen);
4794 out.push((obj_num, repr));
4795 }
4796 }
4797 ObjRepr::Ref(obj_num)
4798 }
4799 }
4800}
4801
4802#[cfg(feature = "pdf-image")]
4803fn convert_pdf_dict(
4804 doc: &lopdf::Document,
4805 dict: &lopdf::Dictionary,
4806 out: &mut Vec<(u32, ObjRepr)>,
4807 seen: &mut BTreeSet<u32>,
4808) -> Vec<(Vec<u8>, ObjRepr)> {
4809 dict.iter()
4810 .map(|(k, v)| (k.clone(), convert_pdf_object(doc, v, out, seen)))
4811 .collect()
4812}
4813
4814/// `read-file : string -> list string` (dev-0-1-0 vminst.ml:3073
4815/// `PrimitiveReadFile`) — REAL, with two documented
4816/// deviations:
4817///
4818/// 1. **Path resolution**: resolves `path` against the process's current
4819/// working directory, the same `load-image` precedent
4820/// (`prim_load_image`'s doc comment) — this port has no job-directory
4821/// notion threaded through `Interp` yet. Upstream resolves against
4822/// `OptionState.job_directory ()` (the input document's own directory).
4823/// 2. **Containment tightening**: upstream rejects any `..` path component
4824/// (`"cannot access files by using '..'"`, vminst.ml:3084-3090) but
4825/// otherwise resolves `Filename.concat jobdir path` literally — an
4826/// absolute `path` silently escapes the job directory upstream. This
4827/// port ALSO rejects absolute paths (same error class), making the
4828/// containment upstream's own message implies actually real.
4829///
4830/// Line splitting is faithful to OCaml's `input_line` loop: split on `'\n'`,
4831/// drop a trailing empty piece (file ends with `\n`), keep `'\r'` (do NOT
4832/// use `BufRead::lines`, which strips `\r\n`) — an empty file yields `[]`.
4833/// Non-UTF-8 content is a clean `EvalError` (upstream's OCaml strings are
4834/// byte-transparent; this port's `Value::Str` must stay valid UTF-8 —
4835/// documented deviation).
4836fn prim_read_file(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4837 let path_str = as_str(args.pop().unwrap())?;
4838 let path = std::path::Path::new(&path_str);
4839 if path.is_absolute() {
4840 return eval_error(
4841 "read-file: cannot access files by using an absolute path (job-directory containment)",
4842 );
4843 }
4844 if path
4845 .components()
4846 .any(|c| matches!(c, std::path::Component::ParentDir))
4847 {
4848 return eval_error("cannot access files by using '..'");
4849 }
4850 let bytes = std::fs::read(path).map_err(|e| EvalError {
4851 span: None,
4852 msg: format!("read-file: cannot open '{path_str}': {e}"),
4853 })?;
4854 let text = String::from_utf8(bytes).map_err(|_| EvalError {
4855 span: None,
4856 msg: format!("read-file '{path_str}': not valid UTF-8"),
4857 })?;
4858 let mut lines: Vec<Value> = text
4859 .split('\n')
4860 .map(|s| Value::Str(s.to_string()))
4861 .collect();
4862 if matches!(lines.last(), Some(Value::Str(s)) if s.is_empty()) {
4863 lines.pop();
4864 }
4865 Ok(Value::List(lines))
4866}
4867
4868/// `(string) option` — `register-document-information`'s `title`/`subject`/
4869/// `author` fields, parsed the same way [`as_border_option`] reads a
4870/// `Value::Ctor`.
4871fn as_option_string(v: Value) -> Result<Option<String>, EvalError> {
4872 match v {
4873 Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
4874 ("None", None) => Ok(None),
4875 ("Some", Some(Value::Str(s))) => Ok(Some(s)),
4876 (other, _) => eval_error(format!(
4877 "expected a string option (None / Some(string)), got variant '{other}'"
4878 )),
4879 },
4880 other => eval_error(format!("expected an option, got {}", other.type_name())),
4881 }
4882}
4883
4884/// `register-document-information : document-information-dictionary ->
4885/// unit` (dev-0-1-0 vminst.ml:2978 `PrimitiveRegisterDocumentInformation`)
4886/// — REAL: extracts `title`/`subject`/`author`
4887/// (`option string`) and `keywords` (`list string`) from the record
4888/// argument (`t_doc_info_dictionary()`'s shape, `prim_types.rs`) and stores
4889/// them onto `Interp::doc_info` — LAST WRITE WINS (upstream's `register`,
4890/// `documentInformationDictionary.ml`), matching the `outline`/
4891/// `annotations`/`destinations` accumulator policy (`eval.rs`): reset per
4892/// trial (fresh `Interp`), the final trial's value drained into
4893/// `DocExtras::doc_info` (`lib.rs`) and emitted as the PDF `/Info`
4894/// dictionary by both writers (`rustyfi-pdf`'s `lib.rs`/`cid.rs`).
4895fn prim_register_document_information(
4896 interp: &mut Interp,
4897 mut args: Vec<Value>,
4898) -> Result<Value, EvalError> {
4899 let fields = match args.pop().unwrap() {
4900 Value::Record(m) => m,
4901 other => {
4902 return eval_error(format!(
4903 "register-document-information: expected a document-information-dictionary \
4904 record, got {}",
4905 other.type_name()
4906 ))
4907 }
4908 };
4909 let record_name = "document-information-dictionary";
4910 let title = as_option_string(record_field(&fields, record_name, "title")?)?;
4911 let subject = as_option_string(record_field(&fields, record_name, "subject")?)?;
4912 let author = as_option_string(record_field(&fields, record_name, "author")?)?;
4913 let keywords = as_list(record_field(&fields, record_name, "keywords")?)?
4914 .into_iter()
4915 .map(as_str)
4916 .collect::<Result<Vec<_>, _>>()?;
4917 interp.doc_info = Some(DocInfo {
4918 title,
4919 subject,
4920 author,
4921 keywords,
4922 });
4923 Ok(Value::Unit)
4924}
4925
4926// ============================================================================
4927// ---- graphics primitives ------
4928// `start-path`/`line-to`/`terminate-path`/`close-with-line`/`fill`/`stroke`/
4929// `inline-graphics`. Argument order matches `tools/gencode/vminst.ml`
4930// (point-first for `line-to`, width-first for `stroke`).
4931// ============================================================================
4932
4933/// `start-path : point -> pre-path` (vminst.ml:713).
4934fn prim_start_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4935 let start = as_point(args.pop().unwrap())?;
4936 Ok(Value::PrePath(PrePath {
4937 start,
4938 segs: Vec::new(),
4939 }))
4940}
4941
4942/// `line-to : point -> pre-path -> pre-path` (vminst.ml:727) — appends a
4943/// straight segment to the pre-path's forward-accumulated `segs`.
4944fn prim_line_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4945 let mut pp = as_prepath(args.pop().unwrap())?;
4946 let pt = as_point(args.pop().unwrap())?;
4947 pp.segs.push(PathSeg::Line(pt));
4948 Ok(Value::PrePath(pp))
4949}
4950
4951/// `terminate-path : pre-path -> path` (vminst.ml:759) — finishes an OPEN
4952/// subpath (no closing segment).
4953fn prim_terminate_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4954 let pp = as_prepath(args.pop().unwrap())?;
4955 Ok(Value::Path(Path {
4956 subpaths: vec![Subpath {
4957 start: pp.start,
4958 segs: pp.segs,
4959 closing: Closing::Open,
4960 }],
4961 }))
4962}
4963
4964/// `close-with-line : pre-path -> path` (vminst.ml:773) — closes the subpath
4965/// with a straight segment back to its start (PDF `h`).
4966fn prim_close_with_line(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4967 let pp = as_prepath(args.pop().unwrap())?;
4968 Ok(Value::Path(Path {
4969 subpaths: vec![Subpath {
4970 start: pp.start,
4971 segs: pp.segs,
4972 closing: Closing::Line,
4973 }],
4974 }))
4975}
4976
4977/// `fill : color -> path -> graphics` (vminst.ml:2398) — a filled region;
4978/// the PDF writer (`place_graphics`, rustyfi-pdf) paints it with the
4979/// even-odd rule, matching upstream's `op_f'`.
4980fn prim_fill(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4981 let path = as_path(args.pop().unwrap())?;
4982 let color = as_color(args.pop().unwrap())?;
4983 Ok(Value::Graphics(GraphicsElem::Fill(color, path)))
4984}
4985
4986/// `stroke : length -> color -> path -> graphics` (vminst.ml:2381) — width
4987/// first, then color, then path.
4988fn prim_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
4989 let path = as_path(args.pop().unwrap())?;
4990 let color = as_color(args.pop().unwrap())?;
4991 let wid = as_length(args.pop().unwrap())?;
4992 Ok(Value::Graphics(GraphicsElem::Stroke(wid, color, path)))
4993}
4994
4995/// Apply a graphics callback under the eager-call window, returning its
4996/// resolved elements with every `register-destination` it made appended as a
4997/// [`GraphicsElem::Destination`] marker. Markers go AFTER the real elements, so
4998/// ink z-order is untouched and a callback that registers nothing yields the
4999/// same `elems` as before.
5000///
5001/// The window is deliberately NOT opened inside a page-break walk
5002/// (`current_page` is `Some` — a deco can build an `inline-graphics` of its
5003/// own): a box built there is drawn straight into `page_graphics`, which
5004/// `fire_hooks` never re-walks, so a marker minted for it would be silently
5005/// dropped. There the direct registration is available and correct.
5006fn apply_graphics_callback(
5007 interp: &mut Interp,
5008 version: RustyfiVersion,
5009 apply: impl FnOnce(&mut Interp) -> Result<Value, EvalError>,
5010) -> Result<Vec<GraphicsElem>, EvalError> {
5011 let defer = interp.current_page.is_none();
5012 let saved = if defer {
5013 interp.pending_dests.replace(Vec::new())
5014 } else {
5015 None
5016 };
5017 let result = apply(interp).and_then(|v| coerce_graphics_result_for(version, v));
5018 let pending = if defer {
5019 interp.pending_dests.take().unwrap_or_default()
5020 } else {
5021 Vec::new()
5022 };
5023 if defer {
5024 interp.pending_dests = saved;
5025 }
5026 let mut elems = result?;
5027 elems.extend(
5028 pending
5029 .into_iter()
5030 .map(|(key, pt)| GraphicsElem::Destination { key, pt }),
5031 );
5032 Ok(elems)
5033}
5034
5035/// `inline-graphics : length -> length -> length -> (point -> graphics
5036/// list) -> inline-boxes` (vminst.ml:1872 `BackendInlineGraphics`) — a box
5037/// of size `(w, h, d)` carrying the callback's resolved graphics elements,
5038/// the minimal on-page sink for a `graphics` value.
5039///
5040/// **Eager-callback shortcut.** Upstream defers the callback until
5041/// the box's *placed* point is known on the page, then calls
5042/// `gfun(placed_point)`. A lang closure cannot live inside a backend box
5043/// (`PureHorzBox::Graphics` only holds resolved `GraphicsElem`s), and the
5044/// placed point isn't known until page-break/render time — so instead this
5045/// calls `gfun` immediately at `(0pt, 0pt)`, and the PDF writer
5046/// (`place_graphics`, rustyfi-pdf) translates the *whole* box to its placed
5047/// position via a single `cm` at render time. This equals upstream's
5048/// behavior if and only if `gfun` uses its point argument purely additively
5049/// (shift-covariant) — true of every real `Gr`/`deco` generator, but not
5050/// enforced by this signature.
5051///
5052/// **The one SIDE EFFECT that survives the shortcut** is
5053/// `register-destination`: at construction time there is no page, so
5054/// `annotation.ml:15`'s gate would refuse it and fail the document (azmath's
5055/// `equation.satyh` anchors every `\label`ed equation this way).
5056/// [`apply_graphics_callback`] turns each call into a
5057/// `GraphicsElem::Destination` marker riding in `elems` — rather than a field
5058/// of its own, so it inherits the `origin_independent` probe below and the
5059/// existing `fire_hooks`/`shift_graphics` pipeline.
5060fn prim_inline_graphics(
5061 interp: &mut Interp,
5062 version: RustyfiVersion,
5063 mut args: Vec<Value>,
5064) -> Result<Value, EvalError> {
5065 let gfun = args.pop().unwrap();
5066 let d = as_length(args.pop().unwrap())?;
5067 let h = as_length(args.pop().unwrap())?;
5068 let w = as_length(args.pop().unwrap())?;
5069 // The callback's result type is `list graphics` under v0.0.6, one
5070 // `graphics` collection under v0.1 — see `coerce_graphics_result`'s doc
5071 // comment.
5072 let gf = gfun.clone();
5073 let elems = apply_graphics_callback(interp, version, move |it| {
5074 let origin = make_point_value((Length::ZERO, Length::ZERO));
5075 it.apply(gf, origin)
5076 })?;
5077 // Detect a PAGE-ABSOLUTE callback: run it again at a far-off probe point
5078 // and compare. If the output is byte-identical the callback ignored its
5079 // placed-point argument (`fun _ -> …`, e.g. slydifi's frame background /
5080 // figbox's `draw-text pt`), so its coordinates are already page-absolute
5081 // and the PDF writer must NOT translate them by the box's placed position
5082 // (which is often a negative text-origin, shifting the decoration off the
5083 // page). A position-relative callback yields different output here, so
5084 // `origin_independent` stays false and the per-box `cm` applies as before.
5085 // Upstream (`handlePdf.ml`) always calls the callback with the true placed
5086 // point and never post-translates; this recovers that for the constant
5087 // case without a post-layout deferral. (The extra evaluation must be free
5088 // of observable side effects — true of every `Gr`/`draw-text` generator;
5089 // `register-destination` is captured per call rather than committed, so
5090 // the probe's copy is dropped.)
5091 //
5092 // The comparison includes the markers on purpose: an ANCHOR-ONLY callback
5093 // draws no ink at either point, so comparing ink alone would classify it
5094 // page-absolute and pin every anchor at the raw callback argument.
5095 let origin_independent = {
5096 let probe = make_point_value((Length::pt(4096.0), Length::pt(2731.0)));
5097 match apply_graphics_callback(interp, version, move |it| it.apply(gfun, probe)) {
5098 Ok(e2) => e2 == elems,
5099 Err(_) => false,
5100 }
5101 };
5102 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5103 PureHorzBox::Graphics {
5104 width: w,
5105 height: h,
5106 depth: d,
5107 elems,
5108 origin_independent,
5109 },
5110 )]))
5111}
5112
5113/// `inline-graphics-outer : length -> length -> (length -> point -> graphics
5114/// list) -> inline-boxes` (vminst.ml:1891 `BackendInlineGraphicsOuter`) — a
5115/// graphics box whose width stretches like `inline-fil` (upstream widinfo
5116/// `Fils(1)`). The callback needs the RESOLVED width, unknown until line
5117/// layout, so it is deferred through `Interp::outer_graphics` (the `HookId`
5118/// pattern) and fired by `resolve_outer_graphics_in_contents` (called from
5119/// `line-break`/`tabular`/`draw-text`) with the width `justify_line` wrote
5120/// into the box and the point `(0pt, 0pt)` — the same shift-covariance
5121/// shortcut as `inline-graphics` above (the writer's `cm` supplies the
5122/// placed point); the width argument is faithful.
5123fn prim_inline_graphics_outer(
5124 interp: &mut Interp,
5125 version: RustyfiVersion,
5126 mut args: Vec<Value>,
5127) -> Result<Value, EvalError> {
5128 let gfun = args.pop().unwrap();
5129 let d = as_length(args.pop().unwrap())?;
5130 let h = as_length(args.pop().unwrap())?;
5131 interp.outer_graphics.push((gfun, version));
5132 let fn_id = GraphicsFnId(interp.outer_graphics.len() - 1);
5133 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5134 PureHorzBox::GraphicsOuter {
5135 height: h,
5136 depth: d,
5137 width: Length::ZERO,
5138 fn_id,
5139 },
5140 )]))
5141}
5142
5143/// Fire every deferred `inline-graphics-outer` callback in an already-
5144/// justified run, replacing its `GraphicsOuter` marker with a resolved
5145/// `Graphics` box (see `prim_inline_graphics_outer`). Idempotent (a resolved
5146/// box no longer matches) and cheap when nothing matches (one pass, no
5147/// allocation).
5148fn resolve_outer_graphics_in_contents(
5149 interp: &mut Interp,
5150 contents: &mut [(Length, PureHorzBox)],
5151) -> Result<(), EvalError> {
5152 for (_, bx) in contents.iter_mut() {
5153 if let PureHorzBox::GraphicsOuter {
5154 height,
5155 depth,
5156 width,
5157 fn_id,
5158 } = bx
5159 {
5160 let (w, h, d) = (*width, *height, *depth);
5161 let (gfun, gver) = match interp.outer_graphics.get(fn_id.0) {
5162 Some((f, v)) => (f.clone(), *v),
5163 None => {
5164 return eval_error(format!(
5165 "inline-graphics-outer: dangling callback index {}",
5166 fn_id.0
5167 ))
5168 }
5169 };
5170 // Same per-version coercion as `prim_inline_graphics`
5171 // above, shared with `tabular`'s per-cell use. The generation is
5172 // the one the callback was REGISTERED under, carried alongside it in
5173 // `Interp::outer_graphics`: this pass is a DEFERRED one
5174 // (`line-break`/`tabular`/`draw-text`), so `interp.version` here
5175 // is the entry document's, not the callback author's.
5176 //
5177 // Deferred to LINE-BREAK time, not page break, so a
5178 // `register-destination` here still has no page: same capture as
5179 // `prim_inline_graphics`.
5180 let elems = apply_graphics_callback(interp, gver, move |it| {
5181 let partial = it.apply(gfun, Value::Length(w))?;
5182 it.apply(partial, make_point_value((Length::ZERO, Length::ZERO)))
5183 })?;
5184 *bx = PureHorzBox::Graphics {
5185 width: w,
5186 height: h,
5187 depth: d,
5188 elems,
5189 origin_independent: false,
5190 };
5191 }
5192 }
5193 Ok(())
5194}
5195
5196/// `tabular : (cell list) list -> (length list -> length list -> graphics
5197/// list) -> inline-boxes` (vminst.ml:539) — solve the grid (backend
5198/// `rustyfi_backend::tabular::main`) and eagerly drive the rule callback
5199/// with the solved box-local grid-line coordinates.
5200///
5201/// **Why eager is faithful here, unlike `inline-graphics`.** The callback's
5202/// arguments are the grid-line coordinates, fully determined by cell
5203/// content alone (`main` computes them before any placement) — so calling
5204/// it once at construction time with the true box-local `xs`/`ys` is exactly
5205/// what upstream's later, placement-time call produces once the PDF
5206/// writer's per-box `cm` translate (shared with `place_graphics`, see
5207/// `rustyfi-pdf`) shifts the resulting rule paths into position. No
5208/// shift-covariance caveat (contrast `prim_inline_graphics` above).
5209fn prim_tabular(
5210 interp: &mut Interp,
5211 version: RustyfiVersion,
5212 mut args: Vec<Value>,
5213) -> Result<Value, EvalError> {
5214 let rulesf = args.pop().unwrap();
5215 let rows = as_cell_grid(args.pop().unwrap())?;
5216 let mut solved = rustyfi_backend::tabular::main(rows);
5217 for cell in &mut solved.cells {
5218 resolve_outer_graphics_in_contents(interp, &mut cell.contents)?;
5219 }
5220
5221 let xs = make_length_list(&solved.xs);
5222 let ys = make_length_list(&solved.ys);
5223 let partial = interp.apply(rulesf, xs)?;
5224 let gval = interp.apply(partial, ys)?;
5225 // The rules callback returns `list graphics` under v0.0.6, one
5226 // `graphics` collection under v0.1 — per the CALLER's generation
5227 // (`version`), which is the one whose `tabular` type this call was
5228 // checked against.
5229 let rules = coerce_graphics_result_for(version, gval)?;
5230
5231 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5232 PureHorzBox::Tabular(TabularBox {
5233 width: solved.width,
5234 height: solved.height,
5235 depth: Length::ZERO,
5236 cells: solved.cells,
5237 rules,
5238 }),
5239 )]))
5240}
5241
5242// ============================================================================
5243// ---- gr.satyh graphics primitives -------------------------------------------
5244// ============================================================================
5245
5246/// `bezier-to : point -> point -> point -> pre-path -> pre-path`
5247/// (vminst.ml:742) — appends a cubic Bézier segment (`ptS`/`ptT` control
5248/// points, `pt1` destination) to the pre-path's forward-accumulated `segs`.
5249fn prim_bezier_to(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5250 let mut pp = as_prepath(args.pop().unwrap())?;
5251 let pt1 = as_point(args.pop().unwrap())?;
5252 let pt_t = as_point(args.pop().unwrap())?;
5253 let pt_s = as_point(args.pop().unwrap())?;
5254 pp.segs.push(PathSeg::Bezier(pt_s, pt_t, pt1));
5255 Ok(Value::PrePath(pp))
5256}
5257
5258/// `close-with-bezier : point -> point -> pre-path -> path` (vminst.ml:787)
5259/// — closes the subpath with a cubic Bézier back to its start (`ptS`/`ptT`
5260/// control points; the destination is always the subpath's own `start`, per
5261/// `Closing::Bezier`'s doc comment).
5262fn prim_close_with_bezier(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5263 let pp = as_prepath(args.pop().unwrap())?;
5264 let pt_t = as_point(args.pop().unwrap())?;
5265 let pt_s = as_point(args.pop().unwrap())?;
5266 Ok(Value::Path(Path {
5267 subpaths: vec![Subpath {
5268 start: pp.start,
5269 segs: pp.segs,
5270 closing: Closing::Bezier(pt_s, pt_t),
5271 }],
5272 }))
5273}
5274
5275/// `shift-path : point -> path -> path` (vminst.ml:663) — translate every
5276/// point of the path by the given vector (`rustyfi_backend::shift_path`).
5277fn prim_shift_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5278 let path = as_path(args.pop().unwrap())?;
5279 let v = as_point(args.pop().unwrap())?;
5280 Ok(Value::Path(shift_path(v, &path)))
5281}
5282
5283/// `linear-transform-path : float -> float -> float -> float -> path ->
5284/// path` (vminst.ml:678) — apply the 2x2 matrix `(a, b, c, d)` to every
5285/// point of the path (`rustyfi_backend::linear_transform_path`).
5286fn prim_linear_transform_path(
5287 _interp: &mut Interp,
5288 mut args: Vec<Value>,
5289) -> Result<Value, EvalError> {
5290 let path = as_path(args.pop().unwrap())?;
5291 let d = as_float(args.pop().unwrap())?;
5292 let c = as_float(args.pop().unwrap())?;
5293 let b = as_float(args.pop().unwrap())?;
5294 let a = as_float(args.pop().unwrap())?;
5295 Ok(Value::Path(linear_transform_path((a, b, c, d), &path)))
5296}
5297
5298/// `shift-graphics : point -> graphics -> graphics` (vminst.ml:2451) —
5299/// translate every point of the graphics element by the given vector.
5300fn prim_shift_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5301 let g = as_graphics(args.pop().unwrap())?;
5302 let v = as_point(args.pop().unwrap())?;
5303 Ok(Value::Graphics(shift_graphics(v, &g)))
5304}
5305
5306/// `linear-transform-graphics : float -> float -> float -> float -> graphics
5307/// -> graphics` (vminst.ml:2432). **Eager, unlike upstream**:
5308/// `graphicD.ml`'s `make_linear_trans` lazily wraps the element in a
5309/// `LinearTrans` node, deferring the matrix to a PDF `cm` operator at render
5310/// time — which also scales any wrapped `Stroke`/`DashedStroke`'s effective
5311/// line width (width is specified in the pre-transform coordinate space).
5312/// This port instead rewrites every point up front (a pure coordinate map,
5313/// no PDF change needed) and leaves `width` untouched, so
5314/// a non-uniform `scale-graphics` (`gr.satyh`) will NOT scale a stroke's
5315/// line width the way upstream does — invisible for pure rotation
5316/// (`rotate-graphics`, orthonormal, preserves lengths) and for `Fill`, which
5317/// is the only `GraphicsElem` shape any bundled package actually
5318/// strokes-then-scales.
5319fn prim_linear_transform_graphics(
5320 _interp: &mut Interp,
5321 mut args: Vec<Value>,
5322) -> Result<Value, EvalError> {
5323 let g = as_graphics(args.pop().unwrap())?;
5324 let d = as_float(args.pop().unwrap())?;
5325 let c = as_float(args.pop().unwrap())?;
5326 let b = as_float(args.pop().unwrap())?;
5327 let a = as_float(args.pop().unwrap())?;
5328 Ok(Value::Graphics(linear_transform_graphics((a, b, c, d), &g)))
5329}
5330
5331/// `get-graphics-bbox : graphics -> point * point` (v0.0.6 vminst.ml:2466)
5332/// — the v006 fork side. `.unwrap_or(…)` is UNREACHABLE under 0.0.6 (no
5333/// 0.0.6-visible constructor produces `Group`/`Clip`, so `graphics_bbox`
5334/// never returns `None` here); documented rather than `.expect`ed so a
5335/// future faithful `Group`/`Clip` leak (a bug) fails soft instead of
5336/// panicking.
5337fn prim_get_graphics_bbox_v006(
5338 _interp: &mut Interp,
5339 mut args: Vec<Value>,
5340) -> Result<Value, EvalError> {
5341 let g = as_graphics(args.pop().unwrap())?;
5342 let (pmin, pmax) =
5343 graphics_bbox(&g).unwrap_or(((Length::ZERO, Length::ZERO), (Length::ZERO, Length::ZERO)));
5344 Ok(Value::Tuple(vec![
5345 make_point_value(pmin),
5346 make_point_value(pmax),
5347 ]))
5348}
5349
5350/// `get-graphics-bbox : graphics -> option (point * point)` (dev-0-1-0
5351/// vminst.ml:2301) — the v01 fork side: `graphics` is a collection,
5352/// so an empty `unite-graphics []` (or an empty `Clip`'s contents-blind
5353/// bbox is still `Some`, but an empty `Group` folds to nothing)
5354/// legitimately has no bbox — surfaced as the SATySFi `option` variant,
5355/// the `probe-cross-reference` building pattern.
5356fn prim_get_graphics_bbox_v01(
5357 _interp: &mut Interp,
5358 mut args: Vec<Value>,
5359) -> Result<Value, EvalError> {
5360 let g = as_graphics(args.pop().unwrap())?;
5361 Ok(match graphics_bbox(&g) {
5362 Some((pmin, pmax)) => Value::Ctor(
5363 "Some".to_string(),
5364 Some(Box::new(Value::Tuple(vec![
5365 make_point_value(pmin),
5366 make_point_value(pmax),
5367 ]))),
5368 ),
5369 None => Value::Ctor("None".to_string(), None),
5370 })
5371}
5372
5373/// `unite-graphics : list graphics -> graphics` (dev-0-1-0 vminst.ml:3119)
5374/// — `GraphicD.concat` = `List.concat`, ported as the `Group` container.
5375/// `unite-graphics []` is legal and yields the
5376/// empty collection (the `None`-bbox witness `get-graphics-bbox` exercises
5377/// above).
5378fn prim_unite_graphics(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5379 let items = as_list(args.pop().unwrap())?;
5380 let mut elems = Vec::with_capacity(items.len());
5381 for it in items {
5382 elems.push(as_graphics(it)?);
5383 }
5384 Ok(Value::Graphics(GraphicsElem::Group(elems)))
5385}
5386
5387/// `clip-graphics-by-path : path -> graphics -> graphics` (dev-0-1-0
5388/// vminst.ml:3105) — `GraphicD.make_clip gr pathlst` = `Clip(paths, gr)`;
5389/// the port's single-element `g` (possibly itself a `Group`) IS the
5390/// collection upstream's `gr` argument names.
5391fn prim_clip_graphics_by_path(
5392 _interp: &mut Interp,
5393 mut args: Vec<Value>,
5394) -> Result<Value, EvalError> {
5395 let g = as_graphics(args.pop().unwrap())?;
5396 let path = as_path(args.pop().unwrap())?;
5397 Ok(Value::Graphics(GraphicsElem::Clip(path, vec![g])))
5398}
5399
5400/// `get-path-bbox : path -> point * point` (vminst.ml:696
5401/// `PathGetBoundingBox`) — `rustyfi_backend::path_bbox` (see that function's
5402/// doc comment for the exact cubic-extrema policy shared with
5403/// `get-graphics-bbox`).
5404fn prim_get_path_bbox(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5405 let path = as_path(args.pop().unwrap())?;
5406 let (pmin, pmax) = path_bbox(&path);
5407 Ok(Value::Tuple(vec![
5408 make_point_value(pmin),
5409 make_point_value(pmax),
5410 ]))
5411}
5412
5413/// `dashed-stroke : length -> (length*length*length) -> color -> path ->
5414/// graphics` (vminst.ml:2414) — width first, then the dash pattern, then
5415/// color, then path (mirrors `stroke`'s argument order with one extra
5416/// dash-pattern argument inserted).
5417fn prim_dashed_stroke(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5418 let path = as_path(args.pop().unwrap())?;
5419 let color = as_color(args.pop().unwrap())?;
5420 let dash = as_dash(args.pop().unwrap())?;
5421 let wid = as_length(args.pop().unwrap())?;
5422 Ok(Value::Graphics(GraphicsElem::DashedStroke(
5423 wid, dash, color, path,
5424 )))
5425}
5426
5427/// `draw-text : point -> inline-boxes -> graphics` (vminst.ml:2363
5428/// `PrimitiveDrawText`) — FAITHFUL: lays the run out at natural width
5429/// (upstream `LineBreak.natural`; here `natural_metrics` + `fit_cell` at that
5430/// width, so slack is 0 and every box keeps its natural advance) and stores
5431/// the placed run in `GraphicsElem::Text`. Also resolves any
5432/// `inline-graphics-outer` marker the run carries (`resolve_outer_graphics_
5433/// in_contents` — width 0 there, since slack is 0 at natural width, upstream
5434/// identical: `widperfil = 0`).
5435fn prim_draw_text(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5436 let ib = as_inline_boxes(args.pop().unwrap())?;
5437 let pt = as_point(args.pop().unwrap())?;
5438 let (width, height, depth) = natural_metrics(&ib);
5439 let (mut contents, _, _) = fit_cell(ib, width);
5440 resolve_outer_graphics_in_contents(interp, &mut contents)?;
5441 Ok(Value::Graphics(GraphicsElem::Text {
5442 pt,
5443 contents,
5444 width,
5445 height,
5446 depth,
5447 transform: None,
5448 }))
5449}
5450
5451// ============================================================================
5452// ---- pervasives.satyh prims -------------------
5453// ============================================================================
5454
5455/// `get-natural-metrics : inline-boxes -> length * length * length`
5456/// (vminst.ml:2020 `PrimitiveGetNaturalMetrics`) — FAITHFUL: delegates to
5457/// `rustyfi_backend::natural_metrics` (see that function's doc comment for
5458/// why no depth sign-flip is needed here, unlike upstream).
5459fn prim_get_natural_metrics(
5460 _interp: &mut Interp,
5461 mut args: Vec<Value>,
5462) -> Result<Value, EvalError> {
5463 let ib = as_inline_boxes(args.pop().unwrap())?;
5464 let (width, height, depth) = natural_metrics(&ib);
5465 Ok(Value::Tuple(vec![
5466 Value::Length(width),
5467 Value::Length(height),
5468 Value::Length(depth),
5469 ]))
5470}
5471
5472/// Build the atomic `PureHorzBox::Frame` for `inline-frame-outer`/`-inner`
5473/// (upstream keeps both atomic too; `-breakable` is transparent instead, see
5474/// [`prim_inline_frame_breakable`]): fit `inner` at its natural width
5475/// (`fit_cell` — the same
5476/// no-Context fit tabular cells use), pad the fitted run by `pads`, intern
5477/// `deco` into `interp.decos`. `deco` is fired lang-side, after
5478/// placement, by `fire_hooks`/`fire_inline_frame` — this constructor never
5479/// calls it.
5480fn make_inline_frame(
5481 interp: &mut Interp,
5482 version: RustyfiVersion,
5483 (pad_l, pad_r, pad_t, pad_b): (Length, Length, Length, Length),
5484 deco: Value,
5485 inner: Vec<HorzBox>,
5486) -> Value {
5487 let (w, _, _) = natural_metrics(&inner);
5488 let (contents, height, depth) = fit_cell(inner, w);
5489 let contents = contents.into_iter().map(|(x, b)| (x + pad_l, b)).collect();
5490 let id = DecoId(interp.decos.len());
5491 // `version` is the CALLING code's generation, threaded in by the
5492 // per-version prim rows below — fire time is a post-page-break pass with
5493 // no version context of its own, so this is the only moment the answer
5494 // is available. See `DecoEntry`'s doc comment.
5495 interp.decos.push(DecoEntry::Inline { deco, version });
5496 Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::Frame {
5497 width: pad_l + w + pad_r,
5498 height: height + pad_t,
5499 depth: depth + pad_b,
5500 deco: id,
5501 contents,
5502 })])
5503}
5504
5505/// `inline-frame-outer : paddings -> deco -> inline-boxes -> inline-boxes`
5506/// (vminst.ml:1787 `BackendOuterFrame`) — FAITHFUL: builds the atomic
5507/// `PureHorzBox::Frame`; see [`make_inline_frame`]. Upstream's
5508/// outer/inner distinction is glue participation in the enclosing line
5509/// (`PHGOuterFrame` vs `PHGInnerFrame`), which this atomic box model
5510/// collapses — both this and [`prim_inline_frame_inner`] build the exact
5511/// same box.
5512fn prim_inline_frame_outer(
5513 interp: &mut Interp,
5514 version: RustyfiVersion,
5515 mut args: Vec<Value>,
5516) -> Result<Value, EvalError> {
5517 let inner = as_inline_boxes(args.pop().unwrap())?;
5518 let deco = args.pop().unwrap();
5519 let pads = as_paddings(args.pop().unwrap())?;
5520 Ok(make_inline_frame(interp, version, pads, deco, inner))
5521}
5522
5523/// `inline-frame-inner : paddings -> deco -> inline-boxes -> inline-boxes`
5524/// (vminst.ml:1807 `BackendInnerFrame`) — same construction as
5525/// [`prim_inline_frame_outer`]; see that function's doc comment for the
5526/// outer/inner distinction this atomic model collapses.
5527fn prim_inline_frame_inner(
5528 interp: &mut Interp,
5529 version: RustyfiVersion,
5530 mut args: Vec<Value>,
5531) -> Result<Value, EvalError> {
5532 let inner = as_inline_boxes(args.pop().unwrap())?;
5533 let deco = args.pop().unwrap();
5534 let pads = as_paddings(args.pop().unwrap())?;
5535 Ok(make_inline_frame(interp, version, pads, deco, inner))
5536}
5537
5538/// `set-manual-rising : length -> context -> context` (vminst.ml:1661
5539/// `PrimitiveSetManualRising`) — FAITHFUL store into
5540/// `Context::manual_rising`, the same shape as `set-font-size`/
5541/// `set-leading` above. Read by `text_to_boxes`'s `flush_word`, which adds
5542/// it to the script font's own baseline raise; the default is
5543/// `Length::ZERO`, so a document that never calls this is unaffected.
5544fn prim_set_manual_rising(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5545 let ctx = as_context(args.pop().unwrap())?;
5546 let rising = as_length(args.pop().unwrap())?;
5547 Ok(Value::Context(Box::new(Context {
5548 manual_rising: rising,
5549 ..ctx
5550 })))
5551}
5552
5553/// `script-guard : script -> inline-boxes -> inline-boxes` (vminst.ml:1908
5554/// `BackendScriptGuard`).
5555///
5556/// STAND-IN: upstream wraps `hblst` in a `HorzScriptGuard` that tells the
5557/// line breaker which script to assume at each edge, for inter-script
5558/// spacing rules (`lineBreak.ml`'s script-boundary handling). This port's
5559/// line breaker has no script-aware spacing at all yet, so this is the
5560/// identity function: the `script` argument is accepted (so callers like
5561/// pervasives.satyh's `\SATySFi`/`\LaTeX`/`\TeX` type-check and run) and
5562/// discarded.
5563fn prim_script_guard(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5564 let ib = as_inline_boxes(args.pop().unwrap())?;
5565 let _script = args.pop().unwrap();
5566 Ok(Value::InlineBoxes(ib))
5567}
5568
5569/// Unwrap `inline-boxes`' `Vec<HorzBox>` down to the bare `Vec<PureHorzBox>`
5570/// a `PureHorzBox::Discretionary` slot stores (mirrors `prim_line_break`'s
5571/// identical unwrap, linebreak.rs's only other consumer of this shape).
5572fn into_pure(boxes: Vec<HorzBox>) -> Vec<PureHorzBox> {
5573 boxes.into_iter().map(|HorzBox::Pure(p)| p).collect()
5574}
5575
5576/// `discretionary : int -> inline-boxes -> inline-boxes -> inline-boxes ->
5577/// inline-boxes` (vminst.ml:1969 `BackendDiscretionary`), params `(pb,
5578/// hblst0, hblst1, hblst2)` — FAITHFUL: builds the same
5579/// `PureHorzBox::Discretionary` the UAX#14 line breaker already produces
5580/// internally. `hblst0` (`no_break`) renders when this point is NOT chosen
5581/// as a line break; `hblst1`/`hblst2` (`pre_break`/`post_break`) render at
5582/// the end/start of the two lines a break here would produce.
5583fn prim_discretionary(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5584 let post_break = as_inline_boxes(args.pop().unwrap())?;
5585 let pre_break = as_inline_boxes(args.pop().unwrap())?;
5586 let no_break = as_inline_boxes(args.pop().unwrap())?;
5587 let penalty = as_int(args.pop().unwrap())?;
5588 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5589 PureHorzBox::Discretionary {
5590 penalty: penalty.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
5591 pre_break: into_pure(pre_break),
5592 post_break: into_pure(post_break),
5593 no_break: into_pure(no_break),
5594 },
5595 )]))
5596}
5597
5598/// `get-axis-height : context -> length` (vminst.ml:1739
5599/// `PrimitiveGetAxisHeight`), needed by `picture.satyh`'s `Picture.node`
5600/// (Tier-2 decoration/graphics wave) — centers text vertically around the
5601/// math axis.
5602///
5603/// FAITHFUL: reads `axis_height` from `ctx.math_font`'s OpenType MATH
5604/// table via `MathC` (`FontInfo.get_axis_height mfabbrev fontsize`), falling
5605/// back to a fixed `0.25` ratio of `ctx.font_size` (`pervasives.satyh`'s
5606/// `\SATySFi`/`\LaTeX` manual-rising ratio) whenever the font has no MATH
5607/// table — so base-14/non-math output is unchanged.
5608fn prim_get_axis_height(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5609 let ctx = as_context(args.pop().unwrap())?;
5610 let mc = MathC::of(interp, &ctx);
5611 Ok(Value::Length(mc.axis(ctx.font_size)))
5612}
5613
5614// ============================================================================
5615// ---- page-break hooks + cross-references -----------------------------------
5616// ============================================================================
5617
5618/// `hook-page-break : (page-break-info -> point -> unit) -> inline-boxes`
5619/// (vminstdef.yaml:576). Pushes the closure argument onto `interp.hooks`
5620/// (the lang-side table `fire_hooks` reads back after placement) and
5621/// returns an inline box carrying only the opaque `HookId` — exactly
5622/// `prim_load_image`'s shape (`ImageId`/`interp.images`), applied to a
5623/// deferred *computation* instead of a resource. The backend places this
5624/// box like any other zero-width content and never sees the closure.
5625fn prim_hook_page_break(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5626 let closure = args.pop().unwrap();
5627 let id = HookId(interp.hooks.len());
5628 interp.hooks.push(closure);
5629 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
5630 PureHorzBox::HookPageBreak { id },
5631 )]))
5632}
5633
5634/// `hook-page-break-block : (page-break-info -> point -> unit) ->
5635/// block-boxes` (vminst.ml:632 `BackendHookPageBreakBlock`) — the
5636/// block-level analog of `prim_hook_page_break` above, FAITHFUL: same
5637/// `interp.hooks` push, same opaque `HookId`, but wrapped in a
5638/// `VertBox::HookPageBreak` marker instead of an inline box. `chop_page`/
5639/// `place_block_at` (rustyfi-backend) place it as a zero-height
5640/// `PlacedLine` carrying the SAME `PureHorzBox::HookPageBreak` wrapper the
5641/// inline primitive uses, so `fire_hooks` (lib.rs) fires it through the
5642/// exact same scan with no changes of its own.
5643fn prim_hook_page_break_block(
5644 interp: &mut Interp,
5645 mut args: Vec<Value>,
5646) -> Result<Value, EvalError> {
5647 let closure = args.pop().unwrap();
5648 let id = HookId(interp.hooks.len());
5649 interp.hooks.push(closure);
5650 Ok(Value::BlockBoxes(vec![VertBox::HookPageBreak(id)]))
5651}
5652
5653/// `register-cross-reference : string -> string -> unit` (vminstdef.yaml:1793).
5654/// Callable anywhere (not just from a hook) — ordinary strict primitive
5655/// over the shared `crossrefs` table.
5656fn prim_register_cross_reference(
5657 interp: &mut Interp,
5658 mut args: Vec<Value>,
5659) -> Result<Value, EvalError> {
5660 let value = as_str(args.pop().unwrap())?;
5661 let key = as_str(args.pop().unwrap())?;
5662 interp.crossrefs.borrow_mut().register(key, value);
5663 Ok(Value::Unit)
5664}
5665
5666/// `get-cross-reference : string -> string option` (vminstdef.yaml:1808).
5667/// A miss is recorded (`CrossRefs::get`) so an unresolved forward reference
5668/// forces another fixpoint trial; the result surfaces as the SATySFi
5669/// `option` variant (`None` / `Some(string)`).
5670fn prim_get_cross_reference(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
5671 let key = as_str(args.pop().unwrap())?;
5672 Ok(match interp.crossrefs.borrow_mut().get(&key) {
5673 Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5674 None => Value::Ctor("None".to_string(), None),
5675 })
5676}
5677
5678/// `probe-cross-reference : string -> string option` (vminst.ml:3043
5679/// `BackendProbeCrossReference`) — FAITHFUL: `get-cross-reference` minus the
5680/// miss bookkeeping (`CrossRefs::probe`, crossRef.ml:112), so a `None` here
5681/// never forces another fixpoint trial.
5682fn prim_probe_cross_reference(
5683 interp: &mut Interp,
5684 mut args: Vec<Value>,
5685) -> Result<Value, EvalError> {
5686 let key = as_str(args.pop().unwrap())?;
5687 Ok(match interp.crossrefs.borrow().probe(&key) {
5688 Some(v) => Value::Ctor("Some".to_string(), Some(Box::new(Value::Str(v)))),
5689 None => Value::Ctor("None".to_string(), None),
5690 })
5691}
5692
5693// ============================================================================
5694// ---- annot.satyh's prim surface (link annotations + the frame/script
5695// stand-ins it needs) --------------------------------------------------------
5696// ============================================================================
5697
5698/// `get-leftmost-script`/`get-rightmost-script : inline-boxes -> script
5699/// option` (vminstdef.yaml:1754/1767 `BackendGetLeftmostScript`/
5700/// `BackendGetRightmostScript`) — STAND-IN: upstream inspects the actual
5701/// Unicode script of the first/last character in `hblst`
5702/// (`LineBreak.get_leftmost_script`/`get_rightmost_script`), which
5703/// `annot.satyh`'s `\href` uses to `script-guard` the link's edges so
5704/// inter-script spacing isn't inserted right at the boundary. This port's
5705/// `PureHorzBox::InnerString` carries no per-character script tag (no
5706/// script-aware line breaking at all yet — `script-guard` above is already
5707/// an identity stand-in for the same reason), so both primitives
5708/// unconditionally return `None`: `\href` then takes its `None` arm
5709/// (`inline-nil`, no guard inserted) — a safe, honest default rather than
5710/// fabricating a script this port cannot actually see.
5711fn prim_get_leftmost_script(
5712 _interp: &mut Interp,
5713 mut args: Vec<Value>,
5714) -> Result<Value, EvalError> {
5715 let _ib = as_inline_boxes(args.pop().unwrap())?;
5716 Ok(Value::Ctor("None".to_string(), None))
5717}
5718
5719/// See [`prim_get_leftmost_script`] — the rightmost-edge twin, identical
5720/// stand-in reasoning.
5721fn prim_get_rightmost_script(
5722 _interp: &mut Interp,
5723 mut args: Vec<Value>,
5724) -> Result<Value, EvalError> {
5725 let _ib = as_inline_boxes(args.pop().unwrap())?;
5726 Ok(Value::Ctor("None".to_string(), None))
5727}
5728
5729/// `inline-frame-breakable : paddings -> deco-set -> inline-boxes ->
5730/// inline-boxes` (vminstdef.yaml:1672 `BackendOuterFrameBreakable`) —
5731/// FAITHFUL: upstream's `HorzFrameBreakable` is *transparent* to the
5732/// paragraph breaker (`lineBreak.ml:1094` threads the enclosing width map
5733/// straight through the frame's contents), so the frame's own glue and
5734/// discretionaries are break candidates of the enclosing paragraph, and
5735/// `cut` (`:824`) re-frames the chosen fragments one line at a time —
5736/// `decoS` for a frame that came out unbroken, `decoH`/`decoM`/`decoT` per
5737/// fragment for one that split.
5738///
5739/// This port's breaker is a flat index DP rather than upstream's recursive
5740/// one, so transparency is spelled by SPLICING: the contents go straight into
5741/// the returned box list, bracketed by a zero-width
5742/// [`PureHorzBox::InlineFrameMarker`] pair that `fire_hooks` walks to
5743/// reassemble the fragments and fire the right closure for each. The
5744/// horizontal paddings become `FixedEmpty` boxes inside the bracket, exactly
5745/// upstream's `append_horz_padding` (`lineBreak.ml:79`); the vertical ones
5746/// ride on the markers (which is how they still reach the line's height and
5747/// depth) and are re-applied per fragment at fire time.
5748///
5749/// The atomic `PureHorzBox::Frame` is NOT usable here — it fires only
5750/// `decoS` and, being width-rigid, can neither break nor let an interior
5751/// `inline-fil` stretch. It is reserved for `inline-frame-outer`/`-inner`,
5752/// which upstream really does keep atomic.
5753fn prim_inline_frame_breakable(
5754 interp: &mut Interp,
5755 version: RustyfiVersion,
5756 mut args: Vec<Value>,
5757) -> Result<Value, EvalError> {
5758 let inner = as_inline_boxes(args.pop().unwrap())?;
5759 let decoset = as_decoset(args.pop().unwrap())?;
5760 let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
5761 let (_, height, depth) = natural_metrics(&inner);
5762 let id = DecoId(interp.decos.len());
5763 // `version` is the CALLING code's generation — see `make_inline_frame`'s
5764 // identical comment and `DecoEntry`'s doc comment.
5765 interp.decos.push(DecoEntry::InlineBreakable {
5766 pads: Paddings {
5767 l: pad_l,
5768 r: pad_r,
5769 t: pad_t,
5770 b: pad_b,
5771 },
5772 decoset,
5773 version,
5774 });
5775 let marker = |end| {
5776 HorzBox::Pure(PureHorzBox::InlineFrameMarker {
5777 id,
5778 end,
5779 height: height + pad_t,
5780 depth: depth + pad_b,
5781 })
5782 };
5783 let mut out = Vec::with_capacity(inner.len() + 4);
5784 out.push(marker(false));
5785 // Upstream emits both padding boxes unconditionally; a zero-width
5786 // `FixedEmpty` is inert everywhere in this port too, but skipping it keeps
5787 // the box stream (and every placed-line snapshot) unchanged for the
5788 // zero-padding callers, which is every bundled one.
5789 if pad_l != Length::ZERO {
5790 out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_l }));
5791 }
5792 out.extend(inner);
5793 if pad_r != Length::ZERO {
5794 out.push(HorzBox::Pure(PureHorzBox::FixedEmpty { width: pad_r }));
5795 }
5796 out.push(marker(true));
5797 Ok(Value::InlineBoxes(out))
5798}
5799
5800/// `deco-set` = `Value::Tuple` of 4 closures (`(decoS, decoH, decoM,
5801/// decoT)`, evalUtil.ml:169 `get_decoset`) — no type check on the elements
5802/// themselves (they're closures, applied later by `apply_deco`).
5803fn as_decoset(v: Value) -> Result<[Value; 4], EvalError> {
5804 match v {
5805 Value::Tuple(vs) if vs.len() == 4 => {
5806 let mut it = vs.into_iter();
5807 let a = it.next().unwrap();
5808 let b = it.next().unwrap();
5809 let c = it.next().unwrap();
5810 let d = it.next().unwrap();
5811 Ok([a, b, c, d])
5812 }
5813 other => eval_error(format!(
5814 "expected a deco-set (4-tuple of decorations), got {}",
5815 other.type_name()
5816 )),
5817 }
5818}
5819
5820/// 0.0.6 graphics-producing callbacks return `list graphics` (`tL tGR`);
5821/// 0.1's return one `graphics` collection (`tGR` — dev-0-1-0
5822/// `primitives.cppo.ml:75-85`). STRICT per
5823/// version: a 0.1 program returning a list here is a bug the type checker
5824/// already rejected; don't mask it with tolerant decoding. Shared by every
5825/// coercion site (`prim_inline_graphics`, `inline-graphics-outer`/
5826/// `tabular`'s `resolve_outer_graphics_in_contents`, `tabular`'s own rules
5827/// callback, and `apply_deco` below).
5828///
5829/// `version` is EXPLICIT rather than read off `interp.version`, and that is
5830/// the whole point. `interp.version` is one whole-program field, set once by
5831/// `lib.rs`'s `eval_document_trials`; in a cross-version program it names
5832/// the ENTRY document's generation, while the callback being decoded here
5833/// may have been written by a spliced 0.0.6 dependency. Every caller gets
5834/// the right answer from a place that genuinely knows it: the six carrier
5835/// prim bodies are registered per version
5836/// (`version_forked_prims!`, folded at compile time by
5837/// `compile.rs`'s `Ast::VersionScope` arm), and the two DEFERRED consumers
5838/// read the generation captured when the closure was interned
5839/// (`DecoEntry::version`, `Interp::outer_graphics`'s second component).
5840fn coerce_graphics_result_for(
5841 version: RustyfiVersion,
5842 v: Value,
5843) -> Result<Vec<GraphicsElem>, EvalError> {
5844 if version.graphics_is_collection() {
5845 Ok(vec![as_graphics(v)?])
5846 } else {
5847 as_list(v)?.into_iter().map(as_graphics).collect()
5848 }
5849}
5850
5851/// `make_frame_deco` (evalUtil.ml:604): apply a curried
5852/// `point -> length -> length -> length -> graphics list` deco and coerce
5853/// the result. Depths here are already user-sign (nonnegative), so no
5854/// negate (upstream negates because ITS internal depths are nonpositive).
5855/// The deco closure's result is `list graphics` under v0.0.6, one `graphics`
5856/// collection under v0.1 — see `coerce_graphics_result`'s doc comment.
5857///
5858/// `version` is the generation the closure was CAPTURED under
5859/// (`DecoEntry::version`), not `interp.version`: this runs from `lib.rs`'s
5860/// post-page-break firing pass, which is outside every `VersionScope`
5861/// window, so `interp.version` there is the entry document's generation. In
5862/// a single-version program the two are the same value.
5863pub(crate) fn apply_deco(
5864 interp: &mut Interp,
5865 version: RustyfiVersion,
5866 deco: Value,
5867 pt: Point,
5868 w: Length,
5869 h: Length,
5870 d: Length,
5871) -> Result<Vec<GraphicsElem>, EvalError> {
5872 let v = interp.apply(deco, make_point_value(pt))?;
5873 let v = interp.apply(v, Value::Length(w))?;
5874 let v = interp.apply(v, Value::Length(h))?;
5875 let v = interp.apply(v, Value::Length(d))?;
5876 coerce_graphics_result_for(version, v)
5877}
5878
5879/// `(length * color) option` — `register-link-to-uri`/`-to-location`'s
5880/// trailing border argument (vminstdef.yaml:2755/2775's `vborderopt`),
5881/// parsed the same way [`as_color`]/[`as_page`] read a `Value::Ctor`.
5882fn as_border_option(v: Value) -> Result<Option<(Length, Color)>, EvalError> {
5883 match v {
5884 Value::Ctor(name, payload) => match (name.as_str(), payload.map(|b| *b)) {
5885 ("None", None) => Ok(None),
5886 ("Some", Some(Value::Tuple(vs))) if vs.len() == 2 => {
5887 let mut it = vs.into_iter();
5888 let w = as_length(it.next().unwrap())?;
5889 let c = as_color(it.next().unwrap())?;
5890 Ok(Some((w, c)))
5891 }
5892 (other, _) => eval_error(format!(
5893 "expected a border option (None / Some(length * color)), got variant '{other}'"
5894 )),
5895 },
5896 other => eval_error(format!("expected an option, got {}", other.type_name())),
5897 }
5898}
5899
5900/// `register-destination : string -> point -> unit` (vminstdef.yaml:2738) —
5901/// FAITHFUL: upstream `NamedDest.register` + `notify_pagebreak` collapsed
5902/// into one step, since our firing window (`fire_hooks`) already knows the
5903/// page. Errors outside that window (`annotation.ml:15`'s
5904/// `State.during_page_break` gate).
5905///
5906/// **ONE exception, a re-timing rather than a relaxation of the gate.** Inside
5907/// an eagerly-applied `inline-graphics` callback this port is running code
5908/// upstream would only run DURING page breaking (see `prim_inline_graphics`),
5909/// so refusing here would refuse a call upstream accepts. Such a call is
5910/// recorded in `Interp::pending_dests` instead, and the caller mints a
5911/// `GraphicsElem::Destination` marker from it. Outside that one window the gate
5912/// is unchanged: no page, no destination.
5913fn prim_register_destination(
5914 interp: &mut Interp,
5915 mut args: Vec<Value>,
5916) -> Result<Value, EvalError> {
5917 let (x, y) = as_point(args.pop().unwrap())?;
5918 let key = as_str(args.pop().unwrap())?;
5919 if interp.current_page.is_none() {
5920 if let Some(pending) = interp.pending_dests.as_mut() {
5921 pending.push((key, (x, y)));
5922 return Ok(Value::Unit);
5923 }
5924 }
5925 let Some(page) = interp.current_page else {
5926 return eval_error(
5927 "register-destination can only be called during page breaking \
5928 (from a page-break hook or a decoration)",
5929 );
5930 };
5931 let name = interp.dest_name(&key);
5932 // See `prim_register_link_to_uri`'s identical comment —
5933 // `register-location-frame`'s `decoR` fires this from inside a firing
5934 // block-frame deco.
5935 if let Some(deco_id) = interp.current_deco_id {
5936 interp.dest_decos.push((deco_id, name.clone()));
5937 }
5938 interp.destinations.push(NamedDest { page, name, x, y });
5939 Ok(Value::Unit)
5940}
5941
5942/// Shared body of `register-link-to-uri` / `register-link-to-location`
5943/// (vminstdef.yaml:2753/2773): pops the common `point/w/h/d/border` suffix,
5944/// builds `annotation.ml:22`'s rect `(x, y - d, x + w, y + h)` (PDF y-up
5945/// points; our depths are already nonnegative), and pushes the `Annot`.
5946fn register_link(
5947 interp: &mut Interp,
5948 mut args: Vec<Value>,
5949 prim_name: &str,
5950 make_action: impl FnOnce(&mut Interp, String) -> AnnotAction,
5951) -> Result<Value, EvalError> {
5952 let border = as_border_option(args.pop().unwrap())?;
5953 let dpt = as_length(args.pop().unwrap())?;
5954 let hgt = as_length(args.pop().unwrap())?;
5955 let wid = as_length(args.pop().unwrap())?;
5956 let (x, y) = as_point(args.pop().unwrap())?;
5957 let target = as_str(args.pop().unwrap())?;
5958 let Some(page) = interp.current_page else {
5959 return eval_error(format!(
5960 "{prim_name} can only be called during page breaking \
5961 (from a page-break hook or a decoration)"
5962 ));
5963 };
5964 let action = make_action(interp, target);
5965 // Tag this link with the DecoId of whatever deco closure is currently
5966 // firing (set by `fire_hooks`'s two `apply_deco` call sites, `lib.rs`) —
5967 // `annot.satyh`'s `\href` always calls this from inside one, so
5968 // `current_deco_id` is `Some` for every real `\href`; a hand-built test
5969 // calling this prim directly (not through a firing deco) legitimately
5970 // leaves it `None`, and the reflow backend just won't find a Frame to
5971 // wrap for that link.
5972 if let Some(deco_id) = interp.current_deco_id {
5973 interp.link_decos.push((deco_id, action.clone()));
5974 }
5975 interp.annotations.push(Annot {
5976 page,
5977 rect: (x, y - dpt, x + wid, y + hgt),
5978 action,
5979 border,
5980 });
5981 Ok(Value::Unit)
5982}
5983
5984/// `register-link-to-uri : string -> point -> length -> length -> length ->
5985/// (length * color) option -> unit` (vminstdef.yaml:2753
5986/// `BackendRegisterLinkToUri`) — FAITHFUL: see [`register_link`].
5987fn prim_register_link_to_uri(interp: &mut Interp, args: Vec<Value>) -> Result<Value, EvalError> {
5988 register_link(interp, args, "register-link-to-uri", |_, uri| {
5989 AnnotAction::Uri(uri)
5990 })
5991}
5992
5993/// `register-link-to-location : string -> point -> length -> length ->
5994/// length -> (length * color) option -> unit` (vminstdef.yaml:2773
5995/// `BackendRegisterLinkToLocation`) — FAITHFUL: same shape as
5996/// [`prim_register_link_to_uri`], but upstream's action is
5997/// `GotoName(NamedDest.get name)` — the key goes through the SAME name table
5998/// as [`prim_register_destination`], so a link to a not-(yet-)registered
5999/// destination still mints a stable name (a viewer no-ops on it), exactly
6000/// like upstream.
6001fn prim_register_link_to_location(
6002 interp: &mut Interp,
6003 args: Vec<Value>,
6004) -> Result<Value, EvalError> {
6005 register_link(interp, args, "register-link-to-location", |interp, key| {
6006 AnnotAction::GotoName(interp.dest_name(&key))
6007 })
6008}
6009
6010// ============================================================================
6011// The faithful `Value::Math` primitive layer `math.satyh` is built
6012// out of. Every `math-*` primitive here builds or consumes a
6013// `Value::Math(Rc<Vec<Math>>)` (`value.rs`'s `Math`); a `math`-typed
6014// argument may equally arrive as a `Value::MathText` (a `${…}` literal —
6015// `as_math` accepts either, reflecting a `MathText`'s `MathElem` tree into
6016// `Math` nodes on the fly, see below).
6017// ============================================================================
6018
6019use crate::value::{Math, MathElement, MathVariantStyle};
6020
6021/// `math-class` = `Value::Ctor("MathOrd"|"MathBin"|…, None)` — mirrors
6022/// `as_color`/`as_page`'s shape exactly.
6023fn as_math_kind(v: Value) -> Result<MathKind, EvalError> {
6024 match v {
6025 Value::Ctor(name, None) => match name.as_str() {
6026 "MathOrd" => Ok(MathKind::Ord),
6027 "MathBin" => Ok(MathKind::Bin),
6028 "MathRel" => Ok(MathKind::Rel),
6029 "MathOp" => Ok(MathKind::Op),
6030 "MathPunct" => Ok(MathKind::Punct),
6031 "MathOpen" => Ok(MathKind::Open),
6032 "MathClose" => Ok(MathKind::Close),
6033 "MathPrefix" => Ok(MathKind::Prefix),
6034 "MathInner" => Ok(MathKind::Inner),
6035 other => eval_error(format!("expected a math-class constructor, got '{other}'")),
6036 },
6037 other => eval_error(format!("expected a math-class, got {}", other.type_name())),
6038 }
6039}
6040
6041/// `math-char-class` = `Value::Ctor("MathItalic"|…, None)`, resolved to the
6042/// backend's [`MathCharClass`] (see `value.rs`'s
6043/// `Math::ChangeCharClass` doc comment).
6044fn as_math_char_class(v: Value) -> Result<MathCharClass, EvalError> {
6045 match v {
6046 Value::Ctor(name, None) => match name.as_str() {
6047 "MathItalic" => Ok(MathCharClass::Italic),
6048 "MathBoldItalic" => Ok(MathCharClass::BoldItalic),
6049 "MathRoman" => Ok(MathCharClass::Roman),
6050 "MathBoldRoman" => Ok(MathCharClass::BoldRoman),
6051 "MathScript" => Ok(MathCharClass::Script),
6052 "MathBoldScript" => Ok(MathCharClass::BoldScript),
6053 "MathFraktur" => Ok(MathCharClass::Fraktur),
6054 "MathBoldFraktur" => Ok(MathCharClass::BoldFraktur),
6055 "MathDoubleStruck" => Ok(MathCharClass::DoubleStruck),
6056 // V0_1-only registration — these 5
6057 // ctor names are only ever declared by `builtin_variants` under
6058 // V0_1, so under V0_0 this arm is simply never reached: the
6059 // ctor name itself is rejected earlier, at typecheck, as
6060 // unknown.
6061 "MathSansSerif" => Ok(MathCharClass::SansSerif),
6062 "MathBoldSansSerif" => Ok(MathCharClass::BoldSansSerif),
6063 "MathItalicSansSerif" => Ok(MathCharClass::ItalicSansSerif),
6064 "MathBoldItalicSansSerif" => Ok(MathCharClass::BoldItalicSansSerif),
6065 "MathTypewriter" => Ok(MathCharClass::Typewriter),
6066 other => eval_error(format!(
6067 "expected a math-char-class constructor, got '{other}'"
6068 )),
6069 },
6070 other => eval_error(format!(
6071 "expected a math-char-class, got {}",
6072 other.type_name()
6073 )),
6074 }
6075}
6076
6077/// `math-variant-char`'s 9-field style record (`value.rs`'s
6078/// `MathVariantStyle`; `prim_types::t_math_variant_style`'s runtime
6079/// counterpart).
6080fn as_math_variant_style(v: Value) -> Result<MathVariantStyle, EvalError> {
6081 match v {
6082 Value::Record(mut fields) => {
6083 let mut take = |label: &str| -> Result<String, EvalError> {
6084 match fields.remove(label) {
6085 Some(v) => as_str(v),
6086 None => eval_error(format!(
6087 "math-variant-char style record missing field '{label}'"
6088 )),
6089 }
6090 };
6091 Ok(MathVariantStyle {
6092 italic: take("italic")?,
6093 bold_italic: take("bold-italic")?,
6094 roman: take("roman")?,
6095 bold_roman: take("bold-roman")?,
6096 script: take("script")?,
6097 bold_script: take("bold-script")?,
6098 fraktur: take("fraktur")?,
6099 bold_fraktur: take("bold-fraktur")?,
6100 double_struck: take("double-struck")?,
6101 })
6102 }
6103 other => eval_error(format!(
6104 "expected a math-variant-char style record, got {}",
6105 other.type_name()
6106 )),
6107 }
6108}
6109
6110/// A `math` argument: either an already-faithful `Value::Math` (built by
6111/// another `math-*` primitive), or a `${…}` literal `Value::MathText`,
6112/// reflected into `Math` nodes on the fly via [`reflect_math_elem`] — see
6113/// `value.rs`'s `Value::Math` doc comment for why both are interchangeable.
6114fn as_math(interp: &mut Interp, v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
6115 match v {
6116 Value::Math(m) => Ok(m),
6117 Value::MathText { elems, env } => {
6118 let mut out = Vec::new();
6119 for e in elems.iter() {
6120 reflect_math_elem(interp, e, &env, &mut out)?;
6121 }
6122 Ok(Rc::new(out))
6123 }
6124 other => eval_error(format!("expected math, got {}", other.type_name())),
6125 }
6126}
6127
6128/// Reflect one elaborated `${…}` literal `MathElem` (a fused,
6129/// math-class-free form) into zero-or-more faithful `Math` atoms, pushed
6130/// onto `out` — the "less churn" resolution:
6131/// `MathElem` stays the fast path for a bare `${x^2}` in prose
6132/// (`read_inline`'s `EmbedMath` arm, untouched), and only gets reflected
6133/// into `Value::Math` at a command/primitive boundary (here — whenever a
6134/// `${…}` literal is passed where a faithful `math` value is expected).
6135/// `Cmd`/`Embed` are resolved by actually evaluating them against `env` (the
6136/// literal's own captured environment) and recursively reflecting/flattening
6137/// the result — the "Embed of a `#…` program value that itself
6138/// evaluates to math" case.
6139fn reflect_math_elem(
6140 interp: &mut Interp,
6141 elem: &MathElem,
6142 env: &Env,
6143 out: &mut Vec<Math>,
6144) -> Result<(), EvalError> {
6145 match elem {
6146 MathElem::Chars(s) => {
6147 // One atom per MATHCHAR token ("one atom per run" —
6148 // the lexer already grouped a symbol run or a single latin
6149 // digit/letter into `s`); class + codepoint remap are both
6150 // deferred to `layout_math_atom`'s `VariantCharPending` arm,
6151 // where `Context::math_class_map`/`math_variant_char_map` and
6152 // the current font are available.
6153 out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
6154 Ok(())
6155 }
6156 MathElem::Group(elems) => {
6157 for e in elems {
6158 reflect_math_elem(interp, e, env, out)?;
6159 }
6160 Ok(())
6161 }
6162 MathElem::Sub(base, script) => {
6163 let mut base_v = Vec::new();
6164 reflect_math_elem(interp, base, env, &mut base_v)?;
6165 let mut script_v = Vec::new();
6166 for e in script {
6167 reflect_math_elem(interp, e, env, &mut script_v)?;
6168 }
6169 out.push(Math::Sub(base_v, script_v));
6170 Ok(())
6171 }
6172 MathElem::Sup(base, script) => {
6173 let mut base_v = Vec::new();
6174 reflect_math_elem(interp, base, env, &mut base_v)?;
6175 let mut script_v = Vec::new();
6176 for e in script {
6177 reflect_math_elem(interp, e, env, &mut script_v)?;
6178 }
6179 out.push(Math::Sup(base_v, script_v));
6180 Ok(())
6181 }
6182 MathElem::Primes(base, n) => {
6183 let mut base_v = Vec::new();
6184 reflect_math_elem(interp, base, env, &mut base_v)?;
6185 let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
6186 out.push(Math::Sup(
6187 base_v,
6188 vec![Math::Pure(MathElement::Char {
6189 class: MathKind::Ord,
6190 big: false,
6191 chars: primes,
6192 })],
6193 ));
6194 Ok(())
6195 }
6196 MathElem::Cmd { cmd, args, .. } => {
6197 let mut v = cmd.run(env, interp)?;
6198 for arg in args {
6199 // `arg.opts` is always empty here — the math-mode application
6200 // grammar has no `?(l=e)` bundle form (see `MathElem::Cmd`'s
6201 // doc comment, `ast.rs`) — but fold through `apply_with_opts`
6202 // uniformly with `read_inline`/`read_block` regardless.
6203 let mut opt_vals = Vec::with_capacity(arg.opts.len());
6204 for (label, e) in &arg.opts {
6205 opt_vals.push((label.clone(), e.run(env, interp)?));
6206 }
6207 let arg_v = arg.arg.run(env, interp)?;
6208 v = interp.apply_with_opts(v, opt_vals, arg_v)?;
6209 }
6210 let m = as_math(interp, v)?;
6211 out.extend(m.iter().cloned());
6212 Ok(())
6213 }
6214 MathElem::Embed { expr, span: _ } => {
6215 let v = expr.run(env, interp)?;
6216 let m = as_math(interp, v)?;
6217 out.extend(m.iter().cloned());
6218 Ok(())
6219 }
6220 }
6221}
6222
6223fn single_math(m: Math) -> Value {
6224 Value::Math(Rc::new(vec![m]))
6225}
6226
6227// ============================================================================
6228// V0_1's `math-text`/`math-boxes` split + `read-math`.
6229// Everything below is additive and V0_1-only — no 0.0.6 path calls any of
6230// this (`as_math`/`reflect_math_elem`/`single_math` above stay byte-
6231// identical and untouched).
6232// ============================================================================
6233
6234fn single_math_boxes(m: Math) -> Value {
6235 Value::MathBoxes(Rc::new(vec![m]))
6236}
6237
6238/// V0_1 strict `math-boxes` extractor: accepts only `Value::MathBoxes` — a
6239/// `math-text` literal reaching a V0_1 `math-*` primitive is a genuine 0.1
6240/// type error (well-typed programs never hit this; it's the runtime
6241/// fallback for a call built by hand, e.g. from a unit test).
6242fn as_math_boxes(v: Value) -> Result<Rc<Vec<Math>>, EvalError> {
6243 match v {
6244 Value::MathBoxes(m) => Ok(m),
6245 other => eval_error(format!(
6246 "expected math-boxes, got {} (V0_1: math-text and math-boxes \
6247 are distinct types — bridge with `read-math`)",
6248 other.type_name()
6249 )),
6250 }
6251}
6252
6253/// V0_1 strict `math-text` extractor: accepts only `Value::MathText`,
6254/// returning its elements together with the environment they were captured
6255/// under (needed to evaluate any `#x` embed / math-command lookup inside).
6256fn as_math_text(v: Value) -> Result<(Rc<Vec<MathElem>>, Env), EvalError> {
6257 match v {
6258 Value::MathText { elems, env } => Ok((elems, env)),
6259 other => eval_error(format!("expected math-text, got {}", other.type_name())),
6260 }
6261}
6262
6263/// `option math-text` extractor (`None`/`Some math-text`) — `%math-attach-
6264/// scripts`' sub/sup arguments.
6265fn as_option_math_text(v: Value) -> Result<Option<(Rc<Vec<MathElem>>, Env)>, EvalError> {
6266 match v {
6267 Value::Ctor(name, None) if name == "None" => Ok(None),
6268 Value::Ctor(name, Some(payload)) if name == "Some" => {
6269 let (elems, env) = as_math_text(*payload)?;
6270 Ok(Some((elems, env)))
6271 }
6272 other => eval_error(format!(
6273 "expected an option (None/Some), got {}",
6274 other.type_name()
6275 )),
6276 }
6277}
6278
6279/// Wrap a raw (ambient-`env`-sharing) script `MathElem` slice as an `option
6280/// math-text` VALUE — `Cmd`'s uniform V0_1 calling convention always
6281/// passes its command's sub/sup arguments this way, never pre-reflected.
6282fn option_math_text_value(opt: Option<&[MathElem]>, env: &Env) -> Value {
6283 match opt {
6284 None => Value::Ctor("None".to_string(), None),
6285 Some(elems) => Value::Ctor(
6286 "Some".to_string(),
6287 Some(Box::new(Value::MathText {
6288 elems: Rc::new(elems.to_vec()),
6289 env: env.clone(),
6290 })),
6291 ),
6292 }
6293}
6294
6295/// `math-char-class` ctor-name mapper — the inverse of `as_math_char_class`
6296/// (above), used by `get-math-char-class` and by `set-math-variant-char`'s
6297/// V0_1 body (which must build a `math-char-class` VALUE to feed the
6298/// caller's selector closure).
6299fn math_char_class_ctor_name(c: MathCharClass) -> &'static str {
6300 match c {
6301 MathCharClass::Italic => "MathItalic",
6302 MathCharClass::BoldItalic => "MathBoldItalic",
6303 MathCharClass::Roman => "MathRoman",
6304 MathCharClass::BoldRoman => "MathBoldRoman",
6305 MathCharClass::Script => "MathScript",
6306 MathCharClass::BoldScript => "MathBoldScript",
6307 MathCharClass::Fraktur => "MathFraktur",
6308 MathCharClass::BoldFraktur => "MathBoldFraktur",
6309 MathCharClass::DoubleStruck => "MathDoubleStruck",
6310 MathCharClass::SansSerif => "MathSansSerif",
6311 MathCharClass::BoldSansSerif => "MathBoldSansSerif",
6312 MathCharClass::ItalicSansSerif => "MathItalicSansSerif",
6313 MathCharClass::BoldItalicSansSerif => "MathBoldItalicSansSerif",
6314 MathCharClass::Typewriter => "MathTypewriter",
6315 }
6316}
6317
6318fn math_char_class_value(c: MathCharClass) -> Value {
6319 Value::Ctor(math_char_class_ctor_name(c).to_string(), None)
6320}
6321
6322/// Port of `dev-0-1-0 src/frontend/context.ml:52-68`: bump `ctx`'s
6323/// `math_script_level` and scale `font_size`
6324/// accordingly. `Base -> Script`: scale by the font's MATH-table
6325/// `script_scale_down` (fallback `0.7`, consistent with the engine's other
6326/// fixed-fraction fallbacks). `Script -> ScriptScript`: scale by
6327/// `script_script_scale_down / script_scale_down` (fallback `5.0/7.0`).
6328/// `ScriptScript`: no-op — saturates at the deepest level, matching
6329/// upstream (no `ScriptScriptScript`).
6330fn enter_script(interp: &Interp, ctx: &Context) -> Context {
6331 let mc = MathC::of(interp, ctx);
6332 let (scale, next_level) = match ctx.math_script_level {
6333 MathScriptLevel::Base => (
6334 mc.c.map(|c| c.script_scale_down).unwrap_or(0.7),
6335 MathScriptLevel::Script,
6336 ),
6337 MathScriptLevel::Script => (
6338 mc.c.map(|c| c.script_script_scale_down / c.script_scale_down)
6339 .unwrap_or(5.0 / 7.0),
6340 MathScriptLevel::ScriptScript,
6341 ),
6342 MathScriptLevel::ScriptScript => return ctx.clone(),
6343 };
6344 Context {
6345 font_size: ctx.font_size * scale,
6346 math_script_level: next_level,
6347 ..ctx.clone()
6348 }
6349}
6350
6351/// Flatten a `Sub`/`Sup` `MathElem`'s (at most two-deep) nesting into `(base,
6352/// sub_opt, sup_opt)` — `elaborate.rs::fold_math_scripts` always builds a
6353/// both-scripts element as `Sup(Box::new(Sub(base, sub)), sup)` regardless
6354/// of source order (`x_a^b` and `x^b_a` both fold this way), so a bare
6355/// `Sub`/`Sup` and the fused two-level shape are the only cases to handle.
6356/// `elem` MUST be `MathElem::Sub` or `MathElem::Sup` — every caller already
6357/// matched on that.
6358fn flatten_math_scripts(elem: &MathElem) -> (&MathElem, Option<&[MathElem]>, Option<&[MathElem]>) {
6359 match elem {
6360 MathElem::Sup(base, sup) => match base.as_ref() {
6361 MathElem::Sub(inner, sub) => {
6362 (inner.as_ref(), Some(sub.as_slice()), Some(sup.as_slice()))
6363 }
6364 _ => (base.as_ref(), None, Some(sup.as_slice())),
6365 },
6366 MathElem::Sub(base, sub) => (base.as_ref(), Some(sub.as_slice()), None),
6367 _ => unreachable!("flatten_math_scripts called on a non-Sub/Sup MathElem"),
6368 }
6369}
6370
6371/// `attach_scripts` — mirrors upstream's
6372/// `append_sub_and_super_scripts` + its `enter_script` iteration
6373/// (`evaluator.cppo.ml:901-904`): reflects `sub_opt`/`sup_opt` (each an
6374/// already-extracted math-text payload — an ambient-env script slice for
6375/// the `reflect_scripted_v01` caller, or a genuine runtime `Value::MathText`
6376/// for the `%math-attach-scripts` primitive caller, both the SAME shape)
6377/// under `enter_script(interp, ctx)` — so commands *inside* a script observe
6378/// script-level context — then wraps `Math::Sub`/`Math::Sup` around `base`.
6379/// Both scripts present wraps as `Sup(Sub(base, sub), sup)`, matching the
6380/// shape `layout_math_atom`'s `check_subscript` already knows how to merge.
6381fn attach_scripts(
6382 interp: &mut Interp,
6383 ctx: &Context,
6384 base: Vec<Math>,
6385 sub_opt: Option<(Rc<Vec<MathElem>>, Env)>,
6386 sup_opt: Option<(Rc<Vec<MathElem>>, Env)>,
6387) -> Result<Vec<Math>, EvalError> {
6388 if sub_opt.is_none() && sup_opt.is_none() {
6389 return Ok(base);
6390 }
6391 let script_ctx = enter_script(interp, ctx);
6392 let mut cur = base;
6393 if let Some((elems, senv)) = sub_opt {
6394 let mut sub_v = Vec::new();
6395 for e in elems.iter() {
6396 reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sub_v)?;
6397 }
6398 cur = vec![Math::Sub(cur, sub_v)];
6399 }
6400 if let Some((elems, senv)) = sup_opt {
6401 let mut sup_v = Vec::new();
6402 for e in elems.iter() {
6403 reflect_math_elem_v01(interp, &script_ctx, e, &senv, &mut sup_v)?;
6404 }
6405 cur = vec![Math::Sup(cur, sup_v)];
6406 }
6407 Ok(cur)
6408}
6409
6410/// One base `MathElem` (already stripped of any wrapping `Sub`/`Sup`) plus
6411/// its (possibly absent) `sub`/`sup` script slices — the shared tail of
6412/// `reflect_math_elem_v01`'s `Sub`/`Sup` arm (after flattening) AND its bare
6413/// `Cmd` arm (`sub = sup = None`). `base` a `Cmd`: route ctx+sub+sup into
6414/// the application per the uniform V0_1 calling convention — a
6415/// SEPARATE math-command value shape does not exist in this port, so every
6416/// V0_1 math command, scripted or not, is applied exactly this way. `base`
6417/// anything else: reflect it plainly, then `attach_scripts`.
6418fn reflect_scripted_v01(
6419 interp: &mut Interp,
6420 ctx: &Context,
6421 base: &MathElem,
6422 sub: Option<&[MathElem]>,
6423 sup: Option<&[MathElem]>,
6424 env: &Env,
6425 out: &mut Vec<Math>,
6426) -> Result<(), EvalError> {
6427 if let MathElem::Cmd { cmd, args, .. } = base {
6428 let mut v = cmd.run(env, interp)?;
6429 for arg in args {
6430 // `arg.opts` is always empty here too (see the bare-`Cmd` arm
6431 // above, `reflect_math_elem`) — folded through `apply_with_opts`
6432 // uniformly regardless.
6433 let mut opt_vals = Vec::with_capacity(arg.opts.len());
6434 for (label, e) in &arg.opts {
6435 opt_vals.push((label.clone(), e.run(env, interp)?));
6436 }
6437 let arg_v = arg.arg.run(env, interp)?;
6438 v = interp.apply_with_opts(v, opt_vals, arg_v)?;
6439 }
6440 // A 0.0.6-authored math command reached from a 0.1 document: the two
6441 // generations invoke a command differently, though `math` relabels
6442 // to `math-text` and both type-check. 0.0.6 gets `\cmd a1..an ->
6443 // math` with scripts attached STRUCTURALLY afterward
6444 // (`reflect_math_elem`'s `Sub`/`Sup` arms); 0.1 applies three extra
6445 // arguments (`ctx sub sup -> math-boxes`, `sub`/`sup : math-text
6446 // option`) so a command can typeset its own scripts. Applying those
6447 // three to a 0.0.6 command used to die with `cannot apply a value of
6448 // type math as a function`.
6449 //
6450 // Discrimination here is DYNAMIC, and total: after its declared
6451 // arguments a 0.1 command is by construction still a function (ends
6452 // `.. -> context -> ..`), so a math VALUE at this point can only be
6453 // a 0.0.6 command's result — a static check can't recover the
6454 // authoring generation, since `Ast::VersionScope` governs which
6455 // `PrimDef` the body folds to and nothing on the resulting closure
6456 // records where it came from. `as_math` runs 0.0.6's own reflection
6457 // (so nested commands in a returned `${..}` literal stay 0.0.6
6458 // commands), and its `Rc<Vec<Math>>` payload is byte-for-byte what
6459 // `Value::MathBoxes` carries, so crossing needs no conversion;
6460 // untaken scripts then attach via `attach_scripts`, the same
6461 // structural `Math::Sub`/`Math::Sup` shape 0.0.6's own reflector
6462 // would have built. A 0.0.6 command still can't RESTYLE its own
6463 // scripts — it never could, in 0.0.6 either.
6464 if matches!(v, Value::Math(_) | Value::MathText { .. }) {
6465 let base_v = as_math(interp, v)?.as_ref().clone();
6466 let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6467 let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6468 let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6469 out.extend(attached);
6470 return Ok(());
6471 }
6472 v = interp.apply(v, Value::Context(Box::new(ctx.clone())))?;
6473 v = interp.apply(v, option_math_text_value(sub, env))?;
6474 v = interp.apply(v, option_math_text_value(sup, env))?;
6475 let m = as_math_boxes(v)?;
6476 out.extend(m.iter().cloned());
6477 return Ok(());
6478 }
6479 let mut base_v = Vec::new();
6480 reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6481 let sub_opt = sub.map(|s| (Rc::new(s.to_vec()), env.clone()));
6482 let sup_opt = sup.map(|s| (Rc::new(s.to_vec()), env.clone()));
6483 let attached = attach_scripts(interp, ctx, base_v, sub_opt, sup_opt)?;
6484 out.extend(attached);
6485 Ok(())
6486}
6487
6488/// V0_1 twin of `reflect_math_elem` (differs only where upstream's
6489/// `read_pdf_mode_math_text` (`evaluator.cppo.ml:887-930`) differs from
6490/// 0.0.6 reflection): `Chars`/`Group`/`Primes` are
6491/// identical to the v006 arms (class/variant resolution stays deferred to
6492/// layout, where `ctx`'s maps live); `Sub`/`Sup` flatten and route through
6493/// [`reflect_scripted_v01`]; a bare `Cmd` also routes through it (with
6494/// `sub = sup = None`) so the uniform ctx+sub+sup calling convention
6495/// applies uniformly, scripted or not; `Embed` (`#x`) requires the embedded
6496/// value to be `math-text` (it typechecked as `math-text`) and
6497/// recurses — upstream `MathTextValueGroup` (`evaluator.cppo.ml:944-949`);
6498/// scripts on an embed attach via [`reflect_scripted_v01`]'s generic
6499/// (non-`Cmd`) path, same as any other non-command base.
6500fn reflect_math_elem_v01(
6501 interp: &mut Interp,
6502 ctx: &Context,
6503 elem: &MathElem,
6504 env: &Env,
6505 out: &mut Vec<Math>,
6506) -> Result<(), EvalError> {
6507 match elem {
6508 MathElem::Chars(s) => {
6509 out.push(Math::Pure(MathElement::VariantCharPending(s.clone())));
6510 Ok(())
6511 }
6512 MathElem::Group(elems) => {
6513 for e in elems {
6514 reflect_math_elem_v01(interp, ctx, e, env, out)?;
6515 }
6516 Ok(())
6517 }
6518 MathElem::Primes(base, n) => {
6519 let mut base_v = Vec::new();
6520 reflect_math_elem_v01(interp, ctx, base, env, &mut base_v)?;
6521 let primes: String = std::iter::repeat('\u{2032}').take(*n).collect();
6522 out.push(Math::Sup(
6523 base_v,
6524 vec![Math::Pure(MathElement::Char {
6525 class: MathKind::Ord,
6526 big: false,
6527 chars: primes,
6528 })],
6529 ));
6530 Ok(())
6531 }
6532 MathElem::Sub(_, _) | MathElem::Sup(_, _) => {
6533 let (base, sub, sup) = flatten_math_scripts(elem);
6534 reflect_scripted_v01(interp, ctx, base, sub, sup, env, out)
6535 }
6536 MathElem::Cmd { .. } => reflect_scripted_v01(interp, ctx, elem, None, None, env, out),
6537 MathElem::Embed { expr, span: _ } => {
6538 let v = expr.run(env, interp)?;
6539 let (elems2, env2) = as_math_text(v)?;
6540 for e in elems2.iter() {
6541 reflect_math_elem_v01(interp, ctx, e, &env2, out)?;
6542 }
6543 Ok(())
6544 }
6545 }
6546}
6547
6548/// `read-math : context -> math-text -> math-boxes` (dev-0-1-0
6549/// vminst.ml:790-793). Reflects every element of
6550/// `mt` under `ctx` via [`reflect_math_elem_v01`], then wraps the whole run
6551/// in a single `Math::WithContext` node so `ctx` (including any color/font/
6552/// size override the caller composed onto it) reaches the layout engine —
6553/// see [`layout_math_list`]'s `Math::WithContext` arm.
6554fn prim_read_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6555 let mt = args.pop().unwrap();
6556 let ctx = as_context(args.pop().unwrap())?;
6557 let (elems, env) = as_math_text(mt)?;
6558 let mut out = Vec::new();
6559 for e in elems.iter() {
6560 reflect_math_elem_v01(interp, &ctx, e, &env, &mut out)?;
6561 }
6562 Ok(Value::MathBoxes(Rc::new(vec![Math::WithContext(
6563 Box::new(ctx),
6564 out,
6565 )])))
6566}
6567
6568/// `stringify-math : text-info -> math-text -> string` (vminst.ml:858) —
6569/// STAND-IN: the text-mode backend is out of scope for this PDF port (same
6570/// scoping note as `prim_convert_string_for_math`'s doc comment); registered
6571/// so 0.1 packages that reference it still typecheck.
6572fn prim_stringify_math(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6573 let _mt = args.pop().unwrap();
6574 let _tctx = args.pop().unwrap();
6575 eval_error(
6576 "stringify-math: the text-mode backend is out of scope for this PDF port \
6577 (see primitives.rs's prim_convert_string_for_math doc comment)"
6578 .to_string(),
6579 )
6580}
6581
6582/// `set-math-char : int -> int -> math-class -> context -> context`
6583/// (vminst.ml:59) — REAL: inserts `(char(cp_from)) -> (char(cp_to), kind)`
6584/// into `Context::math_class_map` (single-char string key, matching the
6585/// map's existing token-keying convention — see `prim_convert_string_for_
6586/// math`'s doc comment on how that map is consulted).
6587fn prim_set_math_char(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6588 let mut ctx = as_context(args.pop().unwrap())?;
6589 let kind = as_math_kind(args.pop().unwrap())?;
6590 let cpto = as_int(args.pop().unwrap())?;
6591 let cpfrom = as_int(args.pop().unwrap())?;
6592 let from = u32::try_from(cpfrom)
6593 .ok()
6594 .and_then(char::from_u32)
6595 .ok_or_else(|| EvalError {
6596 span: None,
6597 msg: format!("set-math-char: {cpfrom} is not a valid Unicode codepoint"),
6598 })?;
6599 let to = u32::try_from(cpto)
6600 .ok()
6601 .and_then(char::from_u32)
6602 .ok_or_else(|| EvalError {
6603 span: None,
6604 msg: format!("set-math-char: {cpto} is not a valid Unicode codepoint"),
6605 })?;
6606 Arc::make_mut(&mut ctx.math_class_map).insert(from.to_string(), (to.to_string(), kind));
6607 Ok(Value::Context(Box::new(ctx)))
6608}
6609
6610/// `set-math-char-class : math-char-class -> context -> context`
6611/// (vminst.ml:445) — REAL: sets `Context::math_char_class`.
6612fn prim_set_math_char_class(
6613 _interp: &mut Interp,
6614 mut args: Vec<Value>,
6615) -> Result<Value, EvalError> {
6616 let ctx = as_context(args.pop().unwrap())?;
6617 let cls = as_math_char_class(args.pop().unwrap())?;
6618 Ok(Value::Context(Box::new(Context {
6619 math_char_class: cls,
6620 ..ctx
6621 })))
6622}
6623
6624/// `get-math-char-class : context -> math-char-class` (vminst.ml:459) —
6625/// REAL: inverse of `as_math_char_class`.
6626fn prim_get_math_char_class(
6627 _interp: &mut Interp,
6628 mut args: Vec<Value>,
6629) -> Result<Value, EvalError> {
6630 let ctx = as_context(args.pop().unwrap())?;
6631 Ok(math_char_class_value(ctx.math_char_class))
6632}
6633
6634/// `embed-inline-to-math : math-class -> inline-boxes -> math-boxes`
6635/// (vminst.ml:432) — REAL data, stand-in render (`MathElement::
6636/// EmbeddedBoxes`'s doc comment).
6637fn prim_embed_inline_to_math(
6638 _interp: &mut Interp,
6639 mut args: Vec<Value>,
6640) -> Result<Value, EvalError> {
6641 let ib = as_inline_boxes(args.pop().unwrap())?;
6642 let class = as_math_kind(args.pop().unwrap())?;
6643 Ok(single_math_boxes(Math::Pure(MathElement::EmbeddedBoxes {
6644 class,
6645 boxes: ib,
6646 })))
6647}
6648
6649/// `get-math-axis-height-ratio : context -> float` (vminst.ml:1305) — REAL:
6650/// the axis-height ratio `MathC` already scales font sizes by
6651/// (`MathC::axis`).
6652fn prim_get_math_axis_height_ratio(
6653 interp: &mut Interp,
6654 mut args: Vec<Value>,
6655) -> Result<Value, EvalError> {
6656 let ctx = as_context(args.pop().unwrap())?;
6657 let ratio = MathC::of(interp, &ctx)
6658 .c
6659 .map(|c| c.axis_height)
6660 .unwrap_or(0.25);
6661 Ok(Value::Float(ratio))
6662}
6663
6664/// `%math-attach-scripts : context -> math-boxes -> option math-text ->
6665/// option math-text -> math-boxes` — hidden:
6666/// the synthesized script-attacher `val math` commands WITHOUT `with sub
6667/// sup` lower to. Body = [`attach_scripts`] directly — the same function
6668/// `reflect_scripted_v01`'s non-`Cmd` path calls.
6669fn prim_math_attach_scripts(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6670 let sup_v = args.pop().unwrap();
6671 let sub_v = args.pop().unwrap();
6672 let base_v = args.pop().unwrap();
6673 let ctx = as_context(args.pop().unwrap())?;
6674 let base = as_math_boxes(base_v)?;
6675 let sub_opt = as_option_math_text(sub_v)?;
6676 let sup_opt = as_option_math_text(sup_v)?;
6677 let out = attach_scripts(interp, &ctx, (*base).clone(), sub_opt, sup_opt)?;
6678 Ok(Value::MathBoxes(Rc::new(out)))
6679}
6680
6681/// `load-hyphenation-dictionary : string -> hyphenation` (`vminst.ml`'s
6682/// `LoadHyphenationDictionary`: upstream calls `LoadHyph.main abspath` to
6683/// build a `BCHyphenation` constant). REAL: unlike upstream, which
6684/// loads a dictionary from an on-disk `.rustyfi-hyph` path, this port has no
6685/// filesystem-loaded pattern data — the argument is instead treated as a
6686/// dictionary NAME (`"english"`/`"en-US"`, matching the `hyph-english.satyh`
6687/// stdlib package's usage) and mapped to the compiled-in `HyphenLang` tag.
6688/// An unrecognized name is a hard error rather than a silent no-op, since a
6689/// document that asks for a dictionary and gets none would silently render
6690/// without hyphenation. The heavy `hyphenation::Standard` dictionary itself
6691/// is not loaded here — only the lightweight tag is; the actual load is
6692/// deferred (load-once, cached) to `crate::hyphenation::hyphenate_word`'s
6693/// first call for that tag.
6694fn prim_load_hyphenation_dictionary(
6695 _interp: &mut Interp,
6696 mut args: Vec<Value>,
6697) -> Result<Value, EvalError> {
6698 let arg = as_str(args.pop().unwrap())?;
6699 // Accept either a bare dictionary NAME ("english"/"en-US") or an
6700 // upstream-style PATH ending `.../<name>.rustyfi-hyph` — this is what
6701 // the real, vendored `hyph-english.satyh` stand-in package actually
6702 // passes (`here ^ "/../hyph/english.rustyfi-hyph"`, mirroring
6703 // upstream's `LoadHyph.main abspath` convention). This port has no
6704 // on-disk pattern-file loader (the dictionary is compiled in via
6705 // `embed_en-us`), so the path's file stem doubles as the dictionary
6706 // name.
6707 let stem = std::path::Path::new(&arg)
6708 .file_stem()
6709 .and_then(|s| s.to_str())
6710 .unwrap_or(arg.as_str())
6711 .to_ascii_lowercase();
6712 let tag = match stem.as_str() {
6713 "english" | "en-us" => HyphenLang::EnglishUS,
6714 // en-GB (en-GB option): "british"/"en-GB"/"british-english",
6715 // mirroring the "english"/ "en-US" naming pair above.
6716 "british" | "en-gb" | "british-english" => HyphenLang::EnglishGB,
6717 _ => {
6718 return eval_error(format!(
6719 "load-hyphenation-dictionary: unknown dictionary {arg:?} \
6720 (supported: \"english\"/\"en-US\", \"british\"/\"en-GB\"/\"british-english\", \
6721 bare or as a `.../<name>.rustyfi-hyph`-style path)"
6722 ))
6723 }
6724 };
6725 Ok(Value::Hyphenation(tag))
6726}
6727
6728/// `load-unicode-char-database : string -> string -> string ->
6729/// unicode-char-database` (`vminst.ml`'s `LoadUnicodeCharDatabase`:
6730/// upstream builds `(ScriptDataMap, LineBreakDataMap)` from the three
6731/// Unicode data file paths into a `BCUnidata` constant). STAND-IN: no-op,
6732/// same rationale as `prim_load_hyphenation_dictionary` above — all three
6733/// paths are popped and dropped.
6734fn prim_load_unicode_char_database(
6735 _interp: &mut Interp,
6736 mut args: Vec<Value>,
6737) -> Result<Value, EvalError> {
6738 args.truncate(0);
6739 Ok(Value::Unit)
6740}
6741
6742/// `set-hyphenation-dictionary : hyphenation -> context -> context`
6743/// (`vminst.ml`'s setter: upstream stores `{ ctx with hyphen_dictionary }`).
6744/// REAL: writes `Context::hyphen_dictionary = Some(tag)`. This is the
6745/// ONLY way a `Context` acquires a dictionary — `Context::initial` seeds
6746/// `None`, so a document that never calls this gets no hyphenation at all.
6747fn prim_set_hyphenation_dictionary(
6748 _interp: &mut Interp,
6749 mut args: Vec<Value>,
6750) -> Result<Value, EvalError> {
6751 let ctx = as_context(args.pop().unwrap())?;
6752 let tag = as_hyphenation(args.pop().unwrap())?;
6753 Ok(Value::Context(Box::new(Context {
6754 hyphen_dictionary: Some(tag),
6755 ..ctx
6756 })))
6757}
6758
6759/// `set-unicode-char-database : unicode-char-database -> context ->
6760/// context` (`vminst.ml`'s setter: upstream stores `{ ctx with script_map;
6761/// line_break_map }`). STAND-IN no-op, same shape as
6762/// `prim_set_hyphenation_dictionary` above.
6763fn prim_set_unicode_char_database(
6764 _interp: &mut Interp,
6765 mut args: Vec<Value>,
6766) -> Result<Value, EvalError> {
6767 let ctx = args.pop().unwrap();
6768 let _db = args.pop().unwrap();
6769 Ok(ctx)
6770}
6771
6772fn prim_math_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6773 let s = as_str(args.pop().unwrap())?;
6774 let class = as_math_kind(args.pop().unwrap())?;
6775 let _ = interp;
6776 Ok(single_math(Math::Pure(MathElement::Char {
6777 class,
6778 big: false,
6779 chars: s,
6780 })))
6781}
6782
6783/// `math-char : context -> math-class -> string -> math-boxes` (dev-0-1-0
6784/// vminst.ml:358) — ctx ACCEPTED, not stored on the atom (coarse,
6785/// `read-math`-granularity context capture only).
6786fn prim_math_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6787 let s = as_str(args.pop().unwrap())?;
6788 let class = as_math_kind(args.pop().unwrap())?;
6789 let _ctx = as_context(args.pop().unwrap())?;
6790 let _ = interp;
6791 Ok(single_math_boxes(Math::Pure(MathElement::Char {
6792 class,
6793 big: false,
6794 chars: s,
6795 })))
6796}
6797
6798fn prim_math_big_char_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6799 let s = as_str(args.pop().unwrap())?;
6800 let class = as_math_kind(args.pop().unwrap())?;
6801 let _ = interp;
6802 Ok(single_math(Math::Pure(MathElement::Char {
6803 class,
6804 big: true,
6805 chars: s,
6806 })))
6807}
6808
6809/// `math-big-char : context -> math-class -> string -> math-boxes`
6810/// (vminst.ml:374) — same fork as `math-char`.
6811fn prim_math_big_char_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6812 let s = as_str(args.pop().unwrap())?;
6813 let class = as_math_kind(args.pop().unwrap())?;
6814 let _ctx = as_context(args.pop().unwrap())?;
6815 let _ = interp;
6816 Ok(single_math_boxes(Math::Pure(MathElement::Char {
6817 class,
6818 big: true,
6819 chars: s,
6820 })))
6821}
6822
6823fn prim_math_char_with_kern_v006(
6824 interp: &mut Interp,
6825 mut args: Vec<Value>,
6826) -> Result<Value, EvalError> {
6827 let kern_r = args.pop().unwrap();
6828 let kern_l = args.pop().unwrap();
6829 let s = as_str(args.pop().unwrap())?;
6830 let class = as_math_kind(args.pop().unwrap())?;
6831 let _ = interp;
6832 Ok(single_math(Math::Pure(MathElement::CharWithKern {
6833 class,
6834 big: false,
6835 chars: s,
6836 kern_l: Box::new(kern_l),
6837 kern_r: Box::new(kern_r),
6838 })))
6839}
6840
6841/// `math-char-with-kern : context -> math-class -> string -> kernf -> kernf
6842/// -> math-boxes` (vminst.ml:390).
6843fn prim_math_char_with_kern_v01(
6844 interp: &mut Interp,
6845 mut args: Vec<Value>,
6846) -> Result<Value, EvalError> {
6847 let kern_r = args.pop().unwrap();
6848 let kern_l = args.pop().unwrap();
6849 let s = as_str(args.pop().unwrap())?;
6850 let class = as_math_kind(args.pop().unwrap())?;
6851 let _ctx = as_context(args.pop().unwrap())?;
6852 let _ = interp;
6853 Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
6854 class,
6855 big: false,
6856 chars: s,
6857 kern_l: Box::new(kern_l),
6858 kern_r: Box::new(kern_r),
6859 })))
6860}
6861
6862fn prim_math_big_char_with_kern_v006(
6863 interp: &mut Interp,
6864 mut args: Vec<Value>,
6865) -> Result<Value, EvalError> {
6866 let kern_r = args.pop().unwrap();
6867 let kern_l = args.pop().unwrap();
6868 let s = as_str(args.pop().unwrap())?;
6869 let class = as_math_kind(args.pop().unwrap())?;
6870 let _ = interp;
6871 Ok(single_math(Math::Pure(MathElement::CharWithKern {
6872 class,
6873 big: true,
6874 chars: s,
6875 kern_l: Box::new(kern_l),
6876 kern_r: Box::new(kern_r),
6877 })))
6878}
6879
6880/// `math-big-char-with-kern : context -> math-class -> string -> kernf ->
6881/// kernf -> math-boxes` (vminst.ml:411) — same fork as
6882/// `math-char-with-kern`.
6883fn prim_math_big_char_with_kern_v01(
6884 interp: &mut Interp,
6885 mut args: Vec<Value>,
6886) -> Result<Value, EvalError> {
6887 let kern_r = args.pop().unwrap();
6888 let kern_l = args.pop().unwrap();
6889 let s = as_str(args.pop().unwrap())?;
6890 let class = as_math_kind(args.pop().unwrap())?;
6891 let _ctx = as_context(args.pop().unwrap())?;
6892 let _ = interp;
6893 Ok(single_math_boxes(Math::Pure(MathElement::CharWithKern {
6894 class,
6895 big: true,
6896 chars: s,
6897 kern_l: Box::new(kern_l),
6898 kern_r: Box::new(kern_r),
6899 })))
6900}
6901
6902/// `math-concat : math -> math -> math` (vminst.ml:193) — FAITHFUL: a plain
6903/// list append (`math` is always a flat sequence of atoms; see `value.rs`'s
6904/// `Value::Math` doc comment).
6905fn prim_math_concat_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6906 let m2 = args.pop().unwrap();
6907 let m1 = args.pop().unwrap();
6908 let m1 = as_math(interp, m1)?;
6909 let m2 = as_math(interp, m2)?;
6910 let mut out = (*m1).clone();
6911 out.extend((*m2).iter().cloned());
6912 Ok(Value::Math(Rc::new(out)))
6913}
6914
6915/// `math-concat : math-boxes -> math-boxes -> math-boxes` (vminst.ml:181).
6916fn prim_math_concat_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6917 let m2 = as_math_boxes(args.pop().unwrap())?;
6918 let m1 = as_math_boxes(args.pop().unwrap())?;
6919 let mut out = (*m1).clone();
6920 out.extend((*m2).iter().cloned());
6921 Ok(Value::MathBoxes(Rc::new(out)))
6922}
6923
6924fn prim_math_group_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6925 let m = args.pop().unwrap();
6926 let cls2 = as_math_kind(args.pop().unwrap())?;
6927 let cls1 = as_math_kind(args.pop().unwrap())?;
6928 let inner = as_math(interp, m)?;
6929 Ok(single_math(Math::Group(cls1, cls2, (*inner).clone())))
6930}
6931
6932/// `math-group : math-class -> math-class -> math-boxes -> math-boxes`
6933/// (vminst.ml:194).
6934fn prim_math_group_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6935 let m = as_math_boxes(args.pop().unwrap())?;
6936 let cls2 = as_math_kind(args.pop().unwrap())?;
6937 let cls1 = as_math_kind(args.pop().unwrap())?;
6938 Ok(single_math_boxes(Math::Group(cls1, cls2, (*m).clone())))
6939}
6940
6941fn prim_math_sup_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6942 let m2 = args.pop().unwrap();
6943 let m1 = args.pop().unwrap();
6944 let base = as_math(interp, m1)?;
6945 let script = as_math(interp, m2)?;
6946 Ok(single_math(Math::Sup((*base).clone(), (*script).clone())))
6947}
6948
6949/// `math-sup : context -> math-boxes -> (context -> math-boxes) ->
6950/// math-boxes` (vminst.ml:208) — the script argument is a context-taking
6951/// callback, run under `enter_script`.
6952fn prim_math_sup_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6953 let f = args.pop().unwrap();
6954 let base_v = args.pop().unwrap();
6955 let ctx = as_context(args.pop().unwrap())?;
6956 let base = as_math_boxes(base_v)?;
6957 let script_ctx = enter_script(interp, &ctx);
6958 let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6959 let script = as_math_boxes(script_v)?;
6960 Ok(single_math_boxes(Math::Sup(
6961 (*base).clone(),
6962 (*script).clone(),
6963 )))
6964}
6965
6966fn prim_math_sub_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6967 let m2 = args.pop().unwrap();
6968 let m1 = args.pop().unwrap();
6969 let base = as_math(interp, m1)?;
6970 let script = as_math(interp, m2)?;
6971 Ok(single_math(Math::Sub((*base).clone(), (*script).clone())))
6972}
6973
6974/// `math-sub : context -> math-boxes -> (context -> math-boxes) ->
6975/// math-boxes` (vminst.ml:228) — same shape as `math-sup`.
6976fn prim_math_sub_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6977 let f = args.pop().unwrap();
6978 let base_v = args.pop().unwrap();
6979 let ctx = as_context(args.pop().unwrap())?;
6980 let base = as_math_boxes(base_v)?;
6981 let script_ctx = enter_script(interp, &ctx);
6982 let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
6983 let script = as_math_boxes(script_v)?;
6984 Ok(single_math_boxes(Math::Sub(
6985 (*base).clone(),
6986 (*script).clone(),
6987 )))
6988}
6989
6990fn prim_math_frac_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
6991 let m2 = args.pop().unwrap();
6992 let m1 = args.pop().unwrap();
6993 let num = as_math(interp, m1)?;
6994 let den = as_math(interp, m2)?;
6995 Ok(single_math(Math::Fraction((*num).clone(), (*den).clone())))
6996}
6997
6998/// `math-frac : context -> math-boxes -> math-boxes -> math-boxes`
6999/// (vminst.ml:248).
7000fn prim_math_frac_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7001 let m2 = as_math_boxes(args.pop().unwrap())?;
7002 let m1 = as_math_boxes(args.pop().unwrap())?;
7003 let _ctx = as_context(args.pop().unwrap())?;
7004 Ok(single_math_boxes(Math::Fraction(
7005 (*m1).clone(),
7006 (*m2).clone(),
7007 )))
7008}
7009
7010/// `math-radical : math option -> math -> math` (vminst.ml:274) — `None`
7011/// degree is `\sqrt`; upstream's `MathRadicalWithDegree` (`\sqrt[n]`) is
7012/// unimplemented too (`math.ml:886`), carried faithfully but not rendered
7013/// specially, matching upstream by parity (see `value.rs`'s `Math::Radical`).
7014fn prim_math_radical_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7015 let m2 = args.pop().unwrap();
7016 let opt = args.pop().unwrap();
7017 let radicand = as_math(interp, m2)?;
7018 let degree = match opt {
7019 Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
7020 Value::Ctor(name, Some(payload)) if name == "Some" => {
7021 Some((*as_math(interp, *payload)?).clone())
7022 }
7023 other => {
7024 return eval_error(format!(
7025 "expected a math option (None/Some), got {}",
7026 other.type_name()
7027 ))
7028 }
7029 };
7030 Ok(single_math(Math::Radical(degree, (*radicand).clone())))
7031}
7032
7033/// `math-radical : context -> option math-boxes -> math-boxes ->
7034/// math-boxes` (vminst.ml:262).
7035fn prim_math_radical_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7036 let m2 = args.pop().unwrap();
7037 let opt = args.pop().unwrap();
7038 let _ctx = as_context(args.pop().unwrap())?;
7039 let radicand = as_math_boxes(m2)?;
7040 let degree = match opt {
7041 Value::Ctor(name, payload) if name == "None" && payload.is_none() => None,
7042 Value::Ctor(name, Some(payload)) if name == "Some" => {
7043 Some((*as_math_boxes(*payload)?).clone())
7044 }
7045 other => {
7046 return eval_error(format!(
7047 "expected a math-boxes option (None/Some), got {}",
7048 other.type_name()
7049 ))
7050 }
7051 };
7052 Ok(single_math_boxes(Math::Radical(
7053 degree,
7054 (*radicand).clone(),
7055 )))
7056}
7057
7058fn prim_math_lower_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7059 let m2 = args.pop().unwrap();
7060 let m1 = args.pop().unwrap();
7061 let base = as_math(interp, m1)?;
7062 let lower = as_math(interp, m2)?;
7063 Ok(single_math(Math::LowerLimit(
7064 (*base).clone(),
7065 (*lower).clone(),
7066 )))
7067}
7068
7069/// `math-lower : context -> math-boxes -> (context -> math-boxes) ->
7070/// math-boxes` (vminst.ml:338) — same script-callback shape as `math-sup`.
7071fn prim_math_lower_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7072 let f = args.pop().unwrap();
7073 let base_v = args.pop().unwrap();
7074 let ctx = as_context(args.pop().unwrap())?;
7075 let base = as_math_boxes(base_v)?;
7076 let script_ctx = enter_script(interp, &ctx);
7077 let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
7078 let lower = as_math_boxes(script_v)?;
7079 Ok(single_math_boxes(Math::LowerLimit(
7080 (*base).clone(),
7081 (*lower).clone(),
7082 )))
7083}
7084
7085fn prim_math_upper_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7086 let m2 = args.pop().unwrap();
7087 let m1 = args.pop().unwrap();
7088 let base = as_math(interp, m1)?;
7089 let upper = as_math(interp, m2)?;
7090 Ok(single_math(Math::UpperLimit(
7091 (*base).clone(),
7092 (*upper).clone(),
7093 )))
7094}
7095
7096/// `math-upper : context -> math-boxes -> (context -> math-boxes) ->
7097/// math-boxes` (vminst.ml:318) — same script-callback shape as `math-sup`.
7098fn prim_math_upper_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7099 let f = args.pop().unwrap();
7100 let base_v = args.pop().unwrap();
7101 let ctx = as_context(args.pop().unwrap())?;
7102 let base = as_math_boxes(base_v)?;
7103 let script_ctx = enter_script(interp, &ctx);
7104 let script_v = interp.apply(f, Value::Context(Box::new(script_ctx)))?;
7105 let upper = as_math_boxes(script_v)?;
7106 Ok(single_math_boxes(Math::UpperLimit(
7107 (*base).clone(),
7108 (*upper).clone(),
7109 )))
7110}
7111
7112/// `math-pull-in-scripts : math-class -> math-class -> (math option -> math
7113/// option -> math) -> math` (vminst.ml:368) — FAITHFUL construction: the
7114/// resolver closure is stored opaquely here, only ever invoked by
7115/// `layout_pull_in_scripts` — with
7116/// the subscript/superscript actually pulled in off an enclosing `Sub`/`Sup`
7117/// (`{scripts} m^{sup}`-style), or with `(None, None)` for the common
7118/// unscripted case (a bare `\sum`/`\int` with nothing pulled in).
7119fn prim_math_pull_in_scripts(
7120 interp: &mut Interp,
7121 mut args: Vec<Value>,
7122) -> Result<Value, EvalError> {
7123 let resolver = args.pop().unwrap();
7124 let cls2 = as_math_kind(args.pop().unwrap())?;
7125 let cls1 = as_math_kind(args.pop().unwrap())?;
7126 let _ = interp;
7127 Ok(single_math(Math::PullInScripts(
7128 cls1,
7129 cls2,
7130 Box::new(resolver),
7131 )))
7132}
7133
7134fn prim_math_color(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7135 let m = args.pop().unwrap();
7136 let color = as_color(args.pop().unwrap())?;
7137 let inner = as_math(interp, m)?;
7138 Ok(single_math(Math::ChangeColor(color, (*inner).clone())))
7139}
7140
7141fn prim_math_char_class(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7142 let m = args.pop().unwrap();
7143 let cls = as_math_char_class(args.pop().unwrap())?;
7144 let inner = as_math(interp, m)?;
7145 Ok(single_math(Math::ChangeCharClass(cls, (*inner).clone())))
7146}
7147
7148fn prim_math_variant_char(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7149 let style = as_math_variant_style(args.pop().unwrap())?;
7150 let class = as_math_kind(args.pop().unwrap())?;
7151 let _ = interp;
7152 Ok(single_math(Math::Pure(MathElement::VariantChar {
7153 class,
7154 big: false,
7155 style: Box::new(style),
7156 })))
7157}
7158
7159fn prim_math_paren_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7160 let m = args.pop().unwrap();
7161 let paren_r = args.pop().unwrap();
7162 let paren_l = args.pop().unwrap();
7163 let inner = as_math(interp, m)?;
7164 Ok(single_math(Math::Paren(
7165 Box::new(paren_l),
7166 Box::new(paren_r),
7167 (*inner).clone(),
7168 )))
7169}
7170
7171/// `math-paren : context -> paren -> paren -> math-boxes -> math-boxes`
7172/// (vminst.ml:279).
7173fn prim_math_paren_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7174 let m = args.pop().unwrap();
7175 let paren_r = args.pop().unwrap();
7176 let paren_l = args.pop().unwrap();
7177 let _ctx = as_context(args.pop().unwrap())?;
7178 let inner = as_math_boxes(m)?;
7179 Ok(single_math_boxes(Math::Paren(
7180 Box::new(paren_l),
7181 Box::new(paren_r),
7182 (*inner).clone(),
7183 )))
7184}
7185
7186fn prim_math_paren_with_middle_v006(
7187 interp: &mut Interp,
7188 mut args: Vec<Value>,
7189) -> Result<Value, EvalError> {
7190 let mlst = args.pop().unwrap();
7191 let middle = args.pop().unwrap();
7192 let paren_r = args.pop().unwrap();
7193 let paren_l = args.pop().unwrap();
7194 let items = as_list(mlst)?;
7195 let mut mlstlst = Vec::with_capacity(items.len());
7196 for it in items {
7197 mlstlst.push((*as_math(interp, it)?).clone());
7198 }
7199 Ok(single_math(Math::ParenWithMiddle(
7200 Box::new(paren_l),
7201 Box::new(paren_r),
7202 Box::new(middle),
7203 mlstlst,
7204 )))
7205}
7206
7207/// `math-paren-with-middle : context -> paren -> paren -> paren -> list
7208/// math-boxes -> math-boxes` (vminst.ml:297).
7209fn prim_math_paren_with_middle_v01(
7210 _interp: &mut Interp,
7211 mut args: Vec<Value>,
7212) -> Result<Value, EvalError> {
7213 let mlst = args.pop().unwrap();
7214 let middle = args.pop().unwrap();
7215 let paren_r = args.pop().unwrap();
7216 let paren_l = args.pop().unwrap();
7217 let _ctx = as_context(args.pop().unwrap())?;
7218 let items = as_list(mlst)?;
7219 let mut mlstlst = Vec::with_capacity(items.len());
7220 for it in items {
7221 mlstlst.push((*as_math_boxes(it)?).clone());
7222 }
7223 Ok(single_math_boxes(Math::ParenWithMiddle(
7224 Box::new(paren_l),
7225 Box::new(paren_r),
7226 Box::new(middle),
7227 mlstlst,
7228 )))
7229}
7230
7231fn prim_text_in_math(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7232 let body = args.pop().unwrap();
7233 let class = as_math_kind(args.pop().unwrap())?;
7234 let _ = interp;
7235 Ok(single_math(Math::Pure(MathElement::EmbeddedText {
7236 class,
7237 body: Box::new(body),
7238 })))
7239}
7240
7241/// `convert-string-for-math : context -> math-char-class -> string ->
7242/// string` (`vminstdef.yaml` `PrimitiveConvertStringForMath`). Faithful to
7243/// upstream: it overrides the context's `math_char_class` with the passed
7244/// `mccls`, then runs `MathContext.convert_math_variant_char`
7245/// (`types.cppo.ml:1602`) over the whole string —
7246/// 1. if the WHOLE string is a key of the (token-level) `math_class_map`
7247/// (`default_math_class_map`, e.g. `"-"` → `"−"` U+2212), return its
7248/// replacement codepoints; else
7249/// 2. remap each char via the runtime `math_variant_char_map`
7250/// (`set-math-variant-char` overrides, keyed by `(char, mccls)`) first,
7251/// then the built-in `default_math_variant_char` table (the
7252/// Mathematical-Alphanumeric-Symbols remap), keeping any char with no
7253/// mapping.
7254/// Unlike the *rendering*-path `resolve_variant_char`, this string primitive
7255/// does NOT gate on font glyph availability (upstream's
7256/// `convert_math_variant_char` never does — it returns codepoints, not
7257/// glyphs), so `abc` under `MathItalic` yields U+1D44E/44F/450 regardless of
7258/// the active font.
7259fn prim_convert_string_for_math(
7260 _interp: &mut Interp,
7261 mut args: Vec<Value>,
7262) -> Result<Value, EvalError> {
7263 let s = as_str(args.pop().unwrap())?;
7264 let class = as_math_char_class(args.pop().unwrap())?;
7265 let ctx = as_context(args.pop().unwrap())?;
7266 // (1) whole-token class-map hit -> its replacement codepoints verbatim.
7267 if let Some((target, _mk)) = ctx.math_class_map.get(&s) {
7268 return Ok(Value::Str(target.clone()));
7269 }
7270 // (2) per-char variant remap under the PASSED class (which upstream
7271 // installs as the effective `math_char_class` before converting).
7272 let mut out = String::with_capacity(s.len());
7273 for ch in s.chars() {
7274 let mapped = ctx
7275 .math_variant_char_map
7276 .get(&(ch, class))
7277 .copied()
7278 .or_else(|| default_math_variant_char(class, ch))
7279 .unwrap_or(ch);
7280 out.push(mapped);
7281 }
7282 Ok(Value::Str(out))
7283}
7284
7285/// `set-math-variant-char : math-char-class -> int -> int -> context ->
7286/// context` — FAITHFUL: installs a per-`(source char, style)`
7287/// override into `Context::math_variant_char_map`, consulted by
7288/// `resolve_variant_char` BEFORE the built-in `default_math_variant_char`
7289/// table. `Arc::make_mut` copy-on-writes the map so contexts that never
7290/// call this keep sharing one `Arc`-refcounted empty table.
7291fn prim_set_math_variant_char_v006(
7292 _interp: &mut Interp,
7293 mut args: Vec<Value>,
7294) -> Result<Value, EvalError> {
7295 let mut ctx = as_context(args.pop().unwrap())?;
7296 let cpto = as_int(args.pop().unwrap())?;
7297 let cpfrom = as_int(args.pop().unwrap())?;
7298 let cls = as_math_char_class(args.pop().unwrap())?;
7299 let from = u32::try_from(cpfrom)
7300 .ok()
7301 .and_then(char::from_u32)
7302 .ok_or_else(|| EvalError {
7303 span: None,
7304 msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
7305 })?;
7306 let to = u32::try_from(cpto)
7307 .ok()
7308 .and_then(char::from_u32)
7309 .ok_or_else(|| EvalError {
7310 span: None,
7311 msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
7312 })?;
7313 Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
7314 Ok(Value::Context(Box::new(ctx)))
7315}
7316
7317/// `set-math-variant-char : int -> (math-char-class -> int) -> context ->
7318/// context` (vminst.ml:36) — the v01 body applies the selector once per
7319/// each of the 9 `MathCharClass` values and inserts into `math_variant_
7320/// char_map` (an eager materialization of upstream's stored selector
7321/// closure; the observable map is the same either way).
7322fn prim_set_math_variant_char_v01(
7323 interp: &mut Interp,
7324 mut args: Vec<Value>,
7325) -> Result<Value, EvalError> {
7326 let mut ctx = as_context(args.pop().unwrap())?;
7327 let selector = args.pop().unwrap();
7328 let cpfrom = as_int(args.pop().unwrap())?;
7329 let from = u32::try_from(cpfrom)
7330 .ok()
7331 .and_then(char::from_u32)
7332 .ok_or_else(|| EvalError {
7333 span: None,
7334 msg: format!("set-math-variant-char: {cpfrom} is not a valid Unicode codepoint"),
7335 })?;
7336 const CLASSES: [MathCharClass; 9] = [
7337 MathCharClass::Italic,
7338 MathCharClass::BoldItalic,
7339 MathCharClass::Roman,
7340 MathCharClass::BoldRoman,
7341 MathCharClass::Script,
7342 MathCharClass::BoldScript,
7343 MathCharClass::Fraktur,
7344 MathCharClass::BoldFraktur,
7345 MathCharClass::DoubleStruck,
7346 ];
7347 for cls in CLASSES {
7348 let cpto_v = interp.apply(selector.clone(), math_char_class_value(cls))?;
7349 let cpto = as_int(cpto_v)?;
7350 let to = u32::try_from(cpto)
7351 .ok()
7352 .and_then(char::from_u32)
7353 .ok_or_else(|| EvalError {
7354 span: None,
7355 msg: format!("set-math-variant-char: {cpto} is not a valid Unicode codepoint"),
7356 })?;
7357 Arc::make_mut(&mut ctx.math_variant_char_map).insert((from, cls), to);
7358 }
7359 Ok(Value::Context(Box::new(ctx)))
7360}
7361
7362/// The `MathKind` one `MathElement` atom presents as its own boundary class
7363/// — `Char`/`CharWithKern`/`EmbeddedText`/`VariantChar` carry an explicit
7364/// `class` field; `VariantCharPending` (not yet resolved to a class
7365/// at this point in the tree) consults `ctx.math_class_map` the same way
7366/// `layout_math_atom`'s own arm does, defaulting to `Ord` when the token
7367/// isn't a whole-token class-map entry (mirrors `layout_math_atom`'s
7368/// fallback path, whose per-char variant remap never changes the class).
7369fn math_element_kind(ctx: &Context, me: &MathElement) -> MathKind {
7370 match me {
7371 MathElement::Char { class, .. }
7372 | MathElement::CharWithKern { class, .. }
7373 | MathElement::EmbeddedText { class, .. }
7374 | MathElement::VariantChar { class, .. }
7375 | MathElement::EmbeddedBoxes { class, .. } => *class,
7376 MathElement::VariantCharPending(s) => ctx
7377 .math_class_map
7378 .get(s.as_str())
7379 .map(|(_, kind)| *kind)
7380 .unwrap_or(MathKind::Ord),
7381 }
7382}
7383
7384/// Upstream `get_left_math_kind`/`get_right_math_kind` (math.ml:481-524),
7385/// fused into one direction-parameterized walk over a `&[Math]` list's
7386/// FIRST (`left = true`) or LAST (`left = false`) element: `Pure` atoms
7387/// report their own class (`math_element_kind`); `Group`/`PullInScripts`
7388/// present an explicit, possibly-asymmetric left/right pair; `Sup`/`Sub`/
7389/// `UpperLimit`/`LowerLimit` recurse into their `base`; `Fraction`/
7390/// `Radical` are always `Inner`; `Paren`/`ParenWithMiddle` are always
7391/// `Open`/`Close`; `ChangeColor`/`ChangeCharClass` recurse into `inner`; an
7392/// empty list is the synthetic `End` boundary sentinel (`MathKind::End`,
7393/// `horzBox.ml:134`) — `make_math_class_option_value` maps that to `None`,
7394/// same as upstream's own list-boundary handling.
7395fn boundary_math_kind(ctx: &Context, ms: &[Math], left: bool) -> MathKind {
7396 let m = if left { ms.first() } else { ms.last() };
7397 let Some(m) = m else {
7398 return MathKind::End;
7399 };
7400 match m {
7401 Math::Pure(me) => math_element_kind(ctx, me),
7402 Math::Group(cls1, cls2, _) => {
7403 if left {
7404 *cls1
7405 } else {
7406 *cls2
7407 }
7408 }
7409 Math::PullInScripts(cls1, cls2, _) => {
7410 if left {
7411 *cls1
7412 } else {
7413 *cls2
7414 }
7415 }
7416 Math::Sup(base, _)
7417 | Math::Sub(base, _)
7418 | Math::UpperLimit(base, _)
7419 | Math::LowerLimit(base, _) => boundary_math_kind(ctx, base, left),
7420 Math::Fraction(..) | Math::Radical(..) => MathKind::Inner,
7421 Math::Paren(..) | Math::ParenWithMiddle(..) => {
7422 if left {
7423 MathKind::Open
7424 } else {
7425 MathKind::Close
7426 }
7427 }
7428 Math::ChangeColor(_, inner) | Math::ChangeCharClass(_, inner) => {
7429 boundary_math_kind(ctx, inner, left)
7430 }
7431 // V0_1 only (`read-math`): the boundary class is a property of the
7432 // wrapped content, not of which context laid it out under, so
7433 // recurse into `inner` with the SAME probing `ctx` (mirrors the
7434 // `ChangeColor`/`ChangeCharClass` arms above, which also recurse
7435 // with the ambient `ctx` rather than switching to their own stored
7436 // state).
7437 Math::WithContext(_, inner) => boundary_math_kind(ctx, inner, left),
7438 }
7439}
7440
7441fn left_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7442 boundary_math_kind(ctx, ms, true)
7443}
7444
7445fn right_math_kind(ctx: &Context, ms: &[Math]) -> MathKind {
7446 boundary_math_kind(ctx, ms, false)
7447}
7448
7449/// `math-class option` — `MathKind::End` (the empty-list sentinel) becomes
7450/// `None`; every real class becomes `Some(<ctor>)`, round-tripping exactly
7451/// with `as_math_kind`'s ctor names.
7452fn make_math_class_option_value(mk: MathKind) -> Value {
7453 let name = match mk {
7454 MathKind::Ord => "MathOrd",
7455 MathKind::Bin => "MathBin",
7456 MathKind::Rel => "MathRel",
7457 MathKind::Op => "MathOp",
7458 MathKind::Punct => "MathPunct",
7459 MathKind::Open => "MathOpen",
7460 MathKind::Close => "MathClose",
7461 MathKind::Prefix => "MathPrefix",
7462 MathKind::Inner => "MathInner",
7463 MathKind::End => return Value::Ctor("None".to_string(), None),
7464 };
7465 Value::Ctor(
7466 "Some".to_string(),
7467 Some(Box::new(Value::Ctor(name.to_string(), None))),
7468 )
7469}
7470
7471/// `get-left-math-class : context -> math -> math-class option`.
7472fn prim_get_left_math_class_v006(
7473 interp: &mut Interp,
7474 mut args: Vec<Value>,
7475) -> Result<Value, EvalError> {
7476 let m = as_math(interp, args.pop().unwrap())?;
7477 let ctx = as_context(args.pop().unwrap())?;
7478 Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7479}
7480
7481/// `get-left-math-class : math-boxes -> math-class option` (vminst.ml:128)
7482/// — ctx DROPPED (matches upstream, which takes no context at all here).
7483/// The boundary-class probe still needs SOME `Context` to resolve an
7484/// unresolved `VariantCharPending` token's whole-token class map
7485/// (`math_element_kind`) — this port's own deferred-resolution design, not
7486/// upstream's, since upstream's `math` atoms already carry a resolved
7487/// class — so a bare default context stands in.
7488fn prim_get_left_math_class_v01(
7489 _interp: &mut Interp,
7490 mut args: Vec<Value>,
7491) -> Result<Value, EvalError> {
7492 let m = as_math_boxes(args.pop().unwrap())?;
7493 let ctx = Context::initial(Length::ZERO);
7494 Ok(make_math_class_option_value(left_math_kind(&ctx, &m)))
7495}
7496
7497/// `get-right-math-class : context -> math -> math-class option`.
7498fn prim_get_right_math_class_v006(
7499 interp: &mut Interp,
7500 mut args: Vec<Value>,
7501) -> Result<Value, EvalError> {
7502 let m = as_math(interp, args.pop().unwrap())?;
7503 let ctx = as_context(args.pop().unwrap())?;
7504 Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7505}
7506
7507/// `get-right-math-class : math-boxes -> math-class option` (vminst.ml:146)
7508/// — same fork as `get-left-math-class`.
7509fn prim_get_right_math_class_v01(
7510 _interp: &mut Interp,
7511 mut args: Vec<Value>,
7512) -> Result<Value, EvalError> {
7513 let m = as_math_boxes(args.pop().unwrap())?;
7514 let ctx = Context::initial(Length::ZERO);
7515 Ok(make_math_class_option_value(right_math_kind(&ctx, &m)))
7516}
7517
7518/// `set-math-command : [math] inline-cmd -> context -> context`
7519/// FAITHFUL: installs the command `read_inline`'s `EmbedMath` arm applies
7520/// to bare `${…}`.
7521fn prim_set_math_command(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7522 let mut ctx = as_context(args.pop().unwrap())?;
7523 let cmd = args.pop().unwrap();
7524 ctx.math_command = Some(interp.register_math_command(cmd));
7525 Ok(Value::Context(Box::new(ctx)))
7526}
7527
7528/// Resolve a font abbrev to one of the 3 base faces by name heuristic — the
7529/// only font-name resolution this port has. Shared by set-font/set-math-font.
7530fn resolve_font_abbrev(abbrev: &str) -> FontKey {
7531 let lower = abbrev.to_ascii_lowercase();
7532 if lower.contains("bold") {
7533 FONT_BOLD
7534 } else if lower.contains("it") || lower.contains("obl") || lower.contains("slant") {
7535 FONT_OBLIQUE
7536 } else {
7537 FONT_REGULAR
7538 }
7539}
7540
7541/// `set-math-font : string -> context -> context` (0.0.6
7542/// `vminstdef.yaml:1364`) — `abbrev` resolves through the font metrics
7543/// provider's registry first (the same upgrade as `set-font`), falling back
7544/// to the 3-face name heuristic, so a math OTF configured under
7545/// any abbrev (not just the CLI regular face) can be selected.
7546fn prim_set_math_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7547 let ctx = as_context(args.pop().unwrap())?;
7548 let abbrev = as_str(args.pop().unwrap())?;
7549 let math_font = interp
7550 .metrics
7551 .resolve_font_abbrev(&abbrev)
7552 .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7553 Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7554}
7555
7556/// `set-math-font : font -> context -> context` (saphe-split
7557/// `tools/gencode/vminst.ml:1462`, whose body is
7558/// `ctx with math_font_key = Some(mathkey)`) — the 0.1 arm takes the opaque
7559/// handle, so there is no abbrev left to resolve. The bundled 0.1 corpus
7560/// already calls it that way (`std-ja.satyh`'s `set-math-font
7561/// FontLatinModernMath.main`).
7562fn prim_set_math_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7563 let ctx = as_context(args.pop().unwrap())?;
7564 let math_font = as_font_key(args.pop().unwrap())?;
7565 Ok(Value::Context(Box::new(Context { math_font, ..ctx })))
7566}
7567
7568/// `load-single-font : string -> font` — LOCAL, non-upstream, V0_1-only.
7569///
7570/// Upstream has no surface name for this: `envelopeChecker.ml`'s
7571/// `check_font_envelope` synthesizes one binding per `files[]` row of a font
7572/// ENVELOPE, typed `BaseType(FontType)`, whose right-hand side is the
7573/// internal `LoadSingleFont{ path; used_as_math_font }` node — and
7574/// `evaluator.cppo.ml:427-434` evaluates that to `BaseConstant(BCFontKey
7575/// (FontInfo.add_single path))`. This port's bundled 0.1 font envelopes are
7576/// ordinary `.satyh` stand-ins (`dist-v01/packages/font-*.satyh`) rather
7577/// than envelopes the loader synthesizes bindings from, so they need a
7578/// spelling for the same step; this is it. Same LOCAL-primitive precedent as
7579/// `set-font-key`.
7580///
7581/// The argument stands in for upstream's font-file PATH: it is the port's
7582/// font-store key, resolved here through exactly the ladder `set-font` used
7583/// to run per call — the metrics provider's registry
7584/// (`FontMetrics::resolve_font_abbrev`, a real `TtfFontStore` built from
7585/// `fonts.satysfi-hash`), falling back to the 3-face name heuristic. Doing
7586/// it HERE rather than at `set-font` time is what makes the resulting `font`
7587/// a genuine handle: resolution is a pure function of the abbrev and the
7588/// provider (`&self`, no interior mutation), so moving it earlier is
7589/// observationally identical.
7590fn prim_load_single_font(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7591 let abbrev = as_str(args.pop().unwrap())?;
7592 let key = interp
7593 .metrics
7594 .resolve_font_abbrev(&abbrev)
7595 .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
7596 Ok(Value::Font(key))
7597}
7598
7599/// `space-between-maths : context -> math -> math -> inline-boxes option`
7600/// (vminst.ml:173) — STAND-IN: the real inter-atom glue is the full
7601/// `space_between_math_kinds` table (`math.ml:319-410`); always returns
7602/// `None` (no extra glue), used by `math.satyh`'s
7603/// `+align` — never invoked eagerly (that binding is a `let-block` closure).
7604fn prim_space_between_maths_v006(
7605 _interp: &mut Interp,
7606 mut args: Vec<Value>,
7607) -> Result<Value, EvalError> {
7608 let _m2 = args.pop().unwrap();
7609 let _m1 = args.pop().unwrap();
7610 let _ctx = as_context(args.pop().unwrap())?;
7611 Ok(Value::Ctor("None".to_string(), None))
7612}
7613
7614/// `space-between-maths : context -> math-boxes -> math-boxes -> inline-
7615/// boxes option` (vminst.ml:164) — shared STAND-IN body, only the extractor
7616/// forks (`as_math_boxes` vs `as_math`).
7617fn prim_space_between_maths_v01(
7618 _interp: &mut Interp,
7619 mut args: Vec<Value>,
7620) -> Result<Value, EvalError> {
7621 let _m2 = as_math_boxes(args.pop().unwrap())?;
7622 let _m1 = as_math_boxes(args.pop().unwrap())?;
7623 let _ctx = as_context(args.pop().unwrap())?;
7624 Ok(Value::Ctor("None".to_string(), None))
7625}
7626
7627/// `raise-inline : length -> inline-boxes -> inline-boxes` — STAND-IN: the
7628/// line model has no per-box vertical-offset wrapper outside
7629/// `PureHorzBox::Math`'s own per-glyph `dy` ("structural difference"
7630/// note); returns the boxes unshifted (used by `math.satyh`'s `\cases`,
7631/// never invoked eagerly).
7632fn prim_raise_inline(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7633 let ib = as_inline_boxes(args.pop().unwrap())?;
7634 let _len = as_length(args.pop().unwrap())?;
7635 Ok(Value::InlineBoxes(ib))
7636}
7637
7638/// `embed-block-breakable : context -> block-boxes -> inline-boxes`
7639/// (vminst.ml:973; upstream `HorzEmbeddedVertBreakable`) — a MANDATORY
7640/// break on both sides: upstream's `LBEmbeddedVertBreakable` resets the
7641/// width map to this breakpoint alone (`lineBreak.ml:1076-1087`), flushes
7642/// the accumulated line, emits the block as its own vertical item, then
7643/// starts a fresh line (`lineBreak.ml:809-818`).
7644///
7645/// Modelled here as a forced `Discretionary` either side of the block —
7646/// without them the block was just an inline box, so latexcmds'
7647/// `\linebreak` (`inline-fil ++ embed-block-breakable ctx (block-skip
7648/// gap)`, `latexcmds.satyh:150`) never broke: the `inline-fil` swallowed
7649/// the line's whole slack and shoved everything after it off the page
7650/// edge, silently losing it (`このように`/`使い`/`すぎると`/`読みにくく`
7651/// all vanished from the render).
7652fn prim_embed_block_breakable(
7653 _interp: &mut Interp,
7654 mut args: Vec<Value>,
7655) -> Result<Value, EvalError> {
7656 let bb = as_block_boxes(args.pop().unwrap())?;
7657 let ctx = as_context(args.pop().unwrap())?;
7658 // Embed the block inline, top-anchored (the block's FIRST line sits on the
7659 // surrounding text baseline — same as `embed-block-top`).
7660 // `make_embedded_block` splits the box's height/depth around the first line
7661 // so the pager accounts for the embedded figure's extent.
7662 let block = match make_embedded_block(ctx.paragraph_width, bb, false, true) {
7663 Value::InlineBoxes(boxes) => boxes,
7664 other => return Ok(other),
7665 };
7666 let forced = || {
7667 HorzBox::Pure(PureHorzBox::Discretionary {
7668 penalty: FORCED_BREAK_PENALTY,
7669 pre_break: Vec::new(),
7670 post_break: Vec::new(),
7671 no_break: Vec::new(),
7672 })
7673 };
7674 let mut out = vec![forced()];
7675 out.extend(block);
7676 out.push(forced());
7677 Ok(Value::InlineBoxes(out))
7678}
7679
7680/// `unite-path : path -> path -> path` — FAITHFUL: `path` is upstream's
7681/// `path list` (a list of independently-closed subpaths — see
7682/// `graphics.rs`'s `Path` doc comment), so uniting two is a plain
7683/// subpath-list append. Used by `math.satyh`'s `\norm` (two parallel bars).
7684fn prim_unite_path(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7685 let p2 = as_path(args.pop().unwrap())?;
7686 let p1 = as_path(args.pop().unwrap())?;
7687 let mut subpaths = p1.subpaths;
7688 subpaths.extend(p2.subpaths);
7689 Ok(Value::Path(Path { subpaths }))
7690}
7691
7692/// `set-min-gap-of-lines : length -> context -> context` (vminst.ml:1291) —
7693/// STAND-IN: no separate `min_gap_of_lines` field on `Context` yet (see
7694/// `set-leading`'s own comment on why IT, not this, is the baseline-distance
7695/// setter); accepted and dropped. Used by `math.satyh`'s `+math-list`, never
7696/// invoked eagerly.
7697fn prim_set_min_gap_of_lines(
7698 _interp: &mut Interp,
7699 mut args: Vec<Value>,
7700) -> Result<Value, EvalError> {
7701 let ctx = as_context(args.pop().unwrap())?;
7702 let _len = as_length(args.pop().unwrap())?;
7703 Ok(Value::Context(Box::new(ctx)))
7704}
7705
7706/// `embed-math : context -> math -> inline-boxes` (vminst.ml:520) — the
7707/// bridge to the page: the faithful, primitive-driven analog of `read_math`,
7708/// operating on a `Value::Math` tree instead. FAITHFUL for the atoms
7709/// `read_math` already draws (plain/kerned/variant chars, groups, sup/sub);
7710/// the structural forms (fraction/radical/paren/limits/pull-in-scripts/
7711/// embedded-text) get a deliberately cheap, documented stand-in rendering
7712/// rather than an error, so `${…}`-shaped math built through these
7713/// primitives is never *unusable*, just not yet typographically faithful.
7714fn prim_embed_math_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7715 let m = args.pop().unwrap();
7716 let ctx = as_context(args.pop().unwrap())?;
7717 let elems = as_math(interp, m)?;
7718 let boxed = layout_math_value(interp, &ctx, &elems)?;
7719 Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7720}
7721
7722/// `embed-math : context -> math-boxes -> inline-boxes` (vminst.ml:472) —
7723/// `as_math_boxes` then the SAME `layout_math_value` (:5165 below) — the
7724/// whole MATH-engine reuse in one primitive.
7725fn prim_embed_math_v01(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
7726 let m = args.pop().unwrap();
7727 let ctx = as_context(args.pop().unwrap())?;
7728 let elems = as_math_boxes(m)?;
7729 let boxed = layout_math_value(interp, &ctx, &elems)?;
7730 Ok(Value::InlineBoxes(vec![HorzBox::Pure(boxed)]))
7731}
7732
7733/// Lay out a faithful `&[Math]` run into one `PureHorzBox::Math`, mirroring
7734/// `read_math`'s glyph-emission shape (fixed-constant super/subscript
7735/// shift/scale, the same minimal `Bin`/`Rel` spacer) but keyed on each
7736/// atom's own EXPLICIT class (from `math-char`/`math-group`/…) rather than
7737/// `ascii_math_kind`'s inference.
7738fn layout_math_value(
7739 interp: &mut Interp,
7740 ctx: &Context,
7741 elems: &[Math],
7742) -> Result<PureHorzBox, EvalError> {
7743 let (glyphs, rules, width, _left, _right) =
7744 layout_math_list(interp, ctx, elems, ctx.font_size)?;
7745 let mut height = Length::ZERO;
7746 let mut depth = Length::ZERO;
7747 for g in &glyphs {
7748 height = height.max(g.dy + g.height);
7749 depth = depth.max(g.depth - g.dy);
7750 }
7751 // A fraction bar/radical sign is a `Fill` with no `MathGlyph` backing it
7752 // at all, so the glyph-only aggregation above would silently undercount
7753 // a run whose bar/sign extends above every glyph's own ink (e.g.
7754 // `${\sqrt{2}}`'s `l_extra` ascender). Fold every rule's own (y-up,
7755 // box-local — same frame as `MathGlyph::dy`) bounding box in too.
7756 for r in &rules {
7757 // `graphics_bbox` -> `Option`; a `None` rule (unreachable here
7758 // under 0.0.6 math rules) contributes nothing.
7759 if let Some(((_, min_y), (_, max_y))) = graphics_bbox(r) {
7760 height = height.max(max_y);
7761 depth = depth.max(-min_y);
7762 }
7763 }
7764 Ok(PureHorzBox::Math {
7765 width,
7766 height,
7767 depth,
7768 glyphs,
7769 rules,
7770 })
7771}
7772
7773/// Lay out a flat `&[Math]` list at `size`, threading inter-atom spacing
7774/// (`space_before`, a minimal spacer) and returning the glyphs (at
7775/// LOCAL coordinates starting at `x = 0`), any graphics `rules` an atom
7776/// pushed (shifted horizontally by the same running `x` a glyph gets
7777/// — `layout_math_list` never shifts an atom vertically, only
7778/// `shift_and_append`'s callers do), the total width, and the boundary
7779/// classes on either end (needed by a `Group` ancestor, which can present
7780/// different left/right classes — see `Math::Group`'s doc comment).
7781fn layout_math_list(
7782 interp: &mut Interp,
7783 ctx: &Context,
7784 elems: &[Math],
7785 size: Length,
7786) -> Result<
7787 (
7788 Vec<MathGlyph>,
7789 Vec<GraphicsElem>,
7790 Length,
7791 MathKind,
7792 MathKind,
7793 ),
7794 EvalError,
7795> {
7796 // Lay every atom out FIRST, because `normalize_math_kind` below needs each
7797 // one's NEIGHBOURS' raw classes — upstream's `convert_to_low` passes
7798 // `mkprev`/`mknext` into `convert_to_low_single` for exactly this
7799 // (`math.ml:753-765`, via `get_right_math_kind`/`get_left_math_kind`).
7800 // The layout of an atom does not depend on its class, only the SPACING
7801 // between atoms does, so splitting the walk in two moves no glyph.
7802 let mut laid: Vec<(
7803 Vec<MathGlyph>,
7804 Vec<GraphicsElem>,
7805 Length,
7806 MathKind,
7807 MathKind,
7808 )> = Vec::with_capacity(elems.len());
7809 for atom in elems {
7810 laid.push(layout_math_atom(interp, ctx, atom, size)?);
7811 }
7812
7813 let mut glyphs = Vec::new();
7814 let mut rules = Vec::new();
7815 let mut x = Length::ZERO;
7816 let mut last_kind: Option<MathKind> = None;
7817 let mut first_kind: Option<MathKind> = None;
7818 let in_script = math_in_script(ctx, size);
7819 for (i, (atom_glyphs, atom_rules, atom_width, left_raw, right_raw)) in
7820 laid.iter().cloned().enumerate()
7821 {
7822 // `mkprev`/`mknext` are the neighbours' RAW classes (upstream never
7823 // feeds a normalized class back in), and the ends of the list are
7824 // `MathEnd` — `math.ml:1270`'s `convert_to_low mathctx MathEnd MathEnd`.
7825 let prev_raw = if i == 0 { MathKind::End } else { laid[i - 1].4 };
7826 let next_raw = laid.get(i + 1).map_or(MathKind::End, |a| a.3);
7827 let left = normalize_math_kind(prev_raw, next_raw, left_raw);
7828 let right = normalize_math_kind(prev_raw, next_raw, right_raw);
7829 if let Some(prev) = last_kind {
7830 x += space_before(prev, left, in_script, size);
7831 }
7832 first_kind.get_or_insert(left);
7833 let base_x = x;
7834 for mut g in atom_glyphs {
7835 g.dx = base_x + g.dx;
7836 glyphs.push(g);
7837 }
7838 for r in &atom_rules {
7839 rules.push(shift_graphics((base_x, Length::ZERO), r));
7840 }
7841 x = base_x + atom_width;
7842 last_kind = Some(right);
7843 }
7844 let left = first_kind.unwrap_or(MathKind::Ord);
7845 let right = last_kind.unwrap_or(MathKind::Ord);
7846 Ok((glyphs, rules, x, left, right))
7847}
7848
7849/// Upstream `check_subscript` (math.ml:682-699): if a superscript base's
7850/// LAST element is itself a `Sub`, strip it — returning `(subscript script,
7851/// new base)` where the new base is the preceding elements followed by the
7852/// inner `Sub`'s own base, so `{x_1}^2` becomes one base carrying both a
7853/// sub and a sup. Recurses through `ChangeColor`/`ChangeCharClass`.
7854fn check_subscript(base: &[Math]) -> Option<(Vec<Math>, Vec<Math>)> {
7855 let (last, head) = base.split_last()?;
7856 match last {
7857 Math::Sub(inner_base, sub_script) => {
7858 let mut new_base = head.to_vec();
7859 new_base.extend(inner_base.iter().cloned());
7860 Some((sub_script.clone(), new_base))
7861 }
7862 Math::ChangeColor(color, inner) => {
7863 let (sub_script, inner_new) = check_subscript(inner)?;
7864 let mut new_base = head.to_vec();
7865 new_base.push(Math::ChangeColor(color.clone(), inner_new));
7866 Some((vec![Math::ChangeColor(color.clone(), sub_script)], new_base))
7867 }
7868 Math::ChangeCharClass(cls, inner) => {
7869 let (sub_script, inner_new) = check_subscript(inner)?;
7870 let mut new_base = head.to_vec();
7871 new_base.push(Math::ChangeCharClass(cls.clone(), inner_new));
7872 Some((
7873 vec![Math::ChangeCharClass(cls.clone(), sub_script)],
7874 new_base,
7875 ))
7876 }
7877 _ => None,
7878 }
7879}
7880
7881/// Upstream `invoke_pull_in_scripts` (math.ml:957-966): call a
7882/// `math-pull-in-scripts` resolver with the actual pulled-in scripts —
7883/// `resolver : math option -> math option -> math`, SUBSCRIPT option first,
7884/// SUPERSCRIPT second — then splice the returned math after the remaining
7885/// base as ONE `Group(cls1, cls2, …)` atom and lay the whole list out.
7886#[allow(clippy::too_many_arguments)]
7887fn layout_pull_in_scripts(
7888 interp: &mut Interp,
7889 ctx: &Context,
7890 head: &[Math],
7891 cls1: MathKind,
7892 cls2: MathKind,
7893 resolver: &Value,
7894 sub: Option<&[Math]>,
7895 sup: Option<&[Math]>,
7896 size: Length,
7897) -> Result<
7898 (
7899 Vec<MathGlyph>,
7900 Vec<GraphicsElem>,
7901 Length,
7902 MathKind,
7903 MathKind,
7904 ),
7905 EvalError,
7906> {
7907 let opt_math = |o: Option<&[Math]>| match o {
7908 Some(m) => Value::Ctor(
7909 "Some".to_string(),
7910 Some(Box::new(Value::Math(Rc::new(m.to_vec())))),
7911 ),
7912 None => Value::Ctor("None".to_string(), None),
7913 };
7914 let partial = interp.apply(resolver.clone(), opt_math(sub))?;
7915 let result = interp.apply(partial, opt_math(sup))?;
7916 let resolved = as_math(interp, result)?;
7917 let mut items: Vec<Math> = head.to_vec();
7918 items.push(Math::Group(cls1, cls2, (*resolved).clone()));
7919 layout_math_list(interp, ctx, &items, size)
7920}
7921
7922/// The metrics-probe fallback policy: resolve `c` under `ctx`'s current
7923/// `math_char_class` (checking the runtime override map first, then the
7924/// built-in `default_math_variant_char` table), but only actually EMIT the
7925/// remapped codepoint if the current font can render it
7926/// (`interp.metrics.advance` returns `Some`) — otherwise fall back to the
7927/// source char `c` (its class, from `Context::math_class_map`/
7928/// `ascii_math_kind`-style inference, is kept regardless). This is what
7929/// keeps base-14/WinAnsi documents byte-identical (`Base14Metrics` returns
7930/// `None` outside ASCII 32-126) while a math-capable TTF, or a permissive
7931/// test stub, gets the real Mathematical-Alphanumeric glyph automatically.
7932fn resolve_variant_char(interp: &Interp, ctx: &Context, c: char, size: Length) -> char {
7933 let mapped = ctx
7934 .math_variant_char_map
7935 .get(&(c, ctx.math_char_class))
7936 .copied()
7937 .or_else(|| default_math_variant_char(ctx.math_char_class, c));
7938 match mapped {
7939 Some(m) if math_char_available(interp, ctx, m, size) => m,
7940 _ => c,
7941 }
7942}
7943
7944/// Invoke ONE `paren` closure (`math.satyh`'s `paren-left`/
7945/// `paren-right`/`abs-left`/`brace-left`/…) exactly the way upstream's
7946/// `make_paren` does (`math.ml:644-649`): 5 CURRIED args in order — inner
7947/// height `h_in` (≥0), inner depth SIGNED (≤0, hence `-d_in` — this port
7948/// carries depths as non-negative magnitudes, see this function's `d_in`
7949/// param doc below), the axis height at the local size, the local
7950/// (script-scaled) size, and the current text color — then unpack the
7951/// returned `(inline-boxes, length -> length)` 2-tuple and harvest the
7952/// boxes' glyphs/rules/width via `math_boxes_of_inline_boxes` (the
7953/// graphics-harvesting sibling of `math_glyphs_of_inline_boxes`, since a
7954/// closure's delimiter is drawn `Fill`/`Stroke` ink via `inline-graphics`,
7955/// not a font glyph). The kernf itself is returned un-invoked (callers
7956/// re-derive/discard it as `math.ml:923` does for `ParenWithMiddle`'s own
7957/// middle).
7958///
7959/// `d_in`: this port's non-negative ink-depth MAGNITUDE (`inner_ink_extent`'s
7960/// second component). Upstream's own box depths are non-positive internally
7961/// (`convert_to_low`'s `dC` folds via `Length.min`, always ≤ `Length.zero`),
7962/// and `half-length` (`math.satyh:1023-1026`) computes the below-axis need
7963/// as `hgtaxis +' dpt` on that SIGNED value — so passing the magnitude
7964/// directly would OVERSIZE every delimiter below the axis (double-counts
7965/// the depth on the wrong side). Negating here is what keeps the closure's
7966/// own arithmetic faithful without changing this port's magnitude
7967/// convention everywhere else.
7968fn make_paren_run(
7969 interp: &mut Interp,
7970 ctx: &Context,
7971 paren: &Value,
7972 h_in: Length,
7973 d_in: Length,
7974 axis: Length,
7975 size: Length,
7976) -> Result<(Vec<MathGlyph>, Vec<GraphicsElem>, Length, Value), EvalError> {
7977 let mut v = paren.clone();
7978 if interp.version.math_is_split() {
7979 // 0.1 protocol (math.ml:640-642): `paren h d ictx` — (height, SIGNED
7980 // depth, context). The closure extracts fontsize / axis-ratio (via
7981 // `get-math-axis-height-ratio`) / color FROM the context instead of
7982 // receiving them as separate explicit arguments (the 0.0.6→0.1
7983 // delta, `t_paren`'s doc comment). Upstream's `ictx` is already
7984 // scaled to the local (script-level) size at this call site; this
7985 // port threads `size` as a separate parameter, so clone-and-set —
7986 // BIGGEST RISK: forgetting this silently
7987 // oversizes script-level delimiters (the closure would read the
7988 // OUTER context's font_size instead of the local scaled one).
7989 let mut c2 = ctx.clone();
7990 c2.font_size = size;
7991 let args = [
7992 Value::Length(h_in),
7993 Value::Length(-d_in),
7994 Value::Context(Box::new(c2)),
7995 ];
7996 for a in args {
7997 v = interp.apply(v, a)?;
7998 }
7999 } else {
8000 // 0.0.6 protocol.
8001 let args = [
8002 Value::Length(h_in),
8003 Value::Length(-d_in),
8004 Value::Length(axis),
8005 Value::Length(size),
8006 make_color_value(ctx.text_color),
8007 ];
8008 for a in args {
8009 v = interp.apply(v, a)?;
8010 }
8011 }
8012 let (boxes_v, kernf) = match v {
8013 Value::Tuple(mut items) if items.len() == 2 => {
8014 let kernf = items.pop().unwrap();
8015 (items.pop().unwrap(), kernf)
8016 }
8017 other => {
8018 return eval_error(format!(
8019 "math-paren: a paren closure must return (inline-boxes, length -> length), got {}",
8020 other.type_name()
8021 ))
8022 }
8023 };
8024 let boxes = as_inline_boxes(boxes_v)?;
8025 let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
8026 Ok((glyphs, rules, width, kernf))
8027}
8028
8029/// The original MATH-native stretchy-delimiter body, extracted verbatim as
8030/// the fallback `Math::Paren`/`Math::ParenWithMiddle` now take when the
8031/// closure route (`make_paren_run`, primary — upstream-faithful) errors:
8032/// every delimiter renders as a correctly-SIZED `(`/`)`/`|` regardless of
8033/// the requested paren kind (identity-wrong, but usable, for any closure
8034/// that can't be run — a synthetic/ill-shaped test closure, or a real error
8035/// from a malformed user-supplied one).
8036fn paren_variant_fallback(
8037 interp: &mut Interp,
8038 ctx: &Context,
8039 parts: Vec<(Vec<MathGlyph>, Vec<GraphicsElem>, Length)>,
8040 h_in: Length,
8041 d_in: Length,
8042 axis: Length,
8043 size: Length,
8044) -> Result<
8045 (
8046 Vec<MathGlyph>,
8047 Vec<GraphicsElem>,
8048 Length,
8049 MathKind,
8050 MathKind,
8051 ),
8052 EvalError,
8053> {
8054 let target = (h_in - axis).max(axis + d_in) * 2.0;
8055 let mut glyphs = Vec::new();
8056 let mut rules = Vec::new();
8057 let mut x = Length::ZERO;
8058 push_delimiter_glyph(interp, ctx, '(', size, target, axis, &mut glyphs, &mut x)?;
8059 for (i, (pg, pr, pw)) in parts.into_iter().enumerate() {
8060 if i > 0 {
8061 push_delimiter_glyph(interp, ctx, '|', size, target, axis, &mut glyphs, &mut x)?;
8062 }
8063 append_at(&mut glyphs, &mut rules, &mut x, pg, pr, pw);
8064 }
8065 push_delimiter_glyph(interp, ctx, ')', size, target, axis, &mut glyphs, &mut x)?;
8066 Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8067}
8068
8069/// Re-derive a paren base's TRAILING (right) delimiter's dense math
8070/// kern function by re-invoking its closure at the script-attachment site
8071/// (`superscript_kern`'s glyph-corner sampling doesn't apply to a paren
8072/// base — it has no single "last glyph" to sample italic-correction/corner
8073/// kerns off; the closure itself IS the source of truth for how much a
8074/// script should tuck into it, exactly upstream's `lp_math_kern_scheme`,
8075/// `math.ml:906`/`922`). Closures are pure (`math.satyh`'s bundled ones
8076/// have no side effects), so re-invoking with the SAME `(h_in, d_in, axis,
8077/// size)` the original `Math::Paren`/`ParenWithMiddle` layout used yields
8078/// the identical `kernf` value. Returns `None` when `base`'s last atom
8079/// isn't a paren, or when re-running its closure(s) errors (the delimiter
8080/// fallback path carries no math-kern scheme at all — `dense_kern`'s
8081/// caller then falls back to zero, matching that stand-in's own
8082/// `kerninfo _ = 0pt` shape).
8083fn paren_trailing_kernf(
8084 interp: &mut Interp,
8085 ctx: &Context,
8086 base: &[Math],
8087 size: Length,
8088) -> Option<Value> {
8089 let (r, h_in, d_in) = match base.last()? {
8090 Math::Paren(_, r, inner) => {
8091 let (g, ru, ..) = layout_math_list(interp, ctx, inner, size).ok()?;
8092 let (h, d) = inner_ink_extent(&g, &ru);
8093 (r, h, d)
8094 }
8095 Math::ParenWithMiddle(_, r, _, parts) => {
8096 let mut h = Length::ZERO;
8097 let mut d = Length::ZERO;
8098 for p in parts {
8099 let (g, ru, ..) = layout_math_list(interp, ctx, p, size).ok()?;
8100 let (ph, pd) = inner_ink_extent(&g, &ru);
8101 h = h.max(ph);
8102 d = d.max(pd);
8103 }
8104 (r, h, d)
8105 }
8106 _ => return None,
8107 };
8108 let mc = MathC::of(interp, ctx);
8109 let axis = mc.axis(size);
8110 let (_, _, _, kernf) = make_paren_run(interp, ctx, r, h_in, d_in, axis, size).ok()?;
8111 Some(kernf)
8112}
8113
8114/// `fontInfo.ml:361`'s `DenseMathKern` branch: `Length.negate (kernf
8115/// corrhgt)` — the closure returns a POSITIVE tuck amount (how far to slide
8116/// the script INTO the delimiter's hollow), and the engine negates it into
8117/// a kern (negative = closer to the previous glyph, `get_math_kern`'s own
8118/// doc comment). Any failure (wrong-shaped return, closure error) collapses
8119/// to `Length::ZERO` — no kern, not a layout error; matches
8120/// `paren_trailing_kernf`'s own `None`-on-error contract.
8121fn dense_kern(interp: &mut Interp, kernf: &Value, corrhgt: Length) -> Length {
8122 match interp.apply(kernf.clone(), Value::Length(corrhgt)) {
8123 Ok(Value::Length(l)) => -l,
8124 _ => Length::ZERO,
8125 }
8126}
8127
8128/// Lay out one `Math` atom at `size` (LOCAL coordinates, `x` starting at
8129/// 0), returning its glyphs, any graphics `rules` it pushed (only the
8130/// `Fraction`/`Radical` arms produce any; every other arm forwards its
8131/// children's), width, and left/right boundary class.
8132fn layout_math_atom(
8133 interp: &mut Interp,
8134 ctx: &Context,
8135 atom: &Math,
8136 size: Length,
8137) -> Result<
8138 (
8139 Vec<MathGlyph>,
8140 Vec<GraphicsElem>,
8141 Length,
8142 MathKind,
8143 MathKind,
8144 ),
8145 EvalError,
8146> {
8147 match atom {
8148 Math::Pure(MathElement::Char { class, big, chars })
8149 | Math::Pure(MathElement::CharWithKern {
8150 class, big, chars, ..
8151 }) => {
8152 let mut glyphs = Vec::new();
8153 let mut x = Length::ZERO;
8154 for c in chars.chars() {
8155 if *big {
8156 push_big_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8157 } else {
8158 push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8159 }
8160 }
8161 Ok((glyphs, Vec::new(), x, *class, *class))
8162 }
8163 Math::Pure(MathElement::VariantChar { class, style, .. }) => {
8164 // Select the target codepoints by the CURRENT restyling
8165 // (`Context::math_char_class`, set by `ChangeCharClass`'s
8166 // layout arm below) rather than always `style.italic` — these
8167 // are explicit per-style codepoints the caller built
8168 // (`math-variant-char`), so no metrics-probe fallback (unlike
8169 // `resolve_variant_char`): `push_char_glyph` errors like any
8170 // other explicit-codepoint atom if the font can't render it.
8171 let text = match ctx.math_char_class {
8172 MathCharClass::Italic => &style.italic,
8173 MathCharClass::BoldItalic => &style.bold_italic,
8174 MathCharClass::Roman => &style.roman,
8175 MathCharClass::BoldRoman => &style.bold_roman,
8176 MathCharClass::Script => &style.script,
8177 MathCharClass::BoldScript => &style.bold_script,
8178 MathCharClass::Fraktur => &style.fraktur,
8179 MathCharClass::BoldFraktur => &style.bold_fraktur,
8180 MathCharClass::DoubleStruck => &style.double_struck,
8181 // `MathVariantStyle` (this
8182 // 9-field record) is deliberately NOT widened to 14 fields
8183 // — it models the 0.0.6 `math-variant-char` prim's record
8184 // shape, which upstream itself never grew sans-
8185 // serif/typewriter fields for either (only `math-char-class`
8186 // itself widened, `horzBox.ml:98-113`). This arm is
8187 // unreachable in practice: `math-variant-char`/
8188 // `MathElement::VariantChar` is a V0_0-only prim
8189 // (registered `v006` only, `primitives.rs`'s prim table),
8190 // and the 5 new `MathCharClass` ctors are V0_1-only
8191 // (`prim_types.rs::math_char_class_decl`) — the two can
8192 // never co-occur. Closest-analog fallback, purely to keep
8193 // the match exhaustive.
8194 MathCharClass::SansSerif | MathCharClass::Typewriter => &style.roman,
8195 MathCharClass::ItalicSansSerif => &style.italic,
8196 MathCharClass::BoldSansSerif => &style.bold_roman,
8197 MathCharClass::BoldItalicSansSerif => &style.bold_italic,
8198 };
8199 let mut glyphs = Vec::new();
8200 let mut x = Length::ZERO;
8201 for c in text.chars() {
8202 push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8203 }
8204 Ok((glyphs, Vec::new(), x, *class, *class))
8205 }
8206 Math::Pure(MathElement::VariantCharPending(s)) => {
8207 // One MATHCHAR token, resolved now that `ctx` (font +
8208 // math_char_class + both override maps) is available: first
8209 // try the whole-TOKEN class map (`=`, `-`, `,`, … ->
8210 // (replacement, MathKind)); if the token isn't there, fall back
8211 // to a per-char variant remap (the metrics-probe policy)
8212 // with `MathKind::Ord`.
8213 let mut glyphs = Vec::new();
8214 let mut x = Length::ZERO;
8215 if let Some((target, kind)) = ctx.math_class_map.get(s.as_str()) {
8216 let kind = *kind;
8217 let all_renderable = target
8218 .chars()
8219 .all(|c| math_char_available(interp, ctx, c, size));
8220 let chosen = if all_renderable {
8221 target.clone()
8222 } else {
8223 s.clone()
8224 };
8225 for c in chosen.chars() {
8226 push_char_glyph(interp, ctx, c, size, &mut glyphs, &mut x)?;
8227 }
8228 return Ok((glyphs, Vec::new(), x, kind, kind));
8229 }
8230 for c in s.chars() {
8231 let chosen = resolve_variant_char(interp, ctx, c, size);
8232 push_char_glyph(interp, ctx, chosen, size, &mut glyphs, &mut x)?;
8233 }
8234 Ok((glyphs, Vec::new(), x, MathKind::Ord, MathKind::Ord))
8235 }
8236 Math::Pure(MathElement::EmbeddedText { class, body }) => {
8237 let v = interp.apply((**body).clone(), Value::Context(Box::new(ctx.clone())))?;
8238 let boxes = as_inline_boxes(v)?;
8239 // `math_boxes_of_inline_boxes`, not the glyphs-only walk: embedded
8240 // inline content can carry its ink as GRAPHICS rather than glyphs.
8241 // latexcmds' `\underset`/`\overset` are exactly that — they reduce
8242 // to `text-in-math (… \normal-underset …)`, which draws through
8243 // `inline-graphics` + `draw-text`. Harvesting glyphs alone kept the
8244 // box's WIDTH and threw the drawing away, so the Schrödinger-equation
8245 // example rendered as `[− + V(x)]Ψ`: a correctly-sized hole where
8246 // the fraction and its under-text should be.
8247 let (glyphs, rules, width) = math_boxes_of_inline_boxes(&boxes);
8248 Ok((glyphs, rules, width, *class, *class))
8249 }
8250 Math::Pure(MathElement::EmbeddedBoxes { class, boxes }) => {
8251 // V0_1 `embed-inline-to-math`: eager, already-materialized
8252 // boxes, so no closure application (contrast `EmbeddedText`
8253 // above) — but the same graphics-bearing content is possible.
8254 let (glyphs, rules, width) = math_boxes_of_inline_boxes(boxes);
8255 Ok((glyphs, rules, width, *class, *class))
8256 }
8257 Math::Group(cls1, cls2, inner) => {
8258 let (glyphs, rules, width, _, _) = layout_math_list(interp, ctx, inner, size)?;
8259 Ok((glyphs, rules, width, *cls1, *cls2))
8260 }
8261 Math::Sup(base, script) => {
8262 // Upstream MathSuperscript: (1) check_subscript merges a
8263 // base-tail `Sub` into one base + (sub, sup) pair;
8264 // (2) check_pull_in hands the script(s) to a base-tail
8265 // `PullInScripts` resolver.
8266 if let Some((sub_script, new_base)) = check_subscript(base) {
8267 if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) =
8268 new_base.split_last()
8269 {
8270 return layout_pull_in_scripts(
8271 interp,
8272 ctx,
8273 head,
8274 *cls1,
8275 *cls2,
8276 resolver,
8277 Some(&sub_script),
8278 Some(script),
8279 size,
8280 );
8281 }
8282 // No pull-in (`{x_1}^2`): one sub+sup pair on the same base.
8283 let (mut glyphs, mut rules, base_width, left, _) =
8284 layout_math_list(interp, ctx, &new_base, size)?;
8285 let mc = MathC::of(interp, ctx);
8286 let script_size = size * mc.script_scale();
8287 // Re-derive ONCE (a paren base's dense math
8288 // kern function, if `new_base`'s trailing atom is a paren —
8289 // `paren_trailing_kernf`'s doc comment) and reuse it for
8290 // BOTH the sup and sub kerns below, mirroring
8291 // `lp_math_kern_scheme`'s single scheme feeding both corner
8292 // attachments upstream.
8293 let paren_kernf = paren_trailing_kernf(interp, ctx, &new_base, size);
8294 // Subscripts are always cramped; the superscript inherits
8295 // the ambient cramped state unchanged (do NOT flip/reset it
8296 // here).
8297 let sub_ctx = Context {
8298 math_cramped: true,
8299 ..ctx.clone()
8300 };
8301 let (sub_glyphs, sub_rules, sub_width, _, _) =
8302 layout_math_list(interp, &sub_ctx, &sub_script, script_size)?;
8303 let (sup_glyphs, sup_rules, sup_width, _, _) =
8304 layout_math_list(interp, ctx, script, script_size)?;
8305 let (h_base, d_base) = inner_ink_extent(&glyphs, &rules);
8306 let (_, d_sup) = inner_ink_extent(&sup_glyphs, &sup_rules);
8307 let (h_sub, _) = inner_ink_extent(&sub_glyphs, &sub_rules);
8308 let sup_shift_raw = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
8309 let sub_shift_raw = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
8310 let (sup_shift, sub_shift) = mc.correct_script_gap(
8311 ctx.font_size,
8312 d_sup,
8313 h_sub,
8314 sup_shift_raw,
8315 sub_shift_raw,
8316 );
8317 let kern = match &paren_kernf {
8318 Some(kf) => dense_kern(interp, kf, sup_shift - d_sup),
8319 None => superscript_kern(
8320 interp,
8321 ctx,
8322 size,
8323 script_size,
8324 &glyphs,
8325 &sup_glyphs,
8326 sup_shift,
8327 h_base,
8328 d_sup,
8329 ),
8330 };
8331 let sub_kern = paren_kernf
8332 .as_ref()
8333 .map(|kf| dense_kern(interp, kf, h_sub - d_base))
8334 .unwrap_or(Length::ZERO);
8335 shift_and_append(
8336 &mut glyphs,
8337 &mut rules,
8338 sub_glyphs,
8339 sub_rules,
8340 base_width + sub_kern,
8341 -sub_shift,
8342 );
8343 shift_and_append(
8344 &mut glyphs,
8345 &mut rules,
8346 sup_glyphs,
8347 sup_rules,
8348 base_width + kern,
8349 sup_shift,
8350 );
8351 return Ok((
8352 glyphs,
8353 rules,
8354 base_width + (sub_kern + sub_width).max(kern + sup_width),
8355 left,
8356 MathKind::Ord,
8357 ));
8358 }
8359 if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
8360 return layout_pull_in_scripts(
8361 interp,
8362 ctx,
8363 head,
8364 *cls1,
8365 *cls2,
8366 resolver,
8367 None,
8368 Some(script),
8369 size,
8370 );
8371 }
8372 let (mut glyphs, mut rules, base_width, left, _) =
8373 layout_math_list(interp, ctx, base, size)?;
8374 let mc = MathC::of(interp, ctx);
8375 let script_size = size * mc.script_scale();
8376 let (script_glyphs, script_rules, script_width, _, _) =
8377 layout_math_list(interp, ctx, script, script_size)?;
8378 let (h_base, _) = inner_ink_extent(&glyphs, &rules);
8379 let (_, d_sup) = inner_ink_extent(&script_glyphs, &script_rules);
8380 let sup_shift = mc.sup_shift_clamped(ctx.font_size, h_base, d_sup);
8381 // A paren base has no italic correction / glyph
8382 // corner kern to sample (`superscript_kern`'s own last-glyph
8383 // sampling would hit the INNER run's last glyph, not the
8384 // delimiter) — its closure's dense kern REPLACES
8385 // `superscript_kern` outright rather than adding to it.
8386 let kern = match paren_trailing_kernf(interp, ctx, base, size) {
8387 Some(kf) => dense_kern(interp, &kf, sup_shift - d_sup),
8388 None => superscript_kern(
8389 interp,
8390 ctx,
8391 size,
8392 script_size,
8393 &glyphs,
8394 &script_glyphs,
8395 sup_shift,
8396 h_base,
8397 d_sup,
8398 ),
8399 };
8400 shift_and_append(
8401 &mut glyphs,
8402 &mut rules,
8403 script_glyphs,
8404 script_rules,
8405 base_width + kern,
8406 sup_shift,
8407 );
8408 Ok((
8409 glyphs,
8410 rules,
8411 base_width + kern + script_width,
8412 left,
8413 MathKind::Ord,
8414 ))
8415 }
8416 Math::Sub(base, script) => {
8417 // Upstream MathSubscript: a `PullInScripts` at the base list's
8418 // TAIL receives the subscript itself instead of a corner script.
8419 if let Some((Math::PullInScripts(cls1, cls2, resolver), head)) = base.split_last() {
8420 return layout_pull_in_scripts(
8421 interp,
8422 ctx,
8423 head,
8424 *cls1,
8425 *cls2,
8426 resolver,
8427 Some(script),
8428 None,
8429 size,
8430 );
8431 }
8432 let (mut glyphs, mut rules, base_width, left, _) =
8433 layout_math_list(interp, ctx, base, size)?;
8434 let mc = MathC::of(interp, ctx);
8435 let script_size = size * mc.script_scale();
8436 // Subscripts are always cramped.
8437 let sub_ctx = Context {
8438 math_cramped: true,
8439 ..ctx.clone()
8440 };
8441 let (script_glyphs, script_rules, script_width, _, _) =
8442 layout_math_list(interp, &sub_ctx, script, script_size)?;
8443 let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8444 let (h_sub, _) = inner_ink_extent(&script_glyphs, &script_rules);
8445 let sub_shift = mc.sub_shift_clamped(ctx.font_size, d_base, h_sub);
8446 // Non-paren subscripts carry no kern (`kern = Length::ZERO`); a
8447 // paren base's closure supplies one via `paren_trailing_kernf`'s
8448 // `Some` arm.
8449 let kern = match paren_trailing_kernf(interp, ctx, base, size) {
8450 Some(kf) => dense_kern(interp, &kf, h_sub - d_base),
8451 None => Length::ZERO,
8452 };
8453 shift_and_append(
8454 &mut glyphs,
8455 &mut rules,
8456 script_glyphs,
8457 script_rules,
8458 base_width + kern,
8459 -sub_shift,
8460 );
8461 Ok((
8462 glyphs,
8463 rules,
8464 base_width + kern + script_width,
8465 left,
8466 MathKind::Ord,
8467 ))
8468 }
8469 Math::ChangeColor(_, inner) => {
8470 // STAND-IN: color restyling doesn't affect glyph rendering yet
8471 // — just render the content.
8472 let (glyphs, rules, width, left, right) = layout_math_list(interp, ctx, inner, size)?;
8473 Ok((glyphs, rules, width, left, right))
8474 }
8475 Math::ChangeCharClass(cls, inner) => {
8476 // Lay `inner` out under a
8477 // context with `math_char_class` set to `cls`, which is what
8478 // `VariantCharPending`/`VariantChar`'s arms above consult.
8479 let ctx2 = Context {
8480 math_char_class: *cls,
8481 ..ctx.clone()
8482 };
8483 let (glyphs, rules, width, left, right) = layout_math_list(interp, &ctx2, inner, size)?;
8484 Ok((glyphs, rules, width, left, right))
8485 }
8486 Math::Fraction(num, den) => {
8487 // Real numerator/denominator placement (`math.ml:574-594`
8488 // `numerator_baseline_height`/ `denominator_baseline_depth`)
8489 // plus a bar `Fill` — replaces the ASCII "num / den" stand-in.
8490 // `num`/`den` are laid out at the SAME `size` as this atom (no
8491 // script-scale reduction — a fraction's own
8492 // numerator/denominator aren't scripts, matching upstream's
8493 // `convert_to_low` call with the ambient `mathctx` unchanged).
8494 let (num_glyphs, num_rules, num_w, ..) = layout_math_list(interp, ctx, num, size)?;
8495 // The denominator is always cramped; the numerator inherits the
8496 // ambient cramped state unchanged.
8497 let den_ctx = Context {
8498 math_cramped: true,
8499 ..ctx.clone()
8500 };
8501 let (den_glyphs, den_rules, den_w, ..) = layout_math_list(interp, &den_ctx, den, size)?;
8502 let w = num_w.max(den_w);
8503 // Center the narrower of the two over/under the wider
8504 // (`math.ml:1140-1155`'s symmetric padding).
8505 let num_dx = (w - num_w) * 0.5;
8506 let den_dx = (w - den_w) * 0.5;
8507 let (_, d_numer) = inner_ink_extent(&num_glyphs, &num_rules);
8508 let (h_denom, _) = inner_ink_extent(&den_glyphs, &den_rules);
8509 let mc = MathC::of(interp, ctx);
8510 let numer_shift = mc.frac_numer_shift(size, d_numer);
8511 let denom_shift = mc.frac_denom_shift(size, h_denom);
8512 let axis = mc.axis(size);
8513 let rule = mc.frac_rule(size);
8514 let mut glyphs = Vec::new();
8515 let mut rules = Vec::new();
8516 // `num dy>0` (raised above the axis), `den dy<0` (`frac_denom_
8517 // shift` is already signed negative — see that method's doc
8518 // comment) — both applied via the SAME up-positive `dy_shift`
8519 // `shift_and_append` uses for Sup/Sub.
8520 shift_and_append(
8521 &mut glyphs,
8522 &mut rules,
8523 num_glyphs,
8524 num_rules,
8525 num_dx,
8526 numer_shift,
8527 );
8528 shift_and_append(
8529 &mut glyphs,
8530 &mut rules,
8531 den_glyphs,
8532 den_rules,
8533 den_dx,
8534 denom_shift,
8535 );
8536 // The bar itself: `rect x∈[0,w], y∈[axis·s, axis·s+rule·s]`
8537 // (a deliberate simplification of
8538 // upstream's own `Rectangle((xpos, ypos+h_bar+t_bar/2), (wid,
8539 // t_bar))`, which centers the rule on its OWN half-thickness
8540 // rather than sitting flush on the axis; this port picks the
8541 // simpler flush-on-axis placement instead).
8542 rules.push(GraphicsElem::Fill(
8543 ctx.text_color,
8544 rect_path((Length::ZERO, axis), (w, rule)),
8545 ));
8546 Ok((glyphs, rules, w, MathKind::Inner, MathKind::Inner))
8547 }
8548 Math::Radical(_degree, inner) => {
8549 // Real bar metrics (`math.ml:620-626` `radical_bar_
8550 // metrics`) plus a ported `default_radical` checkmark `Fill`
8551 // (`primitives.cppo.ml:311-355`) and an overbar rect `Fill` —
8552 // replaces the U+221A stand-in. `RadicalWithDegree` (`_degree =
8553 // Some(..)`, `\sqrt[n]{..}`) stays unimplemented — the degree is
8554 // carried faithfully in the
8555 // `Math` value but silently NOT drawn, matching upstream's own
8556 // parity note (`math.ml:886-899`'s `failwith "unsupported"` is
8557 // upstream's harder failure mode; this port's own stand-in
8558 // policy already chose "render the radicand
8559 // without the degree" over erroring, unchanged since).
8560 // The radicand is always cramped.
8561 let radicand_ctx = Context {
8562 math_cramped: true,
8563 ..ctx.clone()
8564 };
8565 let (inner_glyphs, inner_rules, inner_w, ..) =
8566 layout_math_list(interp, &radicand_ctx, inner, size)?;
8567 let (h_cont, d_cont) = inner_ink_extent(&inner_glyphs, &inner_rules);
8568 let mc = MathC::of(interp, ctx);
8569 let (h_bar, t_bar, l_extra) = mc.radical_bar_metrics(size, h_cont);
8570 // `_nonnegdpt` (the sign's own, slightly deeper, ink extent —
8571 // `default_radical`'s downward checkmark stroke pads `d_cont` by
8572 // `size*0.1`, upstream's own `nonnegdpt`) isn't threaded into
8573 // this atom's reported `depth` directly: unlike upstream's own
8574 // `d_whole = d_cont` (`math.ml:884`, a "temporary" simplification
8575 // per its own comment there), this port's `layout_math_value`
8576 // folds every rule's `graphics_bbox` into the OUTER box's
8577 // height/depth (a correctness fix, `PureHorzBox::Math`'s doc
8578 // comment), so the sign's real ink depth reaches the top-level
8579 // box automatically THROUGH the drawn `Fill` — no separate
8580 // manual accounting needed here.
8581 let (sign_path, sign_w, _nonnegdpt) = radical_sign_geometry(size, h_bar, t_bar, d_cont);
8582 let mut rules = vec![GraphicsElem::Fill(ctx.text_color, sign_path)];
8583 // Overbar + radicand share the same x-range right after the
8584 // sign (`math.ml:1163-1176`'s `hbbar`/`hbback`/`hblstC`); the
8585 // radicand itself stays at `dy = 0` (its own baseline), exactly
8586 // upstream — `h_bar` already clears it via the vertical-gap add
8587 // in `radical_bar_metrics`, so no raise is needed here.
8588 rules.push(GraphicsElem::Fill(
8589 ctx.text_color,
8590 rect_path((sign_w, h_bar), (inner_w, t_bar)),
8591 ));
8592 // `l_extra`: the extra ascender ABOVE the bar this run reports
8593 // to its container (upstream `h_whole = h_rad +% l_extra`,
8594 // `math.ml:882`) — no ink of its own, just headroom, so there's
8595 // no glyph/fill shape to naturally carry it. A single-point
8596 // "extent marker" `Fill` (a subpath with a `move_to` and no
8597 // further segments paints nothing — PDF's `f` on a degenerate
8598 // zero-length path is a no-op) reports it through the SAME
8599 // `graphics_bbox` fold `layout_math_value` already does for
8600 // every rule, without adding a new return channel just for this
8601 // one field.
8602 rules.push(GraphicsElem::Fill(
8603 ctx.text_color,
8604 Path {
8605 subpaths: vec![Subpath {
8606 start: (Length::ZERO, h_bar + t_bar + l_extra),
8607 segs: Vec::new(),
8608 closing: Closing::Open,
8609 }],
8610 },
8611 ));
8612 let mut glyphs = Vec::new();
8613 for mut g in inner_glyphs {
8614 g.dx = sign_w + g.dx;
8615 glyphs.push(g);
8616 }
8617 for r in &inner_rules {
8618 rules.push(shift_graphics((sign_w, Length::ZERO), r));
8619 }
8620 Ok((
8621 glyphs,
8622 rules,
8623 sign_w + inner_w,
8624 MathKind::Inner,
8625 MathKind::Inner,
8626 ))
8627 }
8628 Math::Paren(l, r, inner) => {
8629 // PRIMARY route is upstream's own `make_paren` closure
8630 // invocation (`math.ml:644-649`, `make_paren_run` above) —
8631 // identity (a `\paren` drawing round parens vs. an `\abs`
8632 // drawing vertical bars, etc.) lives ENTIRELY in the `l`/`r`
8633 // closures (`math.satyh`'s `paren-left`/`abs-left`/…), so
8634 // running them is what makes different delimiter kinds actually
8635 // look different. Falls back to the MATH-native
8636 // stretchy-variant stand-in (`paren_variant_fallback`) only if
8637 // either closure errors (synthetic/ill-shaped test closures, or
8638 // a real user error) — that fallback's own delimiter kind is
8639 // always `(`/`)` regardless of what was requested. Inner is laid
8640 // out OUTSIDE the closure
8641 // route so an inner layout error still propagates normally
8642 // (only closure-route errors trigger the fallback); splice
8643 // order `lg ++ inner ++ rg` matches upstream's own
8644 // `LowMathParen(lpL, lpR, lmC)` (`math.ml:909`).
8645 let (inner_glyphs, inner_rules, inner_w, ..) =
8646 layout_math_list(interp, ctx, inner, size)?;
8647 let (h_in, d_in) = inner_ink_extent(&inner_glyphs, &inner_rules);
8648 let mc = MathC::of(interp, ctx);
8649 let axis = mc.axis(size);
8650 let closure_route =
8651 make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8652 let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8653 Ok((left, right))
8654 });
8655 match closure_route {
8656 Ok(((lg, lr, lw, _), (rg, rr, rw, _))) => {
8657 let mut glyphs = Vec::new();
8658 let mut rules = Vec::new();
8659 let mut x = Length::ZERO;
8660 append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8661 append_at(
8662 &mut glyphs,
8663 &mut rules,
8664 &mut x,
8665 inner_glyphs,
8666 inner_rules,
8667 inner_w,
8668 );
8669 append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8670 Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8671 }
8672 Err(_) => paren_variant_fallback(
8673 interp,
8674 ctx,
8675 vec![(inner_glyphs, inner_rules, inner_w)],
8676 h_in,
8677 d_in,
8678 axis,
8679 size,
8680 ),
8681 }
8682 }
8683 Math::ParenWithMiddle(l, r, m, mlstlst) => {
8684 // Same closure-primary/fallback policy as `Math::Paren`,
8685 // but ONE shared `(h_in, d_in)` over every part (the tallest
8686 // part's ink drives the size of every delimiter, including the
8687 // middle separator(s)) — mirrors upstream's own
8688 // `MathParenWithMiddle` fold (`math.ml:912-916`). The middle
8689 // closure's own kernf is DISCARDED (`math.ml:923`: `let
8690 // (hblstmiddle, _) = make_paren mathctx middle hC dC in ...`) —
8691 // a separator never tucks a script into itself.
8692 let mut parts = Vec::with_capacity(mlstlst.len());
8693 let mut h_in = Length::ZERO;
8694 let mut d_in = Length::ZERO;
8695 for part in mlstlst {
8696 let (part_glyphs, part_rules, part_w, ..) =
8697 layout_math_list(interp, ctx, part, size)?;
8698 let (h, d) = inner_ink_extent(&part_glyphs, &part_rules);
8699 h_in = h_in.max(h);
8700 d_in = d_in.max(d);
8701 parts.push((part_glyphs, part_rules, part_w));
8702 }
8703 let mc = MathC::of(interp, ctx);
8704 let axis = mc.axis(size);
8705 let closure_route =
8706 make_paren_run(interp, ctx, l, h_in, d_in, axis, size).and_then(|left| {
8707 let right = make_paren_run(interp, ctx, r, h_in, d_in, axis, size)?;
8708 let middle = make_paren_run(interp, ctx, m, h_in, d_in, axis, size)?;
8709 Ok((left, right, middle))
8710 });
8711 match closure_route {
8712 Ok(((lg, lr, lw, _), (rg, rr, rw, _), (mg, mr, mw, _))) => {
8713 let mut glyphs = Vec::new();
8714 let mut rules = Vec::new();
8715 let mut x = Length::ZERO;
8716 append_at(&mut glyphs, &mut rules, &mut x, lg, lr, lw);
8717 for (i, (part_glyphs, part_rules, part_w)) in parts.into_iter().enumerate() {
8718 if i > 0 {
8719 append_at(&mut glyphs, &mut rules, &mut x, mg.clone(), mr.clone(), mw);
8720 }
8721 append_at(
8722 &mut glyphs,
8723 &mut rules,
8724 &mut x,
8725 part_glyphs,
8726 part_rules,
8727 part_w,
8728 );
8729 }
8730 append_at(&mut glyphs, &mut rules, &mut x, rg, rr, rw);
8731 Ok((glyphs, rules, x, MathKind::Open, MathKind::Close))
8732 }
8733 Err(_) => paren_variant_fallback(interp, ctx, parts, h_in, d_in, axis, size),
8734 }
8735 }
8736 Math::UpperLimit(base, upper) => {
8737 let (mut glyphs, mut rules, base_width, left, right) =
8738 layout_math_list(interp, ctx, base, size)?;
8739 let mc = MathC::of(interp, ctx);
8740 let script_size = size * mc.script_scale();
8741 let (script_glyphs, script_rules, script_width, _, _) =
8742 layout_math_list(interp, ctx, upper, script_size)?;
8743 let (h_base, _) = inner_ink_extent(&glyphs, &rules);
8744 let (_, d_up) = inner_ink_extent(&script_glyphs, &script_rules);
8745 let up_shift = mc.upper_limit_shift(ctx.font_size, h_base, d_up);
8746 // A LIMIT is CENTERED over its base, not set beside it
8747 // (`math.ml:1219-1231`: upstream pads the narrower of the two with
8748 // half the difference on each side, so the whole is
8749 // `max(w_base, w_up)` wide). Placing it at `base_width` — i.e. to
8750 // the right, widening the box to the SUM — set `\sum_a^b`'s limits
8751 // off the operator's shoulder instead of above and below it.
8752 let (base_dx, script_dx) = center_offsets(base_width, script_width);
8753 shift_existing(&mut glyphs, &mut rules, base_dx);
8754 shift_and_append(
8755 &mut glyphs,
8756 &mut rules,
8757 script_glyphs,
8758 script_rules,
8759 script_dx,
8760 up_shift,
8761 );
8762 Ok((glyphs, rules, base_width.max(script_width), left, right))
8763 }
8764 Math::LowerLimit(base, lower) => {
8765 let (mut glyphs, mut rules, base_width, left, right) =
8766 layout_math_list(interp, ctx, base, size)?;
8767 let mc = MathC::of(interp, ctx);
8768 let script_size = size * mc.script_scale();
8769 let (script_glyphs, script_rules, script_width, _, _) =
8770 layout_math_list(interp, ctx, lower, script_size)?;
8771 let (_, d_base) = inner_ink_extent(&glyphs, &rules);
8772 let (h_low, _) = inner_ink_extent(&script_glyphs, &script_rules);
8773 let low_shift = mc.lower_limit_shift(ctx.font_size, d_base, h_low);
8774 // Centered under the base — see the `UpperLimit` arm above.
8775 let (base_dx, script_dx) = center_offsets(base_width, script_width);
8776 shift_existing(&mut glyphs, &mut rules, base_dx);
8777 shift_and_append(
8778 &mut glyphs,
8779 &mut rules,
8780 script_glyphs,
8781 script_rules,
8782 script_dx,
8783 -low_shift,
8784 );
8785 Ok((glyphs, rules, base_width.max(script_width), left, right))
8786 }
8787 Math::PullInScripts(cls1, cls2, resolver) => {
8788 // Not consumed by an enclosing Sub/Sup (bare `\sum` with no
8789 // scripts): resolver gets (None, None).
8790 layout_pull_in_scripts(interp, ctx, &[], *cls1, *cls2, resolver, None, None, size)
8791 }
8792 // V0_1 only (`read-math`): lay `inner` out
8793 // with ambient context = the STORED context, and size = the
8794 // stored context's OWN `font_size` — an ABSOLUTE override, not a
8795 // further multiply of the caller's `size`. This is deliberate: a
8796 // `WithContext` built under an `enter_script`-shrunk context
8797 // already carries the script-shrunk `font_size` in `stored`, so
8798 // laying it out at `stored.font_size` (rather than at this call's
8799 // `size`) means the engine's own Sup/Sub shrink is never applied a
8800 // second time on top of it.
8801 Math::WithContext(stored, inner) => {
8802 layout_math_list(interp, stored, inner, stored.font_size)
8803 }
8804 }
8805}
8806
8807/// Append `glyphs`/`rules` (already at LOCAL coordinates relative to their
8808/// own run) onto `out_glyphs`/`out_rules` at the running `*x`, advancing `*x`
8809/// past them — the no-spacing-adjustment sibling of `layout_math_list`'s
8810/// per-atom loop, used by the structural stand-ins above (paren) that
8811/// concatenate sub-runs directly rather than through the spacing table.
8812/// `rules` shifts horizontally only (`shift_graphics` with a zero `dy` —
8813/// `append_at`'s callers never raise/lower a sub-run, only `dx`-place it;
8814/// contrast `shift_and_append` below, which does both).
8815fn append_at(
8816 out_glyphs: &mut Vec<MathGlyph>,
8817 out_rules: &mut Vec<GraphicsElem>,
8818 x: &mut Length,
8819 glyphs: Vec<MathGlyph>,
8820 rules: Vec<GraphicsElem>,
8821 width: Length,
8822) {
8823 let base_x = *x;
8824 for mut g in glyphs {
8825 g.dx = base_x + g.dx;
8826 out_glyphs.push(g);
8827 }
8828 for r in &rules {
8829 out_rules.push(shift_graphics((base_x, Length::ZERO), r));
8830 }
8831 *x = base_x + width;
8832}
8833
8834/// Horizontal offsets that CENTER a limit against its base: half the width
8835/// difference goes to whichever of the two is narrower, so the pair occupies
8836/// `max(base, script)` (upstream `math.ml:1219-1231`).
8837fn center_offsets(base_width: Length, script_width: Length) -> (Length, Length) {
8838 if base_width < script_width {
8839 ((script_width - base_width) * 0.5, Length::ZERO)
8840 } else {
8841 (Length::ZERO, (base_width - script_width) * 0.5)
8842 }
8843}
8844
8845/// Slide already-emitted glyphs/rules right by `dx` — used when a limit is
8846/// WIDER than its base, so the base itself has to move to stay centered.
8847fn shift_existing(glyphs: &mut [MathGlyph], rules: &mut [GraphicsElem], dx: Length) {
8848 if dx == Length::ZERO {
8849 return;
8850 }
8851 for g in glyphs.iter_mut() {
8852 g.dx = g.dx + dx;
8853 }
8854 for r in rules.iter_mut() {
8855 *r = shift_graphics((dx, Length::ZERO), r);
8856 }
8857}
8858
8859/// Append `glyphs`/`rules` (LOCAL coordinates, from an isolated
8860/// `layout_math_list` call) onto `out_glyphs`/`out_rules`, shifting every
8861/// glyph/rule right by `dx_shift` (its base's own width — placing the
8862/// script/numerator/denominator/radicand right after the preceding content)
8863/// and up/down by `dy_shift` (`> 0` raises, `< 0` lowers) — the `Math`-atom
8864/// analog of `place_script`, which instead threads a single
8865/// running `x` across a flat `MathElem` list. `rules` go through the SAME
8866/// `shift_graphics` a standalone `inline-graphics` box's `shift-graphics`
8867/// primitive uses — box-local, y-**up** coordinates, exactly
8868/// `MathGlyph::dy`'s sign convention (a critical correctness note: get
8869/// this sign wrong and a fraction bar/ radical mirrors instead of landing
8870/// at the axis).
8871fn shift_and_append(
8872 out_glyphs: &mut Vec<MathGlyph>,
8873 out_rules: &mut Vec<GraphicsElem>,
8874 glyphs: Vec<MathGlyph>,
8875 rules: Vec<GraphicsElem>,
8876 dx_shift: Length,
8877 dy_shift: Length,
8878) {
8879 for mut g in glyphs {
8880 g.dx = dx_shift + g.dx;
8881 g.dy = g.dy + dy_shift;
8882 out_glyphs.push(g);
8883 }
8884 for r in &rules {
8885 out_rules.push(shift_graphics((dx_shift, dy_shift), r));
8886 }
8887}
8888
8889/// An axis-aligned rectangle `Fill` path, box-local (y-**up**): bottom-left
8890/// corner `origin`, extending `size.0` right and `size.1` up. Shared by the
8891/// fraction bar and the radical overbar — both are exactly this
8892/// shape, just at different `y`/width.
8893fn rect_path(origin: Point, size: (Length, Length)) -> Path {
8894 let (x, y) = origin;
8895 let (w, h) = size;
8896 Path {
8897 subpaths: vec![Subpath {
8898 start: (x, y),
8899 segs: vec![
8900 PathSeg::Line((x + w, y)),
8901 PathSeg::Line((x + w, y + h)),
8902 PathSeg::Line((x, y + h)),
8903 ],
8904 closing: Closing::Line,
8905 }],
8906 }
8907}
8908
8909/// Port of `default_radical` (`primitives.cppo.ml:311-355`): the radical
8910/// checkmark's `GeneralPath`, plus its own natural advance (`wid`, upstream's
8911/// `PHGFixedGraphics`'s declared width) and `nonnegdpt` (its own depth
8912/// extent, upstream's declared `depth` — returned for completeness though
8913/// The overall `Math::Radical` depth uses `d_cont` directly, matching
8914/// upstream's own "temporary" simplification, see that arm's call site).
8915/// `size` is the ambient LOCAL nesting size (upstream `fontsize`); `hgt_bar`/
8916/// `t_bar` come from `MathC::radical_bar_metrics`; `dpt` is the radicand's
8917/// own depth (a NON-NEGATIVE magnitude, this port's convention — see
8918/// `sup_shift_clamped`'s doc comment; upstream's signed `Length.negate dpt`
8919/// becomes a plain ADD of `dpt` here).
8920///
8921/// Box-local origin `(0, 0)` = this atom's own baseline-left corner (where
8922/// upstream's `graphics (xpos, ypos)` closure is finally called with the
8923/// box's placed anchor — every point below is relative to that same origin,
8924/// matching `PathSeg`/`Subpath`'s y-**up** convention).
8925fn radical_sign_geometry(
8926 size: Length,
8927 hgt_bar: Length,
8928 t_bar: Length,
8929 dpt: Length,
8930) -> (Path, Length, Length) {
8931 let w_m = size * 0.02;
8932 let w1 = size * 0.1;
8933 let w2 = size * 0.15;
8934 let w3 = size * 0.4;
8935 let w_a = size * 0.18;
8936 let h1 = size * 0.3;
8937 let h2 = size * 0.375;
8938
8939 let nonnegdpt = dpt + size * 0.1;
8940 let l_r = hgt_bar + nonnegdpt;
8941
8942 let wid = w_m + w1 + w2 + w3;
8943 let a1 = (h2 - h1) / w1;
8944 let a2 = h2 / w2;
8945 let a3 = l_r / w3;
8946 let t1 = t_bar * (1.0 + a1 * a1).sqrt();
8947 let t3 = t_bar * (((1.0 + a3 * a3).sqrt() - 1.0) / a3);
8948 let h_a = h1 + t1 + w_a * a1;
8949 let w_b = (l_r + t_bar - h_a - (w1 + w2 + w3 - t3 - w_a) * a3) * (-1.0 / (a2 + a3));
8950 let h_b = h_a - w_b * a2;
8951
8952 let path = Path {
8953 subpaths: vec![Subpath {
8954 start: (wid, hgt_bar),
8955 segs: vec![
8956 PathSeg::Line((w_m + w1 + w2, -nonnegdpt)),
8957 PathSeg::Line((w_m + w1, -nonnegdpt + h2)),
8958 PathSeg::Line((w_m, -nonnegdpt + h1)),
8959 PathSeg::Line((w_m, -nonnegdpt + h1 + t1)),
8960 PathSeg::Line((w_m + w_a, -nonnegdpt + h_a)),
8961 PathSeg::Line((w_m + w_a + w_b, -nonnegdpt + h_b)),
8962 PathSeg::Line((wid - t3, hgt_bar + t_bar)),
8963 PathSeg::Line((wid, hgt_bar + t_bar)),
8964 ],
8965 closing: Closing::Line,
8966 }],
8967 };
8968 (path, wid, nonnegdpt)
8969}
8970
8971/// Flatten `text-in-math`'s embedded `inline-boxes` (already laid out
8972/// by `read_inline` against the math atom's own context) into `MathGlyph`s
8973/// nestable in a math run — the box-in-math bridge `layout_math_atom`'s
8974/// `EmbeddedText` arm needs. Mirrors `linebreak.rs`'s `natural_metrics`
8975/// exhaustive `PureHorzBox` walk EXACTLY (same variant list, same "what
8976/// advances `x`" choice per variant) so an added/renamed `PureHorzBox`
8977/// variant can't silently drop content here without also breaking that
8978/// walk. Caveats (faithful to what's actually renderable here): only
8979/// `InnerString`/nested `Math` boxes contribute real glyphs (hence height/
8980/// depth, computed by the caller from the returned glyphs); every other
8981/// box kind (`Image`/`Graphics`/`Tabular`/`EmbeddedBlock`/…) keeps its
8982/// horizontal space but contributes no ink; text run at full (non-script)
8983/// size regardless of the math run's own `size` (upstream-faithful — a
8984/// `text-in-math` body is laid out once, by `read_inline`, before this
8985/// function ever sees it).
8986// UNWIRED. `layout_script` builds the same `(Vec<MathGlyph>, Length)` on the
8987// live path, so nothing calls this. Kept rather than deleted because
8988// `math_boxes_of_inline_boxes` below is documented as its sibling, and
8989// because it is the upstream-faithful flattening a `text-in-math` body needs
8990// if that path is ever wired back up.
8991#[allow(dead_code)]
8992fn math_glyphs_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Length) {
8993 fn go(pure: &PureHorzBox, out: &mut Vec<MathGlyph>, x: &mut Length) {
8994 match pure {
8995 PureHorzBox::InnerString {
8996 info,
8997 text,
8998 width,
8999 height,
9000 depth,
9001 } => {
9002 out.push(MathGlyph {
9003 info: info.clone(),
9004 text: text.clone(),
9005 gid: None,
9006 dx: *x,
9007 dy: Length::ZERO,
9008 width: *width,
9009 height: *height,
9010 depth: *depth,
9011 });
9012 *x += *width;
9013 }
9014 PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
9015 PureHorzBox::OuterFil => {}
9016 PureHorzBox::FixedEmpty { width } => *x += *width,
9017 PureHorzBox::Image { width, .. } => *x += *width,
9018 PureHorzBox::Discretionary { no_break, .. } => {
9019 for p in no_break {
9020 go(p, out, x);
9021 }
9022 }
9023 PureHorzBox::Graphics { width, .. } => *x += *width,
9024 // An unresolved `inline-graphics-outer` marker has zero width
9025 // (fil semantics, see the variant's doc comment) and no glyph
9026 // representation this walk can extract — advance past it like
9027 // `Image`/`Tabular` (a resolved one is an ordinary `Graphics`,
9028 // handled by the arm above).
9029 PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
9030 PureHorzBox::Math { width, glyphs, .. } => {
9031 for g in glyphs {
9032 let mut g = g.clone();
9033 g.dx = *x + g.dx;
9034 out.push(g);
9035 }
9036 *x += *width;
9037 }
9038 PureHorzBox::HookPageBreak { .. } => {}
9039 PureHorzBox::Tabular(tab) => *x += tab.width,
9040 PureHorzBox::EmbeddedBlock { width, .. } => *x += *width,
9041 // A frame in a math context has no glyph representation this
9042 // walk can extract — advance past it like `Image`/`Tabular`.
9043 PureHorzBox::Frame { width, .. } => *x += *width,
9044 PureHorzBox::FrameMarker { .. } => {}
9045 // Zero-width bracket; its contents are spliced siblings, already
9046 // walked by this same loop.
9047 PureHorzBox::InlineFrameMarker { .. } => {}
9048 // Zero-width marker; no glyph representation. Same treatment
9049 // as `HookPageBreak`.
9050 PureHorzBox::Footnote { .. } => {}
9051 // inert reflow marker, no glyph representation — same
9052 // treatment as `HookPageBreak`/`FrameMarker`/`Footnote`
9053 // above.
9054 PureHorzBox::InlineMark(_) => {}
9055 }
9056 }
9057 let mut glyphs = Vec::new();
9058 let mut x = Length::ZERO;
9059 for HorzBox::Pure(p) in boxes {
9060 go(p, &mut glyphs, &mut x);
9061 }
9062 (glyphs, x)
9063}
9064
9065/// `math_glyphs_of_inline_boxes`'s graphics-harvesting sibling — the
9066/// shape a `make_paren` closure's result needs, since a delimiter drawn via
9067/// `inline-graphics` (`math.satyh`'s `paren-left`/`abs-left`/…, `fill`/
9068/// `stroke` a path) carries its ink as a `PureHorzBox::Graphics` box, not a
9069/// `MathGlyph`. The same exhaustive `PureHorzBox` variant list as
9070/// `math_glyphs_of_inline_boxes` (do NOT modify that function — every OTHER
9071/// caller still wants glyphs-only, e.g. `EmbeddedText`), but additionally
9072/// harvests `Graphics::elems` (`dx`-shifted via `shift_graphics`, the box's
9073/// own local-origin convention — see `PureHorzBox::Graphics`'s doc comment)
9074/// and forwards BOTH the glyphs AND `rules` out of any nested
9075/// `PureHorzBox::Math` box (a paren closure could, in principle, embed one
9076/// via `text-in-math`/`embed-math`).
9077///
9078/// ONE arm diverges from that sibling and makes this walk VERTICAL too: `dy`
9079/// (y-up, box-local — `MathGlyph::dy`'s own frame) carries the offset of the
9080/// stacked line a nested `EmbeddedBlock`'s content sits on. `Frame` and
9081/// `Tabular` still contribute width alone; they could be descended into the
9082/// same way, but nothing in the corpus puts either inside a `text-in-math`
9083/// body, so neither has a measured shape to be faithful to.
9084fn math_boxes_of_inline_boxes(boxes: &[HorzBox]) -> (Vec<MathGlyph>, Vec<GraphicsElem>, Length) {
9085 fn go(
9086 pure: &PureHorzBox,
9087 out: &mut Vec<MathGlyph>,
9088 rules: &mut Vec<GraphicsElem>,
9089 x: &mut Length,
9090 dy: Length,
9091 ) {
9092 match pure {
9093 PureHorzBox::InnerString {
9094 info,
9095 text,
9096 width,
9097 height,
9098 depth,
9099 } => {
9100 out.push(MathGlyph {
9101 info: info.clone(),
9102 text: text.clone(),
9103 gid: None,
9104 dx: *x,
9105 dy,
9106 width: *width,
9107 height: *height,
9108 depth: *depth,
9109 });
9110 *x += *width;
9111 }
9112 PureHorzBox::OuterEmpty { natural, .. } => *x += *natural,
9113 PureHorzBox::OuterFil => {}
9114 PureHorzBox::FixedEmpty { width } => *x += *width,
9115 PureHorzBox::Image { width, .. } => *x += *width,
9116 PureHorzBox::Discretionary { no_break, .. } => {
9117 for p in no_break {
9118 go(p, out, rules, x, dy);
9119 }
9120 }
9121 PureHorzBox::Graphics { width, elems, .. } => {
9122 for e in elems {
9123 rules.push(shift_graphics((*x, dy), e));
9124 }
9125 *x += *width;
9126 }
9127 // See `math_glyphs_of_inline_boxes`'s matching arm.
9128 PureHorzBox::GraphicsOuter { width, .. } => *x += *width,
9129 PureHorzBox::Math {
9130 width,
9131 glyphs,
9132 rules: inner_rules,
9133 ..
9134 } => {
9135 for g in glyphs {
9136 let mut g = g.clone();
9137 g.dx = *x + g.dx;
9138 g.dy += dy;
9139 out.push(g);
9140 }
9141 for r in inner_rules {
9142 rules.push(shift_graphics((*x, dy), r));
9143 }
9144 *x += *width;
9145 }
9146 PureHorzBox::HookPageBreak { .. } => {}
9147 PureHorzBox::Tabular(tab) => *x += tab.width,
9148 // A `line-stack-top`/`-bottom` (or `embed-block-top`/`-bottom`) box
9149 // handed BACK to math through `text-in-math` — azmath's
9150 // `\overbrace`/`\underbrace` (`parens.satyh:533`/`:561`) stack the
9151 // brace over the braced formula this way. Placed with
9152 // `place_embedded_block`'s (rustyfi-pdf) geometry but in this walk's
9153 // y-UP frame: `place_block_at` seats the stack at a page-y-DOWN
9154 // origin, the anchored line lands on the math baseline
9155 // (`anchor_last`: the LAST for `-bottom`, the FIRST for `-top` —
9156 // upstream's `adjust_to_last_line`/`adjust_to_first_line`), and
9157 // every other line is offset by the NEGATED difference of their
9158 // placed baselines. That is the same split `make_embedded_block`
9159 // measured the box's own height/depth from, so what
9160 // `layout_math_value` folds back up agrees with the box metrics the
9161 // rest of the pipeline already saw.
9162 PureHorzBox::EmbeddedBlock {
9163 width,
9164 block,
9165 anchor_last,
9166 ..
9167 } => {
9168 let placed = place_block_at((Length::ZERO, Length::ZERO), block.clone());
9169 let anchor = if *anchor_last {
9170 placed.last()
9171 } else {
9172 placed.first()
9173 };
9174 if let Some(anchor) = anchor {
9175 let anchor_y = anchor.baseline_y;
9176 for line in &placed {
9177 let line_dy = dy - (line.baseline_y - anchor_y);
9178 for (cdx, cbx) in &line.contents {
9179 // Each stacked line has its own horizontal origin,
9180 // and must not advance the OUTER run's pen — the
9181 // block's own `width` accounts for that once below.
9182 let mut cx = *x + line.x + *cdx;
9183 go(cbx, out, rules, &mut cx, line_dy);
9184 }
9185 }
9186 }
9187 *x += *width;
9188 }
9189 // See `math_glyphs_of_inline_boxes`'s matching arm.
9190 PureHorzBox::Frame { width, .. } => *x += *width,
9191 PureHorzBox::FrameMarker { .. } => {}
9192 // See `math_glyphs_of_inline_boxes`'s matching arm.
9193 PureHorzBox::InlineFrameMarker { .. } => {}
9194 // See `math_glyphs_of_inline_boxes`'s matching arm.
9195 PureHorzBox::Footnote { .. } => {}
9196 // See `math_glyphs_of_inline_boxes`'s matching arm.
9197 PureHorzBox::InlineMark(_) => {}
9198 }
9199 }
9200 let mut glyphs = Vec::new();
9201 let mut rules = Vec::new();
9202 let mut x = Length::ZERO;
9203 for HorzBox::Pure(p) in boxes {
9204 go(p, &mut glyphs, &mut rules, &mut x, Length::ZERO);
9205 }
9206 (glyphs, rules, x)
9207}
9208
9209// ============================================================================
9210// ---- context-setter + box-combinator prims `code.satyh`/`itemize.satyh`
9211// need. ------------------------------------------------------------------
9212// ============================================================================
9213
9214/// The inverse of `as_color` (mirrors `evalUtil.ml:124`'s `get_color` the
9215/// other way) — `get-text-color`'s result, which `itemize.satyh` feeds
9216/// straight into `fill`, so the tag/payload shape must match `as_color`
9217/// exactly (see that primitive's doc comment).
9218fn make_color_value(c: Color) -> Value {
9219 match c {
9220 Color::Gray(g) => Value::Ctor("Gray".to_string(), Some(Box::new(Value::Float(g)))),
9221 Color::Rgb(r, g, b) => Value::Ctor(
9222 "RGB".to_string(),
9223 Some(Box::new(Value::Tuple(vec![
9224 Value::Float(r),
9225 Value::Float(g),
9226 Value::Float(b),
9227 ]))),
9228 ),
9229 Color::Cmyk(c, m, y, k) => Value::Ctor(
9230 "CMYK".to_string(),
9231 Some(Box::new(Value::Tuple(vec![
9232 Value::Float(c),
9233 Value::Float(m),
9234 Value::Float(y),
9235 Value::Float(k),
9236 ]))),
9237 ),
9238 }
9239}
9240
9241/// `font` = `Value::Tuple([string, float, float])` in `(abbrev, size_ratio,
9242/// rising_ratio)` order (vminst.ml's `tFONT`) — `set-font`'s second argument.
9243fn as_font(v: Value) -> Result<(String, f64, f64), EvalError> {
9244 match v {
9245 Value::Tuple(vs) if vs.len() == 3 => {
9246 let mut it = vs.into_iter();
9247 let abbrev = as_str(it.next().unwrap())?;
9248 let size_ratio = as_float(it.next().unwrap())?;
9249 let rising_ratio = as_float(it.next().unwrap())?;
9250 Ok((abbrev, size_ratio, rising_ratio))
9251 }
9252 other => eval_error(format!(
9253 "expected a font (string * float * float), got {}",
9254 other.type_name()
9255 )),
9256 }
9257}
9258
9259/// [`as_font`]'s V0_1 twin — saphe-split's `tFONTWR = font * float * float`,
9260/// whose head is the opaque handle rather than an abbrev.
9261fn as_font_with_ratio(v: Value) -> Result<(FontKey, f64, f64), EvalError> {
9262 match v {
9263 Value::Tuple(vs) if vs.len() == 3 => {
9264 let mut it = vs.into_iter();
9265 let key = as_font_key(it.next().unwrap())?;
9266 let size_ratio = as_float(it.next().unwrap())?;
9267 let rising_ratio = as_float(it.next().unwrap())?;
9268 Ok((key, size_ratio, rising_ratio))
9269 }
9270 other => eval_error(format!(
9271 "expected a font (font * float * float), got {}",
9272 other.type_name()
9273 )),
9274 }
9275}
9276
9277/// The opaque V0_1 `font` handle (upstream's `BCFontKey of FontKey.t`).
9278fn as_font_key(v: Value) -> Result<FontKey, EvalError> {
9279 match v {
9280 Value::Font(key) => Ok(key),
9281 other => eval_error(format!("expected a font, got {}", other.type_name())),
9282 }
9283}
9284
9285/// `set-text-color : color -> context -> context` (vminst.ml:1603) —
9286/// FAITHFUL store (`Context::text_color`, the `set-font-size` shape); it
9287/// rides on every `HorzStringInfo` and both PDF writers emit `rg`/`g`
9288/// before `Tj` for a non-black run.
9289fn prim_set_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9290 let ctx = as_context(args.pop().unwrap())?;
9291 let color = as_color(args.pop().unwrap())?;
9292 Ok(Value::Context(Box::new(Context {
9293 text_color: color,
9294 ..ctx
9295 })))
9296}
9297
9298/// `get-text-color : context -> color` (vminst.ml:1618) — FAITHFUL and
9299/// load-bearing: `itemize.satyh`'s `make-bullet` feeds this straight into
9300/// `fill color (Gr.circle …)`, so it must round-trip exactly what
9301/// `set-text-color` stored (see `make_color_value`'s doc comment).
9302fn prim_get_text_color(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9303 let ctx = as_context(args.pop().unwrap())?;
9304 Ok(make_color_value(ctx.text_color))
9305}
9306
9307/// `set-hyphen-penalty : int -> context -> context` (vminst.ml:1692) —
9308/// FAITHFUL store (`Context::hyphen_badness`), now a real consumer:
9309/// `text_to_boxes`'s `flush_word` uses this as each injected
9310/// `Discretionary`'s `penalty`, but only when a dictionary is installed via
9311/// `set-hyphenation-dictionary` — with no dictionary installed (the
9312/// default), this is stored but has no layout effect, same as before.
9313/// `code.satyh`'s `set-hyphen-penalty 100000` still works as "disable
9314/// hyphenation" (huge positive penalty, DP avoids it).
9315fn prim_set_hyphen_penalty(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9316 let ctx = as_context(args.pop().unwrap())?;
9317 let n = as_int(args.pop().unwrap())?;
9318 Ok(Value::Context(Box::new(Context {
9319 hyphen_badness: n,
9320 ..ctx
9321 })))
9322}
9323
9324/// `set-hyphen-min : int -> int -> context -> context` (upstream
9325/// `vminstdef.yaml:1163-1177`) — writes
9326/// `Context::left_hyphen_min`/`right_hyphen_min`, each clamped to `>= 0`
9327/// (mirrors `set-space-ratio`'s `.max(0.0)` clamping style; a negative
9328/// minimum would be meaningless to the min-fragment filter in
9329/// `crate::hyphenation::hyphenate_word`).
9330fn prim_set_hyphen_min(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9331 let ctx = as_context(args.pop().unwrap())?;
9332 let right = as_int(args.pop().unwrap())?.max(0);
9333 let left = as_int(args.pop().unwrap())?.max(0);
9334 Ok(Value::Context(Box::new(Context {
9335 left_hyphen_min: left,
9336 right_hyphen_min: right,
9337 ..ctx
9338 })))
9339}
9340
9341/// `set-space-ratio : float -> float -> float -> context -> context`
9342/// (vminst.ml:1309), params `(natural, shrink, stretch)` — FAITHFUL store
9343/// (`Context::space_natural`/`space_shrink`/`space_stretch`, clamped to
9344/// `>= 0.0` like upstream), read by `text_to_boxes`'s interword-glue
9345/// computation.
9346fn prim_set_space_ratio(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9347 let ctx = as_context(args.pop().unwrap())?;
9348 let stretch = as_float(args.pop().unwrap())?.max(0.0);
9349 let shrink = as_float(args.pop().unwrap())?.max(0.0);
9350 let natural = as_float(args.pop().unwrap())?.max(0.0);
9351 Ok(Value::Context(Box::new(Context {
9352 space_natural: natural,
9353 space_shrink: shrink,
9354 space_stretch: stretch,
9355 ..ctx
9356 })))
9357}
9358
9359/// `set-space-ratio-between-scripts : float -> float -> float -> script ->
9360/// script -> context -> context` (`vminstdef.yaml:1230-1250`) — writes one
9361/// ordered script pair's entry of `ctx.script_space_map`
9362/// (`convertText.ml:34-50` reads it back).
9363///
9364/// Only the NATURAL ratio is stored. The other two arguments are accepted and
9365/// dropped, because upstream drops them too: `pure_space_between_scripts`
9366/// misplaces them into `LBAtom`'s height and depth slots, so no
9367/// `set-space-ratio-between-scripts` call in any document has ever been able
9368/// to give this glue stretch or shrink. See [`interscript_glue`].
9369///
9370/// This was a STAND-IN that ignored all three, on the reasoning that slydifi
9371/// only ever calls it with `0. 0. 0.` to SUPPRESS the spacing and the port
9372/// inserted none anyway. The port has inserted Latin↔CJK glue for some time
9373/// now, so ignoring the call left slydifi with a 0.24em space at every
9374/// Japanese/Latin junction that upstream does not set.
9375fn prim_set_space_ratio_between_scripts(
9376 _interp: &mut Interp,
9377 mut args: Vec<Value>,
9378) -> Result<Value, EvalError> {
9379 let ctx = as_context(args.pop().unwrap())?;
9380 let script2 = as_script(args.pop().unwrap())?;
9381 let script1 = as_script(args.pop().unwrap())?;
9382 let _stretch = as_float(args.pop().unwrap())?;
9383 let _shrink = as_float(args.pop().unwrap())?;
9384 // `max 0.`, as `vminstdef.yaml`'s own `set_space_ratio_between_scripts`
9385 // clamps each ratio before storing it.
9386 let natural = as_float(args.pop().unwrap())?.max(0.0);
9387 let mut script_space_map = ctx.script_space_map;
9388 script_space_map[script1 as usize][script2 as usize] = natural;
9389 Ok(Value::Context(Box::new(Context {
9390 script_space_map,
9391 ..ctx
9392 })))
9393}
9394
9395/// `split-into-lines : string -> (int * string) list` (vminst.ml:2269) —
9396/// FAITHFUL: splits on `'\n'` and, per line, counts the leading ASCII spaces
9397/// `i` and returns `(i, rest_after_indent)` — exactly `evalUtil.ml:36`'s
9398/// `chop_space_indent`. Pure string op: no context, no box, no new type.
9399fn prim_split_into_lines(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9400 let s = as_str(args.pop().unwrap())?;
9401 let mut out = Vec::new();
9402 for line in s.split('\n') {
9403 let indent = line.chars().take_while(|c| *c == ' ').count();
9404 let rest: String = line.chars().skip(indent).collect();
9405 out.push(Value::Tuple(vec![
9406 Value::Int(indent as i64),
9407 Value::Str(rest),
9408 ]));
9409 }
9410 Ok(Value::List(out))
9411}
9412
9413/// Shift every content box in `block`'s `Line`s right by `pad_l`
9414/// (`block-frame-breakable`'s left-indent, point 4) — the simplest of the
9415/// two options that section names: adjusting each box's own `x` offset
9416/// directly rather than prepending an extra `FixedEmpty` box (`Skip`s carry
9417/// no `x` offsets to shift, so they pass through unchanged).
9418fn indent_left(block: Vec<VertBox>, pad_l: Length) -> Vec<VertBox> {
9419 block
9420 .into_iter()
9421 .map(|vb| match vb {
9422 VertBox::Line {
9423 height,
9424 depth,
9425 leading,
9426 contents,
9427 } => VertBox::Line {
9428 height,
9429 depth,
9430 leading,
9431 contents: contents
9432 .into_iter()
9433 .map(|(x, bx)| (x + pad_l, bx))
9434 .collect(),
9435 },
9436 // `Skip`/`ClearPage`/`HookPageBreak` carry no `x` offsets to shift.
9437 other => other,
9438 })
9439 .collect()
9440}
9441
9442/// `block-frame-breakable : context -> paddings -> deco-set -> (context ->
9443/// block-boxes) -> block-boxes` (vminst.ml:1090) — the `inline-frame-outer`
9444/// playbook, one dimension up:
9445/// `paddingL`/`paddingR` shrink the inner `reducef` closure's context width,
9446/// and the result is indented and top/bottom-padded with plain `Skip`s,
9447/// bracketed by a `FrameStart(id)`/`FrameEnd(id)` marker pair — the frame's
9448/// pads/width/deco-set are interned into `interp.decos` under `id`
9449/// (`DecoEntry::Block`), and `fire_hooks`'s block-fragment pass fires
9450/// `decoS` once the frame's whole single-page fragment is placed (the first
9451/// cut: multi-page fragments/`decoH`/`decoM`/`decoT` are a documented
9452/// follow-up, see `fire_hooks`'s doc comment).
9453/// Drop the margin boxes at either END of a `block-frame-breakable`'s body —
9454/// the first inner block's top margin and the last inner block's bottom
9455/// margin, which upstream's `normalize` never produces for a frame's contents
9456/// (`pageBreak.ml:664` and `:582-585`; see the call site). Margins in the
9457/// MIDDLE of the body are untouched: those are real inter-block gaps, squashed
9458/// there exactly as they are outside a frame.
9459fn strip_outer_margins(body: &mut Vec<VertBox>) {
9460 let is_margin = |vb: &VertBox| matches!(vb, VertBox::Skip(_) | VertBox::ParagTop(_));
9461 if body.first().is_some_and(is_margin) {
9462 body.remove(0);
9463 }
9464 if body.last().is_some_and(is_margin) {
9465 body.pop();
9466 }
9467}
9468
9469fn prim_block_frame_breakable(
9470 interp: &mut Interp,
9471 version: RustyfiVersion,
9472 mut args: Vec<Value>,
9473) -> Result<Value, EvalError> {
9474 let k = args.pop().unwrap();
9475 let decoset = as_decoset(args.pop().unwrap())?;
9476 let (pad_l, pad_r, pad_t, pad_b) = as_paddings(args.pop().unwrap())?;
9477 let ctx = as_context(args.pop().unwrap())?;
9478 let id = DecoId(interp.decos.len());
9479 interp.decos.push(DecoEntry::Block {
9480 pads: Paddings {
9481 l: pad_l,
9482 r: pad_r,
9483 t: pad_t,
9484 b: pad_b,
9485 },
9486 width: ctx.paragraph_width,
9487 decoset,
9488 // See `make_inline_frame`'s identical capture.
9489 version,
9490 });
9491 let inner_ctx = Context {
9492 paragraph_width: ctx.paragraph_width - pad_l - pad_r,
9493 ..ctx
9494 };
9495 let inner = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9496 let mut indented = indent_left(inner, pad_l);
9497 // THE FRAME CARRIES THE MARGINS, ITS BODY DOES NOT. Upstream normalizes a
9498 // frame's contents STANDALONE — `aux None TopMarginProhibited Alist.empty
9499 // vblstsub` (`pageBreak.ml:664`) — so the first inner block's `margin_top`
9500 // is never appended, and the last inner block's `margin_bottom` goes
9501 // through `squash_margins _ []`, whose empty-list arm (`:582-585`) emits
9502 // no skip at all. What surrounds the frame instead is the frame's OWN
9503 // `margins`, taken from the OUTER context (`vminstdef.yaml`'s
9504 // `BackendVertFrame`: `margin_top = ctx.paragraph_top`, `margin_bottom =
9505 // ctx.paragraph_bottom`), which `squash_margins` max-collapses against the
9506 // neighbouring blocks' margins exactly as `chop_page` collapses adjacent
9507 // `Skip`s.
9508 //
9509 // The distinction is not cosmetic: the body's `ParagTop` carries
9510 // `min_first_line_ascender` folded in (`prim_line_break`,
9511 // `lineBreak.ml:855-857`), and the frame's margin does NOT. Keeping the
9512 // body's made the advance INTO a frame a constant — `max(0, 9pt - hgt)`
9513 // cancels the first line's own height — where upstream's tracks the ink:
9514 // measured on `layout-tests/probes/code_line_height.saty` against real
9515 // SATySFi 0.0.11, `+code(`ooo`)` / `lll` / `ggg` advance 29.114 / 31.166 /
9516 // 29.138pt upstream and a flat 32.835pt here.
9517 strip_outer_margins(&mut indented);
9518 let mut out = Vec::with_capacity(indented.len() + 6);
9519 out.push(VertBox::Skip(ctx.paragraph_top));
9520 out.push(VertBox::FrameStart(id));
9521 out.push(VertBox::FramePad(pad_t));
9522 out.extend(indented);
9523 out.push(VertBox::FramePad(pad_b));
9524 out.push(VertBox::FrameEnd(id));
9525 out.push(VertBox::Skip(ctx.paragraph_bottom));
9526 Ok(Value::BlockBoxes(out))
9527}
9528
9529/// Build the `PureHorzBox::EmbeddedBlock` shared by `embed-block-top`
9530/// (vminst.ml:1145) and `embed-block-bottom` (vminst.ml:1185). FAITHFUL:
9531/// `anchor_last` selects which of `block`'s lines lands on the surrounding
9532/// text baseline — the FIRST for top (upstream's `adjust_to_first_line`) or
9533/// the LAST for bottom (`adjust_to_last_line`) — computed by placing the
9534/// block once (`place_block_at`) to find where each line's baseline falls,
9535/// then splitting the box's total vertical extent around the anchored line:
9536/// around the first line for TOP, so the box hangs DOWN from the baseline;
9537/// around the last line for BOTTOM, so it hangs UP. A degenerate line-less
9538/// block (only skips, no baseline to anchor) falls back to
9539/// `measure_block`'s skip-as-height sum for both.
9540fn make_embedded_block(
9541 width: Length,
9542 block: Vec<VertBox>,
9543 anchor_last: bool,
9544 breakable: bool,
9545) -> Value {
9546 let first_line_height = block.iter().find_map(|vb| match vb {
9547 VertBox::Line { height, .. } => Some(*height),
9548 _ => None,
9549 });
9550 let last_line_depth = block.iter().rev().find_map(|vb| match vb {
9551 VertBox::Line { depth, .. } => Some(*depth),
9552 _ => None,
9553 });
9554 let (height, depth) = match (first_line_height, last_line_depth) {
9555 // Place once to learn each line's baseline, then split the box's
9556 // total vertical extent around the anchored line: `place_block_at`
9557 // seats the first baseline at `first_h` (origin 0), so the block
9558 // spans `[0, last_baseline + last_d]`.
9559 (Some(first_h), Some(last_d)) => {
9560 let placed = place_block_at((Length::ZERO, Length::ZERO), block.clone());
9561 let last_baseline = placed.last().map(|l| l.baseline_y).unwrap_or(first_h);
9562 let bottom_edge = last_baseline + last_d;
9563 if anchor_last {
9564 (last_baseline, last_d)
9565 } else {
9566 (first_h, bottom_edge - first_h)
9567 }
9568 }
9569 // A degenerate line-less block (only skips — no baseline to anchor):
9570 // keep `measure_block` (its skip-as-height fallback is right there).
9571 _ => measure_block(&block),
9572 };
9573 Value::InlineBoxes(vec![HorzBox::Pure(PureHorzBox::EmbeddedBlock {
9574 width,
9575 height,
9576 depth,
9577 block,
9578 anchor_last,
9579 breakable,
9580 })])
9581}
9582
9583/// `embed-block-top : context -> length -> (context -> block-boxes) ->
9584/// inline-boxes` (vminst.ml:1145) — see [`make_embedded_block`].
9585fn prim_embed_block_top(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9586 let k = args.pop().unwrap();
9587 let wid = as_length(args.pop().unwrap())?;
9588 let ctx = as_context(args.pop().unwrap())?;
9589 let inner_ctx = Context {
9590 paragraph_width: wid,
9591 ..ctx
9592 };
9593 let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9594 Ok(make_embedded_block(wid, block, false, false))
9595}
9596
9597/// `embed-block-bottom : context -> length -> (context -> block-boxes) ->
9598/// inline-boxes` (vminst.ml:1185) — see [`make_embedded_block`]; anchors the
9599/// LAST line, used by latexcmds' `\parbox?:(Bottom)`.
9600fn prim_embed_block_bottom(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9601 let k = args.pop().unwrap();
9602 let wid = as_length(args.pop().unwrap())?;
9603 let ctx = as_context(args.pop().unwrap())?;
9604 let inner_ctx = Context {
9605 paragraph_width: wid,
9606 ..ctx
9607 };
9608 let block = as_block_boxes(interp.apply(k, Value::Context(Box::new(inner_ctx)))?)?;
9609 Ok(make_embedded_block(wid, block, true, false))
9610}
9611
9612/// `line-stack-bottom : inline-boxes list -> inline-boxes` (vminst.ml:1229,
9613/// `evalUtil.ml`'s `make_line_stack`) — FAITHFUL: each `inline-boxes` in the
9614/// list becomes exactly one line, fit (not broken) to the widest line's
9615/// natural width via `fit_cell` (this port's `LineBreak.fit`, already used
9616/// by the tabular grid solver — same "no `Context`, `natural_metrics`
9617/// height/depth" fallback upstream's `make_line_stack` needs since it too
9618/// has no context to lean on). Lines are stacked with zero extra margin
9619/// (upstream's `VertParagraph`s all have `margin_top`/`margin_bottom =
9620/// None`): each line's `leading` is set to the previous line's depth plus
9621/// this line's height, so consecutive baselines sit exactly
9622/// `prev_depth + this_height` apart (see `pagebreak.rs`'s
9623/// `leading.max(height)` placement formula — this choice makes that `max`
9624/// always resolve to our computed `leading`). See `line_stack` for the
9625/// shared body and [`prim_line_stack_top`] for the other half.
9626fn prim_line_stack_bottom(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9627 line_stack(args.pop().unwrap(), true)
9628}
9629
9630/// `line-stack-top : inline-boxes list -> inline-boxes`
9631/// (vminstdef.yaml:1109 `BackendLineStackTop`) — FAITHFUL: the same
9632/// `make_line_stack` construction as [`prim_line_stack_bottom`], differing
9633/// only in which stacked line's baseline becomes the result's — one shared
9634/// body and one flag rather than two copies that could drift.
9635///
9636/// `ruby` calls this to sit its annotation above the base run.
9637fn prim_line_stack_top(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9638 line_stack(args.pop().unwrap(), false)
9639}
9640
9641/// `evalUtil.ml`'s `make_line_stack` — shared body of the two `line-stack-*`
9642/// prims; `anchor_last` picks upstream's `adjust_to_last_line` (`true`) or
9643/// `adjust_to_first_line` (`false`).
9644fn line_stack(arg: Value, anchor_last: bool) -> Result<Value, EvalError> {
9645 let hblstlst = as_list(arg)?
9646 .into_iter()
9647 .map(as_inline_boxes)
9648 .collect::<Result<Vec<_>, _>>()?;
9649 let wid = hblstlst
9650 .iter()
9651 .map(|hbs| natural_metrics(hbs).0)
9652 .fold(Length::ZERO, |acc, w| if w > acc { w } else { acc });
9653 let mut block = Vec::with_capacity(hblstlst.len());
9654 let mut prev_depth = Length::ZERO;
9655 for (idx, hbs) in hblstlst.into_iter().enumerate() {
9656 let (contents, height, depth) = fit_cell(hbs, wid);
9657 let leading = if idx == 0 {
9658 height + depth
9659 } else {
9660 prev_depth + height
9661 };
9662 block.push(VertBox::Line {
9663 height,
9664 depth,
9665 leading,
9666 contents,
9667 });
9668 prev_depth = depth;
9669 }
9670 // `anchor_last` IS upstream's `adjust_to_last_line`/`adjust_to_first_line`
9671 // choice: `line-stack-bottom` is BOTTOM-anchored (SATySFi vminst.ml:1229 —
9672 // the result baseline is the LAST stacked line's baseline), so the box's
9673 // height spans everything above that last line and its depth is the last
9674 // line's depth. Top-anchoring it instead put the baseline at the FIRST
9675 // line, which dropped the whole stack below the baseline — e.g. figbox's
9676 // `margin`/`hvmargin` (a `line-stack-bottom` of [top-mgn; content;
9677 // bot-mgn]) had its content rendered below its frame (the E=mc² bug). For
9678 // `line-stack-top` the first line IS the right anchor, which is the whole
9679 // difference between the two prims.
9680 Ok(make_embedded_block(wid, block, anchor_last, false))
9681}
9682
9683/// `add-footnote : block-boxes -> inline-boxes` (vminst.ml:1130
9684/// `BackendAddFootnote`) — FAITHFUL: wraps the block in a zero-metric
9685/// `PureHorzBox::Footnote` marker (upstream `PHGFootnote`,
9686/// vminstdef.yaml:1034-1044; the upstream body's `PageBreak.solidify` is a
9687/// no-op here because this port's block-boxes are already solid
9688/// `Vec<VertBox>`). `chop_page` (rustyfi-backend) extracts the marker when
9689/// its line is committed to a page, reserves the stack's height at the
9690/// column bottom, and places the block bottom-aligned there — see that
9691/// function's doc comment. The cross-trial `changed`-flag protocol
9692/// `footnote-scheme.satyh` layers on top rides the crossref fixpoint.
9693fn prim_add_footnote(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9694 let block = as_block_boxes(args.pop().unwrap())?;
9695 Ok(Value::InlineBoxes(vec![HorzBox::Pure(
9696 PureHorzBox::Footnote { block },
9697 )]))
9698}
9699
9700/// `set-font : script -> string * float * float -> context -> context`
9701/// (0.0.6 `vminstdef.yaml:1335`, `tFONT` head) — real per-script wiring.
9702/// `abbrev` resolves through the font metrics
9703/// provider's registry first (`FontMetrics::resolve_font_abbrev` — a real
9704/// `TtfFontStore` built from `fonts.satysfi-hash`), falling back to
9705/// the 3-face name heuristic (`resolve_font_abbrev` free fn)
9706/// when the provider has no registry entry for it (an abbrev the config
9707/// doesn't name) — never an error, matching this
9708/// port's existing accept-and-degrade stance on unresolvable font names.
9709///
9710/// **Resolution rule (back-compat critical).** `Latin`-script text keeps
9711/// reading `Context::font` directly rather than `font_scheme[Latin]` (see
9712/// that field's doc comment) — `set-font Latin f` therefore writes BOTH so
9713/// the two stay in sync, but `set-font` on any OTHER script only touches
9714/// `font_scheme`, leaving `ctx.font` (and hence `set-font-key`/`\bold`/
9715/// `\emph`, which only ever read `ctx.font`) untouched.
9716fn prim_set_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9717 let mut ctx = as_context(args.pop().unwrap())?;
9718 let (abbrev, size_ratio, rising_ratio) = as_font(args.pop().unwrap())?;
9719 let script = as_script(args.pop().unwrap())?;
9720 let font = interp
9721 .metrics
9722 .resolve_font_abbrev(&abbrev)
9723 .unwrap_or_else(|| resolve_font_abbrev(&abbrev));
9724 install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9725 Ok(Value::Context(Box::new(ctx)))
9726}
9727
9728/// `set-font : script -> font * float * float -> context -> context`
9729/// (saphe-split `tools/gencode/vminst.ml:1433`, `tFONTWR` head). Identical
9730/// to [`prim_set_font_v006`] except that the triple's head is ALREADY a
9731/// resolved handle — 0.1 has no abbrev at this point and nothing to resolve;
9732/// `load-single-font` did that when the font envelope's member was minted.
9733fn prim_set_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9734 let mut ctx = as_context(args.pop().unwrap())?;
9735 let (font, size_ratio, rising_ratio) = as_font_with_ratio(args.pop().unwrap())?;
9736 let script = as_script(args.pop().unwrap())?;
9737 install_script_font(&mut ctx, script, font, size_ratio, rising_ratio);
9738 Ok(Value::Context(Box::new(ctx)))
9739}
9740
9741/// `get-font : script -> context -> string * float * float`
9742/// (vminstdef.yaml:1350 `PrimitiveGetFont`) — FAITHFUL: `script_font` IS
9743/// upstream's `get_font_with_ratio` (normalize the script, then read the
9744/// scheme slot), and the triple is `evalUtil.ml:196`'s `make_font_value`.
9745///
9746/// The head is a font ABBREV. Upstream's `font_scheme` stores abbrevs and
9747/// resolves them to files only at render time; this port resolves eagerly in
9748/// [`prim_set_font_v006`] and stores a `FontKey`, so the name comes back from
9749/// the store that minted it (`FontMetrics::font_abbrev`) and is `""` when the
9750/// key was never named by a registry — see that method for exactly when, and
9751/// why the corpus does not care (every caller in it, and upstream's own
9752/// `convertText.ml:78`, writes `let (_, ratio, _) =` and uses the RATIO,
9753/// which is exact).
9754///
9755/// This is what `ruby` and `quotation` need: the CJK face's size ratio, so a
9756/// ruby annotation or a two-em Japanese indent scales with the face rather
9757/// than with the Latin `get-font-size`.
9758fn prim_get_font_v006(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9759 let ctx = as_context(args.pop().unwrap())?;
9760 let script = as_script(args.pop().unwrap())?;
9761 let sf = script_font(&ctx, script);
9762 let abbrev = interp.metrics.font_abbrev(sf.font).unwrap_or_default();
9763 Ok(Value::Tuple(vec![
9764 Value::Str(abbrev),
9765 Value::Float(sf.ratio),
9766 Value::Float(sf.rising),
9767 ]))
9768}
9769
9770/// `get-font : script -> context -> font * float * float` — the 0.1 arm,
9771/// mirroring [`prim_set_font_v006`]/[`prim_set_font_v01`]'s split. 0.1's
9772/// `font` IS the opaque handle this port already stores, so unlike the 0.0.6
9773/// arm there is nothing to recover: the value round-trips exactly.
9774fn prim_get_font_v01(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9775 let ctx = as_context(args.pop().unwrap())?;
9776 let script = as_script(args.pop().unwrap())?;
9777 let sf = script_font(&ctx, script);
9778 Ok(Value::Tuple(vec![
9779 Value::Font(sf.font),
9780 Value::Float(sf.ratio),
9781 Value::Float(sf.rising),
9782 ]))
9783}
9784
9785/// The half of `set-font` that is NOT version-forked — the `Context` write
9786/// both arms end at, kept in one place so the 0.0.6 behaviour cannot drift
9787/// when the 0.1 one changes. See `prim_set_font_v006`'s "Resolution rule".
9788fn install_script_font(ctx: &mut Context, script: Script, font: FontKey, ratio: f64, rising: f64) {
9789 ctx.font_scheme[script as usize] = ScriptFont {
9790 font,
9791 ratio,
9792 rising,
9793 };
9794 if script == Script::Latin {
9795 ctx.font = font;
9796 }
9797}
9798
9799/// `set-code-text-command : [string] inline-cmd -> context -> context`
9800/// (`stdja:116`; no vminst.ml entry to cite). STAND-IN, same
9801/// shape as `set-math-command`/`set-math-font` above: `(command \cmd)`
9802/// means a real program CAN build a `[string]
9803/// inline-cmd` value to pass here — but `Context` (`rustyfi-backend`) still
9804/// cannot hold an arbitrary lang-side `Value` without a reverse crate
9805/// dependency, and the one seam this codebase uses for that indirection
9806/// (`Interp::hooks`'s ID-table, `eval.rs`) sits outside this file's
9807/// boundary — so the command argument is accepted (to keep the
9808/// arity/signature faithful) and dropped.
9809fn prim_set_code_text_command(
9810 interp: &mut Interp,
9811 mut args: Vec<Value>,
9812) -> Result<Value, EvalError> {
9813 let mut ctx = as_context(args.pop().unwrap())?;
9814 let cmd = args.pop().unwrap();
9815 ctx.code_text_command = Some(interp.register_math_command(cmd));
9816 Ok(Value::Context(Box::new(ctx)))
9817}
9818
9819/// `get-natural-length : block-boxes -> length` (vminst.ml:2040) —
9820/// FAITHFUL: `get-natural-width`'s block sibling (`get-natural-width` itself
9821/// is a `pervasives.satyh` wrapper over `get-natural-metrics`, not a
9822/// primitive). A block's own "natural length" is its total vertical extent
9823/// — `measure_block`'s two components (height above the nominal top, depth
9824/// of the last line) summed into one length.
9825fn prim_get_natural_length(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9826 let bb = as_block_boxes(args.pop().unwrap())?;
9827 let (height, depth) = measure_block(&bb);
9828 Ok(Value::Length(height + depth))
9829}
9830
9831/// `set-dominant-wide-script : script -> context -> context`
9832/// (vminst.ml:1511 `PrimitiveSetDominantWideScript`) — FAITHFUL store,
9833/// consumed by `get-dominant-wide-script` now, by CJK script normalization
9834/// later.
9835fn prim_set_dominant_wide_script(
9836 _interp: &mut Interp,
9837 mut args: Vec<Value>,
9838) -> Result<Value, EvalError> {
9839 let ctx = as_context(args.pop().unwrap())?;
9840 let dominant_wide_script = as_script(args.pop().unwrap())?;
9841 Ok(Value::Context(Box::new(Context {
9842 dominant_wide_script,
9843 ..ctx
9844 })))
9845}
9846
9847/// `set-dominant-narrow-script : script -> context -> context`
9848/// (vminst.ml:1539) — FAITHFUL store, mirror of the wide setter.
9849fn prim_set_dominant_narrow_script(
9850 _interp: &mut Interp,
9851 mut args: Vec<Value>,
9852) -> Result<Value, EvalError> {
9853 let ctx = as_context(args.pop().unwrap())?;
9854 let dominant_narrow_script = as_script(args.pop().unwrap())?;
9855 Ok(Value::Context(Box::new(Context {
9856 dominant_narrow_script,
9857 ..ctx
9858 })))
9859}
9860
9861/// `set-language : script -> language -> context -> context`
9862/// (vminst.ml:1568 `PrimitiveSetLangSys`) — FAITHFUL per-script map insert
9863/// (`langsys_scheme |> ScriptSchemeMap.add script langsys` upstream; a
9864/// 4-slot array write here).
9865fn prim_set_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9866 let ctx = as_context(args.pop().unwrap())?;
9867 let langsys = as_language(args.pop().unwrap())?;
9868 let script = as_script(args.pop().unwrap())?;
9869 let mut langsys_scheme = ctx.langsys_scheme;
9870 langsys_scheme[script as usize] = langsys;
9871 Ok(Value::Context(Box::new(Context {
9872 langsys_scheme,
9873 ..ctx
9874 })))
9875}
9876
9877/// `get-dominant-wide-script : context -> script` (vminst.ml:1526) — FAITHFUL.
9878fn prim_get_dominant_wide_script(
9879 _interp: &mut Interp,
9880 mut args: Vec<Value>,
9881) -> Result<Value, EvalError> {
9882 let ctx = as_context(args.pop().unwrap())?;
9883 Ok(make_script_value(ctx.dominant_wide_script))
9884}
9885
9886/// `get-dominant-narrow-script : context -> script` (vminst.ml:1555) — FAITHFUL.
9887fn prim_get_dominant_narrow_script(
9888 _interp: &mut Interp,
9889 mut args: Vec<Value>,
9890) -> Result<Value, EvalError> {
9891 let ctx = as_context(args.pop().unwrap())?;
9892 Ok(make_script_value(ctx.dominant_narrow_script))
9893}
9894
9895/// `get-language : script -> context -> language` (vminst.ml:1587
9896/// `PrimitiveGetLangSys`) — FAITHFUL. Upstream routes through
9897/// `get_language_system`, whose `normalize_script` step is the identity on
9898/// every script a VALUE can carry (only the char-decoder-internal
9899/// CommonNarrow/CommonWide/Inherited normalize, horzBox.ml:470-479), so
9900/// this is a plain indexed read; absent-entry default `NoLanguageSystem`
9901/// is baked into the array's initial value.
9902fn prim_get_language(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9903 let ctx = as_context(args.pop().unwrap())?;
9904 let script = as_script(args.pop().unwrap())?;
9905 Ok(make_language_value(ctx.langsys_scheme[script as usize]))
9906}
9907
9908/// `set-every-word-break : inline-boxes -> inline-boxes -> context -> context`
9909/// (vminst.ml:3007 `PrimitiveSetEveryWordBreak`) — sets the inline-boxes
9910/// inserted before/after every inter-word break (mdja.satyh uses it for a
9911/// CJK word-break strut). STAND-IN: accepted and dropped (no per-context
9912/// every-word-break state yet), same pattern as `prim_set_language` above.
9913fn prim_set_every_word_break(
9914 _interp: &mut Interp,
9915 mut args: Vec<Value>,
9916) -> Result<Value, EvalError> {
9917 let ctx = as_context(args.pop().unwrap())?;
9918 let _after = args.pop().unwrap();
9919 let _before = args.pop().unwrap();
9920 Ok(Value::Context(Box::new(ctx)))
9921}
9922
9923/// `register-outline : (int * string * string * bool) list -> unit`
9924/// (vminstdef.yaml:2794 `BackendRegisterOutline`) — FAITHFUL: upstream
9925/// REPLACES the whole registered list (`outline.ml`: `registered_outline :=
9926/// ol`), it does not append; and it is callable anywhere (no
9927/// during-page-break gate — upstream's `Outline.register` has no `State`
9928/// check). Keys resolve through [`Interp::dest_name`] (upstream
9929/// `make_entry`'s `NamedDest.get key`).
9930fn prim_register_outline(interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9931 let entries = as_list(args.pop().unwrap())?;
9932 let mut out = Vec::with_capacity(entries.len());
9933 for e in entries {
9934 let Value::Tuple(vs) = e else {
9935 return eval_error("register-outline expects a list of (int * string * string * bool)");
9936 };
9937 if vs.len() != 4 {
9938 return eval_error("register-outline expects 4-tuples (level, text, key, is-open)");
9939 }
9940 let mut it = vs.into_iter();
9941 let level = as_int(it.next().unwrap())?;
9942 let text = as_str(it.next().unwrap())?;
9943 let key = as_str(it.next().unwrap())?;
9944 let is_open = as_bool(it.next().unwrap())?;
9945 let dest_name = interp.dest_name(&key);
9946 out.push(OutlineEntry {
9947 level,
9948 text,
9949 dest_name,
9950 is_open,
9951 });
9952 }
9953 interp.outline = out; // replace, not extend
9954 Ok(Value::Unit)
9955}
9956
9957/// Recursive `extract_one` helper for [`prim_extract_string`] — mirrors
9958/// `horzBox.ml`'s `extract_string`'s `extract_one`: an `InnerString`
9959/// contributes its own text, a `Discretionary` recurses into `no_break`
9960/// (the "not yet broken" reading), every other box contributes nothing.
9961/// This port's box vocabulary has no separate Rising/Frame/ScriptGuard
9962/// wrapper (`inline-frame-breakable` et al. already flatten their padding
9963/// into the same flat `Vec<HorzBox>` — see `prim_inline_frame_breakable`),
9964/// so there is nothing else to recurse into.
9965fn extract_string_pure_one(phb: &PureHorzBox) -> String {
9966 match phb {
9967 PureHorzBox::InnerString { text, .. } => text.clone(),
9968 PureHorzBox::Discretionary { no_break, .. } => {
9969 no_break.iter().map(extract_string_pure_one).collect()
9970 }
9971 // Upstream `extract_string` recurses into frames.
9972 PureHorzBox::Frame { contents, .. } => contents
9973 .iter()
9974 .map(|(_, b)| extract_string_pure_one(b))
9975 .collect(),
9976 _ => String::new(),
9977 }
9978}
9979
9980fn extract_string_one(hb: &HorzBox) -> String {
9981 match hb {
9982 HorzBox::Pure(phb) => extract_string_pure_one(phb),
9983 }
9984}
9985
9986/// `extract-string : inline-boxes -> string` (vminstdef.yaml:1565
9987/// `PrimitiveExtract`) — FAITHFUL (see [`extract_string_one`]).
9988fn prim_extract_string(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
9989 let boxes = as_inline_boxes(args.pop().unwrap())?;
9990 let s: String = boxes.iter().map(extract_string_one).collect();
9991 Ok(Value::Str(s))
9992}
9993
9994/// `get-initial-text-info : unit -> text-info` (v0.0.6 vminst.ml:953
9995/// `TextGetInitialTextModeContext`) — FAITHFUL:
9996/// `TextBackend.get_initial_text_mode_context` is `{ indent = 0;
9997/// escape_list = [] }` (textBackend.ml:9-12); escape_list is omitted from
9998/// the port's `TextInfo` (see its doc comment). The v0.0.6 fork side.
9999fn prim_get_initial_text_info_v006(
10000 _interp: &mut Interp,
10001 mut args: Vec<Value>,
10002) -> Result<Value, EvalError> {
10003 let _unit = args.pop().unwrap();
10004 Ok(Value::TextInfo(TextInfo { indent: 0 }))
10005}
10006
10007/// `get-initial-text-info : inline [math-text] -> (string -> option string
10008/// -> option string -> string) -> text-info` (dev-0-1-0 vminst.ml:904-925)
10009/// — the v0.1 fork side. STAND-IN: pops and
10010/// drops both new arguments (the text-mode default math command and the
10011/// math-scripts stringifier) — this port's `TextInfo` carries no text-mode
10012/// command state, same degenerate policy as `stringify-math`. Returns the
10013/// same `TextInfo{indent: 0}` as the v0.0.6 side.
10014fn prim_get_initial_text_info_v01(
10015 _interp: &mut Interp,
10016 mut args: Vec<Value>,
10017) -> Result<Value, EvalError> {
10018 let _stringifier = args.pop().unwrap();
10019 let _default_math_cmd = args.pop().unwrap();
10020 Ok(Value::TextInfo(TextInfo { indent: 0 }))
10021}
10022
10023/// `deepen-indent : int -> text-info -> text-info` (vminst.ml:921
10024/// `TextDeepenIndent`) — FAITHFUL: `indent + max i 0`
10025/// (`TextBackend.deepen_indent`, textBackend.ml:15-16 — the INCREMENT is
10026/// clamped, not the total).
10027fn prim_deepen_indent(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
10028 let tinfo = as_text_info(args.pop().unwrap())?;
10029 let i = as_int(args.pop().unwrap())?;
10030 Ok(Value::TextInfo(TextInfo {
10031 indent: tinfo.indent + i.max(0),
10032 }))
10033}
10034
10035/// `break : text-info -> string` (vminst.ml:935 `TextBreak`) — FAITHFUL:
10036/// `"\n" ^ String.make indent ' '` (`TextBackend.get_indent`).
10037fn prim_break(_interp: &mut Interp, mut args: Vec<Value>) -> Result<Value, EvalError> {
10038 let tinfo = as_text_info(args.pop().unwrap())?;
10039 let mut s = String::with_capacity(1 + tinfo.indent as usize);
10040 s.push('\n');
10041 for _ in 0..tinfo.indent {
10042 s.push(' ');
10043 }
10044 Ok(Value::Str(s))
10045}
10046
10047// ============================================================================
10048// unit tests: `as_page` (every paper-size ctor),
10049// `read_content_scheme`/`read_parts_scheme` (field extraction +
10050// missing-field errors). These extractors are private, so the tests live
10051// in-module rather than in `tests/`, same pattern as `crossref.rs`'s own
10052// `#[cfg(test)] mod tests`.
10053// ============================================================================
10054#[cfg(test)]
10055mod page_model_tests {
10056 use super::*;
10057
10058 #[test]
10059 fn as_page_maps_every_nullary_ctor_to_the_right_paper_size() {
10060 let cases: &[(&str, PaperSize)] = &[
10061 ("A0Paper", PaperSize::A0),
10062 ("A1Paper", PaperSize::A1),
10063 ("A2Paper", PaperSize::A2),
10064 ("A3Paper", PaperSize::A3),
10065 ("A4Paper", PaperSize::A4),
10066 ("A5Paper", PaperSize::A5),
10067 ("USLetter", PaperSize::USLetter),
10068 ("USLegal", PaperSize::USLegal),
10069 ];
10070 for (name, expected) in cases {
10071 let v = Value::Ctor((*name).to_string(), None);
10072 assert_eq!(as_page(v).unwrap(), *expected, "ctor {name}");
10073 }
10074 }
10075
10076 #[test]
10077 fn as_page_unwraps_user_defined_papers_tuple_payload() {
10078 let v = Value::Ctor(
10079 "UserDefinedPaper".to_string(),
10080 Some(Box::new(Value::Tuple(vec![
10081 Value::Length(Length::pt(100.0)),
10082 Value::Length(Length::pt(200.0)),
10083 ]))),
10084 );
10085 assert_eq!(
10086 as_page(v).unwrap(),
10087 PaperSize::UserDefined(Length::pt(100.0), Length::pt(200.0))
10088 );
10089 }
10090
10091 #[test]
10092 fn a4_paper_dims_are_595_by_842_points() {
10093 let (w, h) = PaperSize::A4.dims();
10094 assert!((w.0 - 595.0).abs() < 1.0, "width: {}", w.0);
10095 assert!((h.0 - 842.0).abs() < 1.0, "height: {}", h.0);
10096 }
10097
10098 #[test]
10099 fn read_content_scheme_extracts_origin_and_height() {
10100 let mut fields = BTreeMap::new();
10101 fields.insert(
10102 "text-origin".to_string(),
10103 Value::Tuple(vec![
10104 Value::Length(Length::pt(10.0)),
10105 Value::Length(Length::pt(20.0)),
10106 ]),
10107 );
10108 fields.insert("text-height".to_string(), Value::Length(Length::pt(300.0)));
10109 let (origin, height) = read_content_scheme(Value::Record(fields)).unwrap();
10110 assert_eq!(origin, (Length::pt(10.0), Length::pt(20.0)));
10111 assert_eq!(height, Length::pt(300.0));
10112 }
10113
10114 #[test]
10115 fn read_content_scheme_errors_on_a_missing_field() {
10116 let mut fields = BTreeMap::new();
10117 fields.insert(
10118 "text-origin".to_string(),
10119 Value::Tuple(vec![
10120 Value::Length(Length::ZERO),
10121 Value::Length(Length::ZERO),
10122 ]),
10123 );
10124 let err = read_content_scheme(Value::Record(fields)).unwrap_err();
10125 assert!(
10126 err.msg.contains("text-height"),
10127 "error should name the missing field: {}",
10128 err.msg
10129 );
10130 }
10131
10132 #[test]
10133 fn read_parts_scheme_extracts_all_four_fields() {
10134 let mut fields = BTreeMap::new();
10135 fields.insert(
10136 "header-origin".to_string(),
10137 Value::Tuple(vec![
10138 Value::Length(Length::ZERO),
10139 Value::Length(Length::ZERO),
10140 ]),
10141 );
10142 fields.insert("header-content".to_string(), Value::BlockBoxes(Vec::new()));
10143 fields.insert(
10144 "footer-origin".to_string(),
10145 Value::Tuple(vec![
10146 Value::Length(Length::pt(1.0)),
10147 Value::Length(Length::pt(2.0)),
10148 ]),
10149 );
10150 fields.insert("footer-content".to_string(), Value::BlockBoxes(Vec::new()));
10151 let (horg, hbb, forg, fbb) = read_parts_scheme(Value::Record(fields)).unwrap();
10152 assert_eq!(horg, (Length::ZERO, Length::ZERO));
10153 assert!(hbb.is_empty());
10154 assert_eq!(forg, (Length::pt(1.0), Length::pt(2.0)));
10155 assert!(fbb.is_empty());
10156 }
10157
10158 #[test]
10159 fn read_parts_scheme_errors_on_a_missing_field() {
10160 let err = read_parts_scheme(Value::Record(BTreeMap::new())).unwrap_err();
10161 assert!(
10162 err.msg.contains("header-origin"),
10163 "error should name the missing field: {}",
10164 err.msg
10165 );
10166 }
10167}
10168
10169/// `enter_script_scales_and_saturates`:
10170/// `enter_script` is crate-private, so this lives here rather than in the
10171/// external `tests/v01_math.rs` integration suite, which can only reach
10172/// `pub` items.
10173#[cfg(test)]
10174mod math_split_tests {
10175 use super::*;
10176 use rustyfi_backend::FontMetrics;
10177
10178 /// A `FontMetrics` stub with NO MATH table (`math_constants` defaults
10179 /// to `None`) — exercises `enter_script`'s documented fallback
10180 /// constants (`0.7`, `5.0/7.0`), the shape every other base-14 fixture
10181 /// in this crate already relies on.
10182 struct NoMath;
10183 impl FontMetrics for NoMath {
10184 fn advance(&self, _f: FontKey, c: char, size: Length) -> Option<Length> {
10185 if c.is_ascii() {
10186 Some(size * 0.5)
10187 } else {
10188 None
10189 }
10190 }
10191 fn ascender(&self, _f: FontKey, size: Length) -> Length {
10192 size * 0.75
10193 }
10194 fn descender(&self, _f: FontKey, size: Length) -> Length {
10195 size * 0.25
10196 }
10197 }
10198
10199 #[test]
10200 fn enter_script_scales_and_saturates() {
10201 let metrics = NoMath;
10202 let interp = Interp::new(&metrics);
10203 let ctx = Context::initial(Length::pt(400.0));
10204 assert_eq!(ctx.math_script_level, MathScriptLevel::Base);
10205 assert_eq!(ctx.font_size, Length::pt(12.0));
10206
10207 // Base -> Script: font_size * script_scale_down (fallback 0.7).
10208 let s1 = enter_script(&interp, &ctx);
10209 assert_eq!(s1.math_script_level, MathScriptLevel::Script);
10210 assert!(
10211 (s1.font_size.0 - ctx.font_size.0 * 0.7).abs() < 1e-9,
10212 "expected {} * 0.7, got {}",
10213 ctx.font_size.0,
10214 s1.font_size.0
10215 );
10216
10217 // Script -> ScriptScript: font_size * (script_script_scale_down /
10218 // script_scale_down) (fallback 5.0/7.0).
10219 let s2 = enter_script(&interp, &s1);
10220 assert_eq!(s2.math_script_level, MathScriptLevel::ScriptScript);
10221 assert!(
10222 (s2.font_size.0 - s1.font_size.0 * (5.0 / 7.0)).abs() < 1e-9,
10223 "expected {} * 5/7, got {}",
10224 s1.font_size.0,
10225 s2.font_size.0
10226 );
10227
10228 // ScriptScript saturates: no further shrink, level stays put.
10229 let s3 = enter_script(&interp, &s2);
10230 assert_eq!(s3.math_script_level, MathScriptLevel::ScriptScript);
10231 assert_eq!(s3.font_size, s2.font_size);
10232 }
10233}