Skip to main content

latexml_post/mathml/
content.rs

1//! Content MathML rendering rules.
2//!
3//! Port of `LaTeXML::Post::MathML::Content` (31 lines) + the content
4//! portion of `LaTeXML::Post::MathML` (main module, ~400 lines).
5//! Converts XMath nodes to Content MathML elements (apply, ci, cn, csymbol, etc.).
6//!
7//! Content MathML aims to capture the semantic structure of math:
8//! - Variables → `m:ci`
9//! - Numbers → `m:cn` (with type: integer, float, etc.)
10//! - Known symbols → `m:csymbol` (with cd attribute)
11//! - Standard symbols → dedicated elements (`m:plus`, `m:eq`, `m:int`, etc.)
12//! - Application → `m:apply`
13//! - XMDual → follows the content branch
14
15use libxml::tree::Node;
16use rustc_hash::FxHashMap as HashMap;
17
18use crate::document::{NodeData, PostDocument, element_children};
19
20/// Well-known meaning → Content MathML element mappings.
21///
22/// Port of the Token:?:* DefMathML content entries.
23/// Also accessible via `meaning_to_cmml_element_pub` for cross-module access.
24pub(crate) fn meaning_to_cmml_element_pub(meaning: &str) -> Option<&'static str> {
25  meaning_to_cmml_element(meaning)
26}
27
28fn meaning_to_cmml_element(meaning: &str) -> Option<&'static str> {
29  match meaning {
30    // Arithmetic
31    "plus" => Some("m:plus"),
32    "minus" | "uminus" => Some("m:minus"),
33    "times" => Some("m:times"),
34    "divide" => Some("m:divide"),
35    "power" => Some("m:power"),
36    "quotient" => Some("m:quotient"),
37    "factorial" => Some("m:factorial"),
38    "remainder" => Some("m:rem"),
39    "maximum" => Some("m:max"),
40    "minimum" => Some("m:min"),
41    "gcd" => Some("m:gcd"),
42    "lcm" => Some("m:lcm"),
43    "absolute-value" => Some("m:abs"),
44    "conjugate" => Some("m:conjugate"),
45    "argument" => Some("m:arg"),
46    "real-part" => Some("m:real"),
47    "imaginary-part" => Some("m:imaginary"),
48    "floor" => Some("m:floor"),
49    "ceiling" => Some("m:ceiling"),
50    // Logic
51    "and" => Some("m:and"),
52    "or" => Some("m:or"),
53    "xor" => Some("m:xor"),
54    "not" => Some("m:not"),
55    "implies" => Some("m:implies"),
56    "forall" => Some("m:forall"),
57    "exists" => Some("m:exists"),
58    // Relations
59    "equals" => Some("m:eq"),
60    "not-equals" => Some("m:neq"),
61    "greater-than" => Some("m:gt"),
62    "less-than" => Some("m:lt"),
63    "greater-than-or-equals" => Some("m:geq"),
64    "less-than-or-equals" => Some("m:leq"),
65    "equivalent-to" => Some("m:equivalent"),
66    "approximately-equals" => Some("m:approx"),
67    "factor-of" => Some("m:factorof"),
68    // Calculus
69    "integral" => Some("m:int"),
70    "differential" => Some("m:diff"),
71    "partial-differential" => Some("m:partialdiff"),
72    "divergence" => Some("m:divergence"),
73    "gradient" => Some("m:grad"),
74    "curl" => Some("m:curl"),
75    "laplacian" => Some("m:laplacian"),
76    // Sets
77    "union" => Some("m:union"),
78    "intersection" => Some("m:intersect"),
79    "element-of" => Some("m:in"),
80    "not-element-of" => Some("m:notin"),
81    "subset-of" | "subset-of-or-equals" => Some("m:subset"),
82    "subset-of-and-not-equals" => Some("m:prsubset"),
83    "set-minus" => Some("m:setdiff"),
84    "cardinality" => Some("m:card"),
85    "cartesian-product" => Some("m:cartesianproduct"),
86    // Sequences and Series
87    "sum" => Some("m:sum"),
88    "prod" => Some("m:prod"),
89    "limit" => Some("m:limit"),
90    "tends-to" => Some("m:tendsto"),
91    // Elementary Classical Functions (trig, exp, log)
92    "exponential" => Some("m:exp"),
93    "natural-logarithm" => Some("m:ln"),
94    "logarithm" => Some("m:log"),
95    "sine" => Some("m:sin"),
96    "cosine" => Some("m:cos"),
97    "tangent" => Some("m:tan"),
98    "secant" => Some("m:sec"),
99    "cosecant" => Some("m:csc"),
100    "cotangent" => Some("m:cot"),
101    "hyperbolic-sine" => Some("m:sinh"),
102    "hyperbolic-cosine" => Some("m:cosh"),
103    "hyperbolic-tangent" => Some("m:tanh"),
104    "hyperbolic-secant" => Some("m:sech"),
105    "hyperbolic-cosecant" => Some("m:csch"),
106    "hyperbolic-cotantent" => Some("m:coth"),
107    "inverse-sine" => Some("m:arcsin"),
108    "inverse-cosine" => Some("m:arccos"),
109    "inverse-tangent" => Some("m:arctan"),
110    "inverse-secant" => Some("m:arcsec"),
111    "inverse-cosecant" => Some("m:arccsc"),
112    "inverse-cotangent" => Some("m:arccot"),
113    "inverse-hyperbolic-sine" => Some("m:arcsinh"),
114    "inverse-hyperbolic-cosine" => Some("m:arccosh"),
115    "inverse-hyperbolic-tangent" => Some("m:arctanh"),
116    "inverse-hyperbolic-secant" => Some("m:arcsech"),
117    "inverse-hyperbolic-cosecant" => Some("m:arccsch"),
118    "inverse-hyperbolic-cotangent" => Some("m:arccoth"),
119    // Statistics
120    "mean" => Some("m:mean"),
121    "standard-deviation" => Some("m:sdev"),
122    "variance" => Some("m:var"),
123    "median" => Some("m:median"),
124    "mode" => Some("m:mode"),
125    "moment" => Some("m:moment"),
126    // Linear Algebra
127    "determinant" => Some("m:determinant"),
128    "transpose" => Some("m:transpose"),
129    "selector" => Some("m:selector"),
130    "vector-product" => Some("m:vectorproduct"),
131    "scalar-product" => Some("m:scalarproduct"),
132    "outer-product" => Some("m:outerproduct"),
133    // Constants
134    "integers" => Some("m:integers"),
135    "reals" => Some("m:reals"),
136    "rationals" => Some("m:rationals"),
137    "numbers" => Some("m:naturalnumbers"),
138    "complexes" => Some("m:complexes"),
139    "primes" => Some("m:primes"),
140    "exponential-e" => Some("m:exponentiale"),
141    "imaginary-i" => Some("m:imaginaryi"),
142    "notanumber" => Some("m:notanumber"),
143    "true" => Some("m:true"),
144    "false" => Some("m:false"),
145    "empty-set" => Some("m:emptyset"),
146    "circular-pi" => Some("m:pi"),
147    "Euler-constant" => Some("m:eulergamma"),
148    "infinity" => Some("m:infinity"),
149    // Other
150    "inverse" => Some("m:inverse"),
151    "lambda" => Some("m:lambda"),
152    "compose" => Some("m:compose"),
153    "identity" => Some("m:ident"),
154    "domain" => Some("m:domain"),
155    "codomain" => Some("m:codomain"),
156    "image" => Some("m:image"),
157    "square-root" => Some("m:root"),
158    _ => None,
159  }
160}
161
162/// Convert an XMath tree to Content MathML.
163///
164/// Port of `MathML::Content::convertNode` + `cmml_top`.
165pub fn convert_to_cmml(doc: &PostDocument, xmath: &Node) -> NodeData {
166  reset_share_counter();
167  // Perl cmml_top (L1290-1300): STYLE='text' + inherited context bindings.
168  super::presentation::bind_cmml_top_context(doc, xmath);
169  CMML_DEPTH.with(|d| d.set(0));
170  CMML_PATH.with(|p| p.borrow_mut().clear());
171  cmml_contents(doc, xmath)
172}
173
174// Recursion guard. Perl uses `no warnings 'recursion'` and relies on
175// its native stack; we cap to avoid blowing the 256 MB worker stack
176// when malformed/cyclic XMath (e.g. duplicate xml:id whose target
177// loops via XMRef) drives `cmml` into unbounded descent. Two failure
178// modes seen on the second-500K canvas (stage_53/54):
179//
180// 1. Plain stack overflow on linearly-deep XMath — witnesses arXiv:1505.06709, 1505.06978 (both
181//    emitted `Info:malformed:id Duplicated attribute xml:id` then overflowed during
182//    MathML[Content]). Cap at `CMML_MAX_DEPTH` (256, well above any legitimate XMApp nesting we've
183//    measured).
184//
185// 2. Cyclic XMRef chains: an `ltx:XMRef` whose `find_node_by_id` target's subtree contains another
186//    XMRef back into an ancestor of the current cmml frame. Linear depth cap alone is not enough —
187//    each cycle iteration doubles the generated NodeData::Element subtree, exhausting the 6 GB
188//    worker memory budget long before the depth limit fires. Witness: arXiv:1508.06324 (stage_54) —
189//    440 maths, OOMs with 5-byte alloc failure mid-CMML. Track a `CMML_PATH` set of node pointers
190//    along the current recursion path; if we visit a node already on the path, return
191//    `cmml_error("cycle")` and unwind.
192const CMML_MAX_DEPTH: u32 = 256;
193thread_local! {
194  static CMML_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
195  static CMML_PATH: std::cell::RefCell<rustc_hash::FxHashSet<usize>>
196    = std::cell::RefCell::new(rustc_hash::FxHashSet::default());
197}
198
199enum CmmlEnter {
200  Ok,
201  DepthExceeded,
202  Cycle,
203}
204
205fn cmml_enter(node: &Node) -> CmmlEnter {
206  let ptr = node.node_ptr() as usize;
207  let in_path = CMML_PATH.with(|p| !p.borrow_mut().insert(ptr));
208  if in_path {
209    // Diagnostic: dump the current path so the math-parser bisect can see
210    // exactly which node-ids form the cycle. Behind an env so production
211    // canvas runs stay silent. Format: `cmml_cycle: <id_or_tag>(<ptr>) <-
212    // ... <- <cycling node>` — most recent ancestor first; the cycling
213    // node is repeated at the end so the loop is visually obvious.
214    if std::env::var_os("LATEXML_CMML_TRACE_CYCLE").is_some() {
215      let id = node
216        .get_attribute("xml:id")
217        .or_else(|| node.get_attribute("id"))
218        .unwrap_or_else(|| format!("({})", node.get_name()));
219      eprintln!("cmml_cycle: re-entering {} ptr=0x{:x}", id, ptr);
220    }
221    return CmmlEnter::Cycle;
222  }
223  CMML_DEPTH.with(|d| {
224    let cur = d.get();
225    if cur >= CMML_MAX_DEPTH {
226      // Pop the path entry we just inserted before returning.
227      CMML_PATH.with(|p| {
228        p.borrow_mut().remove(&ptr);
229      });
230      CmmlEnter::DepthExceeded
231    } else {
232      d.set(cur + 1);
233      CmmlEnter::Ok
234    }
235  })
236}
237
238fn cmml_exit(node: &Node) {
239  CMML_PATH.with(|p| {
240    p.borrow_mut().remove(&(node.node_ptr() as usize));
241  });
242  CMML_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
243}
244
245/// Convert the contents of a node (which normally has a single child).
246///
247/// Port of `cmml_contents`.
248fn cmml_contents(doc: &PostDocument, node: &Node) -> NodeData {
249  let children = element_children(node);
250  if children.is_empty() {
251    cmml_error("missing-subexpression")
252  } else if children.len() == 1 {
253    cmml(doc, &children[0])
254  } else {
255    cmml_unparsed(doc, &children)
256  }
257}
258
259/// Core dispatch: convert a single XMath node to Content MathML.
260///
261/// Port of `cmml` + `cmml_internal`.
262fn cmml(doc: &PostDocument, node: &Node) -> NodeData {
263  match cmml_enter(node) {
264    CmmlEnter::Cycle => return cmml_error("cycle"),
265    CmmlEnter::DepthExceeded => return cmml_error("recursion-depth-exceeded"),
266    CmmlEnter::Ok => {},
267  }
268  let result = cmml_impl(doc, node);
269  cmml_exit(node);
270  result
271}
272
273fn cmml_impl(doc: &PostDocument, node: &Node) -> NodeData {
274  let tag = doc.get_qname(node).unwrap_or_default();
275
276  // Follow XMRef
277  if tag == "ltx:XMRef" {
278    if let Some(idref) = node.get_attribute("idref") {
279      if let Some(target) = doc.find_node_by_id(&idref) {
280        return cmml(doc, target);
281      }
282    }
283    return cmml_error("unresolved-reference");
284  }
285
286  match tag.as_str() {
287    "ltx:XMDual" => {
288      let children = element_children(node);
289      if !children.is_empty() {
290        cmml(doc, &children[0]) // Content branch
291      } else {
292        cmml_error("empty-dual")
293      }
294    },
295    "ltx:XMWrap" | "ltx:XMArg" => cmml_contents(doc, node),
296    "ltx:XMApp" => {
297      // Check if XMApp has a meaning (token-like application)
298      if let Some(meaning) = node.get_attribute("meaning") {
299        return cmml_token_by_meaning(&meaning, node);
300      }
301      // Check if role=ID (decorated symbol)
302      if node.get_attribute("role").as_deref() == Some("ID") {
303        return cmml_decorated_symbol(doc, node);
304      }
305      // Normal application
306      let children = element_children(node);
307      if children.is_empty() {
308        return cmml_error("missing-operator");
309      }
310      let op = &children[0];
311      let args = &children[1..];
312
313      // Realize operator
314      let rop = if doc.is_qname(op, "ltx:XMRef") {
315        op.get_attribute("idref")
316          .and_then(|id| doc.find_node_by_id(&id).cloned())
317          .unwrap_or_else(|| op.clone())
318      } else {
319        op.clone()
320      };
321
322      let meaning = rop.get_attribute("meaning").unwrap_or_default();
323      let _role = rop.get_attribute("role").unwrap_or_default();
324
325      // Special meanings with dedicated structure
326      match meaning.as_str() {
327        // Perl `Apply:?:multirelation` cmml (L1713-1729): chained relations
328        // a<b<c become pairwise applies sharing the middle operands, under
329        // m:and when there is more than one.
330        "multirelation" if !args.is_empty() => {
331          let lhs0 = cmml(doc, &args[0]);
332          if args.len() == 1 {
333            return lhs0;
334          }
335          let mut lhs = lhs0;
336          let mut relations = Vec::new();
337          let mut i = 1;
338          while i < args.len() {
339            let rel = &args[i];
340            let Some(rhs) = args.get(i + 1) else { break };
341            relations.push(NodeData::Element {
342              tag:        "m:apply".to_string(),
343              attributes: None,
344              children:   vec![cmml(doc, rel), lhs, cmml_shared(doc, rhs)],
345            });
346            lhs = cmml_share(rhs);
347            i += 2;
348          }
349          if relations.len() > 1 {
350            let mut children = vec![NodeData::Element {
351              tag:        "m:and".to_string(),
352              attributes: None,
353              children:   vec![],
354            }];
355            children.extend(relations);
356            NodeData::Element {
357              tag: "m:apply".to_string(),
358              attributes: None,
359              children,
360            }
361          } else {
362            relations
363              .pop()
364              .unwrap_or_else(|| cmml_error("multirelation"))
365          }
366        },
367        // Perl `Apply:?:less-than-or-approximately-equals` →
368        // cmml_or_compose(['m:lt','m:approx']) (L1436-1445): each relation
369        // applied to the (shared) operands, disjoined under m:or.
370        "less-than-or-approximately-equals" if !args.is_empty() => {
371          let first: Vec<NodeData> = args.iter().map(|a| cmml_shared(doc, a)).collect();
372          let second: Vec<NodeData> = args.iter().map(cmml_share).collect();
373          let mk = |op: &str, ops: Vec<NodeData>| {
374            let mut children = vec![NodeData::Element {
375              tag:        op.to_string(),
376              attributes: None,
377              children:   vec![],
378            }];
379            children.extend(ops);
380            NodeData::Element {
381              tag: "m:apply".to_string(),
382              attributes: None,
383              children,
384            }
385          };
386          NodeData::Element {
387            tag:        "m:or".to_string(),
388            attributes: None,
389            children:   vec![mk("m:lt", first), mk("m:approx", second)],
390          }
391        },
392        "square-root" if !args.is_empty() => NodeData::Element {
393          tag:        "m:apply".to_string(),
394          attributes: None,
395          children:   vec![
396            NodeData::Element {
397              tag:        "m:root".to_string(),
398              attributes: None,
399              children:   vec![],
400            },
401            cmml(doc, &args[0]),
402          ],
403        },
404        "nth-root" if args.len() >= 2 => NodeData::Element {
405          tag:        "m:apply".to_string(),
406          attributes: None,
407          children:   vec![
408            NodeData::Element {
409              tag:        "m:root".to_string(),
410              attributes: None,
411              children:   vec![],
412            },
413            NodeData::Element {
414              tag:        "m:degree".to_string(),
415              attributes: None,
416              // Perl L1648: degree is args[0], radicand args[1] (was swapped).
417              children:   vec![cmml(doc, &args[0])],
418            },
419            cmml(doc, &args[1]),
420          ],
421        },
422        "set" => {
423          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
424          NodeData::Element {
425            tag:        "m:set".to_string(),
426            attributes: None,
427            children:   items,
428          }
429        },
430        "list" => {
431          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
432          NodeData::Element {
433            tag:        "m:list".to_string(),
434            attributes: None,
435            children:   items,
436          }
437        },
438        "vector" => {
439          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
440          NodeData::Element {
441            tag:        "m:vector".to_string(),
442            attributes: None,
443            children:   items,
444          }
445        },
446        // Interval types
447        "open-interval" => {
448          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
449          NodeData::Element {
450            tag:        "m:interval".to_string(),
451            attributes: Some(HashMap::from_iter([(
452              "closure".to_string(),
453              "open".to_string(),
454            )])),
455            children:   items,
456          }
457        },
458        "closed-interval" => {
459          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
460          NodeData::Element {
461            tag:        "m:interval".to_string(),
462            attributes: Some(HashMap::from_iter([(
463              "closure".to_string(),
464              "closed".to_string(),
465            )])),
466            children:   items,
467          }
468        },
469        "closed-open-interval" => {
470          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
471          NodeData::Element {
472            tag:        "m:interval".to_string(),
473            attributes: Some(HashMap::from_iter([(
474              "closure".to_string(),
475              "closed-open".to_string(),
476            )])),
477            children:   items,
478          }
479        },
480        "open-closed-interval" => {
481          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
482          NodeData::Element {
483            tag:        "m:interval".to_string(),
484            attributes: Some(HashMap::from_iter([(
485              "closure".to_string(),
486              "open-closed".to_string(),
487            )])),
488            children:   items,
489          }
490        },
491        // Complement operations: reverse argument order
492        "contains" | "superset-of" | "superset-of-or-equals" | "superset-of-and-not-equals" => {
493          let cmml_op = match meaning.as_str() {
494            "contains" => "m:in",
495            "superset-of" | "superset-of-or-equals" => "m:subset",
496            "superset-of-and-not-equals" => "m:prsubset",
497            _ => "m:subset",
498          };
499          let mut reversed_args: Vec<NodeData> = args.iter().rev().map(|a| cmml(doc, a)).collect();
500          reversed_args.insert(0, NodeData::Element {
501            tag:        cmml_op.to_string(),
502            attributes: None,
503            children:   vec![],
504          });
505          NodeData::Element {
506            tag:        "m:apply".to_string(),
507            attributes: None,
508            children:   reversed_args,
509          }
510        },
511        // Not-contains: not(in(reversed))
512        "not-contains" => {
513          let mut reversed_args: Vec<NodeData> = args.iter().rev().map(|a| cmml(doc, a)).collect();
514          reversed_args.insert(0, NodeData::Element {
515            tag:        "m:notin".to_string(),
516            attributes: None,
517            children:   vec![],
518          });
519          NodeData::Element {
520            tag:        "m:apply".to_string(),
521            attributes: None,
522            children:   reversed_args,
523          }
524        },
525        // Not-approximately-equals: not(approx(args))
526        "not-approximately-equals" => {
527          let inner_args: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
528          let mut apply_children = vec![NodeData::Element {
529            tag:        "m:approx".to_string(),
530            attributes: None,
531            children:   vec![],
532          }];
533          apply_children.extend(inner_args);
534          NodeData::Element {
535            tag:        "m:apply".to_string(),
536            attributes: None,
537            children:   vec![
538              NodeData::Element {
539                tag:        "m:not".to_string(),
540                attributes: None,
541                children:   vec![],
542              },
543              NodeData::Element {
544                tag:        "m:apply".to_string(),
545                attributes: None,
546                children:   apply_children,
547              },
548            ],
549          }
550        },
551        // Hack for definite integrals with explicit limits
552        "hack-definite-integral" if args.len() >= 4 => NodeData::Element {
553          tag:        "m:apply".to_string(),
554          attributes: None,
555          children:   vec![
556            NodeData::Element {
557              tag:        "m:int".to_string(),
558              attributes: None,
559              children:   vec![],
560            },
561            NodeData::Element {
562              tag:        "m:bvar".to_string(),
563              attributes: None,
564              children:   vec![cmml(doc, &args[3])],
565            },
566            NodeData::Element {
567              tag:        "m:lowlimit".to_string(),
568              attributes: None,
569              children:   vec![cmml(doc, &args[0])],
570            },
571            NodeData::Element {
572              tag:        "m:uplimit".to_string(),
573              attributes: None,
574              children:   vec![cmml(doc, &args[1])],
575            },
576            cmml(doc, &args[2]),
577          ],
578        },
579        // Formulae: sequence of expressions
580        "formulae" => {
581          let items: Vec<NodeData> = args.iter().map(|a| cmml(doc, a)).collect();
582          let mut children = vec![NodeData::Element {
583            tag:        "m:csymbol".to_string(),
584            attributes: Some(HashMap::from_iter([(
585              "cd".to_string(),
586              "ambiguous".to_string(),
587            )])),
588            children:   vec![NodeData::Text("formulae-sequence".to_string())],
589          }];
590          children.extend(items);
591          NodeData::Element {
592            tag: "m:apply".to_string(),
593            attributes: None,
594            children,
595          }
596        },
597        _ => {
598          // Generic application: <m:apply> op args... </m:apply>
599          let mut apply_children = vec![cmml(doc, op)];
600          apply_children.extend(args.iter().map(|a| cmml(doc, a)));
601          NodeData::Element {
602            tag:        "m:apply".to_string(),
603            attributes: None,
604            children:   apply_children,
605          }
606        },
607      }
608    },
609    "ltx:XMTok" => cmml_leaf(doc, node),
610    "ltx:XMHint" => {
611      // Hints are ignored in Content MathML
612      NodeData::Text(String::new())
613    },
614    "ltx:XMArray" => cmml_array(doc, node),
615    "ltx:XMText" => cmml_decorated_symbol(doc, node),
616    _ => cmml_decorated_symbol(doc, node),
617  }
618}
619
620/// Convert an XMTok to a Content MathML leaf element.
621///
622/// Port of `cmml_leaf`.
623fn cmml_leaf(_doc: &PostDocument, node: &Node) -> NodeData {
624  let meaning = node.get_attribute("meaning");
625  let role = node.get_attribute("role").unwrap_or_default();
626
627  if let Some(ref m) = meaning {
628    // Known meaning → check for dedicated MathML element
629    if let Some(element_name) = meaning_to_cmml_element(m) {
630      return NodeData::Element {
631        tag:        element_name.to_string(),
632        attributes: None,
633        children:   vec![],
634      };
635    }
636
637    // Has omcd → csymbol with cd
638    if let Some(cd) = node.get_attribute("omcd") {
639      return NodeData::Element {
640        tag:        "m:csymbol".to_string(),
641        attributes: Some(HashMap::from_iter([("cd".to_string(), cd)])),
642        children:   vec![NodeData::Text(m.clone())],
643      };
644    }
645
646    // Number with meaning
647    if role == "NUMBER" {
648      // Perl /^[+-]?\d+$/ — arbitrary-length integers, unlike i64::parse.
649      let cn_type = if is_perl_integer(m) {
650        "integer"
651      } else {
652        "float"
653      };
654      return NodeData::Element {
655        tag:        "m:cn".to_string(),
656        attributes: Some(HashMap::from_iter([(
657          "type".to_string(),
658          cn_type.to_string(),
659        )])),
660        children:   vec![NodeData::Text(m.clone())],
661      };
662    }
663
664    // Default: csymbol with latexml cd
665    return NodeData::Element {
666      tag:        "m:csymbol".to_string(),
667      attributes: Some(HashMap::from_iter([(
668        "cd".to_string(),
669        "latexml".to_string(),
670      )])),
671      children:   vec![NodeData::Text(m.clone())],
672    };
673  }
674
675  // No meaning: variable or number
676  let content = node.get_content();
677  match role.as_str() {
678    "NUMBER" => {
679      let cn_type = if is_perl_integer(&content) {
680        "integer"
681      } else {
682        "float"
683      };
684      NodeData::Element {
685        tag:        "m:cn".to_string(),
686        attributes: Some(HashMap::from_iter([(
687          "type".to_string(),
688          cn_type.to_string(),
689        )])),
690        children:   vec![NodeData::Text(content)],
691      }
692    },
693    "SUPERSCRIPTOP" => NodeData::Element {
694      tag:        "m:csymbol".to_string(),
695      attributes: Some(HashMap::from_iter([(
696        "cd".to_string(),
697        "ambiguous".to_string(),
698      )])),
699      children:   vec![NodeData::Text("superscript".to_string())],
700    },
701    "SUBSCRIPTOP" => NodeData::Element {
702      tag:        "m:csymbol".to_string(),
703      attributes: Some(HashMap::from_iter([(
704        "cd".to_string(),
705        "ambiguous".to_string(),
706      )])),
707      children:   vec![NodeData::Text("subscript".to_string())],
708    },
709    _ => {
710      // Variable / identifier (Perl cmml_leaf L1388-1391: stylized ci).
711      let name = if content.is_empty() {
712        node
713          .get_attribute("name")
714          .unwrap_or_else(|| "?".to_string())
715      } else {
716        content
717      };
718      NodeData::Element {
719        tag:        "m:ci".to_string(),
720        attributes: None,
721        children:   vec![NodeData::Text(stylize_ci_content(node, name))],
722      }
723    },
724  }
725}
726
727/// Convert a token by its meaning attribute (for XMApp with meaning).
728fn cmml_token_by_meaning(meaning: &str, _node: &Node) -> NodeData {
729  if let Some(element_name) = meaning_to_cmml_element(meaning) {
730    NodeData::Element {
731      tag:        element_name.to_string(),
732      attributes: None,
733      children:   vec![],
734    }
735  } else {
736    NodeData::Element {
737      tag:        "m:csymbol".to_string(),
738      attributes: Some(HashMap::from_iter([(
739        "cd".to_string(),
740        "latexml".to_string(),
741      )])),
742      children:   vec![NodeData::Text(meaning.to_string())],
743    }
744  }
745}
746
747// Per-formula counter for generated share ids (Perl uses the document
748// idcache to find the next free `sh<n>`; ancestor-id scoping makes a
749// per-formula counter equivalent). Reset by `convert_to_cmml`.
750thread_local! {
751  static SH_COUNTER: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
752  /// Monotonic (never reset): uniquifies share ids minted on formulas with
753  /// no ancestor xml:id, across the whole document/process.
754  static SH_GLOBAL_COUNTER: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
755}
756pub(super) fn reset_share_counter() { SH_COUNTER.with(|c| c.set(0)); }
757
758/// Port of Perl `generateNodeID` + `cmml_shared` (MathML.pm L1420-1424,
759/// Post.pm generateNodeID): make sure the XMath node has an xml:id (and a
760/// fragid mirroring its ancestor's) so it can be shared, then convert.
761fn cmml_shared(doc: &PostDocument, node: &Node) -> NodeData {
762  if crate::document::get_xml_id(node).is_none() {
763    // Closest ancestor with an id (Perl walks parentNode chain).
764    // NB xml:id is namespaced — always read via get_xml_id (WISDOM).
765    let mut parent = node.get_parent();
766    while let Some(ref p) = parent {
767      if crate::document::get_xml_id(p).is_some() {
768        break;
769      }
770      parent = p.get_parent();
771    }
772    let pid = parent
773      .as_ref()
774      .and_then(crate::document::get_xml_id)
775      .map(|id| format!("{id}."))
776      .unwrap_or_default();
777    // With an ancestor id the per-formula counter is unique (ancestor ids are
778    // unique per formula). WITHOUT one, a per-formula counter would mint the
779    // same bare `sh1` in every such formula — use the monotonic per-thread
780    // counter instead so ids stay document-unique. (Perl probes the document
781    // idcache to the same effect; our minted ids are href targets only and
782    // are not idcache-registered — the counter guarantees no collision.)
783    let n = if pid.is_empty() {
784      SH_GLOBAL_COUNTER.with(|c| {
785        let v = c.get() + 1;
786        c.set(v);
787        v
788      })
789    } else {
790      SH_COUNTER.with(|c| {
791        let v = c.get() + 1;
792        c.set(v);
793        v
794      })
795    };
796    let mut handle = node.clone();
797    handle.set_attribute("xml:id", &format!("{pid}sh{n}")).ok();
798    if let Some(pfragid) = parent.as_ref().and_then(|p| p.get_attribute("fragid")) {
799      handle
800        .set_attribute("fragid", &format!("{pfragid}.sh{n}"))
801        .ok();
802    }
803  }
804  cmml(doc, node)
805}
806
807/// Port of Perl `cmml_share` (L1426-1434): reference a shared operand.
808fn cmml_share(node: &Node) -> NodeData {
809  if let Some(fragid) = node.get_attribute("fragid") {
810    // NB Perl appends $MATHPROCESSOR->IDSuffix; our cmml runs with the
811    // primary suffix '' (parallel-markup suffix wiring is an audit residual).
812    NodeData::Element {
813      tag:        "m:share".to_string(),
814      attributes: Some(HashMap::from_iter([(
815        "href".to_string(),
816        format!("#{fragid}"),
817      )])),
818      children:   vec![],
819    }
820  } else {
821    crate::Warn!(
822      "expected",
823      "fragid",
824      "Shared node is missing fragid (multirelation/or-compose share)"
825    );
826    NodeData::Element {
827      tag:        "m:share".to_string(),
828      attributes: None,
829      children:   vec![],
830    }
831  }
832}
833
834/// Perl integer test `/^[+-]?\d+$/` (arbitrary length, unlike i64::parse).
835fn is_perl_integer(s: &str) -> bool {
836  let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
837  !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
838}
839
840/// The identifier-content half of Perl `stylizeContent($item, 'm:ci')`
841/// (cmml_leaf L1388-1391): font → mathvariant → plane1 unicode conversion;
842/// a variant that could not be baked into characters prefixes the content
843/// ("bold-x"). This is why Perl's cmml says `<ci>𝑥</ci>`, not `<ci>x</ci>`.
844fn stylize_ci_content(node: &Node, text: String) -> String {
845  // Perl L747-748: Format-only content (invisible operators) gets no
846  // styling — never "bold-⁢".
847  if text.chars().all(|c| {
848    matches!(c,
849      '\u{00AD}' | '\u{200B}'..='\u{200F}' | '\u{2060}'..='\u{2064}' | '\u{FEFF}')
850  }) {
851    return text;
852  }
853  // Perl stylizeContent L678: token font, else the inherited context font.
854  let Some(font) = node
855    .get_attribute("font")
856    .or_else(super::presentation::ctx_font)
857  else {
858    return text;
859  };
860  let variant = crate::unicode::unicode_mathvariant(&font);
861  if variant.is_empty() || variant == "normal" {
862    return text;
863  }
864  if let Some(u) = crate::unicode::unicode_convert(&text, variant)
865    && !u.is_empty()
866  {
867    return u;
868  }
869  format!("{variant}-{text}")
870}
871
872/// Convert a "decorated symbol" — an XMApp with role=ID treated as a ci.
873///
874/// Port of `cmml_decoratedSymbol` (L1396-1404): with a meaning it is a
875/// csymbol (cd from omcd, default latexml); otherwise a ci whose content is
876/// the node's PRESENTATION conversion.
877fn cmml_decorated_symbol(doc: &PostDocument, node: &Node) -> NodeData {
878  if let Some(meaning) = node.get_attribute("meaning") {
879    let cd = node
880      .get_attribute("omcd")
881      .unwrap_or_else(|| "latexml".to_string());
882    return NodeData::Element {
883      tag:        "m:csymbol".to_string(),
884      attributes: Some(HashMap::from_iter([("cd".to_string(), cd)])),
885      children:   vec![NodeData::Text(meaning)],
886    };
887  }
888  NodeData::Element {
889    tag:        "m:ci".to_string(),
890    attributes: None,
891    children:   vec![super::presentation::pmml_for_ci(doc, node)],
892  }
893}
894
895/// Convert an XMArray to Content MathML.
896///
897/// Port of `Array:?:cases` DefMathML content handler.
898fn cmml_array(doc: &PostDocument, node: &Node) -> NodeData {
899  let meaning = node.get_attribute("meaning").unwrap_or_default();
900
901  if meaning == "cases" {
902    // Piecewise construct
903    let mut pieces = Vec::new();
904    let mut otherwises = Vec::new();
905
906    for row in element_children(node) {
907      let items = element_children(&row);
908      if items.is_empty() {
909        continue;
910      }
911      if items.len() == 1 {
912        otherwises.push(cmml_contents(doc, &items[0]));
913      } else if items[1].get_content().contains("otherwise") {
914        otherwises.push(cmml_contents(doc, &items[0]));
915      } else {
916        pieces.push(NodeData::Element {
917          tag:        "m:piece".to_string(),
918          attributes: None,
919          children:   vec![cmml_contents(doc, &items[0]), cmml_contents(doc, &items[1])],
920        });
921      }
922    }
923
924    if let Some(ow) = otherwises.into_iter().next() {
925      pieces.push(NodeData::Element {
926        tag:        "m:otherwise".to_string(),
927        attributes: None,
928        children:   vec![ow],
929      });
930    }
931
932    NodeData::Element {
933      tag:        "m:piecewise".to_string(),
934      attributes: None,
935      children:   pieces,
936    }
937  } else {
938    // Generic array → matrix-like structure
939    let mut rows = Vec::new();
940    for row in element_children(node) {
941      let cells: Vec<NodeData> = element_children(&row)
942        .iter()
943        .map(|cell| cmml_contents(doc, cell))
944        .collect();
945      rows.push(NodeData::Element {
946        tag:        "m:matrixrow".to_string(),
947        attributes: None,
948        children:   cells,
949      });
950    }
951    NodeData::Element {
952      tag:        "m:matrix".to_string(),
953      attributes: None,
954      children:   rows,
955    }
956  }
957}
958
959/// Convert unparsed (multiple) nodes to Content MathML error.
960///
961/// Port of `cmml_unparsed`.
962fn cmml_unparsed(doc: &PostDocument, nodes: &[Node]) -> NodeData {
963  let mut results = vec![NodeData::Element {
964    tag:        "m:csymbol".to_string(),
965    attributes: Some(HashMap::from_iter([(
966      "cd".to_string(),
967      "ambiguous".to_string(),
968    )])),
969    children:   vec![NodeData::Text("fragments".to_string())],
970  }];
971
972  for node in nodes {
973    let tag = doc.get_qname(node).unwrap_or_default();
974    if tag == "ltx:XMTok" && node.get_attribute("role").as_deref() == Some("UNKNOWN") {
975      results.push(NodeData::Element {
976        tag:        "m:csymbol".to_string(),
977        attributes: Some(HashMap::from_iter([(
978          "cd".to_string(),
979          "unknown".to_string(),
980        )])),
981        children:   vec![NodeData::Text(node.get_content())],
982      });
983    } else {
984      results.push(cmml(doc, node));
985    }
986  }
987
988  NodeData::Element {
989    tag:        "m:cerror".to_string(),
990    attributes: None,
991    children:   results,
992  }
993}
994
995/// Create a Content MathML error element.
996fn cmml_error(symbol: &str) -> NodeData {
997  NodeData::Element {
998    tag:        "m:cerror".to_string(),
999    attributes: None,
1000    children:   vec![NodeData::Element {
1001      tag:        "m:csymbol".to_string(),
1002      attributes: Some(HashMap::from_iter([(
1003        "cd".to_string(),
1004        "ambiguous".to_string(),
1005      )])),
1006      children:   vec![NodeData::Text(symbol.to_string())],
1007    }],
1008  }
1009}