Skip to main content

latexml_core/common/
mathchar.rs

1use std::rc::Rc;
2
3use rustc_hash::FxHashMap as HashMap;
4
5use crate::{
6  common::{
7    arena::{self, SymHashMap},
8    error::Result,
9    store::Stored,
10  },
11  digested::Digested,
12  pin, state,
13  tbox::Tbox,
14  token::Token,
15};
16
17const MATH_CLASS_ROLE: [&str; 8] = ["", "BIGOP", "BINOP", "RELOP", "OPEN", "CLOSE", "PUNCT", ""];
18
19/// Properties for a decoded math character, mirroring Perl's decodeMathChar return
20#[derive(Debug, Clone, Default)]
21pub struct MathCharProps {
22  pub role:           Option<String>,
23  pub glyph:          Option<char>,
24  pub meaning:        Option<String>,
25  pub name:           Option<String>,
26  pub stretchy:       Option<String>,
27  pub need_scriptpos: bool,
28  pub need_mathstyle: bool,
29  pub scriptpos:      Option<String>,
30  pub mathstyle:      Option<String>,
31  pub reversion:      Option<crate::tokens::Tokens>,
32  pub font:           Option<crate::common::font::Font>,
33}
34
35impl MathCharProps {
36  /// Convert need_scriptpos/need_mathstyle flags to actual values based on display mode
37  pub fn resolve_style_props(&mut self) {
38    let in_display = state::lookup_bool("IN_MATH_DISPLAY")
39      || state::lookup_font()
40        .map(|f| f.get_mathstyle().map(|s| s.as_ref()) == Some("display"))
41        .unwrap_or(false);
42    if self.need_scriptpos {
43      self.scriptpos = Some(if in_display { "mid" } else { "post" }.to_string());
44    }
45    if self.need_mathstyle {
46      self.mathstyle = Some(if in_display { "display" } else { "text" }.to_string());
47    }
48  }
49
50  /// Insert all properties into a HashMap for Tbox construction
51  pub fn into_props_map(self) -> HashMap<&'static str, Stored> {
52    let mut props = HashMap::default();
53    if let Some(role) = self.role {
54      props.insert("role", Stored::String(arena::pin(role)));
55    }
56    if let Some(meaning) = self.meaning {
57      props.insert("meaning", Stored::String(arena::pin(meaning)));
58    }
59    if let Some(name) = self.name {
60      props.insert("name", Stored::String(arena::pin(name)));
61    }
62    if let Some(stretchy) = self.stretchy {
63      props.insert("stretchy", Stored::String(arena::pin(stretchy)));
64    }
65    if let Some(scriptpos) = self.scriptpos {
66      props.insert("scriptpos", Stored::String(arena::pin(scriptpos)));
67    }
68    if let Some(mathstyle) = self.mathstyle {
69      props.insert("mathstyle", Stored::String(arena::pin(mathstyle)));
70    }
71    props
72  }
73}
74
75/// Lookup Unicode math properties for a character, mirroring Perl's %math_props in Unicode.pm
76pub fn unicode_math_properties(c: char) -> Option<MathCharProps> {
77  // The struct fields: role, meaning, name, stretchy, need_scriptpos, need_mathstyle
78  // (glyph is set separately)
79  let (role, meaning, name, stretchy, need_sp, need_ms) = match c {
80    // Digits
81    '0'..='9' => ("NUMBER", Some(c.to_string()), None, None, false, false),
82    // ASCII operators and punctuation
83    '=' => ("RELOP", Some("equals".into()), None, None, false, false),
84    '+' => ("ADDOP", Some("plus".into()), None, None, false, false),
85    '-' => ("ADDOP", Some("minus".into()), None, None, false, false),
86    '*' => ("MULOP", Some("times".into()), None, None, false, false),
87    '/' => ("MULOP", Some("divide".into()), None, None, false, false),
88    '!' => (
89      "POSTFIX",
90      Some("factorial".into()),
91      None,
92      None,
93      false,
94      false,
95    ),
96    ',' => ("PUNCT", None, None, None, false, false),
97    '.' => ("PERIOD", None, None, None, false, false),
98    ';' => ("PUNCT", None, None, None, false, false),
99    ':' => ("METARELOP", None, Some("colon".into()), None, false, false),
100    '|' => ("VERTBAR", None, None, Some("false".into()), false, false),
101    '<' => ("RELOP", Some("less-than".into()), None, None, false, false),
102    '>' => (
103      "RELOP",
104      Some("greater-than".into()),
105      None,
106      None,
107      false,
108      false,
109    ),
110    '(' => ("OPEN", None, None, Some("false".into()), false, false),
111    ')' => ("CLOSE", None, None, Some("false".into()), false, false),
112    '[' => ("OPEN", None, None, Some("false".into()), false, false),
113    ']' => ("CLOSE", None, None, Some("false".into()), false, false),
114    '{' => ("OPEN", None, None, Some("false".into()), false, false),
115    '}' => ("CLOSE", None, None, Some("false".into()), false, false),
116    '&' => ("ADDOP", Some("and".into()), None, None, false, false),
117    '%' => ("POSTFIX", Some("percent".into()), None, None, false, false),
118    '$' => (
119      "OPERATOR",
120      Some("currency-dollar".into()),
121      None,
122      None,
123      false,
124      false,
125    ),
126    '?' => ("UNKNOWN", None, None, None, false, false),
127    // Backslash
128    '\\' => ("ADDOP", Some("set-minus".into()), None, None, false, false),
129    // Latin-1 supplement
130    '\u{00AC}' => ("BIGOP", Some("not".into()), None, None, false, false), // ¬ \neg, \lnot
131    '\u{00B1}' => (
132      "ADDOP",
133      Some("plus-or-minus".into()),
134      None,
135      None,
136      false,
137      false,
138    ), // ± \pm
139    '\u{00D7}' => ("MULOP", Some("times".into()), None, None, false, false), // × \times
140    '\u{00F7}' => ("MULOP", Some("divide".into()), None, None, false, false), // ÷ \div
141    // General symbols
142    '\u{2020}' => ("MULOP", None, None, None, false, false), // † \dagger
143    '\u{2021}' => ("MULOP", None, None, None, false, false), // ‡ \ddagger
144    '\u{2032}' => ("SUPOP", None, None, None, false, false), // ′ \prime
145    '\u{2061}' => ("APPLYOP", None, Some(String::new()), None, false, false), // ⁡ function application
146    '\u{2062}' => (
147      "MULOP",
148      Some("times".into()),
149      Some(String::new()),
150      None,
151      false,
152      false,
153    ), // ⁢ invisible times
154    '\u{2063}' => ("PUNCT", None, Some(String::new()), None, false, false), // ⁣ invisible separator
155    '\u{2064}' => (
156      "ADDOP",
157      Some("plus".into()),
158      Some(String::new()),
159      None,
160      false,
161      false,
162    ), // ⁤ invisible plus
163    '\u{210F}' => (
164      "ID",
165      Some("Planck-constant-over-2-pi".into()),
166      None,
167      None,
168      false,
169      false,
170    ), // ℏ \hbar
171    '\u{2111}' => (
172      "OPFUNCTION",
173      Some("imaginary-part".into()),
174      None,
175      None,
176      false,
177      false,
178    ), // ℑ \Im
179    '\u{2118}' => (
180      "OPFUNCTION",
181      Some("Weierstrass-p".into()),
182      None,
183      None,
184      false,
185      false,
186    ), // ℘ \wp
187    '\u{211C}' => (
188      "OPFUNCTION",
189      Some("real-part".into()),
190      None,
191      None,
192      false,
193      false,
194    ), // ℜ \Re
195    // Arrows
196    '\u{2190}' => ("ARROW", None, None, None, false, false), // ← \leftarrow
197    '\u{2191}' => ("ARROW", None, Some("uparrow".into()), None, false, false), // ↑ \uparrow
198    '\u{2192}' => ("ARROW", None, None, None, false, false), // → \rightarrow
199    '\u{2193}' => ("ARROW", None, Some("downarrow".into()), None, false, false), // ↓ \downarrow
200    '\u{2194}' => ("METARELOP", None, None, None, false, false), // ↔ \leftrightarrow
201    '\u{2195}' => (
202      "ARROW",
203      None,
204      Some("updownarrow".into()),
205      None,
206      false,
207      false,
208    ), // ↕ \updownarrow
209    '\u{2196}' => ("ARROW", None, None, None, false, false), // ↖ \nwarrow
210    '\u{2197}' => ("ARROW", None, None, None, false, false), // ↗ \nearrow
211    '\u{2198}' => ("ARROW", None, None, None, false, false), // ↘ \searrow
212    '\u{2199}' => ("ARROW", None, None, None, false, false), // ↙ \swarrow
213    '\u{219D}' => ("ARROW", Some("leads-to".into()), None, None, false, false), // ⇝ \leadsto
214    '\u{21A6}' => ("ARROW", Some("maps-to".into()), None, None, false, false), // ↦ \mapsto
215    '\u{21A9}' => ("ARROW", None, None, None, false, false), // ↩ \hookleftarrow
216    '\u{21AA}' => ("ARROW", None, None, None, false, false), // ↪ \hookrightarrow
217    '\u{21BC}' => ("ARROW", None, None, None, false, false), // ↼ \leftharpoonup
218    '\u{21BD}' => ("ARROW", None, None, None, false, false), // ⇀ \leftharpoondown
219    '\u{21C0}' => ("ARROW", None, None, None, false, false), // ⇁ \rightharpoonup
220    '\u{21C1}' => ("ARROW", None, None, None, false, false), // ⇂ \rightharpoondown
221    '\u{21CC}' => ("METARELOP", None, None, None, false, false), // ⇌ \rightleftharpoons
222    '\u{21D0}' => ("ARROW", None, None, None, false, false), // ⇐ \Leftarrow
223    '\u{21D1}' => ("ARROW", None, Some("Uparrow".into()), None, false, false), // ⇑ \Uparrow
224    '\u{21D2}' => ("ARROW", None, None, None, false, false), // ⇒ \Rightarrow
225    '\u{21D3}' => ("ARROW", None, Some("Downarrow".into()), None, false, false), // ⇓ \Downarrow
226    '\u{21D4}' => ("METARELOP", Some("iff".into()), None, None, false, false), // ⇔ \Leftrightarrow
227    '\u{21D5}' => (
228      "ARROW",
229      None,
230      Some("Updownarrow".into()),
231      None,
232      false,
233      false,
234    ), // ⇕ \Updownarrow
235    // Quantifiers and set theory
236    '\u{2200}' => ("BIGOP", Some("for-all".into()), None, None, false, false), // ∀ \forall
237    '\u{2202}' => (
238      "DIFFOP",
239      Some("partial-differential".into()),
240      None,
241      None,
242      false,
243      false,
244    ), // ∂ \partial
245    '\u{2203}' => ("BIGOP", Some("exists".into()), None, None, false, false),  // ∃ \exists
246    '\u{2205}' => ("ID", Some("empty-set".into()), None, None, false, false),  // ∅ \emptyset
247    '\u{2207}' => ("OPERATOR", None, None, None, false, false),                // ∇ \nabla
248    '\u{2208}' => ("RELOP", Some("element-of".into()), None, None, false, false), // ∈ \in
249    '\u{2209}' => (
250      "RELOP",
251      Some("not-element-of".into()),
252      None,
253      None,
254      false,
255      false,
256    ), // ∉ \notin
257    '\u{220B}' => ("RELOP", Some("contains".into()), None, None, false, false), // ∋ \ni
258    // Big operators
259    '\u{220F}' => ("SUMOP", Some("product".into()), None, None, true, true), // ∏ \prod
260    '\u{2210}' => ("SUMOP", Some("coproduct".into()), None, None, true, true), // ∐ \coprod
261    '\u{2211}' => ("SUMOP", Some("sum".into()), None, None, true, true),     // ∑ \sum
262    // Arithmetic operators
263    '\u{2213}' => (
264      "ADDOP",
265      Some("minus-or-plus".into()),
266      None,
267      None,
268      false,
269      false,
270    ), // ∓ \mp
271    '\u{2216}' => ("ADDOP", Some("set-minus".into()), None, None, false, false), // ∖ \setminus
272    '\u{2217}' => ("MULOP", Some("times".into()), None, None, false, false),     // ∗ \ast
273    '\u{2218}' => ("MULOP", Some("compose".into()), None, None, false, false),   // ∘ \circ
274    '\u{2219}' => ("MULOP", None, None, None, false, false),                     // ∙ \bullet
275    '\u{221A}' => (
276      "OPERATOR",
277      Some("square-root".into()),
278      None,
279      None,
280      false,
281      false,
282    ), // √ \surd
283    '\u{221D}' => (
284      "RELOP",
285      Some("proportional-to".into()),
286      None,
287      None,
288      false,
289      false,
290    ), // ∝ \propto
291    '\u{221E}' => ("ID", Some("infinity".into()), None, None, false, false),     // ∞ \infty
292    '\u{2223}' => ("VERTBAR", None, None, None, false, false),                   // ∣ \mid
293    '\u{2225}' => (
294      "VERTBAR",
295      Some("parallel-to".into()),
296      Some("||".into()),
297      None,
298      false,
299      false,
300    ), // ∥ \parallel
301    // Logical operators
302    '\u{2227}' => ("ADDOP", Some("and".into()), None, None, false, false), // ∧ \land, \wedge
303    '\u{2228}' => ("ADDOP", Some("or".into()), None, None, false, false),  // ∨ \lor, \vee
304    '\u{2229}' => (
305      "ADDOP",
306      Some("intersection".into()),
307      None,
308      None,
309      false,
310      false,
311    ), // ∩ \cap
312    '\u{222A}' => ("ADDOP", Some("union".into()), None, None, false, false), // ∪ \cup
313    // Integrals
314    '\u{222B}' => ("INTOP", Some("integral".into()), None, None, false, true), // ∫ \int
315    '\u{222E}' => (
316      "INTOP",
317      Some("contour-integral".into()),
318      None,
319      None,
320      false,
321      true,
322    ), // ∮ \oint
323    // Relations
324    '\u{223C}' => ("RELOP", Some("similar-to".into()), None, None, false, false), // ∼ \sim
325    '\u{2240}' => ("MULOP", None, None, None, false, false),                      // ≀ \wr
326    '\u{2243}' => (
327      "RELOP",
328      Some("similar-to-or-equals".into()),
329      None,
330      None,
331      false,
332      false,
333    ), // ≃ \simeq
334    '\u{2245}' => (
335      "RELOP",
336      Some("approximately-equals".into()),
337      None,
338      None,
339      false,
340      false,
341    ), // ≅ \cong
342    '\u{2248}' => (
343      "RELOP",
344      Some("approximately-equals".into()),
345      None,
346      None,
347      false,
348      false,
349    ), // ≈ \approx
350    '\u{224D}' => (
351      "RELOP",
352      Some("asymptotically-equals".into()),
353      None,
354      None,
355      false,
356      false,
357    ), // ≍ \asymp
358    '\u{2250}' => (
359      "RELOP",
360      Some("approaches-limit".into()),
361      None,
362      None,
363      false,
364      false,
365    ), // ≐ \doteq
366    '\u{2260}' => ("RELOP", Some("not-equals".into()), None, None, false, false), // ≠ \neq
367    '\u{2261}' => (
368      "RELOP",
369      Some("equivalent-to".into()),
370      None,
371      None,
372      false,
373      false,
374    ), // ≡ \equiv
375    '\u{2264}' => (
376      "RELOP",
377      Some("less-than-or-equals".into()),
378      None,
379      None,
380      false,
381      false,
382    ), // ≤ \leq
383    '\u{2265}' => (
384      "RELOP",
385      Some("greater-than-or-equals".into()),
386      None,
387      None,
388      false,
389      false,
390    ), // ≥ \geq
391    '\u{226A}' => (
392      "RELOP",
393      Some("much-less-than".into()),
394      None,
395      None,
396      false,
397      false,
398    ), // ≪ \ll
399    '\u{226B}' => (
400      "RELOP",
401      Some("much-greater-than".into()),
402      None,
403      None,
404      false,
405      false,
406    ), // ≫ \gg
407    '\u{227A}' => ("RELOP", Some("precedes".into()), None, None, false, false),   // ≺ \prec
408    '\u{227B}' => ("RELOP", Some("succeeds".into()), None, None, false, false),   // ≻ \succ
409    // Subset/superset
410    '\u{2282}' => ("RELOP", Some("subset-of".into()), None, None, false, false), // ⊂ \subset
411    '\u{2283}' => (
412      "RELOP",
413      Some("superset-of".into()),
414      None,
415      None,
416      false,
417      false,
418    ), // ⊃ \supset
419    '\u{2286}' => (
420      "RELOP",
421      Some("subset-of-or-equals".into()),
422      None,
423      None,
424      false,
425      false,
426    ), // ⊆ \subseteq
427    '\u{2287}' => (
428      "RELOP",
429      Some("superset-of-or-equals".into()),
430      None,
431      None,
432      false,
433      false,
434    ), // ⊇ \supseteq
435    '\u{228E}' => ("ADDOP", None, None, None, false, false),                     // ⊎ \uplus
436    '\u{228F}' => (
437      "RELOP",
438      Some("square-image-of".into()),
439      None,
440      None,
441      false,
442      false,
443    ), // ⊏ \sqsubset
444    '\u{2290}' => (
445      "RELOP",
446      Some("square-original-of".into()),
447      None,
448      None,
449      false,
450      false,
451    ), // ⊐ \sqsupset
452    '\u{2291}' => (
453      "RELOP",
454      Some("square-image-of-or-equals".into()),
455      None,
456      None,
457      false,
458      false,
459    ), // ⊑ \sqsubseteq
460    '\u{2292}' => (
461      "RELOP",
462      Some("square-original-of-or-equals".into()),
463      None,
464      None,
465      false,
466      false,
467    ), // ⊒ \sqsupseteq
468    '\u{2293}' => (
469      "ADDOP",
470      Some("square-intersection".into()),
471      None,
472      None,
473      false,
474      false,
475    ), // ⊓ \sqcap
476    '\u{2294}' => (
477      "ADDOP",
478      Some("square-union".into()),
479      None,
480      None,
481      false,
482      false,
483    ), // ⊔ \sqcup
484    // Circled operators
485    '\u{2295}' => ("ADDOP", Some("direct-sum".into()), None, None, false, false), // ⊕ \oplus
486    '\u{2296}' => (
487      "ADDOP",
488      Some("symmetric-difference".into()),
489      None,
490      None,
491      false,
492      false,
493    ), // ⊖ \ominus
494    '\u{2297}' => (
495      "MULOP",
496      Some("tensor-product".into()),
497      None,
498      None,
499      false,
500      false,
501    ), // ⊗ \otimes
502    '\u{2298}' => ("MULOP", None, None, None, false, false),                      // ⊘ \oslash
503    '\u{2299}' => (
504      "MULOP",
505      Some("direct-product".into()),
506      None,
507      None,
508      false,
509      false,
510    ), // ⊙ \odot
511    // Turnstiles
512    '\u{22A2}' => ("METARELOP", Some("proves".into()), None, None, false, false), // ⊢ \vdash
513    '\u{22A3}' => (
514      "METARELOP",
515      Some("does-not-prove".into()),
516      None,
517      None,
518      false,
519      false,
520    ), // ⊣ \dashv
521    '\u{22A4}' => ("ADDOP", Some("top".into()), None, None, false, false),        // ⊤ \top
522    '\u{22A5}' => ("ADDOP", Some("bottom".into()), None, None, false, false),     // ⊥ \bot
523    '\u{22A7}' => ("RELOP", Some("models".into()), None, None, false, false),     // ⊧ \models
524    '\u{22B2}' => (
525      "ADDOP",
526      Some("subgroup-of".into()),
527      None,
528      None,
529      false,
530      false,
531    ), // ⊲ \lhd
532    '\u{22B3}' => (
533      "ADDOP",
534      Some("contains-as-subgroup".into()),
535      None,
536      None,
537      false,
538      false,
539    ), // ⊳ \rhd
540    '\u{22B4}' => (
541      "ADDOP",
542      Some("subgroup-of-or-equals".into()),
543      None,
544      None,
545      false,
546      false,
547    ), // ⊴ \unlhd
548    '\u{22B5}' => (
549      "ADDOP",
550      Some("contains-as-subgroup-or-equals".into()),
551      None,
552      None,
553      false,
554      false,
555    ), // ⊵ \unrhd
556    // Big operators (N-ary)
557    '\u{22C0}' => ("SUMOP", Some("and".into()), None, None, true, true), // ⋀ \bigwedge
558    '\u{22C1}' => ("SUMOP", Some("or".into()), None, None, true, true),  // ⋁ \bigvee
559    '\u{22C2}' => ("SUMOP", Some("intersection".into()), None, None, true, true), // ⋂ \bigcap
560    '\u{22C3}' => ("SUMOP", Some("union".into()), None, None, true, true), // ⋃ \bigcup
561    '\u{22C4}' => ("ADDOP", None, None, None, false, false),             // ⋄ \diamond
562    '\u{22C5}' => ("MULOP", None, None, None, false, false),             // ⋅ \cdot
563    '\u{22C6}' => ("MULOP", None, None, None, false, false),             // ⋆ \star
564    '\u{22C8}' => ("RELOP", None, None, None, false, false),             // ⋈ \bowtie
565    '\u{22EF}' => ("ID", None, None, None, false, false),                // ⋯ \cdots
566    '\u{22F1}' => ("ID", None, None, None, false, false),                // ⋱ \ddots
567    // Delimiters
568    '\u{2308}' => (
569      "OPEN",
570      None,
571      Some("lceil".into()),
572      Some("false".into()),
573      false,
574      false,
575    ), // ⌈ \lceil
576    '\u{2309}' => (
577      "CLOSE",
578      None,
579      Some("rceil".into()),
580      Some("false".into()),
581      false,
582      false,
583    ), // ⌉ \rceil
584    '\u{230A}' => (
585      "OPEN",
586      None,
587      Some("lfloor".into()),
588      Some("false".into()),
589      false,
590      false,
591    ), // ⌊ \lfloor
592    '\u{230B}' => (
593      "CLOSE",
594      None,
595      Some("rfloor".into()),
596      Some("false".into()),
597      false,
598      false,
599    ), // ⌋ \rfloor
600    '\u{2322}' => ("RELOP", None, None, None, false, false), // ⌢ \frown
601    '\u{2323}' => ("RELOP", None, None, None, false, false), // ⌣ \smile
602    // Triangles
603    '\u{25B3}' => ("ADDOP", None, None, None, false, false), // △ \bigtriangleup
604    '\u{25B7}' => ("ADDOP", None, None, None, false, false), // ▷ \triangleright
605    '\u{25B9}' => ("ADDOP", None, None, None, false, false), // ▹ \triangleright
606    '\u{25BD}' => ("ADDOP", None, None, None, false, false), // ▽ \bigtriangledown
607    '\u{25C1}' => ("ADDOP", None, None, None, false, false), // ◁ \triangleleft
608    '\u{25C3}' => ("ADDOP", None, None, None, false, false), // ◃ \triangleleft
609    '\u{25CB}' => ("MULOP", None, None, None, false, false), // ○ \bigcirc
610    '\u{27C2}' => (
611      "RELOP",
612      Some("perpendicular-to".into()),
613      None,
614      None,
615      false,
616      false,
617    ), // ⟂ \perp
618    // Angle brackets
619    '\u{27E8}' => (
620      "OPEN",
621      None,
622      Some("langle".into()),
623      Some("false".into()),
624      false,
625      false,
626    ), // ⟨ \langle
627    '\u{27E9}' => (
628      "CLOSE",
629      None,
630      Some("rangle".into()),
631      Some("false".into()),
632      false,
633      false,
634    ), // ⟩ \rangle
635    '\u{27EE}' => (
636      "OPEN",
637      None,
638      Some("lgroup".into()),
639      Some("false".into()),
640      false,
641      false,
642    ), // ⟮ \lgroup
643    '\u{27EF}' => (
644      "CLOSE",
645      None,
646      Some("rgroup".into()),
647      Some("false".into()),
648      false,
649      false,
650    ), // ⟯ \rgroup
651    // Long arrows
652    '\u{27F5}' => ("ARROW", None, None, None, false, false), // ⟵ \longleftarrow
653    '\u{27F6}' => ("ARROW", None, None, None, false, false), // ⟶ \longrightarrow
654    '\u{27F7}' => ("METARELOP", None, None, None, false, false), // ⟷ \longleftrightarrow
655    '\u{27F8}' => ("ARROW", None, None, None, false, false), // ⟸ \Longleftarrow
656    '\u{27F9}' => ("ARROW", None, None, None, false, false), // ⟹ \Longrightarrow
657    '\u{27FA}' => ("METARELOP", None, None, None, false, false), // ⟺ \Longleftrightarrow
658    '\u{27FC}' => ("ARROW", None, None, None, false, false), // ⟼ \longmapsto
659    // N-ary circled operators
660    '\u{2A00}' => ("SUMOP", None, None, None, true, true), // ⨀ \bigodot
661    '\u{2A01}' => ("SUMOP", Some("direct-sum".into()), None, None, true, true), // ⨁ \bigoplus
662    '\u{2A02}' => (
663      "SUMOP",
664      Some("tensor-product".into()),
665      None,
666      None,
667      true,
668      true,
669    ), // ⨂ \bigotimes
670    '\u{2A04}' => (
671      "SUMOP",
672      Some("symmetric-difference".into()),
673      None,
674      None,
675      true,
676      true,
677    ), // ⨄ \biguplus
678    '\u{2A06}' => ("SUMOP", Some("square-union".into()), None, None, true, true), // ⨆ \bigsqcup
679    '\u{2A1D}' => ("RELOP", Some("join".into()), None, None, false, false), // ⨝ \Join
680    '\u{2AAF}' => (
681      "RELOP",
682      Some("precedes-or-equals".into()),
683      None,
684      None,
685      false,
686      false,
687    ), // ⪯ \preceq
688    '\u{2AB0}' => (
689      "RELOP",
690      Some("succeeds-or-equals".into()),
691      None,
692      None,
693      false,
694      false,
695    ), // ⪰ \succeq
696    '\u{FF0F}' => ("OPFUNCTION", Some("not".into()), None, None, false, false), // / \not
697    _ => return None,
698  };
699  Some(MathCharProps {
700    role: Some(role.to_string()),
701    glyph: None,
702    meaning,
703    name,
704    stretchy,
705    need_scriptpos: need_sp,
706    need_mathstyle: need_ms,
707    scriptpos: None,
708    mathstyle: None,
709    reversion: None,
710    font: None,
711  })
712}
713
714// Is this "fontinfo" stuff sufficient to maintain a math font "family" ??
715// What we're really after is a connection to a font encoding mapping.
716pub fn decode_math_char(
717  mut n: u16,
718  reversion: Option<crate::tokens::Tokens>,
719) -> Result<MathCharProps> {
720  let class: u16 = n / (16 * 256);
721  n %= 16 * 256;
722  let mut fam: u16 = n / 256;
723
724  // Perl Package.pm:2928 reads the internal `fontfamily` value DIRECTLY
725  // (`$STATE->lookupValue('fontfamily') // -1`), NOT the `\fam` register CS.
726  // The `\fam` register is merely backed by `fontfamily` (its getter, tex_math.rs;
727  // Perl TeX_Math.pool.ltxml:639-641). Reading the value directly mirrors Perl,
728  // stays correct when a document shadows `\fam` (e.g. `\let`/`\renewcommand`),
729  // and avoids the spurious `expected:register` warning that `lookup_register`
730  // emits per math char when `\fam` is no longer a register.
731  let curfam_val: i32 = state::with_value("fontfamily", |v| match v {
732    Some(Stored::Int(i)) => *i as i32,
733    Some(Stored::Number(n)) => n.0 as i32,
734    _ => -1,
735  });
736
737  if class == 7 && (0..=15).contains(&curfam_val) {
738    fam = curfam_val as u16;
739  }
740  n %= 256;
741
742  let curfont = state::lookup_font().unwrap();
743  // `with_value` borrows the Stored — for `Stored::Font(f)` we need
744  // the `Rc<Font>` out, so clone the Rc (cheap refcount bump) rather
745  // than the entire Stored enum.
746  let initfont = state::with_value("initial_math_font", |v| match v {
747    Some(Stored::Font(f)) => Rc::clone(f),
748    _ => Rc::clone(&curfont),
749  });
750
751  let mut use_current_font = false;
752  let mut maybe_rev = curfam_val >= 0 && fam != 1;
753  let mut fontdef_tok: Option<Token> = None;
754
755  if class == 7 && curfam_val < 0 && curfont != initfont {
756    use_current_font = true;
757    maybe_rev = true;
758    fontdef_tok = Some(crate::T_CS!("\\font"));
759  }
760
761  let mut downsize = 0;
762  if fontdef_tok.is_none() {
763    // Token is Copy (SymStr + Catcode = 8 bytes), so the closure extracts
764    // the Token by-value without cloning the enclosing Stored.
765    let extract_token = |key: &str| -> Option<Token> {
766      state::with_value(key, |v| match v {
767        Some(Stored::Token(t)) => Some(*t),
768        _ => None,
769      })
770    };
771    let style = curfont
772      .get_mathstyle()
773      .map(|s| s.to_string())
774      .unwrap_or_default();
775    let style_str = if style == "script" || style == "scriptscript" || style == "text" {
776      style.as_str()
777    } else {
778      "text"
779    };
780    if style_str == "text" {
781      fontdef_tok = extract_token(&crate::s!("textfont_{fam}"));
782    } else if style_str == "script" {
783      fontdef_tok = extract_token(&crate::s!("scriptfont_{fam}"));
784      if fontdef_tok.is_none() {
785        fontdef_tok = extract_token(&crate::s!("textfont_{fam}"));
786        if fontdef_tok.is_some() {
787          downsize = 1;
788        }
789      }
790    } else if style_str == "scriptscript" {
791      fontdef_tok = extract_token(&crate::s!("scriptscriptfont_{fam}"));
792      if fontdef_tok.is_none() {
793        fontdef_tok = extract_token(&crate::s!("scriptfont_{fam}"));
794        if fontdef_tok.is_some() {
795          downsize = 1;
796        } else {
797          fontdef_tok = extract_token(&crate::s!("textfont_{fam}"));
798          if fontdef_tok.is_some() {
799            downsize = 2;
800          }
801        }
802      }
803    }
804  }
805
806  let c = n as u8 as char;
807  // Guard against invalid class values from corrupted mathchar codes
808  // (e.g., during expl3 loading when \__int_eval_end: errors corrupt state)
809  if (class as usize) >= MATH_CLASS_ROLE.len() {
810    return Ok(MathCharProps::default());
811  }
812  let class_role = MATH_CLASS_ROLE[class as usize];
813
814  let mut f = (*curfont).clone();
815  if let Some(ftok) = &fontdef_tok {
816    if use_current_font {
817      // f is already curfont
818    } else {
819      // Merge textfont info (family, encoding, etc.) but preserve the current
820      // font's size. The textfont defines design-time properties, while the
821      // current size comes from context (e.g. \big's font => { size => 12 }).
822      let preserved_size = f.size;
823      state::with_font_info(ftok, |fontinfo| {
824        if let Some(Stored::Font(info)) = fontinfo.unwrap_or(None) {
825          f = f.merge_ref(info);
826        } else {
827          // Perl: fallback to \lx@default@font if not found
828          let d_tok_opt = state::with_value("\\lx@default@font", |v| match v {
829            Some(Stored::Token(t)) => Some(*t),
830            _ => None,
831          });
832          if let Some(d_tok) = d_tok_opt {
833            state::with_font_info(&d_tok, |d_info| {
834              if let Some(Stored::Font(d_f)) = d_info.unwrap_or(None) {
835                f = f.merge_ref(d_f);
836              }
837            });
838          }
839        }
840      });
841      f.size = preserved_size;
842    }
843  }
844
845  if downsize > 0 {
846    f.scripted = Some(true);
847  }
848  if downsize > 1 {
849    f.scripted = Some(true);
850  }
851
852  let d = f.relative_to(&curfont);
853
854  let glyph = if use_current_font {
855    if let Some(ref data) = curfont.encoding {
856      crate::common::font::decode(n as u8, Some(data.to_string()), false)
857    } else {
858      Some(c)
859    }
860  } else if let Some(ftok) = &fontdef_tok {
861    // Extract the encoding BEFORE calling font::decode. font::decode may
862    // call preload_font_map which mutates state, and with_font_info holds
863    // a State borrow while its closure runs — the reentrant mutation
864    // panics with "RefCell already borrowed" (sandbox paper 0711.4787).
865    let mut encoding_opt: Option<String> = state::with_font_info(ftok, |fontinfo| {
866      if let Some(Stored::Font(info)) = fontinfo? {
867        Ok::<Option<String>, crate::common::error::Error>(
868          info.encoding.as_ref().map(|s| s.to_string()),
869        )
870      } else {
871        Ok(None)
872      }
873    })?;
874    // Fallback: when `fontinfo_<token>` is missing (typical post-dump-load:
875    // `Stored::Font` isn't currently Font-serialized in the dump_writer,
876    // so the rich props don't round-trip — only the `font_shared_key_<cs>`
877    // pointer survives), derive the encoding from the font NAME via
878    // `font::decode_fontname`. This recovers `cmmi10 → OML`, so plain.tex's
879    // `.` mathcode 0x013A (class 0, fam 1, char 0x3A) decodes to glyph
880    // `.` instead of the raw ASCII `:` that the no-encoding path emits.
881    // Without this, `12345.67890` math input split as
882    // <NUMBER>12345</NUMBER><METARELOP>:</METARELOP><NUMBER>67890</NUMBER>
883    // — see `00_tokenize::ligatures_test` / `mathtokens_test` 2026-04-27.
884    if encoding_opt.is_none() {
885      let shared_key = state::with_value(
886        &crate::s!("font_shared_key_{}", ftok.with_str(ToString::to_string)),
887        |v| match v {
888          Some(Stored::String(s)) => arena::with(*s, |str| Some(str.to_string())),
889          _ => None,
890        },
891      );
892      if let Some(sk) = shared_key {
893        // shared_key is "fontinfo_<name>"; strip the "fontinfo_" prefix to get the font name
894        if let Some(name) = sk.strip_prefix("fontinfo_")
895          && let Some(props) = crate::common::font::decode_fontname(name, None, None)
896        {
897          encoding_opt = props.encoding.as_ref().map(|s| s.to_string());
898        }
899      }
900    }
901    if let Some(data) = encoding_opt {
902      crate::common::font::decode(n as u8, Some(data), false)
903    } else {
904      Some(c)
905    }
906  } else {
907    Some(c)
908  };
909
910  let glyph_char = glyph.unwrap_or(c);
911  let charinfo = unicode_math_properties(glyph_char);
912  let mut props = charinfo.clone().unwrap_or_default();
913  props.glyph = glyph;
914
915  let mut role = charinfo.as_ref().and_then(|info| info.role.clone());
916  if role.is_none() && !class_role.is_empty() {
917    role = Some(class_role.to_string());
918  }
919  if role.is_some() && props.role.is_none() {
920    props.role = role;
921  }
922
923  props.resolve_style_props();
924
925  let mut final_reversion = reversion;
926  if let Some(rev) = final_reversion.clone() {
927    let mut wrap = maybe_rev && !d.is_empty();
928    if state::with_value("LaTeX.pool_loaded", |v| v.is_some()) {
929      wrap = false;
930    }
931    if wrap && let Some(ftok) = fontdef_tok {
932      let mut new_rev = vec![crate::T_BEGIN!(), ftok];
933      new_rev.extend(rev.unlist());
934      new_rev.push(crate::T_END!());
935      final_reversion = Some(crate::tokens::Tokens::new(new_rev));
936    }
937    props.reversion = final_reversion;
938  }
939
940  props.font = Some(f);
941
942  Ok(props)
943}
944
945/// Stomach-level hook for decoding math characters.
946/// Called from stomach::invoke_token_simple when IN_MATH and mathcode is set.
947/// Perl: decodeMathChar($mathcode, $meaning) in Stomach::invokeToken_simple
948pub fn decode_math_char_for_stomach(mathcode: u16, meaning: Token) -> Result<Option<Digested>> {
949  let props = decode_math_char(mathcode, Some(crate::Tokens!(meaning)))?;
950
951  let glyph = match props.glyph {
952    Some(g) => g,
953    None => return Ok(None),
954  };
955
956  let mut properties = SymHashMap::default();
957  properties.insert("mode", Stored::String(pin!("math")));
958  if let Some(ref role) = props.role {
959    properties.insert("role", Stored::String(arena::pin(role)));
960  }
961  if let Some(ref m) = props.meaning {
962    properties.insert("meaning", Stored::String(arena::pin(m)));
963  }
964  if let Some(ref name) = props.name {
965    properties.insert("name", Stored::String(arena::pin(name)));
966  }
967  if let Some(ref stretchy) = props.stretchy {
968    properties.insert("stretchy", Stored::String(arena::pin(stretchy)));
969  }
970  if let Some(ref scriptpos) = props.scriptpos {
971    properties.insert("scriptpos", Stored::String(arena::pin(scriptpos)));
972  }
973  if let Some(ref mathstyle) = props.mathstyle {
974    properties.insert("mathstyle", Stored::String(arena::pin(mathstyle)));
975  }
976
977  let glyph_sym = arena::pin_char(glyph);
978
979  let font = props
980    .font
981    .map(|f| Rc::new(arena::with(glyph_sym, |s| f.specialize(s))));
982  // token-locators: carry the math char's own source origin (matching the text
983  // path in `invoke_token_simple`), so math content boxes are located — the
984  // basis for a `$…$`-range `ltx:Math` wrapper and in-equation provenance.
985  // docs/performance/SOURCE_PROVENANCE.md §3.1.3 / §7 A.3. `None` → falls back to the gullet
986  // locator (feature-off identical).
987  #[cfg(feature = "token-locators")]
988  let origin_loc: Option<crate::common::locator::Locator> =
989    crate::token::get_token_origin(meaning.loc).map(|o| {
990      arena::with(o.source, |s| {
991        crate::common::locator::Locator::new(s, o.line, o.col, o.line, o.col)
992      })
993    });
994  #[cfg(not(feature = "token-locators"))]
995  let origin_loc: Option<crate::common::locator::Locator> = None;
996  Ok(Some(Digested::from(Tbox::new(
997    glyph_sym,
998    font,
999    origin_loc,
1000    props.reversion.unwrap_or_else(|| crate::Tokens!(meaning)),
1001    properties,
1002  ))))
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007  use super::*;
1008
1009  #[test]
1010  fn math_class_role_table_has_expected_values() {
1011    // The Perl %mathclass mapping: 0,7 (ord, variable — no role);
1012    // 1=BIGOP, 2=BINOP, 3=RELOP, 4=OPEN, 5=CLOSE, 6=PUNCT.
1013    assert_eq!(MATH_CLASS_ROLE[0], "");
1014    assert_eq!(MATH_CLASS_ROLE[1], "BIGOP");
1015    assert_eq!(MATH_CLASS_ROLE[2], "BINOP");
1016    assert_eq!(MATH_CLASS_ROLE[3], "RELOP");
1017    assert_eq!(MATH_CLASS_ROLE[4], "OPEN");
1018    assert_eq!(MATH_CLASS_ROLE[5], "CLOSE");
1019    assert_eq!(MATH_CLASS_ROLE[6], "PUNCT");
1020    assert_eq!(MATH_CLASS_ROLE[7], "");
1021  }
1022
1023  #[test]
1024  fn unicode_math_properties_digits() {
1025    let p = unicode_math_properties('5').expect("digits should resolve");
1026    assert_eq!(p.role.as_deref(), Some("NUMBER"));
1027    assert_eq!(p.meaning.as_deref(), Some("5"));
1028  }
1029
1030  #[test]
1031  fn unicode_math_properties_basic_relops() {
1032    let eq = unicode_math_properties('=').unwrap();
1033    assert_eq!(eq.role.as_deref(), Some("RELOP"));
1034    assert_eq!(eq.meaning.as_deref(), Some("equals"));
1035    let lt = unicode_math_properties('<').unwrap();
1036    assert_eq!(lt.role.as_deref(), Some("RELOP"));
1037    assert_eq!(lt.meaning.as_deref(), Some("less-than"));
1038  }
1039
1040  #[test]
1041  fn unicode_math_properties_basic_addops() {
1042    let plus = unicode_math_properties('+').unwrap();
1043    assert_eq!(plus.role.as_deref(), Some("ADDOP"));
1044    assert_eq!(plus.meaning.as_deref(), Some("plus"));
1045    let minus = unicode_math_properties('-').unwrap();
1046    assert_eq!(minus.role.as_deref(), Some("ADDOP"));
1047    assert_eq!(minus.meaning.as_deref(), Some("minus"));
1048  }
1049
1050  #[test]
1051  fn unicode_math_properties_openclose() {
1052    // Paired delimiters get OPEN/CLOSE with stretchy="false".
1053    for (c, role) in [
1054      ('(', "OPEN"),
1055      (')', "CLOSE"),
1056      ('[', "OPEN"),
1057      (']', "CLOSE"),
1058      ('{', "OPEN"),
1059      ('}', "CLOSE"),
1060    ] {
1061      let p = unicode_math_properties(c).unwrap();
1062      assert_eq!(p.role.as_deref(), Some(role), "{c}");
1063      assert_eq!(p.stretchy.as_deref(), Some("false"), "{c} stretchy");
1064    }
1065  }
1066
1067  #[test]
1068  fn unicode_math_properties_punct() {
1069    let comma = unicode_math_properties(',').unwrap();
1070    assert_eq!(comma.role.as_deref(), Some("PUNCT"));
1071    let semi = unicode_math_properties(';').unwrap();
1072    assert_eq!(semi.role.as_deref(), Some("PUNCT"));
1073  }
1074
1075  #[test]
1076  fn into_props_map_only_includes_set_fields() {
1077    // A mostly-empty MathCharProps should produce an empty map —
1078    // into_props_map skips None fields.
1079    let p = MathCharProps {
1080      role:           Some("RELOP".into()),
1081      meaning:        Some("equals".into()),
1082      name:           None,
1083      stretchy:       None,
1084      need_scriptpos: false,
1085      need_mathstyle: false,
1086      scriptpos:      None,
1087      mathstyle:      None,
1088      reversion:      None,
1089      font:           None,
1090      glyph:          None,
1091    };
1092    let m = p.into_props_map();
1093    assert_eq!(m.len(), 2, "only role and meaning should populate");
1094    assert!(m.contains_key("role"));
1095    assert!(m.contains_key("meaning"));
1096    assert!(!m.contains_key("name"));
1097    assert!(!m.contains_key("stretchy"));
1098  }
1099
1100  #[test]
1101  fn into_props_map_empty_when_all_none() {
1102    let p = MathCharProps::default();
1103    let m = p.into_props_map();
1104    assert_eq!(m.len(), 0);
1105  }
1106}