Skip to main content

rustyfi_backend/
math.rs

1//! Math value / box model: trimmed analog of `math.ml`'s `math_kind`
2//! (`horzBox.ml:134`) and `low_math_atom` (`math.ml:9`).
3
4use crate::hbox::HorzStringInfo;
5use crate::length::Length;
6use std::collections::BTreeMap;
7
8/// v0.0.6 `math_kind` (horzBox.ml:134-145). `Prefix` is a SATySFi-specific
9/// class (differential `d`, `\partial`); `End` is a synthetic list-boundary
10/// sentinel.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum MathKind {
13    Ord,
14    Bin,
15    Rel,
16    Op,
17    Punct,
18    Open,
19    Close,
20    Prefix,
21    Inner,
22    End,
23}
24
25/// One already-positioned glyph inside a laid-out math run: a string set in
26/// `info` (font + size — a superscript carries a *smaller* size here), placed
27/// at `dx` right of the math box's origin and `dy` **above** its baseline
28/// (dy < 0 = below, for subscripts). The analog of `math.ml`'s `LowMathGlyph`
29/// after `horz_of_low_math` has resolved its `PHGRising` shift into an
30/// offset.
31#[derive(Clone, Debug, PartialEq)]
32pub struct MathGlyph {
33    pub info: HorzStringInfo,
34    pub text: String,
35    /// `Some(gid)`: a raw MATH-table variant glyph id
36    /// (`push_big_char_glyph`/`push_delimiter_glyph`), NOT necessarily
37    /// cmap-reachable from `text`; the CID writer emits it directly rather
38    /// than re-deriving a gid, keeping `text` as the ToUnicode source. `None`
39    /// for every ordinary cmap-driven glyph, and on every base-14 path (that
40    /// writer never reads this field).
41    pub gid: Option<u16>,
42    pub dx: Length,
43    pub dy: Length,
44    pub width: Length,
45    pub height: Length,
46    pub depth: Length,
47}
48
49/// v0.0.6 `math_char_class` (`primitives.cppo.ml`'s `MathItalic`/…): which
50/// Mathematical-Alphanumeric style block a plain `${…}` letter resolves to
51/// (`\mathrm`/`\mathbf`/… — `math.satyh`'s `\math-style`). `Ord`/`Hash` so
52/// it can key `Context::math_variant_char_map`'s override table.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub enum MathCharClass {
55    Italic,
56    BoldItalic,
57    Roman,
58    BoldRoman,
59    Script,
60    BoldScript,
61    Fraktur,
62    BoldFraktur,
63    DoubleStruck,
64    /// Upstream dev-0-1-0 widens `math_char_class` 9 → 14
65    /// (`b836d512:src/backend/horzBox.ml:98-113`); v0.0.6 has exactly the 9
66    /// above (`v0.0.6:src/backend/horzBox.ml:147-158`). These 5 are
67    /// unreachable under V0_0: registration
68    /// (`prim_types.rs::math_char_class_decl`) is V0_1-gated and typecheck
69    /// rejects unregistered constructor names — version-blind at this
70    /// enum/backend layer, version-gated at the registration layer.
71    SansSerif,
72    BoldSansSerif,
73    ItalicSansSerif,
74    BoldItalicSansSerif,
75    Typewriter,
76}
77
78/// Upstream `default_math_class_map` (`primitives.cppo.ml:465-480`):
79/// whole-TOKEN entries consulted BEFORE the per-char variant lookup below;
80/// value = (replacement codepoints, math class). Only `-` changes codepoint
81/// (`-` -> U+2212 MINUS SIGN); every other entry is an identity remap that
82/// exists purely to attach the right `MathKind` to the whole run.
83pub(crate) fn default_math_class_map() -> BTreeMap<String, (String, MathKind)> {
84    [
85        ("=", "=", MathKind::Rel),
86        ("<", "<", MathKind::Rel),
87        (">", ">", MathKind::Rel),
88        (":", ":", MathKind::Rel),
89        ("+", "+", MathKind::Bin),
90        ("-", "\u{2212}", MathKind::Bin),
91        ("|", "|", MathKind::Bin),
92        ("/", "/", MathKind::Ord),
93        (",", ",", MathKind::Punct),
94    ]
95    .into_iter()
96    .map(|(k, v, mk)| (k.to_string(), (v.to_string(), mk)))
97    .collect()
98}
99
100/// Upstream `default_math_variant_char_map` (`primitives.cppo.ml:358-460`)
101/// as a pure function (base-offset + exception lists) rather than a big table
102/// cloned into every `Context`, which stores only the runtime override map
103/// (`set-math-variant-char`). `None` means "no remap" — either `class`/`c` has
104/// no Unicode Mathematical-Alphanumeric counterpart (e.g. a digit), or the
105/// letter has no assigned codepoint in that style block (a handful of
106/// Script/Fraktur/Double-Struck letters use a distinct legacy symbol instead,
107/// per Unicode's own gaps — the hardcoded exceptions below).
108pub fn default_math_variant_char(class: MathCharClass, c: char) -> Option<char> {
109    let cap = c.is_ascii_uppercase().then(|| c as u32 - 'A' as u32);
110    let small = c.is_ascii_lowercase().then(|| c as u32 - 'a' as u32);
111    fn cp(base: u32, i: u32) -> Option<char> {
112        char::from_u32(base + i)
113    }
114    match class {
115        MathCharClass::Italic => match (cap, small) {
116            (Some(i), _) => cp(0x1D434, i),
117            (_, Some(7)) => Some('\u{210E}'),
118            (_, Some(i)) => cp(0x1D44E, i),
119            _ => None,
120        },
121        MathCharClass::BoldItalic => match (cap, small) {
122            (Some(i), _) => cp(0x1D468, i),
123            (_, Some(i)) => cp(0x1D482, i),
124            _ => None,
125        },
126        MathCharClass::Roman => (cap.is_some() || small.is_some()).then_some(c),
127        MathCharClass::BoldRoman => match (cap, small) {
128            (Some(i), _) => cp(0x1D400, i),
129            (_, Some(i)) => cp(0x1D41A, i),
130            _ => None,
131        },
132        MathCharClass::Script => match c {
133            'B' => Some('\u{212C}'),
134            'E' => Some('\u{2130}'),
135            'F' => Some('\u{2131}'),
136            'H' => Some('\u{210B}'),
137            'I' => Some('\u{2110}'),
138            'L' => Some('\u{2112}'),
139            'M' => Some('\u{2133}'),
140            'R' => Some('\u{211B}'),
141            'e' => Some('\u{212F}'),
142            'g' => Some('\u{210A}'),
143            'o' => Some('\u{2134}'),
144            _ => match (cap, small) {
145                (Some(i), _) => cp(0x1D49C, i),
146                (_, Some(i)) => cp(0x1D4B6, i),
147                _ => None,
148            },
149        },
150        MathCharClass::BoldScript => match (cap, small) {
151            (Some(i), _) => cp(0x1D4D0, i),
152            (_, Some(i)) => cp(0x1D4EA, i),
153            _ => None,
154        },
155        MathCharClass::Fraktur => match c {
156            'C' => Some('\u{212D}'),
157            'H' => Some('\u{210C}'),
158            'I' => Some('\u{2111}'),
159            'R' => Some('\u{211C}'),
160            'Z' => Some('\u{2128}'),
161            _ => match (cap, small) {
162                (Some(i), _) => cp(0x1D504, i),
163                (_, Some(i)) => cp(0x1D51E, i),
164                _ => None,
165            },
166        },
167        MathCharClass::BoldFraktur => match (cap, small) {
168            (Some(i), _) => cp(0x1D56C, i),
169            (_, Some(i)) => cp(0x1D586, i),
170            _ => None,
171        },
172        MathCharClass::DoubleStruck => match c {
173            'C' => Some('\u{2102}'),
174            'H' => Some('\u{210D}'),
175            'N' => Some('\u{2115}'),
176            'P' => Some('\u{2119}'),
177            'Q' => Some('\u{211A}'),
178            'R' => Some('\u{211D}'),
179            'Z' => Some('\u{2124}'),
180            _ => match (cap, small) {
181                (Some(i), _) => cp(0x1D538, i),
182                (_, Some(i)) => cp(0x1D552, i),
183                _ => None,
184            },
185        },
186        // These 5 Unicode blocks (`primitives.cppo.ml`'s capitals/smalls
187        // folds) are gap-free — no exception chars, unlike
188        // Script/Fraktur/DoubleStruck above.
189        // KNOWN GAP: upstream also remaps DIGITS in every class (sans
190        // `0x1D7E2`, bold-sans `0x1D7EC`, typewriter `0x1D7F6`, …); this
191        // returns `None` for digits under every class.
192        MathCharClass::SansSerif => match (cap, small) {
193            (Some(i), _) => cp(0x1D5A0, i),
194            (_, Some(i)) => cp(0x1D5BA, i),
195            _ => None,
196        },
197        MathCharClass::BoldSansSerif => match (cap, small) {
198            (Some(i), _) => cp(0x1D5D4, i),
199            (_, Some(i)) => cp(0x1D5EE, i),
200            _ => None,
201        },
202        MathCharClass::ItalicSansSerif => match (cap, small) {
203            (Some(i), _) => cp(0x1D608, i),
204            (_, Some(i)) => cp(0x1D622, i),
205            _ => None,
206        },
207        MathCharClass::BoldItalicSansSerif => match (cap, small) {
208            (Some(i), _) => cp(0x1D63C, i),
209            (_, Some(i)) => cp(0x1D656, i),
210            _ => None,
211        },
212        MathCharClass::Typewriter => match (cap, small) {
213            (Some(i), _) => cp(0x1D670, i),
214            (_, Some(i)) => cp(0x1D68A, i),
215            _ => None,
216        },
217    }
218}
219
220/// The *unstyled* letter a Mathematical Alphanumeric Symbol stands for:
221/// Unicode's own `<font>` compatibility decomposition, for the whole
222/// U+1D400..=U+1D7FF block plus **every** `<font>` decomposition in the
223/// Letterlike Symbols block U+2100..U+214F. `None` for anything else.
224///
225/// **Why this exists.** [`default_math_variant_char`] is the FORWARD
226/// direction — plain letter to styled codepoint — and it is what makes a
227/// `${x}` come out as `𝑥` U+1D465. Fonts, however, cover this block very
228/// unevenly, and a codepoint the chosen face has no `cmap` entry for is
229/// emitted as gid 0 (`.notdef`). What that LOOKS like depends on the face and
230/// is never what the author asked for: a TrueType face draws a tofu box, and
231/// a CFF/OTF face — including `latinmodern-math.otf`, this port's own default
232/// math font — usually has an EMPTY `.notdef`, so the character occupies its
233/// advance and paints nothing at all. That is the whole of "some glyphs are
234/// not drawn in PDF mode": no error, no warning, just absent letters.
235///
236/// **What the bundled faces actually cover**, measured off their `cmap`s
237/// rather than assumed, because the argument for this function rests on it:
238/// `latinmodern-math.otf` covers every ASSIGNED codepoint of U+1D400..=U+1D7FF
239/// *except* the two script LOWERCASE runs (U+1D4B6..=U+1D4CF and
240/// U+1D4EA..=U+1D503, plus the Letterlike `ℯ ℊ ℴ` that fill their holes) and
241/// the two bold digammas U+1D7CA/U+1D7CB — 51 codepoints in all. Its Fraktur,
242/// Double-struck, Greek, digit, sans-serif, typewriter and script-CAPITAL runs
243/// are complete. `DejaVuMathTeXGyre.ttf` lacks only the two digammas. The
244/// bundled TEXT faces (Junicode, IPAex) cover none of the block at all, which
245/// is the configuration that actually bites: a document with an uploaded text
246/// font and no math font (the playground, and `--font`) has every `\pi` from
247/// `math.satyh` land on `.notdef`.
248///
249/// So this is the INVERSE direction, used only as a last resort by the math
250/// layout path (`primitives::push_char_glyph`) when neither the math font nor
251/// the text font covers the styled codepoint. Falling back to `π` for a
252/// `𝜋` no font in the document can draw loses the italic styling and keeps
253/// the mathematics; falling back to `.notdef` loses both. This continues the
254/// port's existing metrics-probe policy — `primitives::resolve_variant_char`
255/// already declines the forward remap when the target is uncoverable — rather
256/// than introducing a new one; it just reaches the cases that policy cannot,
257/// because `math.satyh` hands those codepoints over ALREADY styled
258/// (`greek-lowercase 0x1D70B 0x1D745` for `\pi`) and there is no plain letter
259/// left to decline back to.
260///
261/// **Provenance.** The whole table was diffed against Unicode 14.0's own
262/// `<font>` decompositions (Python `unicodedata.decomposition`) over all of
263/// U+0000..U+10FFFF: zero disagreements on any codepoint both sides map. What
264/// the tests below pin is that same data, transcribed run by run
265/// (`every_alphabetic_run_decomposes_to_a_z_a_z`, `every_greek_run_…`,
266/// `every_digit_run_…`, `letterlike_table_is_exactly_unicodes_font_set`), so a
267/// re-derivation is a diff against those literals rather than a fresh audit.
268///
269/// Three `<font>` groups outside the two blocks above are DELIBERATELY left
270/// out, not missed: the Hebrew presentation forms U+FB20..U+FB29, the Arabic
271/// Mathematical Alphabetic Symbols U+1EE00.., and the segmented digits
272/// U+1FBF0..U+1FBF9. The first and third are not mathematics this port can
273/// receive from `default_math_variant_char`; the second is, but its base
274/// letters are Arabic, which no bundled face covers either — so the
275/// substitute-is-itself-covered guard would decline all 143 of them anyway.
276///
277/// **A DELIBERATE DIVERGENCE from upstream, and the argument that it is
278/// safe.** SATySFi v0.0.6 has no counterpart to this function: its
279/// `fontInfo.ml:180-187` `get_glyph_id` warns (`Logging.warn_no_glyph`) and
280/// returns `FontFormat.notdef`, full stop. This port takes the warning
281/// (`cid::report_missing_glyphs`) AND substitutes, and the two halves are
282/// separable on purpose — the warning is fidelity, the substitution is not.
283///
284/// It is safe because the substitution's precondition is exactly upstream's
285/// `None` branch: `primitives::degrade_unrenderable_variant` fires only when
286/// neither the math font nor the text font can draw the codepoint, i.e. only
287/// on inputs for which upstream's answer is `notdef`. Any document whose
288/// glyphs all resolve is untouched, byte for byte. Where it does fire it
289/// replaces a `notdef` — a tofu box on a TrueType face, and NOTHING AT ALL on
290/// a CFF one — with the right letter in the wrong style, which is the better
291/// of the two wrong answers available and the only one an author can see.
292/// The price is that the ToUnicode CMap then carries the base letter rather
293/// than the styled codepoint; that is the same trade, since the styled
294/// codepoint in ToUnicode was previously the only trace the character had
295/// left, and it made `pdftotext` report a character the page did not show.
296pub fn math_alphanumeric_base(c: char) -> Option<char> {
297    let u = c as u32;
298
299    // Every `<font>` decomposition Unicode gives in the Letterlike Symbols
300    // block U+2100..U+214F — deliberately the WHOLE machine-checkable set
301    // rather than only the codepoints that fill a reserved hole in
302    // U+1D400..=U+1D7FF, because "is there a `<font>` decomposition here" is a
303    // property a reader can re-verify in one line and "does this fill a hole"
304    // is not. The hole-fillers are a strict subset: they are exactly the
305    // characters `default_math_variant_char`'s exception arms above produce,
306    // so the two functions stay inverse to each other, and the rest (`ℏ ℓ ℹ`,
307    // the double-struck Greek, the double-struck italics) are Letterlike
308    // characters with no block position at all.
309    let letterlike = |u: u32| -> Option<char> {
310        Some(match u {
311            0x210E => 'h',                                     // italic small h
312            0x212C => 'B',                                     // script capitals
313            0x2130 => 'E',
314            0x2131 => 'F',
315            0x210B => 'H',
316            0x2110 => 'I',
317            0x2112 => 'L',
318            0x2133 => 'M',
319            0x211B => 'R',
320            0x212F => 'e',                                     // script smalls
321            0x210A => 'g',
322            0x2134 => 'o',
323            0x2113 => 'l',                                     // ℓ — no block position
324            0x212D => 'C',                                     // fraktur
325            0x210C => 'H',
326            0x2111 => 'I',
327            0x211C => 'R',
328            0x2128 => 'Z',
329            0x2102 => 'C',                                     // double-struck
330            0x210D => 'H',
331            0x2115 => 'N',
332            0x2119 => 'P',
333            0x211A => 'Q',
334            0x211D => 'R',
335            0x2124 => 'Z',
336            // `ℏ` decomposes to `ħ` U+0127, NOT to `h`: Unicode's own target
337            // keeps the stroke, and a bare `h` where the author wrote an
338            // h-bar would be wrong physics rather than merely unstyled — the
339            // same reasoning as the dotless pair below.
340            0x210F => '\u{127}',
341            // `ℹ` is an emoji-presentation character rather than mathematics,
342            // but it carries a `<font>` decomposition to `i` and this arm is
343            // only ever consulted for a codepoint no font in the document can
344            // draw, in MATH. Included so the table is exactly Unicode's set.
345            0x2139 => 'i',
346            0x213C => '\u{3C0}',                               // double-struck Greek
347            0x213D => '\u{3B3}',
348            0x213E => '\u{393}',
349            0x213F => '\u{3A0}',
350            0x2140 => '\u{2211}',
351            0x2145 => 'D',                                     // double-struck italic
352            0x2146 => 'd',
353            0x2147 => 'e',
354            0x2148 => 'i',
355            0x2149 => 'j',
356            _ => return None,
357        })
358    };
359    if let Some(base) = letterlike(u) {
360        return Some(base);
361    }
362
363    if !(0x1D400..=0x1D7FF).contains(&u) {
364        return None;
365    }
366
367    // The block is laid out as runs of fixed stride, and the strides tile it
368    // exactly — which is the check worth keeping in mind when reading the
369    // constants below: 0x1D400 + 13*52 == 0x1D6A4 (the two dotless letters),
370    // 0x1D6A8 + 5*58 == 0x1D7CA (the two digammas), and 0x1D7CE + 5*10 ==
371    // 0x1D800 (one past the block). `alphanumeric_block_strides_tile_the_block`
372    // pins all three.
373
374    // 13 alphabetic runs of 52: A..Z then a..z.
375    if u < 0x1D6A4 {
376        let off = (u - 0x1D400) % 52;
377        return Some(if off < 26 {
378            (b'A' + off as u8) as char
379        } else {
380            (b'a' + (off - 26) as u8) as char
381        });
382    }
383    // The two dotless letters, decomposing to Latin Extended-A (NOT to
384    // 'i'/'j' — Unicode's `<font>` targets are U+0131/U+0237, and a font that
385    // draws a dotted 'i' where the author wrote a dotless one would be
386    // silently wrong rather than merely unstyled).
387    if u == 0x1D6A4 {
388        return Some('\u{131}');
389    }
390    if u == 0x1D6A5 {
391        return Some('\u{237}');
392    }
393    if u < 0x1D6A8 {
394        return None; // 0x1D6A6/0x1D6A7 are unassigned
395    }
396    // 5 Greek runs of 58.
397    if u < 0x1D7CA {
398        let off = (u - 0x1D6A8) % 58;
399        return Some(match off {
400            // Α..Ρ, then the CAPITAL THETA SYMBOL that sits where the
401            // ordinary capital theta already was, then Σ..Ω.
402            0..=16 => char::from_u32(0x391 + off)?,
403            17 => '\u{3F4}',
404            18..=24 => char::from_u32(0x3A3 + (off - 18))?,
405            25 => '\u{2207}', // nabla
406            // α..ω, final sigma ς included at its Unicode position.
407            26..=50 => char::from_u32(0x3B1 + (off - 26))?,
408            51 => '\u{2202}', // partial differential
409            52 => '\u{3F5}',  // lunate epsilon
410            53 => '\u{3D1}',  // theta symbol
411            54 => '\u{3F0}',  // kappa symbol
412            55 => '\u{3D5}',  // phi symbol
413            56 => '\u{3F1}',  // rho symbol
414            57 => '\u{3D6}',  // pi symbol
415            _ => unreachable!("offset is `% 58`"),
416        });
417    }
418    // The two digammas, which are their own one-off pair.
419    if u == 0x1D7CA {
420        return Some('\u{3DC}');
421    }
422    if u == 0x1D7CB {
423        return Some('\u{3DD}');
424    }
425    if u < 0x1D7CE {
426        return None; // 0x1D7CC/0x1D7CD are unassigned
427    }
428    // 5 digit runs of 10.
429    let off = (u - 0x1D7CE) % 10;
430    Some((b'0' + off as u8) as char)
431}
432
433#[cfg(test)]
434mod alphanumeric_base_tests {
435    use super::*;
436
437    /// The three stride runs must tile U+1D400..=U+1D7FF exactly. Getting one
438    /// stride wrong would silently shift a whole run's decomposition (a
439    /// `𝜋` coming back as `ο`, say), which no spot check on one letter would
440    /// catch — so assert the arithmetic itself.
441    #[test]
442    fn alphanumeric_block_strides_tile_the_block() {
443        assert_eq!(0x1D400 + 13 * 52, 0x1D6A4, "13 alphabetic runs of 52");
444        assert_eq!(0x1D6A8 + 5 * 58, 0x1D7CA, "5 Greek runs of 58");
445        assert_eq!(0x1D7CE + 5 * 10, 0x1D800, "5 digit runs of 10");
446    }
447
448    // ------------------------------------------------------------------
449    // The block, run by run, against Unicode's own `<font>` data.
450    //
451    // The three tests below together assert ALL 1024 values the block can
452    // produce, not a sample: each run's whole output is compared against one
453    // literal string transcribed from `unicodedata.decomposition` under
454    // Unicode 14.0. That is the useful shape here, because the failure mode
455    // this function has is not "one letter is wrong" but "a whole run is
456    // shifted by one slot", and a shifted run still passes every spot check
457    // on the letters either side of the shift.
458    // ------------------------------------------------------------------
459
460    /// 13 runs of 52 at U+1D400: bold, italic, bold-italic, script,
461    /// bold-script, fraktur, double-struck, bold-fraktur, sans-serif,
462    /// sans-bold, sans-italic, sans-bold-italic, monospace. Every one
463    /// decomposes to `A..Z` then `a..z`, INCLUDING at the 24 reserved slots
464    /// (`U+1D455`, `U+1D49D`, …) whose letters Unicode parks in the
465    /// Letterlike block — Unicode assigns those positions no decomposition
466    /// because it assigns them no character, but answering with the letter
467    /// the position stands for is strictly better than answering `None`.
468    #[test]
469    fn every_alphabetic_run_decomposes_to_a_z_a_z() {
470        let expected = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
471        assert_eq!(expected.chars().count(), 52);
472        for run in 0..13u32 {
473            let base = 0x1D400 + run * 52;
474            let got: String = (0..52)
475                .map(|i| math_alphanumeric_base(char::from_u32(base + i).unwrap()).unwrap())
476                .collect();
477            assert_eq!(got, expected, "run {run} at U+{base:04X}");
478        }
479    }
480
481    /// 5 runs of 58 at U+1D6A8: bold, italic, bold-italic, sans-bold,
482    /// sans-bold-italic Greek. The literal is `unicodedata`'s answer for the
483    /// first run, and all five runs must produce it — including the four
484    /// slots that are not plain Greek letters (`ϴ` at 17, `∇` at 25, `∂` at
485    /// 51) and the six variant forms trailing each run.
486    #[test]
487    fn every_greek_run_decomposes_to_the_same_58_characters() {
488        let expected = "\u{391}\u{392}\u{393}\u{394}\u{395}\u{396}\u{397}\u{398}\u{399}\u{39A}\
489                        \u{39B}\u{39C}\u{39D}\u{39E}\u{39F}\u{3A0}\u{3A1}\u{3F4}\u{3A3}\u{3A4}\
490                        \u{3A5}\u{3A6}\u{3A7}\u{3A8}\u{3A9}\u{2207}\u{3B1}\u{3B2}\u{3B3}\u{3B4}\
491                        \u{3B5}\u{3B6}\u{3B7}\u{3B8}\u{3B9}\u{3BA}\u{3BB}\u{3BC}\u{3BD}\u{3BE}\
492                        \u{3BF}\u{3C0}\u{3C1}\u{3C2}\u{3C3}\u{3C4}\u{3C5}\u{3C6}\u{3C7}\u{3C8}\
493                        \u{3C9}\u{2202}\u{3F5}\u{3D1}\u{3F0}\u{3D5}\u{3F1}\u{3D6}";
494        assert_eq!(expected.chars().count(), 58);
495        for run in 0..5u32 {
496            let base = 0x1D6A8 + run * 58;
497            let got: String = (0..58)
498                .map(|i| math_alphanumeric_base(char::from_u32(base + i).unwrap()).unwrap())
499                .collect();
500            assert_eq!(got, expected, "Greek run {run} at U+{base:04X}");
501        }
502    }
503
504    /// 5 runs of 10 at U+1D7CE: bold, double-struck, sans-serif, sans-bold
505    /// and monospace DIGITS. `0`..`9` each time — never a letter, which is
506    /// the one way an off-by-one in the preceding Greek stride would show up
507    /// here rather than there.
508    #[test]
509    fn every_digit_run_decomposes_to_ascii_digits() {
510        for run in 0..5u32 {
511            let base = 0x1D7CE + run * 10;
512            let got: String = (0..10)
513                .map(|i| math_alphanumeric_base(char::from_u32(base + i).unwrap()).unwrap())
514                .collect();
515            assert_eq!(got, "0123456789", "digit run {run} at U+{base:04X}");
516            assert!(
517                got.chars().all(|c| c.is_ascii_digit()),
518                "a styled digit must never decompose to a letter"
519            );
520        }
521    }
522
523    /// The Letterlike arm is the only hand-written part of the table, so pin
524    /// it as data: this list IS `unicodedata`'s complete `<font>` set for
525    /// U+2100..U+214F under Unicode 14.0, and the assertion runs both ways —
526    /// every listed pair must match, and every OTHER codepoint in the block
527    /// must answer `None`, so an entry cannot be quietly added or dropped.
528    #[test]
529    fn letterlike_table_is_exactly_unicodes_font_set() {
530        let expected: &[(u32, char)] = &[
531            (0x2102, 'C'),        // DOUBLE-STRUCK CAPITAL C
532            (0x210A, 'g'),        // SCRIPT SMALL G
533            (0x210B, 'H'),        // SCRIPT CAPITAL H
534            (0x210C, 'H'),        // BLACK-LETTER CAPITAL H
535            (0x210D, 'H'),        // DOUBLE-STRUCK CAPITAL H
536            (0x210E, 'h'),        // PLANCK CONSTANT
537            (0x210F, '\u{127}'),  // PLANCK CONSTANT OVER TWO PI -> ħ
538            (0x2110, 'I'),        // SCRIPT CAPITAL I
539            (0x2111, 'I'),        // BLACK-LETTER CAPITAL I
540            (0x2112, 'L'),        // SCRIPT CAPITAL L
541            (0x2113, 'l'),        // SCRIPT SMALL L
542            (0x2115, 'N'),        // DOUBLE-STRUCK CAPITAL N
543            (0x2119, 'P'),        // DOUBLE-STRUCK CAPITAL P
544            (0x211A, 'Q'),        // DOUBLE-STRUCK CAPITAL Q
545            (0x211B, 'R'),        // SCRIPT CAPITAL R
546            (0x211C, 'R'),        // BLACK-LETTER CAPITAL R
547            (0x211D, 'R'),        // DOUBLE-STRUCK CAPITAL R
548            (0x2124, 'Z'),        // DOUBLE-STRUCK CAPITAL Z
549            (0x2128, 'Z'),        // BLACK-LETTER CAPITAL Z
550            (0x212C, 'B'),        // SCRIPT CAPITAL B
551            (0x212D, 'C'),        // BLACK-LETTER CAPITAL C
552            (0x212F, 'e'),        // SCRIPT SMALL E
553            (0x2130, 'E'),        // SCRIPT CAPITAL E
554            (0x2131, 'F'),        // SCRIPT CAPITAL F
555            (0x2133, 'M'),        // SCRIPT CAPITAL M
556            (0x2134, 'o'),        // SCRIPT SMALL O
557            (0x2139, 'i'),        // INFORMATION SOURCE
558            (0x213C, '\u{3C0}'),  // DOUBLE-STRUCK SMALL PI
559            (0x213D, '\u{3B3}'),  // DOUBLE-STRUCK SMALL GAMMA
560            (0x213E, '\u{393}'),  // DOUBLE-STRUCK CAPITAL GAMMA
561            (0x213F, '\u{3A0}'),  // DOUBLE-STRUCK CAPITAL PI
562            (0x2140, '\u{2211}'), // DOUBLE-STRUCK N-ARY SUMMATION
563            (0x2145, 'D'),        // DOUBLE-STRUCK ITALIC CAPITAL D
564            (0x2146, 'd'),        // DOUBLE-STRUCK ITALIC SMALL D
565            (0x2147, 'e'),        // DOUBLE-STRUCK ITALIC SMALL E
566            (0x2148, 'i'),        // DOUBLE-STRUCK ITALIC SMALL I
567            (0x2149, 'j'),        // DOUBLE-STRUCK ITALIC SMALL J
568        ];
569        assert_eq!(expected.len(), 37, "Unicode 14.0 has 37 of them");
570        for &(u, base) in expected {
571            let c = char::from_u32(u).unwrap();
572            assert_eq!(
573                math_alphanumeric_base(c),
574                Some(base),
575                "U+{u:04X} should decompose to {base:?}"
576            );
577        }
578        for u in 0x2100..0x2150u32 {
579            if expected.iter().any(|&(e, _)| e == u) {
580                continue;
581            }
582            let c = char::from_u32(u).unwrap();
583            assert_eq!(
584                math_alphanumeric_base(c),
585                None,
586                "U+{u:04X} has no <font> decomposition and must not be remapped"
587            );
588        }
589    }
590
591    /// [`math_alphanumeric_base`] is the inverse of
592    /// [`default_math_variant_char`] wherever the latter produces anything:
593    /// for every class and every ASCII letter, styling then un-styling is the
594    /// identity. This is the property that makes the fallback safe — it can
595    /// only ever hand back the letter the author actually wrote.
596    #[test]
597    fn it_inverts_default_math_variant_char_for_every_class_and_letter() {
598        let classes = [
599            MathCharClass::Italic,
600            MathCharClass::BoldItalic,
601            MathCharClass::Roman,
602            MathCharClass::BoldRoman,
603            MathCharClass::Script,
604            MathCharClass::BoldScript,
605            MathCharClass::Fraktur,
606            MathCharClass::BoldFraktur,
607            MathCharClass::DoubleStruck,
608            MathCharClass::SansSerif,
609            MathCharClass::BoldSansSerif,
610            MathCharClass::ItalicSansSerif,
611            MathCharClass::BoldItalicSansSerif,
612            MathCharClass::Typewriter,
613        ];
614        for class in classes {
615            for c in ('A'..='Z').chain('a'..='z') {
616                let Some(styled) = default_math_variant_char(class, c) else {
617                    continue;
618                };
619                if styled == c {
620                    continue; // `Roman` is the identity; nothing to invert.
621                }
622                assert_eq!(
623                    math_alphanumeric_base(styled),
624                    Some(c),
625                    "{class:?} {c:?} -> U+{:04X} did not invert",
626                    styled as u32
627                );
628            }
629        }
630    }
631
632    /// The Greek run's internal layout is the irregular part: two symbol
633    /// letters interrupt the capitals and smalls, and seven variant forms
634    /// trail each run. `\pi` is the case the playground actually hit.
635    #[test]
636    fn greek_runs_decompose_including_their_irregular_slots() {
637        // `math.satyh`'s `\pi = greek-lowercase 0x1D70B 0x1D745`.
638        assert_eq!(math_alphanumeric_base('\u{1D70B}'), Some('\u{3C0}'));
639        assert_eq!(math_alphanumeric_base('\u{1D745}'), Some('\u{3C0}'));
640        // First and last capital of the bold run, either side of the
641        // capital-theta-symbol slot.
642        assert_eq!(math_alphanumeric_base('\u{1D6A8}'), Some('\u{391}'));
643        assert_eq!(math_alphanumeric_base('\u{1D6C0}'), Some('\u{3A9}'));
644        assert_eq!(math_alphanumeric_base('\u{1D6B9}'), Some('\u{3F4}'));
645        // Nabla and partial, which sit inside the run rather than beside it.
646        assert_eq!(math_alphanumeric_base('\u{1D6C1}'), Some('\u{2207}'));
647        assert_eq!(math_alphanumeric_base('\u{1D6DB}'), Some('\u{2202}'));
648        // The trailing variant forms of the last (sans-serif bold italic) run.
649        assert_eq!(math_alphanumeric_base('\u{1D7C9}'), Some('\u{3D6}'));
650    }
651
652    /// Digits, the dotless pair and the digammas — the runs that are not
653    /// letters — plus the guarantee that ordinary text is left alone, since
654    /// this function gates a substitution.
655    #[test]
656    fn non_letter_runs_and_the_none_cases() {
657        assert_eq!(math_alphanumeric_base('\u{1D7CE}'), Some('0'));
658        assert_eq!(math_alphanumeric_base('\u{1D7FF}'), Some('9'));
659        assert_eq!(math_alphanumeric_base('\u{1D6A4}'), Some('\u{131}'));
660        assert_eq!(math_alphanumeric_base('\u{1D6A5}'), Some('\u{237}'));
661        assert_eq!(math_alphanumeric_base('\u{1D7CA}'), Some('\u{3DC}'));
662        // Unassigned holes between runs.
663        assert_eq!(math_alphanumeric_base('\u{1D6A6}'), None);
664        assert_eq!(math_alphanumeric_base('\u{1D7CC}'), None);
665        // Everything outside the block, including the operators and
666        // delimiters math documents are otherwise full of.
667        for c in ['a', 'Z', '0', 'π', '∑', '∫', '√', '±', '∞', '→', '(', ' '] {
668            assert_eq!(math_alphanumeric_base(c), None, "{c:?} is not a remap");
669        }
670    }
671
672    /// The Letterlike Symbols are the block's holes, so they have to invert
673    /// too or `\mathcal{B}` would keep degrading to `.notdef` while
674    /// `\mathcal{A}` recovered.
675    #[test]
676    fn letterlike_holes_decompose_to_their_plain_letter() {
677        assert_eq!(math_alphanumeric_base('\u{210E}'), Some('h'));
678        assert_eq!(math_alphanumeric_base('\u{212C}'), Some('B'));
679        assert_eq!(math_alphanumeric_base('\u{2112}'), Some('L'));
680        assert_eq!(math_alphanumeric_base('\u{211C}'), Some('R'));
681        assert_eq!(math_alphanumeric_base('\u{2115}'), Some('N'));
682        assert_eq!(math_alphanumeric_base('\u{2147}'), Some('e'));
683    }
684}