Skip to main content

latexml_post/mathml/
operator_dictionary.rs

1//! MathML Operator Dictionary.
2//!
3//! Port of `LaTeXML::Post::MathML::OperatorDictionary` (252 lines of Perl).
4//! Implements the MathML Core operator dictionary algorithm (Section 2.6.1).
5//! Maps (operator-content, form) → category → spacing and properties.
6
7/// Spacing constants (in em units, matching MathML Core).
8pub const THIN: f64 = 0.167; // 3 mu
9pub const MED: f64 = 0.222; // 4 mu
10pub const THICK: f64 = 0.278; // 5 mu
11
12/// Properties returned by the operator dictionary lookup.
13#[derive(Debug, Clone, Default)]
14pub struct OpDictProperties {
15  pub lspace:        f64,
16  pub rspace:        f64,
17  pub stretchy:      bool,
18  pub symmetric:     bool,
19  pub largeop:       bool,
20  pub movablelimits: bool,
21  pub fence:         bool,
22  pub separator:     bool,
23}
24
25/// Map LaTeXML role to MathML form.
26///
27/// Port of `$role_form`.
28pub fn role_to_form(role: &str) -> &'static str {
29  match role {
30    "OPEN" => "prefix",
31    "CLOSE" | "VERTBAR" | "PERIOD" | "POSTFIX" => "postfix",
32    "OPFUNCTION" | "TRIGFUNCTION" | "BIGOP" | "SUMOP" | "INTOP" | "LIMITOP" | "OPERATOR"
33    | "DIFFOP" => "prefix",
34    _ => "infix",
35  }
36}
37
38/// Look up operator properties from the MathML Core dictionary.
39///
40/// Port of `opdict_lookup`.
41/// Returns properties including spacing, stretchy, fence, separator, etc.
42pub fn opdict_lookup(content: &str, role: &str) -> OpDictProperties {
43  let form = role_to_form(role);
44  let category = lookup_category(content, form);
45
46  let mut props = category_to_properties(category);
47
48  // Add fence/separator from special tables
49  if is_fence_operator(content) {
50    props.fence = true;
51  }
52  if is_separator_operator(content) {
53    props.separator = true;
54  }
55
56  props
57}
58
59/// Determine the operator category from content and form.
60///
61/// Port of `lookup_category` — implements MathML Core Section 2.6.1 algorithm.
62fn lookup_category(content: &str, form: &str) -> &'static str {
63  let chars: Vec<char> = content.chars().collect();
64  let len = chars.len();
65
66  // (1) Length > 2: Default
67  if len > 2 {
68    return "Default";
69  }
70  if len == 0 {
71    return "Default";
72  }
73
74  let code1 = chars[0] as u32;
75
76  // (2) Single character in U+0320–U+03FF: Default
77  if len == 1 && (0x0320..=0x03FF).contains(&code1) {
78    return "Default";
79  }
80
81  // Two-character handling
82  let effective_code = if len == 2 {
83    let code2 = chars[1] as u32;
84    // Combining overlays: strip to first character
85    if code2 == 0x0338 || code2 == 0x20D2 {
86      code1
87    }
88    // Two-ASCII-char operators: map to pseudo-codepoint
89    else if let Some(idx) = two_ascii_char_index(content) {
90      0x0320 + idx as u32
91    } else {
92      return "Default";
93    }
94  } else {
95    code1
96  };
97
98  // (3) Special case: | and ~ in infix form
99  if form == "infix" && (effective_code == 0x007C || effective_code == 0x223C) {
100    return "ForceDefault";
101  }
102
103  // Look up in the content-form tables
104  lookup_content_form(effective_code, form).unwrap_or("Default")
105}
106
107/// Two-ASCII-character operators (Perl's Operators_2_ascii_chars).
108fn two_ascii_char_index(content: &str) -> Option<usize> {
109  const OPS: &[&str] = &[
110    "!!", "!=", "&&", "**", "*=", "++", "+=", "--", "-=", "->", "//", "/=", ":=", "<=", "<>", "==",
111    ">=", "||",
112  ];
113  OPS.iter().position(|&op| op == content)
114}
115
116/// Look up category for a codepoint + form in the content-form table.
117///
118/// Port of `$Content_form` tables.
119fn lookup_content_form(code: u32, form: &str) -> Option<&'static str> {
120  match form {
121    "infix" => lookup_infix(code),
122    "prefix" => lookup_prefix(code),
123    "postfix" => lookup_postfix(code),
124    _ => None,
125  }
126}
127
128// === generated from Perl $Content_form / $Operators_fence (verbatim) ===
129const INFIX_TABLE: &[(u32, u32, &str)] = &[
130  (0x0025, 0x0025, "C"),
131  (0x002A, 0x002A, "C"),
132  (0x002B, 0x002B, "B"),
133  (0x002C, 0x002C, "M"),
134  (0x002D, 0x002D, "B"),
135  (0x002E, 0x002E, "C"),
136  (0x002F, 0x002F, "B"),
137  (0x003A, 0x003A, "M"),
138  (0x003B, 0x003B, "M"),
139  (0x003F, 0x0040, "C"),
140  (0x005C, 0x005C, "K"),
141  (0x005E, 0x005E, "C"),
142  (0x005F, 0x005F, "K"),
143  (0x00B1, 0x00B1, "B"),
144  (0x00B7, 0x00B7, "C"),
145  (0x00D7, 0x00D7, "C"),
146  (0x00F7, 0x00F7, "B"),
147  (0x0322, 0x0322, "B"),
148  (0x0323, 0x0323, "C"),
149  (0x032E, 0x032E, "C"),
150  (0x2022, 0x2022, "C"),
151  (0x2043, 0x2043, "C"),
152  (0x2044, 0x2044, "B"),
153  (0x2061, 0x2064, "K"),
154  (0x2190, 0x2195, "A"),
155  (0x219A, 0x21AE, "A"),
156  (0x21B0, 0x21B5, "A"),
157  (0x21B9, 0x21B9, "A"),
158  (0x21BC, 0x21D5, "A"),
159  (0x21DA, 0x21F0, "A"),
160  (0x21F3, 0x21FF, "A"),
161  (0x2206, 0x2206, "K"),
162  (0x2212, 0x2216, "B"),
163  (0x2217, 0x2219, "C"),
164  (0x2227, 0x222A, "B"),
165  (0x2236, 0x2236, "B"),
166  (0x2238, 0x2238, "B"),
167  (0x2240, 0x2240, "C"),
168  (0x228C, 0x228E, "B"),
169  (0x2293, 0x2296, "B"),
170  (0x2297, 0x2297, "C"),
171  (0x2298, 0x2298, "B"),
172  (0x2299, 0x229B, "C"),
173  (0x229D, 0x229F, "B"),
174  (0x22A0, 0x22A1, "C"),
175  (0x22BA, 0x22BA, "C"),
176  (0x22BB, 0x22BD, "B"),
177  (0x22C4, 0x22C7, "C"),
178  (0x22C9, 0x22CC, "C"),
179  (0x22CE, 0x22CF, "B"),
180  (0x22D2, 0x22D3, "B"),
181  (0x2305, 0x2306, "C"),
182  (0x2794, 0x2794, "A"),
183  (0x2795, 0x2797, "B"),
184  (0x2799, 0x2799, "A"),
185  (0x279B, 0x27A1, "A"),
186  (0x27A5, 0x27A6, "A"),
187  (0x27A8, 0x27AF, "A"),
188  (0x27B1, 0x27B1, "A"),
189  (0x27B3, 0x27B3, "A"),
190  (0x27B5, 0x27B5, "A"),
191  (0x27B8, 0x27B8, "A"),
192  (0x27BA, 0x27BE, "A"),
193  (0x27CB, 0x27CB, "C"),
194  (0x27CD, 0x27CD, "C"),
195  (0x27F0, 0x27F1, "A"),
196  (0x27F4, 0x27FF, "A"),
197  (0x2900, 0x2920, "A"),
198  (0x2934, 0x2937, "A"),
199  (0x2942, 0x2975, "A"),
200  (0x297C, 0x297F, "A"),
201  (0x29B8, 0x29B8, "B"),
202  (0x29BC, 0x29BC, "B"),
203  (0x29C4, 0x29C5, "B"),
204  (0x29C6, 0x29C8, "C"),
205  (0x29D4, 0x29D7, "C"),
206  (0x29E2, 0x29E2, "C"),
207  (0x29F5, 0x29FB, "B"),
208  (0x2A1D, 0x2A1E, "C"),
209  (0x2A1F, 0x2A2E, "B"),
210  (0x2A2F, 0x2A37, "C"),
211  (0x2A38, 0x2A3A, "B"),
212  (0x2A3B, 0x2A3D, "C"),
213  (0x2A3E, 0x2A3E, "B"),
214  (0x2A3F, 0x2A3F, "C"),
215  (0x2A40, 0x2A4F, "B"),
216  (0x2A50, 0x2A50, "C"),
217  (0x2A51, 0x2A63, "B"),
218  (0x2A64, 0x2A65, "C"),
219  (0x2ADB, 0x2ADB, "B"),
220  (0x2ADC, 0x2ADD, "C"),
221  (0x2AF6, 0x2AF6, "B"),
222  (0x2AFB, 0x2AFB, "B"),
223  (0x2AFD, 0x2AFD, "B"),
224  (0x2AFE, 0x2AFE, "C"),
225  (0x2B04, 0x2B07, "A"),
226  (0x2B0C, 0x2B11, "A"),
227  (0x2B30, 0x2B3E, "A"),
228  (0x2B40, 0x2B4C, "A"),
229  (0x2B60, 0x2B65, "A"),
230  (0x2B6A, 0x2B6D, "A"),
231  (0x2B70, 0x2B73, "A"),
232  (0x2B7A, 0x2B7D, "A"),
233  (0x2B80, 0x2B87, "A"),
234  (0x2B95, 0x2B95, "A"),
235  (0x2BA0, 0x2BAF, "A"),
236  (0x2BB8, 0x2BB8, "A"),
237];
238const PREFIX_TABLE: &[(u32, u32, &str)] = &[
239  (0x0021, 0x0021, "D"),
240  (0x0028, 0x0028, "F"),
241  (0x002B, 0x002B, "D"),
242  (0x002D, 0x002D, "D"),
243  (0x005B, 0x005B, "F"),
244  (0x007B, 0x007B, "F"),
245  (0x007C, 0x007C, "F"),
246  (0x00AC, 0x00AC, "D"),
247  (0x00B1, 0x00B1, "D"),
248  (0x0331, 0x0331, "D"),
249  (0x2016, 0x2016, "F"),
250  (0x2018, 0x2018, "D"),
251  (0x201C, 0x201C, "D"),
252  (0x2145, 0x2146, "L"),
253  (0x2200, 0x2201, "D"),
254  (0x2202, 0x2202, "L"),
255  (0x2203, 0x2204, "D"),
256  (0x2207, 0x2207, "D"),
257  (0x220F, 0x2211, "J"),
258  (0x2212, 0x2213, "D"),
259  (0x221A, 0x221C, "L"),
260  (0x221F, 0x2222, "D"),
261  (0x222B, 0x2233, "H"),
262  (0x2234, 0x2235, "D"),
263  (0x223C, 0x223C, "D"),
264  (0x22BE, 0x22BF, "D"),
265  (0x22C0, 0x22C3, "J"),
266  (0x2308, 0x2308, "F"),
267  (0x230A, 0x230A, "F"),
268  (0x2310, 0x2310, "D"),
269  (0x2319, 0x2319, "D"),
270  (0x2329, 0x2329, "F"),
271  (0x2772, 0x2772, "F"),
272  (0x2795, 0x2796, "D"),
273  (0x27C0, 0x27C0, "D"),
274  (0x27E6, 0x27E6, "F"),
275  (0x27E8, 0x27E8, "F"),
276  (0x27EA, 0x27EA, "F"),
277  (0x27EC, 0x27EC, "F"),
278  (0x27EE, 0x27EE, "F"),
279  (0x2980, 0x2980, "F"),
280  (0x2983, 0x2983, "F"),
281  (0x2985, 0x2985, "F"),
282  (0x2987, 0x2987, "F"),
283  (0x2989, 0x2989, "F"),
284  (0x298B, 0x298B, "F"),
285  (0x298D, 0x298D, "F"),
286  (0x298F, 0x298F, "F"),
287  (0x2991, 0x2991, "F"),
288  (0x2993, 0x2993, "F"),
289  (0x2995, 0x2995, "F"),
290  (0x2997, 0x2997, "F"),
291  (0x2999, 0x2999, "F"),
292  (0x299B, 0x29AF, "D"),
293  (0x29D8, 0x29D8, "F"),
294  (0x29DA, 0x29DA, "F"),
295  (0x29FC, 0x29FC, "F"),
296  (0x2A00, 0x2A0A, "J"),
297  (0x2A0B, 0x2A1C, "H"),
298  (0x2A1D, 0x2A1E, "J"),
299  (0x2AEC, 0x2AED, "D"),
300  (0x2AFC, 0x2AFC, "J"),
301  (0x2AFF, 0x2AFF, "J"),
302];
303const POSTFIX_TABLE: &[(u32, u32, &str)] = &[
304  (0x0021, 0x0022, "E"),
305  (0x0025, 0x0027, "E"),
306  (0x0029, 0x0029, "G"),
307  (0x005D, 0x005D, "G"),
308  (0x005E, 0x005F, "I"),
309  (0x0060, 0x0060, "E"),
310  (0x007C, 0x007C, "G"),
311  (0x007D, 0x007D, "G"),
312  (0x007E, 0x007E, "I"),
313  (0x00A8, 0x00A8, "E"),
314  (0x00AF, 0x00AF, "I"),
315  (0x00B0, 0x00B0, "E"),
316  (0x00B2, 0x00B4, "E"),
317  (0x00B8, 0x00B9, "E"),
318  (0x02C6, 0x02C7, "I"),
319  (0x02C9, 0x02C9, "I"),
320  (0x02CA, 0x02CB, "E"),
321  (0x02CD, 0x02CD, "I"),
322  (0x02D8, 0x02DA, "E"),
323  (0x02DC, 0x02DC, "I"),
324  (0x02DD, 0x02DD, "E"),
325  (0x02F7, 0x02F7, "I"),
326  (0x0302, 0x0302, "I"),
327  (0x0311, 0x0311, "E"),
328  (0x0320, 0x0320, "E"),
329  (0x0325, 0x0325, "E"),
330  (0x0327, 0x0327, "E"),
331  (0x0331, 0x0331, "E"),
332  (0x2016, 0x2016, "G"),
333  (0x2019, 0x201B, "E"),
334  (0x201D, 0x201F, "E"),
335  (0x2032, 0x2037, "E"),
336  (0x203E, 0x203E, "I"),
337  (0x2057, 0x2057, "E"),
338  (0x20DB, 0x20DC, "E"),
339  (0x2309, 0x2309, "G"),
340  (0x230B, 0x230B, "G"),
341  (0x2322, 0x2323, "I"),
342  (0x232A, 0x232A, "G"),
343  (0x23B4, 0x23B5, "I"),
344  (0x23CD, 0x23CD, "E"),
345  (0x23DC, 0x23E1, "I"),
346  (0x2773, 0x2773, "G"),
347  (0x27E7, 0x27E7, "G"),
348  (0x27E9, 0x27E9, "G"),
349  (0x27EB, 0x27EB, "G"),
350  (0x27ED, 0x27ED, "G"),
351  (0x27EF, 0x27EF, "G"),
352  (0x2980, 0x2980, "G"),
353  (0x2984, 0x2984, "G"),
354  (0x2986, 0x2986, "G"),
355  (0x2988, 0x2988, "G"),
356  (0x298A, 0x298A, "G"),
357  (0x298C, 0x298C, "G"),
358  (0x298E, 0x298E, "G"),
359  (0x2990, 0x2990, "G"),
360  (0x2992, 0x2992, "G"),
361  (0x2994, 0x2994, "G"),
362  (0x2996, 0x2996, "G"),
363  (0x2998, 0x2998, "G"),
364  (0x2999, 0x2999, "G"),
365  (0x29D9, 0x29D9, "G"),
366  (0x29DB, 0x29DB, "G"),
367  (0x29FD, 0x29FD, "G"),
368  (0x1EEF0, 0x1EEF1, "I"),
369];
370const FENCE_TABLE: &[(u32, u32)] = &[
371  (0x0028, 0x0029),
372  (0x005B, 0x005B),
373  (0x005D, 0x005D),
374  (0x007B, 0x007D),
375  (0x0331, 0x0331),
376  (0x2016, 0x2016),
377  (0x2018, 0x2019),
378  (0x201C, 0x201D),
379  (0x2308, 0x230B),
380  (0x2329, 0x232A),
381  (0x2772, 0x2773),
382  (0x27E6, 0x27EF),
383  (0x2980, 0x2980),
384  (0x2983, 0x2999),
385  (0x29D8, 0x29DB),
386  (0x29FC, 0x29FD),
387];
388
389/// Binary-search a (start, end, category) range table.
390fn range_lookup(table: &[(u32, u32, &'static str)], code: u32) -> Option<&'static str> {
391  match table.binary_search_by(|&(a, b, _)| {
392    if code < a {
393      std::cmp::Ordering::Greater
394    } else if code > b {
395      std::cmp::Ordering::Less
396    } else {
397      std::cmp::Ordering::Equal
398    }
399  }) {
400    Ok(i) => Some(table[i].2),
401    Err(_) => None,
402  }
403}
404
405fn lookup_infix(code: u32) -> Option<&'static str> { range_lookup(INFIX_TABLE, code) }
406fn lookup_prefix(code: u32) -> Option<&'static str> { range_lookup(PREFIX_TABLE, code) }
407fn lookup_postfix(code: u32) -> Option<&'static str> { range_lookup(POSTFIX_TABLE, code) }
408
409/// Map category name to operator properties.
410///
411/// Port of `$Category_data`.
412fn category_to_properties(category: &str) -> OpDictProperties {
413  match category {
414    "A" => OpDictProperties {
415      lspace: THICK,
416      rspace: THICK,
417      stretchy: true,
418      ..Default::default()
419    },
420    "B" => OpDictProperties {
421      lspace: MED,
422      rspace: MED,
423      ..Default::default()
424    },
425    "C" => OpDictProperties {
426      lspace: THIN,
427      rspace: THIN,
428      ..Default::default()
429    },
430    "D" => OpDictProperties {
431      lspace: 0.0,
432      rspace: 0.0,
433      ..Default::default()
434    },
435    "E" => OpDictProperties {
436      lspace: 0.0,
437      rspace: 0.0,
438      ..Default::default()
439    },
440    "F" => OpDictProperties {
441      lspace: 0.0,
442      rspace: 0.0,
443      stretchy: true,
444      symmetric: true,
445      ..Default::default()
446    },
447    "G" => OpDictProperties {
448      lspace: 0.0,
449      rspace: 0.0,
450      stretchy: true,
451      symmetric: true,
452      ..Default::default()
453    },
454    "H" => OpDictProperties {
455      lspace: THIN,
456      rspace: THIN,
457      symmetric: true,
458      largeop: true,
459      ..Default::default()
460    },
461    "I" => OpDictProperties {
462      lspace: 0.0,
463      rspace: 0.0,
464      stretchy: true,
465      ..Default::default()
466    },
467    "J" => OpDictProperties {
468      lspace: THIN,
469      rspace: THIN,
470      symmetric: true,
471      largeop: true,
472      movablelimits: true,
473      ..Default::default()
474    },
475    "K" => OpDictProperties {
476      lspace: 0.0,
477      rspace: 0.0,
478      ..Default::default()
479    },
480    "L" => OpDictProperties {
481      lspace: THIN,
482      rspace: 0.0,
483      ..Default::default()
484    },
485    "M" => OpDictProperties {
486      lspace: 0.0,
487      rspace: THIN,
488      ..Default::default()
489    },
490    _ => OpDictProperties {
491      lspace: THICK,
492      rspace: THICK,
493      ..Default::default()
494    }, // Default & ForceDefault
495  }
496}
497
498/// Check if a character is in the MathML Core fence operators table.
499fn is_fence_operator(content: &str) -> bool {
500  let c = match content.chars().next() {
501    Some(c) => c as u32,
502    None => return false,
503  };
504  FENCE_TABLE.iter().any(|&(a, b)| (a..=b).contains(&c))
505}
506
507/// Check if a character is a separator (comma, semicolon, invisible separator).
508fn is_separator_operator(content: &str) -> bool {
509  let c = match content.chars().next() {
510    Some(c) => c as u32,
511    None => return false,
512  };
513  c == 0x002C || c == 0x003B || c == 0x2063
514}
515
516#[cfg(test)]
517mod tests {
518  use super::*;
519
520  #[test]
521  fn test_content_form_table_regressions() {
522    // U+2A50 (⩐) was misclassified Cat B (MED) — Perl: Cat C (THIN).
523    let p = opdict_lookup("\u{2A50}", "BINOP");
524    assert_eq!(p.lspace, THIN);
525    assert_eq!(p.rspace, THIN);
526    // U+27A1 (➡) sat in a Cat-A dingbat-arrow hole — Perl: Cat A (THICK).
527    let p = opdict_lookup("\u{27A1}", "ARROW");
528    assert_eq!(p.lspace, THICK);
529    // U+0331 combining macron-below is in the fence table (Perl).
530    let p = opdict_lookup("\u{0331}", "OPEN");
531    assert!(p.fence);
532  }
533
534  #[test]
535  fn test_opdict_lookup_plus() {
536    let props = opdict_lookup("+", "ADDOP");
537    assert!((props.lspace - MED).abs() < 0.001); // Category B
538    assert!((props.rspace - MED).abs() < 0.001);
539    assert!(!props.stretchy);
540  }
541
542  /// Issue #535 contract: the SAME operator glyph yields PREFIX spacing
543  /// (lspace 0) when looked up as a unary `OPERATOR` and INFIX spacing (MED)
544  /// as a binary `ADDOP`. `pmml_infix`'s single-argument branch depends on this
545  /// split — it re-looks-up a unary minus/plus as `OPERATOR` so the prefix form
546  /// (lspace 0) is chosen, otherwise the spacewalk collapses `= -x`. `−` is the
547  /// Unicode MINUS (U+2212) that `pmml_token_inner` normalizes `-` to before the
548  /// dictionary lookup.
549  #[test]
550  fn prefix_role_zeroes_left_spacing_that_infix_role_keeps() {
551    for glyph in ["\u{2212}", "+"] {
552      let prefix = opdict_lookup(glyph, "OPERATOR");
553      let infix = opdict_lookup(glyph, "ADDOP");
554      assert_eq!(prefix.lspace, 0.0, "{glyph} prefix (OPERATOR) lspace");
555      assert!(
556        (infix.lspace - MED).abs() < 0.001,
557        "{glyph} infix (ADDOP) lspace should be MED"
558      );
559      // The whole point: prefix hugs its operand where infix would not.
560      assert!(
561        infix.lspace > prefix.lspace,
562        "{glyph}: infix must reserve more left space than prefix"
563      );
564    }
565  }
566
567  #[test]
568  fn test_opdict_lookup_open_paren() {
569    let props = opdict_lookup("(", "OPEN");
570    assert!((props.lspace - 0.0).abs() < 0.001); // Category F
571    assert!(props.stretchy);
572    assert!(props.symmetric);
573    assert!(props.fence);
574  }
575
576  #[test]
577  fn test_opdict_lookup_integral() {
578    let props = opdict_lookup("\u{222B}", "INTOP");
579    assert!(props.largeop); // Category H
580    assert!(props.symmetric);
581  }
582
583  #[test]
584  fn test_opdict_lookup_sum() {
585    let props = opdict_lookup("\u{2211}", "SUMOP");
586    assert!(props.largeop); // Category J
587    assert!(props.movablelimits);
588  }
589
590  #[test]
591  fn test_opdict_lookup_comma() {
592    let props = opdict_lookup(",", "PUNCT");
593    assert!(props.separator);
594    assert!((props.rspace - THIN).abs() < 0.001); // Category M
595  }
596
597  #[test]
598  fn test_opdict_lookup_invisible_times() {
599    let props = opdict_lookup("\u{2062}", "MULOP");
600    assert!((props.lspace - 0.0).abs() < 0.001); // Category K
601    assert!((props.rspace - 0.0).abs() < 0.001);
602  }
603
604  #[test]
605  fn test_two_ascii_char_ops() {
606    assert_eq!(two_ascii_char_index("!="), Some(1));
607    assert_eq!(two_ascii_char_index("->"), Some(9));
608    assert_eq!(two_ascii_char_index("xx"), None);
609  }
610}