Skip to main content

latexml_post/
unicode.rs

1//! Unicode character mapping for mathematical typesetting.
2//!
3//! Port of `LaTeXML::Util::Unicode` (~350 lines).
4//! Maps base characters to Unicode Plane 1 Mathematical Alphanumeric Symbols
5//! (U+1D400–U+1D7FF) based on mathvariant style. Also handles superscript/subscript
6//! mappings and font name normalization to MathML mathvariant values.
7
8use std::sync::LazyLock;
9
10use rustc_hash::FxHashMap as HashMap;
11
12/// Build a Plane 1 character mapping from base addresses.
13///
14/// Port of Perl's `makePlane1Map($latin, $GREEK, $greek, $digits)`.
15/// Creates mappings for:
16/// - Latin uppercase A-Z (26 chars) at `latin + 0..25`
17/// - Latin lowercase a-z (26 chars) at `latin + 26..51`
18/// - Greek uppercase Α-Ω (25 chars) at `greek_upper + 0..24` (if Some)
19/// - Greek lowercase α-ω (25 chars) at `greek_lower + 0..24` (if Some)
20/// - Digits 0-9 (10 chars) at `digits + 0..9` (if Some)
21fn make_plane1_map(
22  latin: u32,
23  greek_upper: Option<u32>,
24  greek_lower: Option<u32>,
25  digits: Option<u32>,
26) -> HashMap<char, char> {
27  let mut map = HashMap::default();
28  // Latin uppercase A-Z
29  for i in 0..26u32 {
30    let src = char::from_u32('A' as u32 + i).unwrap();
31    let dst = char::from_u32(latin + i).unwrap();
32    map.insert(src, dst);
33  }
34  // Latin lowercase a-z
35  for i in 0..26u32 {
36    let src = char::from_u32('a' as u32 + i).unwrap();
37    let dst = char::from_u32(latin + 26 + i).unwrap();
38    map.insert(src, dst);
39  }
40  // Greek uppercase Α(U+0391) through Ω — 25 characters
41  if let Some(base) = greek_upper {
42    for i in 0..25u32 {
43      let src = char::from_u32(0x0391 + i).unwrap();
44      let dst = char::from_u32(base + i).unwrap();
45      map.insert(src, dst);
46    }
47  }
48  // Greek lowercase α(U+03B1) through ω — 25 characters
49  if let Some(base) = greek_lower {
50    for i in 0..25u32 {
51      let src = char::from_u32(0x03B1 + i).unwrap();
52      let dst = char::from_u32(base + i).unwrap();
53      map.insert(src, dst);
54    }
55  }
56  // Digits 0-9
57  if let Some(base) = digits {
58    for i in 0..10u32 {
59      let src = char::from_u32('0' as u32 + i).unwrap();
60      let dst = char::from_u32(base + i).unwrap();
61      map.insert(src, dst);
62    }
63  }
64  map
65}
66
67/// All Plane 1 Unicode mapping tables, keyed by mathvariant style name.
68///
69/// Port of Perl's `%unicode_map` hash.
70static UNICODE_MAP: LazyLock<HashMap<&'static str, HashMap<char, char>>> = LazyLock::new(|| {
71  let mut map: HashMap<&'static str, HashMap<char, char>> = HashMap::default();
72
73  // bold: full set (Latin + Greek + digits)
74  map.insert(
75    "bold",
76    make_plane1_map(0x1D400, Some(0x1D6A8), Some(0x1D6C2), Some(0x1D7CE)),
77  );
78
79  // italic: Latin + Greek, no digits. Special: h => PLANCK CONSTANT
80  {
81    let mut m = make_plane1_map(0x1D434, Some(0x1D6E2), Some(0x1D6FC), None);
82    m.insert('h', '\u{210E}');
83    map.insert("italic", m);
84  }
85
86  // bold-italic: Latin + Greek, no digits
87  map.insert(
88    "bold-italic",
89    make_plane1_map(0x1D468, Some(0x1D71C), Some(0x1D736), None),
90  );
91
92  // sans-serif: Latin + digits, no Greek
93  map.insert(
94    "sans-serif",
95    make_plane1_map(0x1D5A0, None, None, Some(0x1D7E2)),
96  );
97
98  // bold-sans-serif: full set
99  map.insert(
100    "bold-sans-serif",
101    make_plane1_map(0x1D5D4, Some(0x1D756), Some(0x1D770), Some(0x1D7EC)),
102  );
103
104  // sans-serif-italic: Latin only
105  map.insert(
106    "sans-serif-italic",
107    make_plane1_map(0x1D608, None, None, None),
108  );
109
110  // sans-serif-bold-italic: Latin + Greek, no digits
111  map.insert(
112    "sans-serif-bold-italic",
113    make_plane1_map(0x1D63C, Some(0x1D790), Some(0x1D7AA), None),
114  );
115
116  // monospace: Latin + digits, no Greek
117  map.insert(
118    "monospace",
119    make_plane1_map(0x1D670, None, None, Some(0x1D7F6)),
120  );
121
122  // script: Latin only, with 11 special character overrides
123  {
124    let mut m = make_plane1_map(0x1D49C, None, None, None);
125    m.insert('B', '\u{212C}'); // SCRIPT CAPITAL B
126    m.insert('E', '\u{2130}'); // SCRIPT CAPITAL E
127    m.insert('F', '\u{2131}'); // SCRIPT CAPITAL F
128    m.insert('H', '\u{210B}'); // SCRIPT CAPITAL H
129    m.insert('I', '\u{2110}'); // SCRIPT CAPITAL I
130    m.insert('L', '\u{2112}'); // SCRIPT CAPITAL L
131    m.insert('M', '\u{2133}'); // SCRIPT CAPITAL M
132    m.insert('R', '\u{211B}'); // SCRIPT CAPITAL R
133    m.insert('e', '\u{212F}'); // SCRIPT SMALL E
134    m.insert('g', '\u{210A}'); // SCRIPT SMALL G
135    m.insert('o', '\u{2134}'); // SCRIPT SMALL O
136    map.insert("script", m);
137  }
138
139  // bold-script: Latin only
140  map.insert("bold-script", make_plane1_map(0x1D4D0, None, None, None));
141
142  // fraktur: Latin only, with 5 special character overrides
143  {
144    let mut m = make_plane1_map(0x1D504, None, None, None);
145    m.insert('C', '\u{212D}'); // FRAKTUR CAPITAL C
146    m.insert('H', '\u{210C}'); // FRAKTUR CAPITAL H
147    m.insert('I', '\u{2111}'); // FRAKTUR CAPITAL I
148    m.insert('R', '\u{211C}'); // FRAKTUR CAPITAL R
149    m.insert('Z', '\u{2128}'); // FRAKTUR CAPITAL Z
150    map.insert("fraktur", m);
151  }
152
153  // bold-fraktur: Latin only
154  map.insert("bold-fraktur", make_plane1_map(0x1D56C, None, None, None));
155
156  // double-struck: Latin + digits, with 7 special character overrides
157  {
158    let mut m = make_plane1_map(0x1D538, None, None, Some(0x1D7D8));
159    m.insert('C', '\u{2102}'); // DOUBLE-STRUCK CAPITAL C (complex numbers)
160    m.insert('H', '\u{210D}'); // DOUBLE-STRUCK CAPITAL H (quaternions)
161    m.insert('N', '\u{2115}'); // DOUBLE-STRUCK CAPITAL N (natural numbers)
162    m.insert('P', '\u{2119}'); // DOUBLE-STRUCK CAPITAL P
163    m.insert('Q', '\u{211A}'); // DOUBLE-STRUCK CAPITAL Q (rationals)
164    m.insert('R', '\u{211D}'); // DOUBLE-STRUCK CAPITAL R (reals)
165    m.insert('Z', '\u{2124}'); // DOUBLE-STRUCK CAPITAL Z (integers)
166    map.insert("double-struck", m);
167  }
168
169  // superscript: scattered Unicode characters
170  {
171    let mut m = HashMap::default();
172    m.insert('\u{2032}', '\''); // \prime
173    m.insert('0', '\u{2070}');
174    m.insert('1', '\u{00B9}');
175    m.insert('2', '\u{00B2}');
176    m.insert('3', '\u{00B3}');
177    m.insert('4', '\u{2074}');
178    m.insert('5', '\u{2075}');
179    m.insert('6', '\u{2076}');
180    m.insert('7', '\u{2077}');
181    m.insert('8', '\u{2078}');
182    m.insert('9', '\u{2079}');
183    m.insert('+', '\u{207A}');
184    m.insert('-', '\u{207B}');
185    m.insert('=', '\u{207C}');
186    m.insert('(', '\u{207D}');
187    m.insert(')', '\u{207E}');
188    m.insert('n', '\u{207F}');
189    m.insert('i', '\u{2071}');
190    m.insert('V', '\u{2C7D}');
191    m.insert('h', '\u{02B0}');
192    m.insert('j', '\u{02B2}');
193    m.insert('r', '\u{02B3}');
194    m.insert('w', '\u{02B7}');
195    m.insert('y', '\u{02B8}');
196    m.insert('s', '\u{02E2}');
197    m.insert('x', '\u{02E3}');
198    m.insert('A', '\u{1D2C}');
199    m.insert('\u{00C6}', '\u{1D2D}'); // Æ
200    m.insert('B', '\u{1D2E}');
201    m.insert('D', '\u{1D30}');
202    m.insert('E', '\u{1D31}');
203    m.insert('G', '\u{1D33}');
204    m.insert('H', '\u{1D34}');
205    m.insert('I', '\u{1D35}');
206    m.insert('J', '\u{1D36}');
207    m.insert('K', '\u{1D37}');
208    m.insert('L', '\u{1D38}');
209    m.insert('M', '\u{1D39}');
210    m.insert('N', '\u{1D3A}');
211    m.insert('O', '\u{1D3C}');
212    m.insert('P', '\u{1D3E}');
213    m.insert('R', '\u{1D3F}');
214    m.insert('T', '\u{1D40}');
215    m.insert('U', '\u{1D41}');
216    m.insert('W', '\u{1D42}');
217    m.insert('a', '\u{1D43}');
218    m.insert('\u{03B1}', '\u{1D45}'); // α
219    m.insert('\u{00E6}', '\u{1D46}'); // æ
220    m.insert('b', '\u{1D47}');
221    m.insert('d', '\u{1D48}');
222    m.insert('e', '\u{1D49}');
223    m.insert('\u{03B5}', '\u{1D4B}'); // ε (varepsilon)
224    m.insert('\u{03F5}', '\u{1D4B}'); // ϵ (epsilon) — close enough
225    m.insert('g', '\u{1D4D}');
226    m.insert('!', '\u{1D4E}');
227    m.insert('k', '\u{1D4F}');
228    m.insert('m', '\u{1D50}');
229    m.insert('o', '\u{1D52}');
230    m.insert('p', '\u{1D56}');
231    m.insert('t', '\u{1D57}');
232    m.insert('u', '\u{1D58}');
233    m.insert('v', '\u{1D5B}');
234    m.insert('\u{03B2}', '\u{1D5D}'); // β
235    m.insert('\u{03B3}', '\u{1D5E}'); // γ
236    m.insert('\u{03B4}', '\u{1D5F}'); // δ
237    m.insert('\u{03C6}', '\u{1D60}'); // φ (varphi)
238    m.insert('\u{03D5}', '\u{1D60}'); // ϕ (phi) — close enough
239    m.insert('\u{03BE}', '\u{1D61}'); // ξ
240    m.insert('c', '\u{1D9C}');
241    m.insert('f', '\u{1DA0}');
242    m.insert('\u{03A6}', '\u{1DB2}'); // Φ
243    m.insert('\u{03C5}', '\u{1DB7}'); // υ (upsilon)
244    m.insert('z', '\u{1DBB}');
245    m.insert('\u{03B8}', '\u{1DBF}'); // θ
246    map.insert("superscript", m);
247  }
248
249  // subscript: scattered Unicode characters
250  {
251    let mut m = HashMap::default();
252    m.insert('0', '\u{2080}');
253    m.insert('1', '\u{2081}');
254    m.insert('2', '\u{2082}');
255    m.insert('3', '\u{2083}');
256    m.insert('4', '\u{2084}');
257    m.insert('5', '\u{2085}');
258    m.insert('6', '\u{2086}');
259    m.insert('7', '\u{2087}');
260    m.insert('8', '\u{2088}');
261    m.insert('9', '\u{2089}');
262    m.insert('+', '\u{208A}');
263    m.insert('-', '\u{208B}');
264    m.insert('=', '\u{208C}');
265    m.insert('(', '\u{208D}');
266    m.insert(')', '\u{208E}');
267    m.insert('a', '\u{2090}');
268    m.insert('e', '\u{2091}');
269    m.insert('o', '\u{2092}');
270    m.insert('x', '\u{2093}');
271    // 'upsidedowne' => "\x{2094}" — not a single char, skipped
272    m.insert('h', '\u{2095}');
273    m.insert('k', '\u{2096}');
274    m.insert('l', '\u{2097}');
275    m.insert('m', '\u{2098}');
276    m.insert('n', '\u{2099}');
277    m.insert('p', '\u{209A}');
278    m.insert('s', '\u{209B}');
279    m.insert('t', '\u{209C}');
280    m.insert('j', '\u{2C7C}');
281    m.insert('i', '\u{1D62}');
282    m.insert('r', '\u{1D63}');
283    m.insert('u', '\u{1D64}');
284    m.insert('v', '\u{1D65}');
285    m.insert('\u{03B2}', '\u{1D66}'); // β
286    m.insert('\u{03B3}', '\u{1D67}'); // γ
287    m.insert('\u{03C1}', '\u{1D68}'); // ρ
288    m.insert('\u{03C6}', '\u{1D69}'); // φ (varphi)
289    m.insert('\u{03D5}', '\u{1D69}'); // ϕ (phi) — close enough
290    m.insert('\u{03BE}', '\u{1D6A}'); // ξ
291    map.insert("subscript", m);
292  }
293
294  map
295});
296
297/// Convert a string's characters to the styled Unicode equivalent for the given mathvariant.
298///
299/// Port of Perl's `unicode_convert($string, $style)`.
300/// Returns `None` if any character in the string has no mapping (all-or-nothing semantics).
301pub fn unicode_convert(string: &str, style: &str) -> Option<String> {
302  let mapping = UNICODE_MAP.get(style)?;
303  let mut result = String::with_capacity(string.len() * 4);
304  for ch in string.chars() {
305    match mapping.get(&ch) {
306      Some(&mapped) => result.push(mapped),
307      None => return None, // All-or-nothing: if ANY char has no mapping, fail
308    }
309  }
310  Some(result)
311}
312
313/// Mathvariant normalization table.
314///
315/// Port of Perl's `%mathvariants` hash (28 entries → 12 canonical variants).
316static MATHVARIANTS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
317  let mut m = HashMap::default();
318  m.insert("upright", "normal");
319  m.insert("serif", "normal");
320  m.insert("medium", "normal");
321  m.insert("bold", "bold");
322  m.insert("italic", "italic");
323  m.insert("medium italic", "italic");
324  m.insert("bold italic", "bold-italic");
325  m.insert("doublestruck", "double-struck");
326  m.insert("blackboard", "double-struck");
327  m.insert("blackboard bold", "double-struck");
328  m.insert("blackboard upright", "double-struck");
329  m.insert("blackboard bold upright", "double-struck");
330  m.insert("fraktur", "fraktur");
331  m.insert("fraktur italic", "fraktur");
332  m.insert("fraktur bold", "bold-fraktur");
333  m.insert("script", "script");
334  m.insert("script italic", "script");
335  m.insert("script bold", "bold-script");
336  m.insert("caligraphic", "script");
337  m.insert("caligraphic bold", "bold-script");
338  m.insert("sansserif", "sans-serif");
339  m.insert("sansserif bold", "bold-sans-serif");
340  m.insert("sansserif italic", "sans-serif-italic");
341  m.insert("sansserif bold italic", "sans-serif-bold-italic");
342  m.insert("typewriter", "monospace");
343  m.insert("typewriter bold", "monospace");
344  m.insert("typewriter italic", "monospace");
345  m.insert("typewriter bold italic", "monospace");
346  m
347});
348
349/// Normalize a LaTeXML font name to a MathML mathvariant value.
350///
351/// Port of Perl's `unicode_mathvariant($font)`.
352/// Applies normalization: slanted→italic, removes redundant "serif"/"upright"/"medium",
353/// then looks up in the canonical table. Returns "normal" as fallback.
354pub fn unicode_mathvariant(font: &str) -> &'static str {
355  let mut f = font.to_string();
356
357  // slanted → italic (equivalent in math)
358  f = f.replace("slanted", "italic");
359
360  // Remove "serif" unless font is exactly "serif"
361  if f != "serif" {
362    // Perl: s/(?<!\w)serif// — remove "serif" not preceded by a word char
363    // In practice, this removes standalone "serif" or "serif" at start
364    f = remove_non_prefixed(&f, "serif");
365  }
366
367  // Remove "upright" unless it's the first component
368  // Perl: s/(?<!^)upright// — remove "upright" not at start
369  if !f.starts_with("upright") {
370    f = f.replace("upright", "");
371  } else if f.len() > "upright".len() {
372    // starts with upright but has more — remove subsequent occurrences
373    let (prefix, rest) = f.split_at("upright".len());
374    f = format!("{}{}", prefix, rest.replace("upright", ""));
375  }
376
377  // Remove "medium" unless it's the first component
378  // Perl: s/(?<!^)medium// — remove "medium" not at start
379  if !f.starts_with("medium") {
380    f = f.replace("medium", "");
381  } else if f.len() > "medium".len() {
382    let (prefix, rest) = f.split_at("medium".len());
383    f = format!("{}{}", prefix, rest.replace("medium", ""));
384  }
385
386  // Trim whitespace
387  let f = f.trim();
388
389  if let Some(&variant) = MATHVARIANTS.get(f) {
390    return variant;
391  }
392  "normal"
393}
394
395/// Remove a word that is not preceded by a word character.
396/// Approximation of Perl's `s/(?<!\w)word//`.
397fn remove_non_prefixed(s: &str, word: &str) -> String {
398  let mut result = String::with_capacity(s.len());
399  let mut i = 0;
400  let bytes = s.as_bytes();
401  let word_bytes = word.as_bytes();
402  while i < bytes.len() {
403    if i + word_bytes.len() <= bytes.len() && &bytes[i..i + word_bytes.len()] == word_bytes {
404      // Check if preceded by a word character
405      let preceded_by_word = if i > 0 {
406        let prev = bytes[i - 1];
407        prev.is_ascii_alphanumeric() || prev == b'_'
408      } else {
409        false
410      };
411      if !preceded_by_word {
412        i += word_bytes.len();
413        continue;
414      }
415    }
416    result.push(bytes[i] as char);
417    i += 1;
418  }
419  result
420}
421
422/// Plane1-hackable variants: maps bold variants to their non-bold base for partial conversion.
423///
424/// Port of Perl's `%plane1hackable`.
425/// When `hackplane1` is true, only these variants are converted (bold variants map to non-bold).
426pub fn plane1_hackable(variant: &str) -> Option<&'static str> {
427  match variant {
428    "script" => Some("script"),
429    "bold-script" => Some("script"),
430    "fraktur" => Some("fraktur"),
431    "bold-fraktur" => Some("fraktur"),
432    "double-struck" => Some("double-struck"),
433    _ => None,
434  }
435}
436
437#[cfg(test)]
438mod tests {
439  use super::*;
440
441  #[test]
442  fn test_unicode_convert_bold() {
443    // "ABC" in bold should map to Mathematical Bold Capital A/B/C
444    let result = unicode_convert("ABC", "bold");
445    assert_eq!(result, Some("\u{1D400}\u{1D401}\u{1D402}".to_string()));
446  }
447
448  #[test]
449  fn test_unicode_convert_bold_lowercase() {
450    let result = unicode_convert("abc", "bold");
451    assert_eq!(result, Some("\u{1D41A}\u{1D41B}\u{1D41C}".to_string()));
452  }
453
454  #[test]
455  fn test_unicode_convert_italic_h() {
456    // italic h → PLANCK CONSTANT (U+210E)
457    let result = unicode_convert("h", "italic");
458    assert_eq!(result, Some("\u{210E}".to_string()));
459  }
460
461  #[test]
462  fn test_unicode_convert_script_special() {
463    // Script B → U+212C (not the Plane 1 address)
464    let result = unicode_convert("B", "script");
465    assert_eq!(result, Some("\u{212C}".to_string()));
466  }
467
468  #[test]
469  fn test_unicode_convert_double_struck_special() {
470    // Double-struck R → U+211D (real numbers)
471    let result = unicode_convert("R", "double-struck");
472    assert_eq!(result, Some("\u{211D}".to_string()));
473  }
474
475  #[test]
476  fn test_unicode_convert_fraktur_special() {
477    let result = unicode_convert("C", "fraktur");
478    assert_eq!(result, Some("\u{212D}".to_string()));
479  }
480
481  #[test]
482  fn test_unicode_convert_all_or_nothing() {
483    // "A1" with italic: A maps but 1 doesn't (italic has no digits)
484    let result = unicode_convert("A1", "italic");
485    assert_eq!(result, None);
486  }
487
488  #[test]
489  fn test_unicode_convert_bold_digits() {
490    // "1" = digit 1 → 0x1D7CE + 1 = 0x1D7CF, "2" → 0x1D7D0, "3" → 0x1D7D1
491    let result = unicode_convert("123", "bold");
492    assert_eq!(result, Some("\u{1D7CF}\u{1D7D0}\u{1D7D1}".to_string()));
493  }
494
495  #[test]
496  fn test_unicode_convert_bold_greek() {
497    // Bold Alpha (Α U+0391) → U+1D6A8
498    let result = unicode_convert("\u{0391}", "bold");
499    assert_eq!(result, Some("\u{1D6A8}".to_string()));
500  }
501
502  #[test]
503  fn test_unicode_convert_empty_string() {
504    let result = unicode_convert("", "bold");
505    assert_eq!(result, Some(String::new()));
506  }
507
508  #[test]
509  fn test_unicode_convert_unknown_style() {
510    let result = unicode_convert("A", "nonexistent");
511    assert_eq!(result, None);
512  }
513
514  #[test]
515  fn test_unicode_mathvariant_basic() {
516    assert_eq!(unicode_mathvariant("italic"), "italic");
517    assert_eq!(unicode_mathvariant("bold"), "bold");
518    assert_eq!(unicode_mathvariant("bold italic"), "bold-italic");
519    assert_eq!(unicode_mathvariant("typewriter"), "monospace");
520    assert_eq!(unicode_mathvariant("caligraphic"), "script");
521  }
522
523  #[test]
524  fn test_unicode_mathvariant_normalization() {
525    // slanted → italic
526    assert_eq!(unicode_mathvariant("slanted"), "italic");
527    // "medium italic" → "italic"
528    assert_eq!(unicode_mathvariant("medium italic"), "italic");
529    // "serif" alone → "normal"
530    assert_eq!(unicode_mathvariant("serif"), "normal");
531    // Unknown → "normal"
532    assert_eq!(unicode_mathvariant("unknown_font"), "normal");
533  }
534
535  #[test]
536  fn test_unicode_mathvariant_compound() {
537    assert_eq!(
538      unicode_mathvariant("sansserif bold italic"),
539      "sans-serif-bold-italic"
540    );
541    assert_eq!(unicode_mathvariant("blackboard bold"), "double-struck");
542    assert_eq!(unicode_mathvariant("fraktur bold"), "bold-fraktur");
543  }
544
545  #[test]
546  fn test_plane1_hackable() {
547    assert_eq!(plane1_hackable("script"), Some("script"));
548    assert_eq!(plane1_hackable("bold-script"), Some("script"));
549    assert_eq!(plane1_hackable("bold"), None);
550    assert_eq!(plane1_hackable("italic"), None);
551  }
552}