Skip to main content

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}