rustyfi_backend/context.rs
1use crate::font::FontKey;
2use crate::graphics::Color;
3use crate::length::Length;
4use crate::math::{default_math_class_map, MathCharClass, MathKind};
5use std::collections::BTreeMap;
6use std::sync::Arc;
7
8/// Opaque handle to the context's installed math command (v0.0.6
9/// `context_main.math_command`). The closure VALUE lives lang-side in
10/// `Interp::math_commands` — this crate cannot depend on
11/// `rustyfi_lang::Value`, so this is the same id-into-an-`Interp`-table
12/// seam as `ImageId`/`HookId` (hbox.rs).
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub struct MathCmdId(pub usize);
15
16/// v0.0.6 `CharBasis.script`, SURFACE subset: the four constructors a
17/// `script` VALUE can carry (`get_script`, evalUtil.ml:235-241; the port's
18/// `script` variant decl, prim_types.rs `script_decl`). Upstream's
19/// internal-only `CommonNarrow`/`CommonWide`/`Inherited` (charBasis.ml:11-13)
20/// arise solely inside the char decoder; context storage never sees them.
21/// Discriminants index `langsys_scheme`.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Script {
24 HanIdeographic = 0,
25 /// Upstream internal name `HiraganaOrKatakana`; surface ctor `Kana`.
26 Kana = 1,
27 Latin = 2,
28 OtherScript = 3,
29}
30
31/// v0.0.6 `CharBasis.language_system` (the port's `language` variant decl).
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Language {
34 Japanese,
35 English,
36 NoLanguageSystem,
37}
38
39/// The set of Knuth–Liang hyphenation dictionaries a `Context` may have
40/// installed. Deliberately a `Copy` tag with **no dependency on the
41/// `hyphenation` crate** — `Context` is cloned constantly, so it cannot own a
42/// `hyphenation::Standard` dictionary (~89 KiB, not cheaply clonable). The
43/// dictionaries live in a process-global load-once cache in
44/// `crates/rustyfi-lang/src/hyphenation.rs`, keyed by this tag.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub enum HyphenLang {
47 EnglishUS,
48 /// en-GB. Maps to `hyphenation::Language::EnglishGB`.
49 EnglishGB,
50}
51
52/// SATySFi 0.1's `math_script_level` (`dev-0-1-0 src/backend/horzBox.ml:
53/// 139-142`) — how many script-nesting levels deep the current math reading
54/// context sits. V0_0 never reads it (its script-size shrink is a fixed
55/// per-call constant, not context-carried).
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum MathScriptLevel {
58 Base,
59 Script,
60 ScriptScript,
61}
62
63/// One script's font selection within a `Context::font_scheme`:
64/// upstream `font_with_ratio` (`horzBox.ml`) folded into a plain struct.
65/// `ratio` scales `ctx.font_size` for this script's glyphs; `rising` is a
66/// further fraction-of-size baseline raise (`fontInfo.ml`'s
67/// `get_font_with_ratio`).
68#[derive(Clone, Copy, Debug, PartialEq)]
69pub struct ScriptFont {
70 pub font: FontKey,
71 pub ratio: f64,
72 pub rising: f64,
73}
74
75/// The typesetting context (a subset of `context_main` in horzBox.ml).
76#[derive(Clone, Debug, PartialEq)]
77pub struct Context {
78 pub font: FontKey,
79 /// The dedicated math font (v0.0.6 context_main.math_font; set-math-font).
80 /// Math layout measures/emits glyphs under THIS key, falling back to `font`
81 /// per-glyph when it has no glyph (see primitives::math_glyph_font). The
82 /// OpenType MATH-table lookups key on this same FontKey.
83 pub math_font: FontKey,
84 pub font_size: Length,
85 /// Baseline-to-baseline distance.
86 pub leading: Length,
87 /// Wrap width for paragraphs.
88 pub paragraph_width: Length,
89 /// Extra vertical skip inserted above a paragraph
90 /// (`set-paragraph-margin`'s first argument; v0.0.6
91 /// `context_main.paragraph_top`, horzBox.ml:227). Emitted by
92 /// `prim_line_break` as a leading `VertBox::Skip`. A skip at the
93 /// very top of a page/column is discarded by `chop_page` (upstream's
94 /// page-top glue suppression), so this adds no space above a page's first
95 /// paragraph.
96 pub paragraph_top: Length,
97 /// Extra vertical skip inserted below a paragraph
98 /// (`set-paragraph-margin`'s second argument; v0.0.6
99 /// `context_main.paragraph_bottom`, horzBox.ml:228) — a trailing
100 /// `VertBox::Skip`.
101 pub paragraph_bottom: Length,
102 /// A manual vertical shift applied to text set under this context
103 /// (`set-manual-rising`'s argument; v0.0.6 `context_main.manual_rising`,
104 /// horzBox.ml:232). Stored only: nothing downstream reads it (upstream's
105 /// `PHGRising` box has no analogue here).
106 pub manual_rising: Length,
107 /// `set-dominant-wide-script` (v0.0.6 `context_main.dominant_wide_script`,
108 /// horzBox.ml:218). Storage only; no layout consumer yet.
109 pub dominant_wide_script: Script,
110 /// `set-dominant-narrow-script` (horzBox.ml:219). Same status.
111 pub dominant_narrow_script: Script,
112 /// `set-language`/`get-language` (v0.0.6 `context_main.langsys_scheme`,
113 /// horzBox.ml:216 — a script→language_system map). Stored as a dense
114 /// 4-slot array indexed by `Script`'s discriminant; upstream's "absent
115 /// from map" IS `NoLanguageSystem` (`get_language_system`'s default,
116 /// horzBox.ml:483-487), so the empty map and `[NoLanguageSystem; 4]`
117 /// are indistinguishable — no Option needed.
118 pub langsys_scheme: [Language; 4],
119 /// `set-font`'s per-script font/ratio/rising scheme (v0.0.6
120 /// `context_main.font_scheme`, horzBox.ml:214), indexed by `Script`'s
121 /// discriminant. Resolution rule (back-compat critical, see `set-font`'s
122 /// doc in rustyfi-lang): `Latin`-script text reads `Context::font`
123 /// directly, NOT this scheme's `Latin` slot — `set-font Latin f` writes
124 /// BOTH so the two stay in sync, leaving a bare
125 /// `set-font-key`/`\bold`/`\emph` (which only touches `font`) unaffected.
126 pub font_scheme: [ScriptFont; 4],
127 /// `set-space-ratio-between-scripts` (v0.0.6
128 /// `context_main.script_space_map`, horzBox.ml:222) — the space inserted
129 /// between two adjacent runs of DIFFERENT scripts, as a ratio of
130 /// `font_size`. Indexed `[left script][right script]` by `Script`'s
131 /// discriminant, so the map is directional: upstream keys
132 /// `ScriptSpaceMap` on the ordered pair and registers the four Latin↔CJK
133 /// directions separately (`primitives.cppo.ml:491-494`).
134 ///
135 /// **Only the NATURAL ratio is stored, because only it is observable.**
136 /// The primitive takes three (natural, shrink, stretch), and upstream's
137 /// `ScriptSpaceMap` really does hold all three — but
138 /// `pure_space_between_scripts` spends them as
139 /// `LBAtom((natural (size *% r0), size *% r1, size *% r2), _)`, whose
140 /// first field is `metrics = length_info * length * length`, i.e. *(width
141 /// info, height, depth)*. `r1` and `r2` land in the height and depth slots
142 /// and never reach the glue's elasticity; see `primitives.rs`'s
143 /// `interscript_glue` for the full argument. Keeping the two dead ratios
144 /// here would imply a stretch this glue does not have.
145 ///
146 /// Upstream's map is SPARSE and a miss falls through to the JLreq class
147 /// table and then to `adjacent_space`; this dense array cannot distinguish
148 /// "absent" from "present and 0.0". That costs nothing, because the port
149 /// only ever consults it at a `is_latin_cjk_boundary`, where upstream's
150 /// fall-through is already unreachable — the four Latin↔CJK keys are
151 /// exactly the ones the default map fills. A 0.0 entry still emits a
152 /// zero-width glue rather than nothing, keeping the break opportunity
153 /// upstream's `discretionary_if_breakable` wrapper grants regardless of
154 /// the box's width.
155 pub script_space_map: [[f64; 4]; 4],
156 /// `set-text-color`/`get-text-color` (row 1-2; v0.0.6
157 /// `context_main.text_color`). Copied into each run's
158 /// `HorzStringInfo::color` at box-construction time — which is what both
159 /// PDF writers emit their fill-color op from.
160 pub text_color: Color,
161 /// `set-hyphen-penalty` (row 3; v0.0.6 `context_main.hyphen_badness`).
162 /// Consumed by `rustyfi-lang`'s `flush_word` injection as each injected
163 /// `Discretionary`'s `penalty`, but only when `hyphen_dictionary` is
164 /// `Some(_)`; with no dictionary installed it has no layout effect.
165 pub hyphen_badness: i64,
166 /// The installed hyphenation dictionary
167 /// (`set-hyphenation-dictionary`/`load-hyphenation-dictionary`; v0.0.6
168 /// `context_main.hyphen_dictionary`). `flush_word` (`rustyfi-lang`) runs
169 /// the hyphenation branch **iff** this is `Some(tag)` and the run's
170 /// script is `Latin`.
171 pub hyphen_dictionary: Option<HyphenLang>,
172 /// Minimum number of chars that must PRECEDE an accepted hyphenation
173 /// break (`set-hyphen-min`'s first argument; v0.0.6
174 /// `context_main.left_hyphen_min`). Default 3.
175 pub left_hyphen_min: i64,
176 /// Minimum number of chars that must FOLLOW it (`set-hyphen-min`'s
177 /// second argument). Default 2.
178 pub right_hyphen_min: i64,
179 /// `set-space-ratio`'s three fields (row 4; v0.0.6
180 /// `context_main.space_natural`/`space_shrink`/`space_stretch`). Each is
181 /// a ratio of `font_size` DIRECTLY, not of the natural width:
182 /// `text_to_boxes`'s interword glue is natural = `font_size *
183 /// space_natural`, shrink/stretch likewise.
184 pub space_natural: f64,
185 pub space_shrink: f64,
186 pub space_stretch: f64,
187 /// `set-adjacent-stretch` (v0.0.6 `context_main.adjacent_stretch`) — the
188 /// stretch, as a ratio of `font_size`, of the glue SATySFi puts between
189 /// two DIRECTLY ADJACENT CJK characters (`convertText.ml:101`
190 /// `adjacent_space`: natural 0, shrink 0, stretch `font_size * ratio`).
191 /// This is what lets a Japanese line fill its column: unspaced CJK has no
192 /// interword glue, so without it a CJK line's only elasticity is whatever
193 /// incidental Latin spaces it happens to contain.
194 pub adjacent_stretch: f64,
195 /// The installed `[math] inline-cmd` applied to bare `${…}` in inline
196 /// text (v0.0.6 `context_main.math_command`). `None` only for contexts
197 /// built by `Context::initial` directly (unit tests) — the
198 /// `get-initial-context` primitive always installs its second argument.
199 pub math_command: Option<MathCmdId>,
200 /// `set-code-text-command` (v0.0.6 `context_main.code_text_command`) — the
201 /// `[string] inline-cmd` a backtick literal inside inline text is handed
202 /// to. `None` is upstream's `DefaultCodeTextCommand`: the literal is set as
203 /// ordinary text. Same id-into-`Interp` handle as `math_command`.
204 pub code_text_command: Option<MathCmdId>,
205 /// `\mathrm`/`\bm`/… restyling target (v0.0.6 `context_main.
206 /// math_char_class`): which Mathematical- Alphanumeric style block a
207 /// plain `${…}` letter resolves to. Set by `Math::ChangeCharClass`'s
208 /// layout arm (`primitives.rs`), consulted by `resolve_variant_char`.
209 /// Defaults to `Italic`, v0.0.6's own default.
210 pub math_char_class: MathCharClass,
211 /// Upstream `default_math_class_map` (`primitives.cppo.ml:465-480`):
212 /// whole-TOKEN entries (`=`, `-`, `,`, …) consulted BEFORE the per-char
213 /// variant lookup below. `Arc` so that cloning a `Context` (every `..ctx`
214 /// spread) stays a refcount bump.
215 pub math_class_map: Arc<BTreeMap<String, (String, MathKind)>>,
216 /// `set-math-variant-char`'s runtime override table: `(source
217 /// char, style) -> replacement char`, consulted BEFORE
218 /// `default_math_variant_char`'s built-in Mathematical-Alphanumeric
219 /// remap. Empty by default; copy-on-written via `Arc::make_mut`.
220 pub math_variant_char_map: Arc<BTreeMap<(char, MathCharClass), char>>,
221 /// V0_1-only (`enter_script`, port of `dev-0-1-0
222 /// src/frontend/context.ml:52-68`). `Base` under V0_0 always.
223 pub math_script_level: MathScriptLevel,
224 /// Whether the current math sub-formula is laid out "cramped" (TeXbook
225 /// Appendix G): set on the recursive layout `Context` for a radical's
226 /// radicand, a fraction's denominator, and any subscript. Read by BOTH
227 /// V0_0 and V0_1 (the bit rides the shared layout-recursion clone, not a
228 /// version-gated primitive). Only consumed by `sup_shift_clamped`'s
229 /// superscript shift-up formula.
230 pub math_cramped: bool,
231}
232
233/// `default_script_space_map` (`primitives.cppo.ml:487-494`): the four
234/// Latin↔CJK directions carry `space_latin_cjk`, every other ordered pair is
235/// unset. Only the natural ratio survives into layout — see
236/// [`Context::script_space_map`].
237pub fn default_script_space_map() -> [[f64; 4]; 4] {
238 // `let space_latin_cjk = (0.24, 0.08, 0.16)` — the 0.08 and 0.16 are the
239 // shrink and stretch upstream never actually applies.
240 const LATIN_CJK_NATURAL: f64 = 0.24;
241 let mut m = [[0.0; 4]; 4];
242 for cjk in [Script::HanIdeographic, Script::Kana] {
243 m[Script::Latin as usize][cjk as usize] = LATIN_CJK_NATURAL;
244 m[cjk as usize][Script::Latin as usize] = LATIN_CJK_NATURAL;
245 }
246 m
247}
248
249impl Context {
250 /// The default context `get-initial-context` hands to `document`.
251 pub fn initial(paragraph_width: Length) -> Context {
252 Context {
253 font: FontKey(0),
254 math_font: FontKey(0),
255 font_size: Length::pt(12.0),
256 leading: Length::pt(18.0),
257 paragraph_width,
258 // Default paragraph margin. Measured against the reference SATySFi
259 // 0.0.11 (flake.nix): a body paragraph boundary advances 27pt =
260 // leading(18) + 9, i.e. the effective paragraph margin is 9pt
261 // (= font_size 12 × 0.75), NOT the 18pt an older reading of
262 // primitives.cppo.ml assumed. Matching it makes the port's page
263 // breaks coincide with SATySFi's across the prose corpus.
264 paragraph_top: Length::pt(18.0),
265 paragraph_bottom: Length::pt(18.0),
266 manual_rising: Length::ZERO,
267 dominant_wide_script: Script::OtherScript,
268 dominant_narrow_script: Script::OtherScript,
269 langsys_scheme: [Language::NoLanguageSystem; 4],
270 font_scheme: [ScriptFont {
271 font: FontKey(0),
272 ratio: 1.0,
273 rising: 0.0,
274 }; 4],
275 script_space_map: default_script_space_map(),
276 // v0.0.6's `get_pdf_mode_initial_context` (primitives.cppo.ml):
277 // `text_color = DeviceGray 0.`, `hyphen_badness = 100`,
278 // `space_natural = 0.33`, `space_shrink = 0.08`,
279 // `space_stretch = 0.16`, `adjacent_stretch = 0.025`.
280 text_color: Color::Gray(0.0),
281 hyphen_badness: 100,
282 // Upstream loads `dist/hyph/english.satysfi-hyph` into
283 // `default_hyphen_dictionary` at startup and hands it to EVERY
284 // initial context (`primitives.cppo.ml:500,607`).
285 hyphen_dictionary: Some(HyphenLang::EnglishUS),
286 left_hyphen_min: 3,
287 right_hyphen_min: 2,
288 space_natural: 0.33,
289 space_shrink: 0.08,
290 space_stretch: 0.16,
291 // `convertText.ml:103` — inter-CJK glue stretch ratio.
292 adjacent_stretch: 0.025,
293 math_command: None,
294 code_text_command: None,
295 math_char_class: MathCharClass::Italic,
296 math_class_map: Arc::new(default_math_class_map()),
297 math_variant_char_map: Arc::new(BTreeMap::new()),
298 math_script_level: MathScriptLevel::Base,
299 math_cramped: false,
300 }
301 }
302}
303
304/// Page geometry (A4 with even margins by default).
305#[derive(Clone, Debug, PartialEq)]
306pub struct PageGeometry {
307 pub paper_width: Length,
308 pub paper_height: Length,
309 /// Top-left corner of the text area.
310 pub text_origin: (Length, Length),
311 pub text_width: Length,
312 pub text_height: Length,
313}
314
315impl Default for PageGeometry {
316 fn default() -> Self {
317 let paper_width = Length::from_unit(210.0, "mm").unwrap();
318 let paper_height = Length::from_unit(297.0, "mm").unwrap();
319 let margin = Length::from_unit(25.0, "mm").unwrap();
320 PageGeometry {
321 paper_width,
322 paper_height,
323 text_origin: (margin, margin),
324 text_width: paper_width - margin - margin,
325 text_height: paper_height - margin - margin,
326 }
327 }
328}
329
330impl PageGeometry {
331 /// Build a geometry from `page`'s paper dimensions. Only
332 /// `paper_width`/`paper_height` are read by the PDF writer; the `text_*`
333 /// fields are vestigial here — each page's real text area lives in its
334 /// `PlacedLine` coordinates, set per page by the content scheme
335 /// (`chop_page`'s caller).
336 pub fn for_paper(paper_width: Length, paper_height: Length) -> PageGeometry {
337 PageGeometry {
338 paper_width,
339 paper_height,
340 text_origin: (Length::ZERO, Length::ZERO),
341 text_width: paper_width,
342 text_height: paper_height,
343 }
344 }
345}
346
347/// v0.0.6's `page_size` (`primitives.cppo.ml:203-212`) — the paper-size
348/// constant set `page-break`'s first argument selects from, the port of
349/// `get_pdf_paper` (`handlePdf.ml:406`, `Pdfpaper.t` dims from
350/// `pdfpaper.ml`).
351#[derive(Clone, Copy, Debug, PartialEq)]
352pub enum PaperSize {
353 A0,
354 A1,
355 A2,
356 A3,
357 A4,
358 A5,
359 USLetter,
360 USLegal,
361 UserDefined(Length, Length),
362}
363
364impl PaperSize {
365 /// `(width, height)` in points.
366 pub fn dims(&self) -> (Length, Length) {
367 fn mm(w: f64, h: f64) -> (Length, Length) {
368 (
369 Length::from_unit(w, "mm").unwrap(),
370 Length::from_unit(h, "mm").unwrap(),
371 )
372 }
373 fn inch(w: f64, h: f64) -> (Length, Length) {
374 (
375 Length::from_unit(w, "inch").unwrap(),
376 Length::from_unit(h, "inch").unwrap(),
377 )
378 }
379 match *self {
380 PaperSize::A0 => mm(841.0, 1189.0),
381 PaperSize::A1 => mm(594.0, 841.0),
382 PaperSize::A2 => mm(420.0, 594.0),
383 PaperSize::A3 => mm(297.0, 420.0),
384 PaperSize::A4 => mm(210.0, 297.0),
385 PaperSize::A5 => mm(148.0, 210.0),
386 PaperSize::USLetter => inch(8.5, 11.0),
387 PaperSize::USLegal => inch(8.5, 14.0),
388 PaperSize::UserDefined(w, h) => (w, h),
389 }
390 }
391}