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}