Skip to main content

latexml_post/mathml/
presentation.rs

1//! Presentation MathML rendering rules.
2//!
3//! Port of `LaTeXML::Post::MathML::Presentation` (146 lines) +
4//! the presentation portion of `LaTeXML::Post::MathML` (main module, ~1000 lines).
5//! Converts XMath nodes to Presentation MathML elements (mi, mo, mn, mrow, etc.).
6//!
7//! Key concepts:
8//! - `pmml(node)` dispatches conversion by tag (XMTok, XMApp, XMDual, etc.)
9//! - `stylizeContent(item, tag)` determines text, mathvariant, size, spacing
10//! - Scripts (sub/sup/under/over) handle pre/mid/post positioning
11//! - Style context tracks display/text/script/scriptscript levels
12
13use std::cell::Cell;
14
15use libxml::tree::Node;
16use rustc_hash::FxHashMap as HashMap;
17
18use super::operator_dictionary;
19use crate::document::{NodeData, PostDocument, XMBranch, element_children, element_children_iter};
20
21// Thread-local flag for invisible times emission.
22// When false, U+2062 is replaced with U+200B (zero-width space).
23thread_local! {
24  static INVISIBLE_TIMES: Cell<bool> = const { Cell::new(true) };
25  /// Whether to remap styled alphanumerics into Unicode's Plane-1 Mathematical
26  /// Alphanumeric Symbols. Perl `$$MATHPROCESSOR{plane1}`, defaulted to 1 in
27  /// `preprocess` (L70) and negatable with `latexmlpost --noplane1`. When off, the
28  /// text stays ASCII and the style is carried by a `mathvariant` attribute
29  /// instead β€” which some renderers and screen readers handle far better than the
30  /// Plane-1 codepoints, whose font coverage is patchy.
31  static PLANE1: Cell<bool> = const { Cell::new(true) };
32  /// Perl `$$MATHPROCESSOR{hackplane1}` (`--hackplane1`): remap only the variants
33  /// in `plane1_hackable`, and to the SIMPLER variant it names. This exists
34  /// because the doubly-styled blocks (bold-script, bold-fraktur) are the worst
35  /// supported of all, so `\mathbf{\mathcal{E}}` is better served by the plain
36  /// script codepoint than by a bold-script one no font will have. Implies
37  /// `plane1` (Perl L71).
38  static HACK_PLANE1: Cell<bool> = const { Cell::new(false) };
39  /// Inherited style context (Perl `pmml_top` L278-285 binds
40  /// $LaTeXML::MathML::FONT/COLOR/BGCOLOR/OPACITY from the XMath node's
41  /// ancestor chain; `pmml` L332-335 locally rebinds them from each node on
42  /// the way down). A token missing its own attribute styles from context β€”
43  /// e.g. `{\color{red}$a+b$}` colors every token. (audit F8b; the SIZE/
44  /// DESIRED_SIZE half is deliberately NOT ported: our engine stamps
45  /// absolute fontsize on tokens, which the mathsize context gate already
46  /// compensates.)
47  static CTX_FONT: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
48  static CTX_COLOR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
49  static CTX_BGCOLOR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
50  static CTX_OPACITY: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
51  /// Current math style context, tracking Perl's `$LaTeXML::MathML::STYLE` /
52  /// `$LaTeXML::MathML::SIZE`. Stepped down inside sub/superscripts (`pmml_scriptsize`)
53  /// and fraction parts (`pmml_smaller`); its `size_percent()` is the contextual size a
54  /// token's `fontsize` is compared against, so a token only emits an explicit
55  /// `mathsize` when it *differs* from what the surrounding script/fraction structure
56  /// already implies (Perl `stylizeContent` L777). Reset to `Display` at each
57  /// `convert_to_pmml` entry.
58  static CURRENT_STYLE: Cell<MathStyle> = const { Cell::new(MathStyle::Display) };
59}
60
61/// Set whether to emit invisible times (called by MathML processor before rendering).
62pub fn set_invisible_times(emit: bool) { INVISIBLE_TIMES.with(|f| f.set(emit)); }
63
64fn get_invisible_times() -> bool { INVISIBLE_TIMES.with(|f| f.get()) }
65
66/// Set the Plane-1 remapping mode (called by the MathML processor before
67/// rendering). Mirrors Perl `preprocess` L69-71: `hackplane1` implies `plane1`.
68pub fn set_plane1(plane1: bool, hack_plane1: bool) {
69  PLANE1.with(|f| f.set(plane1 || hack_plane1));
70  HACK_PLANE1.with(|f| f.set(hack_plane1));
71}
72
73/// Perl `%plane1hackable` (L659-664) β€” the mathvariants worth remapping under
74/// `--hackplane1`, each mapped to the simpler variant to remap it AS. A variant
75/// absent from this table is left un-remapped in hack mode, which is the whole
76/// point: `bold` stays `mathvariant="bold"` on ASCII rather than becoming 𝐃.
77fn plane1_hackable(variant: &str) -> Option<&'static str> {
78  match variant {
79    "script" | "bold-script" => Some("script"),
80    "fraktur" | "bold-fraktur" => Some("fraktur"),
81    "double-struck" => Some("double-struck"),
82    _ => None,
83  }
84}
85
86/// The variant to actually remap with, or `None` to skip remapping entirely.
87///
88/// Port of Perl `stylizeContent` L734-736:
89/// ```text
90/// my $u_variant = $variant
91///   && ($plane1hack ? $plane1hackable{$variant}
92///   : ($plane1 ? $variant : undef));
93/// ```
94fn plane1_target_variant(variant: &str) -> Option<&str> {
95  if HACK_PLANE1.with(|f| f.get()) {
96    plane1_hackable(variant)
97  } else if PLANE1.with(|f| f.get()) {
98    Some(variant)
99  } else {
100    None
101  }
102}
103
104/// The contextual font size (e.g. "100%", "70%", "50%") implied by the current math style.
105/// A token whose own `fontsize` equals this needs no explicit `mathsize` attribute.
106fn current_context_size() -> &'static str { CURRENT_STYLE.with(|s| s.get().size_percent()) }
107
108/// Math style levels.
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub enum MathStyle {
111  Display,
112  Text,
113  Script,
114  ScriptScript,
115}
116
117impl MathStyle {
118  /// Step down one level (for fractions, etc.).
119  pub fn step_down(self) -> Self {
120    match self {
121      MathStyle::Display => MathStyle::Text,
122      MathStyle::Text => MathStyle::Script,
123      MathStyle::Script => MathStyle::ScriptScript,
124      MathStyle::ScriptScript => MathStyle::ScriptScript,
125    }
126  }
127
128  /// Step to script size (for sub/superscripts).
129  pub fn script_step(self) -> Self {
130    match self {
131      MathStyle::Display | MathStyle::Text => MathStyle::Script,
132      MathStyle::Script => MathStyle::ScriptScript,
133      MathStyle::ScriptScript => MathStyle::ScriptScript,
134    }
135  }
136
137  /// CSS-style size percentage.
138  pub fn size_percent(self) -> &'static str {
139    match self {
140      MathStyle::Display | MathStyle::Text => "100%",
141      MathStyle::Script => "70%",
142      MathStyle::ScriptScript => "50%",
143    }
144  }
145
146  /// Parse a source `mathstyle` attribute (Perl gates on `$stylestep{$style}`,
147  /// so only the four canonical names count).
148  pub fn from_attr(s: &str) -> Option<Self> {
149    match s {
150      "display" => Some(MathStyle::Display),
151      "text" => Some(MathStyle::Text),
152      "script" => Some(MathStyle::Script),
153      "scriptscript" => Some(MathStyle::ScriptScript),
154      _ => None,
155    }
156  }
157}
158
159/// m:mstyle attributes for a mathstyle transition.
160///
161/// Port of Perl `%stylemap` (`needs`=true: something below cares about
162/// displaystyle) and `%stylemap2` (`needs`=false: only a fontsize context is
163/// required), MathML.pm L240-268. Same-style transitions yield nothing.
164fn stylemap_attrs(
165  ostyle: MathStyle,
166  nstyle: MathStyle,
167  needs: bool,
168) -> &'static [(&'static str, &'static str)] {
169  use MathStyle::*;
170  match (ostyle, nstyle, needs) {
171    (Display, Text, true) => &[("displaystyle", "false")],
172    (Display, Script, true) => &[("displaystyle", "false"), ("scriptlevel", "+1")],
173    (Display, ScriptScript, true) => &[("displaystyle", "false"), ("scriptlevel", "+2")],
174    (Text, Display, true) => &[("displaystyle", "true")],
175    (Display, Script, false) | (Text, Script, _) => &[("scriptlevel", "+1")],
176    (Display, ScriptScript, false) | (Text, ScriptScript, _) => &[("scriptlevel", "+2")],
177    (Script, Display, _) => &[("displaystyle", "true"), ("scriptlevel", "-1")],
178    (Script, Text, _) => &[("scriptlevel", "-1")],
179    (Script, ScriptScript, _) => &[("scriptlevel", "+1")],
180    (ScriptScript, Display, _) => &[("displaystyle", "true"), ("scriptlevel", "-2")],
181    (ScriptScript, Text, _) => &[("scriptlevel", "-2")],
182    (ScriptScript, Script, _) => &[("scriptlevel", "-1")],
183    _ => &[],
184  }
185}
186
187/// Does this subtree contain something whose rendering depends on
188/// displaystyle? Port of Perl `needsMathstyle` (MathML.pm L512-523):
189/// m:mfrac β†’ yes; `_largeop` β†’ yes; an m:mstyle that already pins
190/// displaystyle shields its subtree.
191fn needs_mathstyle(node: &NodeData) -> bool {
192  if let NodeData::Element { tag, attributes, children } = node {
193    if tag == "m:mfrac" {
194      return true;
195    }
196    if let Some(attrs) = attributes {
197      if attrs.contains_key("_largeop") {
198        return true;
199      }
200      if tag == "m:mstyle" && attrs.contains_key("displaystyle") {
201        return false;
202      }
203    }
204    return children.iter().any(needs_mathstyle);
205  }
206  false
207}
208
209/// Wrap `result` in m:mstyle for an ostyle→nstyle transition, when the
210/// transition table says the wrap carries information (Perl MathML.pm
211/// L421-427 / L487-491).
212fn maybe_style_wrap(result: NodeData, ostyle: MathStyle, nstyle: Option<MathStyle>) -> NodeData {
213  let Some(nstyle) = nstyle else { return result };
214  let style_attrs = stylemap_attrs(ostyle, nstyle, needs_mathstyle(&result));
215  if style_attrs.is_empty() {
216    return result;
217  }
218  NodeData::Element {
219    tag:        "m:mstyle".to_string(),
220    attributes: Some(HashMap::from_iter(
221      style_attrs
222        .iter()
223        .map(|(k, v)| (k.to_string(), v.to_string())),
224    )),
225    children:   vec![result],
226  }
227}
228
229/// Wrap `result` in m:mpadded / frame it, when the source node (or its
230/// containing XMDual) carries sizing attributes.
231///
232/// Port of Perl `pmml_maybe_resize` (MathML.pm L525-575): stretchy-ARROW
233/// width β†’ m:mover + m:mspace; width/height/depth/xoffset/yoffset β†’
234/// m:mpadded (reusing an existing mpadded/mrow); framed/framecolor β†’
235/// ltx_framed_* class + border-color style.
236fn pmml_maybe_resize(doc: &PostDocument, node: &Node, result: NodeData) -> NodeData {
237  // Relevant attributes MAY sit on a containing XMDual (Perl L529-531).
238  let parent = node.get_parent().filter(|p| doc.is_qname(p, "ltx:XMDual"));
239  let getattr = |name: &str| {
240    node
241      .get_attribute(name)
242      .or_else(|| parent.as_ref().and_then(|p| p.get_attribute(name)))
243  };
244  let width = getattr("width");
245  let height = getattr("height");
246  let depth = getattr("depth");
247  let xoff = getattr("xoffset");
248  let yoff = getattr("yoffset");
249  let role = getattr("role");
250  let class = getattr("class");
251
252  let mut result = result;
253  if let Some(ref w) = width
254    && role.as_deref() == Some("ARROW")
255    && class.as_deref().is_some_and(|c| {
256      c.split_ascii_whitespace()
257        .any(|w| w == "ltx_horizontally_stretchy")
258    })
259  {
260    // Special-case hack for stretchy arrows with a specified width;
261    // stretchiness (currently) only has effect within munder/mover (Perl L543-545).
262    result = NodeData::Element {
263      tag:        "m:mover".to_string(),
264      attributes: None,
265      children:   vec![result, NodeData::Element {
266        tag:        "m:mspace".to_string(),
267        attributes: Some(HashMap::from_iter([("width".to_string(), w.clone())])),
268        children:   vec![],
269      }],
270    };
271  } else if width.is_some()
272    || height.is_some()
273    || depth.is_some()
274    || xoff.is_some()
275    || yoff.is_some()
276  {
277    // Reuse an m:mpadded, convert an m:mrow, else wrap (Perl L547-552).
278    let needs_wrap = !matches!(&result,
279      NodeData::Element { tag, .. } if tag == "m:mpadded" || tag == "m:mrow");
280    if needs_wrap {
281      result = NodeData::Element {
282        tag:        "m:mpadded".to_string(),
283        attributes: None,
284        children:   vec![result],
285      };
286    }
287    if let NodeData::Element { tag, attributes, .. } = &mut result {
288      if tag == "m:mrow" {
289        *tag = "m:mpadded".to_string();
290      }
291      let attrs = attributes.get_or_insert_with(Default::default);
292      for (key, val) in [
293        ("width", width),
294        ("height", height),
295        ("depth", depth),
296        ("lspace", xoff),
297        ("voffset", yoff),
298      ] {
299        if let Some(v) = val {
300          attrs.insert(key.to_string(), v);
301        }
302      }
303    }
304  }
305
306  // framed/framecolor come from the node itself only (Perl L566-574).
307  if let Some(frame) = node.get_attribute("framed")
308    && let NodeData::Element { attributes, .. } = &mut result
309  {
310    let attrs = attributes.get_or_insert_with(Default::default);
311    let frame_class = format!("ltx_framed_{frame}");
312    let merged = match attrs.get("class") {
313      Some(c) if !c.is_empty() => format!("{c} {frame_class}"),
314      _ => frame_class,
315    };
316    attrs.insert("class".to_string(), merged);
317    if let Some(color) = node.get_attribute("framecolor") {
318      let style = format!("border-color: {color}");
319      let merged = match attrs.get("style") {
320        Some(s) if !s.is_empty() => format!("{s}; {style}"),
321        _ => style,
322      };
323      attrs.insert("style".to_string(), merged);
324    }
325  }
326  result
327}
328
329fn ctx_get(
330  cell: &'static std::thread::LocalKey<std::cell::RefCell<Option<String>>>,
331) -> Option<String> {
332  cell.with(|c| c.borrow().clone())
333}
334
335/// Rebind a context cell to the node's attribute (if present) for a scope;
336/// returns the saved value for restore. Perl's `local $CTX = attr || $CTX`.
337fn ctx_rebind(
338  cell: &'static std::thread::LocalKey<std::cell::RefCell<Option<String>>>,
339  attr: Option<String>,
340) -> Option<String> {
341  // Perl-falsy: an empty attribute value keeps the inherited context.
342  let attr = attr.filter(|v| !v.is_empty());
343  cell.with(|c| {
344    let old = c.borrow().clone();
345    if attr.is_some() {
346      *c.borrow_mut() = attr;
347    }
348    old
349  })
350}
351
352fn ctx_set(
353  cell: &'static std::thread::LocalKey<std::cell::RefCell<Option<String>>>,
354  val: Option<String>,
355) {
356  cell.with(|c| *c.borrow_mut() = val);
357}
358
359/// Embellishing roles (scripts/accents applied to operators).
360fn is_embellishing_role(role: &str) -> bool {
361  matches!(
362    role,
363    "SUPERSCRIPTOP" | "SUBSCRIPTOP" | "OVERACCENT" | "UNDERACCENT" | "MODIFIER" | "MODIFIEROP"
364  )
365}
366
367/// Default token content for invisible operators β€” Perl's
368/// `%default_token_content`, consulted by `stylizeContent`'s empty-item
369/// failsafe. Shared with the `m:mtext` half of that function in the parent
370/// module, so the two arms cannot drift.
371pub(super) fn default_token_content(role: &str) -> Option<&'static str> {
372  match role {
373    "MULOP" => Some("\u{2062}"), // INVISIBLE TIMES
374    "ADDOP" => Some("\u{2064}"), // INVISIBLE PLUS
375    "PUNCT" => Some("\u{2063}"), // INVISIBLE SEPARATOR
376    _ => None,
377  }
378}
379
380/// Get the operator role, following embellished operators.
381fn get_operator_role(doc: &PostDocument, node: &Node) -> Option<String> {
382  if let Some(role) = node.get_attribute("role") {
383    return Some(role);
384  }
385  if doc.is_qname(node, "ltx:XMApp") {
386    let children = element_children(node);
387    if children.len() >= 2 {
388      let op_role = children[0].get_attribute("role").unwrap_or_default();
389      if is_embellishing_role(&op_role) {
390        return get_operator_role(doc, &children[1]);
391      }
392    }
393  }
394  None
395}
396
397/// Convert an XMath tree to Presentation MathML.
398///
399/// Entry point for Presentation MathML conversion.
400/// Port of `MathML::Presentation::convertNode` + `pmml_top`.
401pub fn convert_to_pmml(doc: &PostDocument, xmath: &Node) -> NodeData {
402  // Reentrancy-safe: `convert_to_pmml` can run WHILE an outer conversion is in
403  // progress β€” a nested `ltx:Math` inside a \parbox/\mbox/inline-block that
404  // itself sits in math (arXiv html_feedback #6847). This function overwrites
405  // the inherited style/context thread-locals below, so snapshot them on entry
406  // and restore on exit; otherwise the outer conversion resumes with the
407  // nested math's style/font and mangles its remaining tokens.
408  let saved_style = CURRENT_STYLE.with(|s| s.get());
409  let saved_font = CTX_FONT.with(|c| c.borrow().clone());
410  let saved_color = CTX_COLOR.with(|c| c.borrow().clone());
411  let saved_bgcolor = CTX_BGCOLOR.with(|c| c.borrow().clone());
412  let saved_opacity = CTX_OPACITY.with(|c| c.borrow().clone());
413
414  // Perl Presentation.pm `convertNode` L20-21 + `pmml_top`: display-mode math
415  // starts in displaystyle, everything else in textstyle. Both are 100% size,
416  // but the mathstyle transitions (m:mstyle wraps for \tfrac/\dfrac/
417  // \displaystyle, audit F7) key off this baseline.
418  let mode_is_display = xmath
419    .get_parent()
420    .and_then(|p| p.get_attribute("mode"))
421    .is_some_and(|m| m == "display");
422  CURRENT_STYLE.with(|s| {
423    s.set(if mode_is_display {
424      MathStyle::Display
425    } else {
426      MathStyle::Text
427    })
428  });
429  // Perl pmml_top L278-285: bind the inherited style context from the XMath
430  // node's ancestor chain, so tokens without their own attributes pick up
431  // the surrounding font/color ({\color{red}$a+b$}).
432  ctx_set(
433    &CTX_FONT,
434    super::find_inherited_attribute(doc, xmath, "font"),
435  );
436  ctx_set(
437    &CTX_COLOR,
438    super::find_inherited_attribute(doc, xmath, "color"),
439  );
440  ctx_set(
441    &CTX_BGCOLOR,
442    super::find_inherited_attribute(doc, xmath, "backgroundcolor"),
443  );
444  ctx_set(
445    &CTX_OPACITY,
446    super::find_inherited_attribute(doc, xmath, "opacity"),
447  );
448  let children = element_children(xmath);
449  let results: Vec<NodeData> = children.iter().map(|c| pmml(doc, c)).collect();
450  let mut result = if results.len() == 1 {
451    results.into_iter().next().unwrap()
452  } else {
453    pmml_row(results)
454  };
455  // Adjust spacing to match TeX rules (Perl's adjust_spacing)
456  adjust_spacing(&mut result);
457  // Clean up internal _role/_lspace/_rspace before serialization
458  clean_internal_attrs(&mut result);
459
460  // Restore the caller's inherited context (see the reentrancy note above).
461  CURRENT_STYLE.with(|s| s.set(saved_style));
462  ctx_set(&CTX_FONT, saved_font);
463  ctx_set(&CTX_COLOR, saved_color);
464  ctx_set(&CTX_BGCOLOR, saved_bgcolor);
465  ctx_set(&CTX_OPACITY, saved_opacity);
466  result
467}
468
469/// Presentation conversion of a node for use as ci CONTENT by the content
470/// side (Perl `cmml_decoratedSymbol` L1403 calls pmml($item)).
471pub(super) fn pmml_for_ci(doc: &PostDocument, node: &Node) -> NodeData { pmml(doc, node) }
472
473/// Bind the top-level conversion context for the CONTENT pipeline.
474///
475/// Port of Perl `cmml_top` (MathML.pm L1290-1300): content conversion runs
476/// under STYLE='text' with the same inherited FONT/COLOR/BGCOLOR/OPACITY
477/// bindings as `pmml_top` β€” the pmml subtrees embedded in content (ci
478/// interiors via `cmml_decoratedSymbol`) and `stylize_ci_content`'s font
479/// fallback depend on them. Without this the content path ran at whatever
480/// the previous conversion left (audit should-fix 11).
481pub(super) fn bind_cmml_top_context(doc: &PostDocument, xmath: &Node) {
482  CURRENT_STYLE.with(|s| s.set(MathStyle::Text));
483  ctx_set(
484    &CTX_FONT,
485    super::find_inherited_attribute(doc, xmath, "font"),
486  );
487  ctx_set(
488    &CTX_COLOR,
489    super::find_inherited_attribute(doc, xmath, "color"),
490  );
491  ctx_set(
492    &CTX_BGCOLOR,
493    super::find_inherited_attribute(doc, xmath, "backgroundcolor"),
494  );
495  ctx_set(
496    &CTX_OPACITY,
497    super::find_inherited_attribute(doc, xmath, "opacity"),
498  );
499}
500
501/// The inherited font context, for the content side's ci stylization
502/// (Perl stylizeContent's `|| $LaTeXML::MathML::FONT` fallback).
503pub(super) fn ctx_font() -> Option<String> { ctx_get(&CTX_FONT) }
504
505/// The inherited color context (Perl `|| $LaTeXML::MathML::COLOR`).
506pub(super) fn ctx_color() -> Option<String> { ctx_get(&CTX_COLOR) }
507
508/// The inherited background-color context (Perl `|| $LaTeXML::MathML::BGCOLOR`).
509pub(super) fn ctx_bgcolor() -> Option<String> { ctx_get(&CTX_BGCOLOR) }
510
511/// The inherited opacity context (Perl `|| $LaTeXML::MathML::OPACITY`).
512pub(super) fn ctx_opacity() -> Option<String> { ctx_get(&CTX_OPACITY) }
513
514/// The current style's nominal size, as a percentage string β€” Perl's
515/// `$LaTeXML::MathML::SIZE || 'text'` comparand in `stylizeContent` L779-781.
516pub(super) fn context_size() -> &'static str { current_context_size() }
517
518/// `resolve_token_size`, for the `m:mtext` arm of `stylizeContent`.
519pub(super) fn resolve_size(s: String) -> String { resolve_token_size(s) }
520
521/// `pmml_maybe_resize`, for the `ltx:text` arm of `pmml_text_aux` (Perl L1059).
522pub(super) fn maybe_resize(doc: &PostDocument, node: &Node, result: NodeData) -> NodeData {
523  pmml_maybe_resize(doc, node, result)
524}
525
526/// Core dispatch: convert a single XMath node to Presentation MathML.
527///
528/// Port of `pmml` + `pmml_internal`.
529fn pmml(doc: &PostDocument, node: &Node) -> NodeData {
530  // Perl L332-335: rebind the color/background/opacity context from this
531  // node for the subtree, so styles inherit downward.
532  let saved_color = ctx_rebind(&CTX_COLOR, node.get_attribute("color"));
533  let saved_bg = ctx_rebind(&CTX_BGCOLOR, node.get_attribute("backgroundcolor"));
534  let saved_op = ctx_rebind(&CTX_OPACITY, node.get_attribute("opacity"));
535  let mut result = pmml_inner(doc, node);
536  ctx_set(&CTX_COLOR, saved_color);
537  ctx_set(&CTX_BGCOLOR, saved_bg);
538  ctx_set(&CTX_OPACITY, saved_op);
539  // Perl MathML.pm L339-341: wrap in m:menclose if the source node carries an
540  // `enclose` attribute (e.g. \boxed puts enclose="box" on the whole XMApp).
541  if let Some(enclose) = node.get_attribute("enclose").filter(|e| !e.is_empty()) {
542    let mut attrs = HashMap::default();
543    attrs.insert("notation".to_string(), enclose);
544    result = NodeData::Element {
545      tag:        "m:menclose".to_string(),
546      attributes: Some(attrs),
547      children:   vec![result],
548    };
549  }
550  // Port of Perl MathML.pm L344-348: attach author spacing (lpadding/rpadding,
551  // e.g. from `~` ties or collapsed XMHints β€” `{\rm number~of~…}`) as the
552  // internal `_lpadding`/`_rpadding` attributes the space_walk consumes.
553  // Perl's `_getspace($refr, $node, …)` SUMS the referring XMRef's padding
554  // with the target's; here the XMRef branch of `pmml_inner` recurses through
555  // this wrapper for the target, and the XMRef's own padding is then added on
556  // top β€” same sum. Without this attachment the spacewalk sees zero author
557  // padding and `~`-separated words render jammed together (witness
558  // astro-ph0001001 S9.Ex4.m1). Same recursion argument covers enclose /
559  // class / _role below, with refr-preference falling out of the outer level
560  // overwriting (_role) or appending (class) the inner one; sole corner
561  // divergence: an XMRef AND its target both carrying `enclose` would nest
562  // two m:menclose where Perl picks the XMRef's one.
563  if doc.is_qname(node, "ltx:XMRef") {
564    // XMRef level: SUM the referring node's padding onto the target's
565    // (Perl `_getspace` refr+node).
566    add_source_padding(node, &mut result);
567  } else {
568    // Ordinary level: the node's padding ASSIGNS (an XMDual over a padded
569    // presentation child must not double-count β€” Perl overwrites).
570    attach_source_padding(node, &mut result);
571  }
572  if let NodeData::Element { ref mut attributes, .. } = result {
573    // Perl L350-352: merge the source node's class onto the result.
574    if let Some(cl) = node.get_attribute("class")
575      && !cl.is_empty()
576    {
577      let attrs = attributes.get_or_insert_with(Default::default);
578      match attrs.get("class") {
579        Some(ocl) if !ocl.is_empty() && *ocl != cl => {
580          let merged = format!("{ocl} {cl}");
581          attrs.insert("class".to_string(), merged);
582        },
583        _ => {
584          attrs.insert("class".to_string(), cl);
585        },
586      }
587    }
588    // Perl L354-355: record the source role so the spacewalk can atom-type
589    // composite results (XMApp/XMDual with role=RELOP etc., not just tokens).
590    if let Some(role) = node.get_attribute("role") {
591      let attrs = attributes.get_or_insert_with(Default::default);
592      attrs.insert("_role".to_string(), role);
593    }
594  }
595  result
596}
597
598/// Add `node`'s lpadding/rpadding (converted to em) onto `result`'s internal
599/// `_lpadding`/`_rpadding`, summing with any value already present.
600fn attach_source_padding(node: &Node, result: &mut NodeData) {
601  // Perl ASSIGNS (`$$result[1]{_lpadding} = $l if $l`) β€” the outer node's
602  // padding wins over whatever the inner conversion set (an XMDual whose
603  // presentation child also carries padding must not double-count). The
604  // XMRef branch separately ADDS the referring node's padding on top,
605  // reproducing Perl's `_getspace` refr+node SUM (see `pmml_inner`).
606  for (src, dst) in [("lpadding", "_lpadding"), ("rpadding", "_rpadding")] {
607    if let Some(v) = node.get_attribute(src) {
608      let em = super::get_xm_hint_spacing(&v);
609      if em != 0.0
610        && let NodeData::Element { ref mut attributes, .. } = *result
611      {
612        let attrs = attributes.get_or_insert_with(Default::default);
613        attrs.insert(dst.to_string(), fmt_em(em));
614      }
615    }
616  }
617}
618
619/// ADD `node`'s padding onto the result (XMRef-over-target: Perl `_getspace`
620/// SUMS the referring node's padding with the target's).
621fn add_source_padding(node: &Node, result: &mut NodeData) {
622  for (src, dst) in [("lpadding", "_lpadding"), ("rpadding", "_rpadding")] {
623    if let Some(v) = node.get_attribute(src) {
624      let em = super::get_xm_hint_spacing(&v);
625      if em != 0.0
626        && let NodeData::Element { ref mut attributes, .. } = *result
627      {
628        let attrs = attributes.get_or_insert_with(Default::default);
629        let prior = attrs
630          .get(dst)
631          .and_then(|s| s.trim_end_matches("em").parse::<f64>().ok())
632          .unwrap_or(0.0);
633        attrs.insert(dst.to_string(), fmt_em(prior + em));
634      }
635    }
636  }
637}
638
639fn pmml_inner(doc: &PostDocument, node: &Node) -> NodeData {
640  // Fast-path dispatch: all tags we recognize live in the ltx namespace.
641  // Compare localname directly and check namespace prefix separately to
642  // avoid the `format!("{}:{}", prefix, localname)` allocation inside
643  // `get_qname`. On non-ltx nodes, fall through to the generic m:mtext
644  // wrapping (same as the original catchall).
645  let is_ltx = doc.qname_prefix(node).as_deref() == Some("ltx");
646  let localname = if is_ltx {
647    node.get_name()
648  } else {
649    String::new()
650  };
651
652  // Follow XMRef
653  if is_ltx && localname == "XMRef" {
654    if let Some(idref) = node.get_attribute("idref") {
655      if let Some(target) = doc.find_node_by_id(&idref) {
656        return pmml(doc, target);
657      }
658    }
659    return pmml_error("Unresolved XMRef");
660  }
661
662  if is_ltx {
663    match localname.as_str() {
664      "XMath" => {
665        let results: Vec<NodeData> = element_children_iter(node).map(|c| pmml(doc, &c)).collect();
666        return pmml_row(results);
667      },
668      "XMDual" => {
669        let children = element_children(node);
670        return if children.len() >= 2 {
671          pmml(doc, &children[1]) // Presentation branch
672        } else {
673          pmml_error("Empty XMDual")
674        };
675      },
676      "XMWrap" | "XMArg" => {
677        // Perl L400-401: only present when parsing failed; resizable.
678        let results: Vec<NodeData> = element_children_iter(node).map(|c| pmml(doc, &c)).collect();
679        return pmml_maybe_resize(doc, node, pmml_row(results));
680      },
681      "XMApp" => return pmml_apply(doc, node),
682      "XMTok" => return pmml_token(doc, node),
683      "XMHint" => return pmml_hint(doc, node),
684      "XMArray" => return pmml_array(doc, node),
685      "XMText" => {
686        // Perl L494-501: iterate over child nodes, not just text content.
687        // This preserves ltx:picture (SVG) elements inside XMText.
688        let mut children = Vec::new();
689        if let Some(child) = node.get_first_child() {
690          let mut current = Some(child);
691          while let Some(ref c) = current {
692            children.extend(super::pmml_text_aux(doc, c, &super::TextAttrs::default()));
693            current = c.get_next_sibling();
694          }
695        }
696        return pmml_maybe_resize(doc, node, pmml_row(children));
697      },
698      _ => {},
699    }
700  }
701
702  // Catchall: wrap content in m:mtext.
703  NodeData::Element {
704    tag:        "m:mtext".to_string(),
705    attributes: None,
706    children:   vec![NodeData::Text(node.get_content())],
707  }
708}
709
710/// Convert an XMApp to Presentation MathML.
711///
712/// Port of `pmml_internal` XMApp branch.
713fn pmml_apply(doc: &PostDocument, node: &Node) -> NodeData {
714  let children = element_children(node);
715  if children.is_empty() {
716    return pmml_error("Missing Operator");
717  }
718
719  let role = node.get_attribute("role").unwrap_or_default();
720
721  // Handle floating/post scripts.
722  //
723  // `<msub>` / `<msup>` require a base; for "floating" scripts (e.g.
724  // `{}^c`, `_d`) the base is structurally absent. We materialize
725  // the missing base as `<m:mrow></m:mrow>` β€” not `<m:mi></m:mi>`.
726  // Same rationale as the `absent` case below: `<mi>` is a semantic
727  // claim ("here is an identifier") that's false when empty; `<mrow>`
728  // is presentational scaffolding with no semantic content. Task #264.
729  if role.contains("SUBSCRIPT") || role.contains("SUPERSCRIPT") {
730    let is_sub = role.contains("SUB");
731    let tag = if is_sub { "m:msub" } else { "m:msup" };
732    return NodeData::Element {
733      tag:        tag.to_string(),
734      attributes: None,
735      children:   vec![
736        NodeData::Element {
737          tag:        "m:mrow".to_string(),
738          attributes: None,
739          children:   vec![],
740        },
741        pmml_scriptsize(doc, &children[0]),
742      ],
743    };
744  }
745
746  let op = &children[0];
747  let args = &children[1..];
748
749  // Realize the operator
750  let rop = if doc.is_qname(op, "ltx:XMRef") {
751    op.get_attribute("idref")
752      .and_then(|id| doc.find_node_by_id(&id).cloned())
753      .unwrap_or_else(|| op.clone())
754  } else {
755    op.clone()
756  };
757
758  let op_role = get_operator_role(doc, &rop).unwrap_or_default();
759  let meaning = rop.get_attribute("meaning").unwrap_or_default();
760
761  // Perl MathML.pm L413-427: the operator's `mathstyle` switches the current
762  // style for the conversion of this application, and the result is wrapped
763  // in m:mstyle per the transition tables (\tfrac in display math β†’
764  // <mstyle displaystyle="false">, \dfrac in text β†’ displaystyle="true").
765  let style_attr = rop
766    .get_attribute("mathstyle")
767    .or_else(|| op.get_attribute("mathstyle"));
768  let ostyle = CURRENT_STYLE.with(|s| s.get());
769  let nstyle = style_attr.as_deref().and_then(MathStyle::from_attr);
770  if let Some(n) = nstyle {
771    CURRENT_STYLE.with(|s| s.set(n));
772  }
773  let result = pmml_apply_dispatch(doc, op, &rop, args, &op_role, &meaning);
774  CURRENT_STYLE.with(|s| s.set(ostyle));
775  // Perl L421: resize BEFORE the mstyle wrap.
776  let result = pmml_maybe_resize(doc, node, result);
777  maybe_style_wrap(result, ostyle, nstyle)
778}
779
780/// Role/meaning dispatch for an XMApp (the body of Perl's
781/// `lookupPresenter('Apply',…)` call in `pmml_internal`).
782fn pmml_apply_dispatch(
783  doc: &PostDocument,
784  op: &Node,
785  rop: &Node,
786  args: &[Node],
787  op_role: &str,
788  meaning: &str,
789) -> NodeData {
790  // Dispatch by role
791  match op_role {
792    "SUPERSCRIPTOP" | "SUBSCRIPTOP" if args.len() >= 2 => {
793      pmml_script_full(doc, op, &args[0], &args[1])
794    },
795    "FRACOP" if args.len() >= 2 => {
796      // Perl MathML.pm L1597-1605 `Apply:FRACOP:?`: linethickness passes
797      // through VERBATIM whenever defined (\binom β†’ "0pt", \genfrac 2pt β†’
798      // "2.0pt"); mathcolor from the op's color OR the inherited context
799      // ({\color{red}$\frac{a}{b}$} colors the fraction bar, Perl L1600);
800      // bevelled fractions carry class="ltx_bevelled" β†’ bevelled="true".
801      let mut attrs = HashMap::default();
802      if let Some(t) = rop.get_attribute("thickness") {
803        attrs.insert("linethickness".to_string(), t);
804      }
805      if let Some(c) = rop
806        .get_attribute("color")
807        .filter(|c| !c.is_empty())
808        .or_else(|| ctx_get(&CTX_COLOR))
809      {
810        attrs.insert("mathcolor".to_string(), c);
811      }
812      if let Some(cl) = rop.get_attribute("class")
813        && cl.split_ascii_whitespace().any(|c| c == "ltx_bevelled")
814      {
815        attrs.insert("bevelled".to_string(), "true".to_string());
816      }
817      NodeData::Element {
818        tag:        "m:mfrac".to_string(),
819        attributes: if attrs.is_empty() { None } else { Some(attrs) },
820        children:   vec![pmml_smaller(doc, &args[0]), pmml_smaller(doc, &args[1])],
821      }
822    },
823    "OVERACCENT" if !args.is_empty() => {
824      // Perl MathML.pm L1492-1504: check if base is XMApp with UNDERACCENT β†’ m:munderover
825      let base = &args[0];
826      let base_children = element_children(base);
827      if doc.is_qname(base, "ltx:XMApp") && base_children.len() == 2 {
828        let inner_role = base_children[0].get_attribute("role").unwrap_or_default();
829        if inner_role == "UNDERACCENT" {
830          // Combine into m:munderover: base_of_inner, under_accent, over_accent
831          return NodeData::Element {
832            tag:        "m:munderover".to_string(),
833            attributes: Some(HashMap::from_iter([
834              ("accent".to_string(), "true".to_string()),
835              ("accentunder".to_string(), "true".to_string()),
836            ])),
837            children:   vec![
838              pmml(doc, &base_children[1]), // the actual base
839              pmml(doc, &base_children[0]), // the under-accent
840              pmml(doc, op),                // the over-accent
841            ],
842          };
843        }
844      }
845      NodeData::Element {
846        tag:        "m:mover".to_string(),
847        attributes: Some(HashMap::from_iter([(
848          "accent".to_string(),
849          "true".to_string(),
850        )])),
851        children:   vec![pmml(doc, base), pmml(doc, op)],
852      }
853    },
854    "UNDERACCENT" if !args.is_empty() => {
855      // Perl MathML.pm L1507-1519: check if base is XMApp with OVERACCENT β†’ m:munderover
856      let base = &args[0];
857      let base_children = element_children(base);
858      if doc.is_qname(base, "ltx:XMApp") && base_children.len() == 2 {
859        let inner_role = base_children[0].get_attribute("role").unwrap_or_default();
860        if inner_role == "OVERACCENT" {
861          return NodeData::Element {
862            tag:        "m:munderover".to_string(),
863            attributes: Some(HashMap::from_iter([
864              ("accent".to_string(), "true".to_string()),
865              ("accentunder".to_string(), "true".to_string()),
866            ])),
867            children:   vec![
868              pmml(doc, &base_children[1]), // the actual base
869              pmml(doc, op),                // the under-accent
870              pmml(doc, &base_children[0]), // the over-accent
871            ],
872          };
873        }
874      }
875      NodeData::Element {
876        tag:        "m:munder".to_string(),
877        attributes: Some(HashMap::from_iter([(
878          "accentunder".to_string(),
879          "true".to_string(),
880        )])),
881        children:   vec![pmml(doc, base), pmml(doc, op)],
882      }
883    },
884    "POSTFIX" if !args.is_empty() => {
885      let mut items: Vec<NodeData> = args.iter().map(|a| pmml(doc, a)).collect();
886      items.push(pmml(doc, op));
887      pmml_row(items)
888    },
889    "ADDOP" | "RELOP" | "MULOP" | "BINOP" | "ARROW" | "METARELOP" | "COMPOSEOP" | "MODIFIEROP"
890    | "MIDDLE" => {
891      // Infix: arg1 op arg2 op arg3 ...
892      pmml_infix(doc, op, args)
893    },
894    "SUMOP" | "INTOP" | "BIGOP" | "LIMITOP" => {
895      // Big operator: Σ/∫ applied to args (gets FUNCTION APPLICATION ⁑).
896      // DIFFOP (βˆ‚, d, βˆ‡-as-diff) is deliberately EXCLUDED β€” Perl MathML.pm:702
897      // `$ismoveop = … (SUMOP|INTOP|BIGOP|LIMITOP)$/  # Not DIFFOP`; a DIFFOP
898      // falls to the generic apply below, which juxtaposes (no ⁑) because its
899      // base renders as <m:mo>. (Witness: `\partial f` β†’ βˆ‚f, not βˆ‚β‘f.)
900      pmml_summation(doc, op, args)
901    },
902    "OPEN" | "CLOSE" if !args.is_empty() => {
903      // Fenced: (args)
904      pmml_parenthesize(doc, op, args)
905    },
906    "ENCLOSE" if !args.is_empty() => {
907      // Perl MathML.pm L1507-1513 `Apply:ENCLOSE:?`: m:menclose with the
908      // operator's `enclose` attribute as notation (e.g. \cancel β†’
909      // updiagonalstrike); if the op (or the inherited context) carries a
910      // color, the enclosure gets it as mathcolor and the base is reset via
911      // m:mstyle to the context color (default black).
912      let mut attrs = HashMap::default();
913      if let Some(notation) = rop.get_attribute("enclose").filter(|n| !n.is_empty()) {
914        attrs.insert("notation".to_string(), notation);
915      }
916      let color = rop
917        .get_attribute("color")
918        .filter(|c| !c.is_empty())
919        .or_else(|| ctx_get(&CTX_COLOR));
920      let base = pmml(doc, &args[0]);
921      let inner = if let Some(ref c) = color {
922        attrs.insert("mathcolor".to_string(), c.clone());
923        // Perl: ['m:mstyle', { mathcolor => $COLOR || 'black' }, …] β€” reset
924        // the base to the CONTEXT color so only the enclosure is tinted.
925        NodeData::Element {
926          tag:        "m:mstyle".to_string(),
927          attributes: Some(HashMap::from_iter([(
928            "mathcolor".to_string(),
929            ctx_get(&CTX_COLOR).unwrap_or_else(|| "black".to_string()),
930          )])),
931          children:   vec![base],
932        }
933      } else {
934        base
935      };
936      NodeData::Element {
937        tag:        "m:menclose".to_string(),
938        attributes: if attrs.is_empty() { None } else { Some(attrs) },
939        children:   vec![inner],
940      }
941    },
942    _ if meaning == "multirelation" => {
943      // Multirelation: a = b = c (interleaved args and operators)
944      // Port of `Apply:?:multirelation` handler.
945      let mut items = Vec::new();
946      for (i, arg) in args.iter().enumerate() {
947        if i > 0 && i % 2 == 1 {
948          // Odd positions are operators in multirelation
949          items.push(pmml(doc, arg));
950        } else {
951          items.push(pmml(doc, arg));
952        }
953      }
954      pmml_row(items)
955    },
956    _ => {
957      // Default: function application
958      if meaning == "limit-from" && !args.is_empty() {
959        // limit-from: base followed by direction
960        let items: Vec<NodeData> = args.iter().map(|a| pmml(doc, a)).collect();
961        pmml_row(items)
962      } else if meaning == "annotated" && args.len() >= 2 {
963        // annotated: variable with annotation (e.g. "x modulo p")
964        pmml_row(vec![
965          pmml(doc, &args[0]),
966          NodeData::Element {
967            tag:        "m:mspace".to_string(),
968            attributes: Some(HashMap::from_iter([(
969              "width".to_string(),
970              "0.389em".to_string(),
971            )])),
972            children:   vec![],
973          },
974          pmml(doc, &args[1]),
975        ])
976      } else if meaning == "square-root" && !args.is_empty() {
977        // Perl L1639-1642: mathcolor from the op's color or the context.
978        NodeData::Element {
979          tag:        "m:msqrt".to_string(),
980          attributes: rop
981            .get_attribute("color")
982            .or_else(|| ctx_get(&CTX_COLOR))
983            .map(|c| HashMap::from_iter([("mathcolor".to_string(), c)])),
984          children:   vec![pmml(doc, &args[0])],
985        }
986      } else if meaning == "continued-fraction" && args.len() >= 2 {
987        pmml_cfrac(doc, op, &args[0], &args[1])
988      } else if meaning == "nth-root" && args.len() >= 2 {
989        // Perl L1644-1647: `['m:mroot', …, pmml($_[2]), pmml_scriptsize($_[1])]`
990        // β€” args are (degree, radicand) in BOTH engines' XMath; m:mroot takes
991        // the base first, then the scriptsized degree. (Previously swapped:
992        // degree rendered as the base, radicand shrunk.)
993        NodeData::Element {
994          tag:        "m:mroot".to_string(),
995          attributes: rop
996            .get_attribute("color")
997            .or_else(|| ctx_get(&CTX_COLOR))
998            .map(|c| HashMap::from_iter([("mathcolor".to_string(), c)])),
999          children:   vec![pmml(doc, &args[1]), pmml_scriptsize(doc, &args[0])],
1000        }
1001      } else {
1002        // Generic application: op(arg1, arg2, ...). Insert FUNCTION APPLICATION
1003        // (⁑, U+2061) ONLY when the operator's base is NOT an <m:mo> β€” Perl
1004        // MathML.pm `Apply:?:?` (`$is_mo ? () : pmml_mo("\x{2061}")`). So an
1005        // OPERATOR/DIFFOP like βˆ‡ juxtaposes (βˆ‡Ο•, spacing via the mo's rspace),
1006        // while a function identifier f gets f⁑(x).
1007        let pop = pmml(doc, op);
1008        let needs_apply = !op_base_is_mo(&pop);
1009        let mut items = vec![pop];
1010        if needs_apply {
1011          items.push(pmml_mo_str("\u{2061}")); // FUNCTION APPLICATION
1012        }
1013        for arg in args {
1014          items.push(pmml(doc, arg));
1015        }
1016        pmml_row(items)
1017      }
1018    },
1019  }
1020}
1021
1022/// Convert an XMTok to the appropriate Presentation MathML token.
1023///
1024/// Port of `stylizeContent` + token converter.
1025fn pmml_token(doc: &PostDocument, node: &Node) -> NodeData {
1026  // Perl `pmml_bigop` (MathML.pm L847-856): a SUMOP/INTOP/BIGOP token whose
1027  // recorded `mathstyle` differs from the current style converts under the
1028  // switched style and wraps in m:mstyle via %stylemap (the displaystyle-
1029  // carrying table, unconditionally) β€” e.g. `\displaystyle\sum` in inline
1030  // math keeps its large rendering. Token:LIMITOP is plain pmml_mo in Perl.
1031  let nstyle = match node.get_attribute("role").as_deref() {
1032    Some("SUMOP" | "INTOP" | "BIGOP") => node
1033      .get_attribute("mathstyle")
1034      .as_deref()
1035      .and_then(MathStyle::from_attr),
1036    _ => None,
1037  };
1038  let ostyle = CURRENT_STYLE.with(|s| s.get());
1039  if let Some(n) = nstyle {
1040    CURRENT_STYLE.with(|s| s.set(n));
1041  }
1042  let result = pmml_token_inner(doc, node, None);
1043  CURRENT_STYLE.with(|s| s.set(ostyle));
1044  match nstyle {
1045    Some(n) if n != ostyle => {
1046      let style_attrs = stylemap_attrs(ostyle, n, true);
1047      if style_attrs.is_empty() {
1048        result
1049      } else {
1050        NodeData::Element {
1051          tag:        "m:mstyle".to_string(),
1052          attributes: Some(HashMap::from_iter(
1053            style_attrs
1054              .iter()
1055              .map(|(k, v)| (k.to_string(), v.to_string())),
1056          )),
1057          children:   vec![result],
1058        }
1059      }
1060    },
1061    _ => result,
1062  }
1063}
1064
1065/// Resolve a token's explicit fontsize for emission (Perl `stylizeContent`
1066/// L782-792): a %-size in script context is re-expressed relative to the
1067/// script style's nominal size, then any %-size is converted to em ("safari
1068/// apparently ignores %").
1069fn resolve_token_size(mut s: String) -> String {
1070  if let Some(req) = s.strip_suffix('%') {
1071    let ctx = current_context_size().trim_end_matches('%');
1072    if matches!(
1073      CURRENT_STYLE.with(|c| c.get()),
1074      MathStyle::Script | MathStyle::ScriptScript
1075    ) && let (Ok(req), Ok(ex)) = (req.parse::<f64>(), ctx.parse::<f64>())
1076      && ex != 0.0
1077    {
1078      s = format!("{}%", (100.0 * req / ex) as i32);
1079    }
1080    if let Some(pct) = s.strip_suffix('%')
1081      && let Ok(pct) = pct.parse::<f64>()
1082    {
1083      s = fmt_em(pct / 100.0);
1084    }
1085  }
1086  s
1087}
1088
1089/// `role_override` mirrors Perl's `pmml_mo($op, role => 'OPERATOR')` call
1090/// (MathML.pm L634): the caller forces the operator dictionary to a specific
1091/// role (`OPERATOR` β†’ PREFIX form) rather than the node's stored role. When set,
1092/// this is a DIRECT `pmml_mo` invocation, so β€” like Perl's `pmml_mo`, which
1093/// records no `_role` (only the `pmml` wrapper does, MathML.pm L354) β€” we leave
1094/// `_role` unset so the operator atom-types as `Ord` rather than by its infix
1095/// role. See `pmml_infix`'s single-argument (prefix) branch and issue #535.
1096fn pmml_token_inner(doc: &PostDocument, node: &Node, role_override: Option<&str>) -> NodeData {
1097  let role = role_override
1098    .map(String::from)
1099    .or_else(|| node.get_attribute("role"))
1100    .unwrap_or_else(|| "UNKNOWN".to_string());
1101  // Perl stylizeContent L678: token attribute, else the inherited context.
1102  let font = node.get_attribute("font").or_else(|| ctx_get(&CTX_FONT));
1103  let mut text = node.get_content();
1104  let meaning = node.get_attribute("meaning");
1105
1106  // Handle special meanings
1107  if meaning.as_deref() == Some("absent") {
1108    // "absent" is an XMath placeholder for a structurally-missing operand
1109    // (e.g. the LHS of a continuation row `& = ...` in `align*` whose
1110    // LHS is inherited from the previous row, or a prefix operator
1111    // applied with no left argument). At the MathML Presentation
1112    // layer we materialize it as an EMPTY `<m:mphantom/>` β€” deliberately NOT
1113    // the `<m:mi/>` Perl uses (`MathML.pm:1474`). `<m:mi>` is a semantic
1114    // assertion ("here is an identifier") with no defined meaning when empty:
1115    // renderers vary, screen readers announce "blank", indexers ingest a
1116    // content-free token. That antipattern is enforced against downstream by a
1117    // `debug_assert!` in `latexml_post/src/document.rs`, which refuses to
1118    // materialize an empty `<m:mi>` by any route. Task #264.
1119    //
1120    // `<m:mphantom>` rather than a bare `<m:mrow>` because it says exactly what
1121    // this node is: content that occupies layout space but is not rendered.
1122    // A bare `<m:mrow>` is merely "a group", leaving a reader (human or AT) to
1123    // infer why an empty one is there; `mphantom`'s whole definition is
1124    // "invisible placeholder", which is precisely the operand slot's job.
1125    // Measured equivalent in rendering: an inline `= q` is 35.58px in Chrome
1126    // with an empty `mphantom`, an empty `mrow`, an empty `mi`, or no slot at
1127    // all β€” so the choice is free at the pixel level and decided on clarity.
1128    //
1129    // What matters for rendering is only that the slot EXISTS: MathML infers
1130    // an `<mo>`'s form from its position, so a sibling β€” of either element β€”
1131    // keeps the operator infix. Dropping the slot is what broke issue #312;
1132    // see `pmml_infix`. Because our placeholder differs from Perl's, the
1133    // `tests/post/alignrows` guard asserts the STRUCTURE (the operator is not
1134    // its `<mrow>`'s first child) rather than diffing against a Perl golden β€”
1135    // a diff budget cannot tell "different placeholder" from "no placeholder".
1136    return NodeData::Element {
1137      tag:        "m:mphantom".to_string(),
1138      attributes: None,
1139      children:   vec![],
1140    };
1141  }
1142
1143  // Determine tag based on role
1144  let tag = match role.as_str() {
1145    "NUMBER" => "m:mn",
1146    "ID" | "UNKNOWN" => "m:mi",
1147    "FUNCTION" | "OPFUNCTION" | "TRIGFUNCTION" => "m:mi",
1148    _ => "m:mo",
1149  };
1150
1151  // Handle empty tokens
1152  if text.is_empty() {
1153    // arXiv/html_feedback#970 (paper 2312.06275): a siunitx unit declared with an
1154    // empty symbol β€” `\DeclareSIUnit{\nothing}{\relax}` β€” reaches here with empty
1155    // content but `meaning`=<unit name>. The general fallback below turns that
1156    // into a visible `<m:mi>nothing</m:mi>` (Perl renders the same, in red β€”
1157    // SHARED-FAILURE). A unit whose symbol produces nothing must render
1158    // INVISIBLY: emit an empty `<m:mphantom>` β€” the same invisible placeholder
1159    // used for `absent` above β€” never the unit name. OXIDIZED_DESIGN #114.
1160    if node
1161      .get_attribute("class")
1162      .is_some_and(|c| c.split_whitespace().any(|w| w == "ltx_unit"))
1163    {
1164      return NodeData::Element {
1165        tag:        "m:mphantom".to_string(),
1166        attributes: None,
1167        children:   vec![],
1168      };
1169    }
1170    if let Some(default) = default_token_content(&role) {
1171      text = default.to_string();
1172    } else {
1173      text = meaning
1174        .or_else(|| node.get_attribute("name"))
1175        .unwrap_or_else(|| role.clone());
1176    }
1177  }
1178
1179  // Minus sign normalization
1180  if text == "-" && matches!(role.as_str(), "ADDOP" | "OPERATOR") {
1181    text = "\u{2212}".to_string(); // MINUS SIGN
1182  }
1183
1184  // Perl L772-775: when invisibletimes is false, replace U+2062 with U+200B
1185  let is_replaced_invisible_times = text == "\u{2062}" && !get_invisible_times();
1186  if is_replaced_invisible_times {
1187    text = "\u{200B}".to_string(); // ZERO WIDTH SPACE
1188  }
1189
1190  let mut attrs = HashMap::default();
1191
1192  // Perl L747-748: text consisting only of Format characters (invisible
1193  // times/apply/separator, ZWSP, …) gets NO visual styling attributes β€”
1194  // without this, context color would paint invisible operators.
1195  // (Approximates \p{Format} with the Cf codepoints that occur in math.)
1196  let is_format_only = text.chars().all(|c| {
1197    matches!(c,
1198      '\u{00AD}' | '\u{200B}'..='\u{200F}' | '\u{2060}'..='\u{2064}' | '\u{FEFF}')
1199  });
1200
1201  // Perl: zero-width space <mo> needs lspace/rspace="0em" to prevent browser
1202  // default operator spacing that creates visible gaps between letters.
1203  if is_replaced_invisible_times && tag == "m:mo" {
1204    attrs.insert("lspace".to_string(), "0em".to_string());
1205    attrs.insert("rspace".to_string(), "0em".to_string());
1206  }
1207
1208  // Math variant from font β€” with Plane 1 Unicode conversion.
1209  // Port of Perl stylizeContent lines 689-756.
1210  if !is_format_only {
1211    use crate::unicode;
1212    let mut variant: Option<&str> = font.as_deref().map(unicode::unicode_mathvariant);
1213
1214    // Single char mi: italic is default
1215    if tag == "m:mi" && text.chars().count() == 1 {
1216      if variant == Some("italic") {
1217        variant = None;
1218      } else if variant.is_none() && font.is_none() {
1219        // Check if it's a named symbol (not a variable) β†’ use "normal"
1220        if node.get_attribute("name").is_some() {
1221          variant = Some("normal");
1222        }
1223      } else if variant.is_none() {
1224        variant = Some("normal");
1225      }
1226    } else if font.is_some() && variant == Some("normal") {
1227      variant = None; // normal is default for non-single-char-mi tokens
1228    } else if tag == "m:mi" && text.chars().count() > 1 && font.is_none() {
1229      variant = Some("normal"); // multi-char mi without font β†’ normal
1230    }
1231
1232    // Plane 1 Unicode conversion. Perl L734-737 picks the variant to remap WITH:
1233    // under `--hackplane1` only the `plane1_hackable` variants remap (and to the
1234    // simpler variant named there), under the default `plane1` the variant itself,
1235    // and under `--noplane1` nothing remaps β€” the text stays ASCII and the
1236    // `mathvariant` attribute emitted below carries the style instead. `m:mtext`
1237    // never remaps at all.
1238    if let Some(v) = variant
1239      && tag != "m:mtext"
1240      && let Some(u_variant) = plane1_target_variant(v)
1241      && let Some(u_text) = unicode::unicode_convert(&text, u_variant)
1242      && (!u_text.is_empty() || text.is_empty())
1243    {
1244      text = u_text;
1245      // Perl L739-740 keeps a BOLD variant when the hack downgraded it (e.g.
1246      // `bold-script` remapped as `script` still deserves `mathvariant="bold"`,
1247      // since the codepoint carries only the script-ness); otherwise the
1248      // character carries the whole style and the attribute is dropped.
1249      variant = if u_variant != v && v.starts_with("bold") {
1250        Some("bold")
1251      } else {
1252        None
1253      };
1254    }
1255
1256    // Emit remaining variant attribute
1257    if let Some(v) = variant {
1258      if tag == "m:mi" && text.chars().count() == 1 {
1259        if v != "italic" {
1260          attrs.insert("mathvariant".to_string(), v.to_string());
1261        }
1262      } else if v != "normal" {
1263        attrs.insert("mathvariant".to_string(), v.to_string());
1264      }
1265    }
1266
1267    // Font-based CSS class fallbacks.
1268    // Port of Perl L746-756: added regardless of plane1 conversion.
1269    let is_format_only = text.chars().all(|c| {
1270      matches!(c,
1271        '\u{200B}'..='\u{200F}' | '\u{2028}'..='\u{202F}'
1272        | '\u{2060}'..='\u{2064}' | '\u{FEFF}' | '\u{00AD}')
1273    }) && !text.is_empty();
1274    if let Some(ref f) = font {
1275      if !is_format_only {
1276        if f.contains("caligraphic") {
1277          let prev = attrs.get("class").cloned().unwrap_or_default();
1278          let new = if prev.is_empty() {
1279            "ltx_font_mathcaligraphic".to_string()
1280          } else {
1281            format!("{} ltx_font_mathcaligraphic", prev)
1282          };
1283          attrs.insert("class".to_string(), new);
1284        } else if f.contains("script") {
1285          let prev = attrs.get("class").cloned().unwrap_or_default();
1286          let new = if prev.is_empty() {
1287            "ltx_font_mathscript".to_string()
1288          } else {
1289            format!("{} ltx_font_mathscript", prev)
1290          };
1291          attrs.insert("class".to_string(), new);
1292        } else if f.contains("fraktur") && text.chars().all(|c| "+-0123456789.".contains(c)) {
1293          let prev = attrs.get("class").cloned().unwrap_or_default();
1294          let new = if prev.is_empty() {
1295            "ltx_font_oldstyle".to_string()
1296          } else {
1297            format!("{} ltx_font_oldstyle", prev)
1298          };
1299          attrs.insert("class".to_string(), new);
1300        } else if f.contains("smallcaps") {
1301          let prev = attrs.get("class").cloned().unwrap_or_default();
1302          let new = if prev.is_empty() {
1303            "ltx_font_smallcaps".to_string()
1304          } else {
1305            format!("{} ltx_font_smallcaps", prev)
1306          };
1307          attrs.insert("class".to_string(), new);
1308        } else if let Some(v) = variant {
1309          if v != "normal" {
1310            let prev = attrs.get("class").cloned().unwrap_or_default();
1311            let new = if prev.is_empty() {
1312              format!("ltx_mathvariant_{}", v)
1313            } else {
1314              format!("{} ltx_mathvariant_{}", prev, v)
1315            };
1316            attrs.insert("class".to_string(), new);
1317          }
1318        }
1319      }
1320    }
1321  }
1322
1323  // Perl emits mathsize for ALL token types, not just m:mo (witness:
1324  // smallmatrix cells get mathsize="0.700em"). The context gate compensates
1325  // for our engine stamping absolute fontsize="70%" on script tokens where
1326  // Perl's leaves them bare β€” a matching size must NOT be re-emitted.
1327  if tag != "m:mo"
1328    && let Some(size) = node.get_attribute("fontsize")
1329    && size != current_context_size()
1330  {
1331    attrs.insert("mathsize".to_string(), resolve_token_size(size));
1332  }
1333
1334  // Operator-specific attributes: the mo half of Perl `stylizeContent`
1335  // (L697-827) β€” operator-dictionary xor-emission, size/stretchy interplay,
1336  // largeop/movablelimits/symmetric. (audit F8)
1337  if tag == "m:mo" {
1338    let props = operator_dictionary::opdict_lookup(&text, &role);
1339    // Perl L697-704: implied attributes.
1340    let mut stretchy = node.get_attribute("stretchy").as_deref() == Some("true");
1341    let is_fence = matches!(role.as_str(), "OPEN" | "CLOSE" | "MIDDLE");
1342    let is_sep = role == "PUNCT";
1343    let is_largeop = matches!(role.as_str(), "SUMOP" | "INTOP");
1344    let is_moveop = matches!(role.as_str(), "SUMOP" | "INTOP" | "BIGOP" | "LIMITOP"); // Not DIFFOP
1345    let is_symm = is_largeop || text == "/"; // WANTS to be symmetric
1346    let pos = node
1347      .get_attribute("scriptpos")
1348      .unwrap_or_else(|| "post".to_string());
1349
1350    // Perl L774-778: ignore size when stretching; invisible operators get
1351    // neither size nor stretchiness.
1352    let mut size = node.get_attribute("fontsize");
1353    if stretchy {
1354      size = None;
1355    }
1356    // Include U+200B: under --noinvisibletimes the ⁒ was already replaced by
1357    // ZWSP above, and its size/stretchiness must still be cleared (Perl runs
1358    // this check on the ORIGINAL text before replacing).
1359    let is_invisible = !text.is_empty()
1360      && text
1361        .chars()
1362        .all(|c| matches!(c, '\u{2061}'..='\u{2063}' | '\u{200B}'));
1363    if is_invisible {
1364      stretchy = false;
1365      size = None;
1366    }
1367
1368    // Perl L779-798: size resolution. Emit only when the token's size
1369    // differs from the current contextual size (inside a script the msup/
1370    // msub structure already shrinks it β€” a matching "70%" must NOT be
1371    // re-emitted); a differing %-size in script context is re-expressed
1372    // relative to the script's nominal size, then converted to em ("safari
1373    // apparently ignores %"). Symmetric-wanting delimiters at explicit
1374    // sizes use the minsize/maxsize stretchyhack ("Thanks Peter
1375    // Krautzberger") instead of mathsize.
1376    let mut props_stretchy = props.stretchy;
1377    let mut stretchyhack = false;
1378    let resolved_size = size
1379      .filter(|s| s != current_context_size())
1380      .map(resolve_token_size);
1381    if let Some(size) = resolved_size {
1382      if is_symm || props.symmetric {
1383        stretchyhack = true;
1384        // Force the attribute to avoid browser bugs (esp "|").
1385        if !matches!(text.as_str(), "(" | ")" | "[" | "]" | "{" | "}") {
1386          props_stretchy = false;
1387        }
1388        stretchy = true; // pretend we asked for stretchy
1389        attrs.insert("minsize".to_string(), size.clone());
1390        attrs.insert("maxsize".to_string(), size);
1391      } else {
1392        stretchy = false; // size specifically set β†’ don't stretch it
1393        attrs.insert("mathsize".to_string(), size);
1394      }
1395    }
1396    let _ = stretchyhack;
1397
1398    // Perl L811-826: emit operator-dictionary attributes only where the
1399    // wanted value differs from what the dictionary already implies (xor).
1400    if stretchy != props_stretchy {
1401      attrs.insert(
1402        "stretchy".to_string(),
1403        (if stretchy { "true" } else { "false" }).to_string(),
1404      );
1405    }
1406    if is_fence != props.fence {
1407      attrs.insert(
1408        "fence".to_string(),
1409        (if is_fence { "true" } else { "false" }).to_string(),
1410      );
1411    }
1412    if is_sep != props.separator {
1413      attrs.insert(
1414        "separator".to_string(),
1415        (if is_sep { "true" } else { "false" }).to_string(),
1416      );
1417    }
1418    if is_largeop != props.largeop {
1419      attrs.insert(
1420        "largeop".to_string(),
1421        (if is_largeop { "true" } else { "false" }).to_string(),
1422      );
1423    }
1424    if is_largeop {
1425      attrs.insert("_largeop".to_string(), "1".to_string()); // For needsMathstyle
1426    }
1427    if is_symm && !props.symmetric && (stretchy || props_stretchy) {
1428      attrs.insert("symmetric".to_string(), "true".to_string());
1429    }
1430    // If an operator has specifically located its scripts, don't let MathML
1431    // move them. (Perl also honors $NOMOVABLELIMITS from script layout β€”
1432    // unported, audit F17.)
1433    if is_moveop && pos.contains("mid") {
1434      attrs.insert("movablelimits".to_string(), "false".to_string());
1435    }
1436
1437    // Store internal spacing attributes for adjust_spacing (Perl L821-824).
1438    // Perl's `pmml_mo` records no `_role` β€” only the `pmml()` wrapper does
1439    // (MathML.pm L354). A direct `pmml_mo` call (role_override set) therefore
1440    // leaves `_role` absent so the spacewalk atom-types the operator as `Ord`,
1441    // not by an infix role it does not actually carry here (issue #535). On the
1442    // ordinary path the `pmml()` wrapper re-stamps `_role` anyway.
1443    if role_override.is_none() {
1444      attrs.insert("_role".to_string(), role.clone());
1445    }
1446    if props.lspace > 0.0 {
1447      attrs.insert("_lspace".to_string(), fmt_em(props.lspace));
1448    }
1449    if props.rspace > 0.0 {
1450      attrs.insert("_rspace".to_string(), fmt_em(props.rspace));
1451    }
1452  }
1453
1454  // Color / background / opacity (Perl L680-687 fallback to the inherited
1455  // context; L761-762 opacity β†’ css style; the Format suppression above).
1456  if !is_format_only {
1457    if let Some(color) = node.get_attribute("color").or_else(|| ctx_get(&CTX_COLOR)) {
1458      attrs.insert("mathcolor".to_string(), color);
1459    }
1460    // NB Perl's `$attr{backgroundcolor} && $item->getAttribute(…) || $BGCOLOR`
1461    // (L683-684) means a token's OWN backgroundcolor attribute is never
1462    // consulted on this path β€” only the context. Mirrored faithfully.
1463    if let Some(bg) = ctx_get(&CTX_BGCOLOR) {
1464      attrs.insert("mathbackground".to_string(), bg);
1465    }
1466    let cssstyle = node.get_attribute("cssstyle").unwrap_or_default();
1467    let opacity = node
1468      .get_attribute("opacity")
1469      .or_else(|| ctx_get(&CTX_OPACITY));
1470    let style = match (cssstyle.is_empty(), opacity) {
1471      (true, None) => String::new(),
1472      (true, Some(op)) => format!("opacity:{op}"),
1473      (false, None) => cssstyle,
1474      (false, Some(op)) => format!("{cssstyle};opacity:{op}"),
1475    };
1476    if !style.is_empty() {
1477      attrs.insert("style".to_string(), style);
1478    }
1479  }
1480
1481  // Href
1482  if let Some(href) = node.get_attribute("href") {
1483    attrs.insert("href".to_string(), href);
1484  }
1485
1486  // Class
1487  if let Some(class) = node.get_attribute("class") {
1488    attrs.insert("class".to_string(), class);
1489  }
1490
1491  // Source locator (token-locators): carry the XMTok's source position onto the
1492  // MathML token element so the editor can map a rendered symbol back to its
1493  // source (per-token in-equation provenance, Β§7 A.3). The math XSLT copies the
1494  // generated MathML verbatim, so emit the final HTML5 `data-sourcepos` name.
1495  // `data:sourcepos` is namespaced (the LaTeXML `data:` namespace), so read it
1496  // by local name + namespace URI (like `xml:id` is read elsewhere).
1497  if let Some(sp) = node.get_attribute_ns("sourcepos", "http://dlmf.nist.gov/LaTeXML/data") {
1498    attrs.insert("data-sourcepos".to_string(), sp);
1499  }
1500
1501  // Perl pmml_mi/pmml_mn/pmml_mo (L830-845) all pass the token through
1502  // pmml_maybe_resize (raised/framed/phantom-sized tokens).
1503  pmml_maybe_resize(doc, node, NodeData::Element {
1504    tag:        tag.to_string(),
1505    attributes: if attrs.is_empty() { None } else { Some(attrs) },
1506    children:   vec![NodeData::Text(text)],
1507  })
1508}
1509
1510/// Convert an XMHint to MathML.
1511///
1512/// Port of Hint handler.
1513fn pmml_hint(_doc: &PostDocument, node: &Node) -> NodeData {
1514  // Perl `Hint:?:?` (MathML.pm L1479-1483): the width is normalized through
1515  // getXMHintSpacing to em (so `\qquad` β†’ width="2em", not the raw "20pt");
1516  // zero-width hints still MUST return a node, marked `_ignorable` so
1517  // filter_row drops them from rows.
1518  let w = node
1519    .get_attribute("width")
1520    .map(|w| super::get_xm_hint_spacing(&w))
1521    .unwrap_or(0.0);
1522  let attrs = if w != 0.0 {
1523    // Perl appends 'em' to the raw number ($w . 'em'), NOT fmt_em β€” emulate
1524    // Perl's default %.15g stringification.
1525    HashMap::from_iter([("width".to_string(), format!("{}em", perl_num(w)))])
1526  } else {
1527    HashMap::from_iter([("_ignorable".to_string(), "1".to_string())])
1528  };
1529  NodeData::Element {
1530    tag:        "m:mspace".to_string(),
1531    attributes: Some(attrs),
1532    children:   vec![],
1533  }
1534}
1535
1536/// Format a float the way Perl stringifies numbers (%.15g): 15 SIGNIFICANT
1537/// digits, no trailing zeros. (The previous {v:.15} was 15 DECIMALS β€” wrong
1538/// for |v| < 0.1 or >= 10; PR_READINESS batch-14 fix.)
1539fn perl_num(v: f64) -> String {
1540  if v == 0.0 {
1541    return "0".to_string();
1542  }
1543  let magnitude = v.abs().log10().floor() as i32;
1544  let decimals = (14 - magnitude).clamp(0, 17) as usize;
1545  let s = format!("{v:.decimals$}");
1546  if s.contains('.') {
1547    s.trim_end_matches('0').trim_end_matches('.').to_string()
1548  } else {
1549    s
1550  }
1551}
1552
1553/// Convert an XMArray to an mtable.
1554///
1555/// Port of `pmml_internal` XMArray branch (`MathML.pm` L432-486).
1556fn pmml_array(doc: &PostDocument, node: &Node) -> NodeData {
1557  // Perl `pmml_internal` XMArray branch (L432-506): the array's `mathstyle`
1558  // switches the current style for the cell conversions; the mtable gets
1559  // displaystyle="true" when that style is display ("Mozilla seems to need
1560  // some encouragement?"), and the whole result wraps in m:mstyle per the
1561  // transition tables.
1562  let ostyle = CURRENT_STYLE.with(|s| s.get());
1563  let nstyle = node
1564    .get_attribute("mathstyle")
1565    .as_deref()
1566    .and_then(MathStyle::from_attr);
1567  if let Some(n) = nstyle {
1568    CURRENT_STYLE.with(|s| s.set(n));
1569  }
1570  let result = pmml_array_inner(doc, node);
1571  CURRENT_STYLE.with(|s| s.set(ostyle));
1572  // Perl L492-506: XMArray resizes AFTER the mstyle wrap (XMApp: before).
1573  let result = maybe_style_wrap(result, ostyle, nstyle);
1574  pmml_maybe_resize(doc, node, result)
1575}
1576
1577fn pmml_array_inner(doc: &PostDocument, node: &Node) -> NodeData {
1578  let mut rows = Vec::new();
1579  let width = node.get_attribute("width");
1580  let vattach = node
1581    .get_attribute("vattach")
1582    .unwrap_or_else(|| "middle".to_string());
1583  let align = match vattach.as_str() {
1584    "top" => "bottom1",
1585    "middle" | "" => "axis",
1586    _ => vattach.as_str(),
1587  };
1588  let rowsep = node
1589    .get_attribute("rowsep")
1590    .unwrap_or_else(|| "0pt".to_string());
1591  let colsep = node
1592    .get_attribute("colsep")
1593    .unwrap_or_else(|| "5pt".to_string());
1594
1595  let mut nrows = 0;
1596  let mut ncols = 0;
1597  for row_node in element_children(node) {
1598    let mut cols = Vec::new();
1599    let mut nc = 0;
1600    for cell_node in element_children(&row_node) {
1601      nc += 1;
1602      let cell_align = cell_node.get_attribute("align");
1603      let colspan = cell_node.get_attribute("colspan");
1604      let rowspan = cell_node.get_attribute("rowspan");
1605      let mut td_attrs = HashMap::default();
1606      if let Some(a) = &cell_align {
1607        if a != "center" {
1608          td_attrs.insert("columnalign".to_string(), a.clone());
1609          td_attrs.insert("class".to_string(), format!("ltx_align_{}", a));
1610        }
1611      }
1612      if let Some(cs) = colspan {
1613        td_attrs.insert("columnspan".to_string(), cs);
1614      }
1615      if let Some(rs) = rowspan {
1616        td_attrs.insert("rowspan".to_string(), rs);
1617      }
1618
1619      let cell_children = element_children(&cell_node);
1620      let cell_content = if cell_children.is_empty() {
1621        vec![]
1622      } else {
1623        // Perl L468: cells filter _ignorable items too.
1624        filter_row(cell_children.iter().map(|c| pmml(doc, c)).collect())
1625      };
1626
1627      cols.push(NodeData::Element {
1628        tag:        "m:mtd".to_string(),
1629        attributes: if td_attrs.is_empty() {
1630          None
1631        } else {
1632          Some(td_attrs)
1633        },
1634        children:   cell_content,
1635      });
1636    }
1637    if nc > ncols {
1638      ncols = nc;
1639    }
1640    nrows += 1;
1641    rows.push(NodeData::Element {
1642      tag:        "m:mtr".to_string(),
1643      attributes: None,
1644      children:   cols,
1645    });
1646  }
1647
1648  // Perl L478-479: drop separators if there's only one row/column.
1649  let emit_rowsep = nrows >= 2;
1650  let emit_colsep = ncols >= 2;
1651
1652  let mut table_attrs = HashMap::default();
1653  if align != "axis" {
1654    table_attrs.insert("align".to_string(), align.to_string());
1655  }
1656  if emit_rowsep {
1657    table_attrs.insert("rowspacing".to_string(), rowsep);
1658  }
1659  if emit_colsep {
1660    table_attrs.insert("columnspacing".to_string(), colsep);
1661  }
1662  if let Some(w) = width {
1663    table_attrs.insert("width".to_string(), w);
1664  }
1665  // Perl L484-485: "Mozilla seems to need some encouragement?"
1666  if CURRENT_STYLE.with(|s| s.get()) == MathStyle::Display {
1667    table_attrs.insert("displaystyle".to_string(), "true".to_string());
1668  }
1669
1670  NodeData::Element {
1671    tag:        "m:mtable".to_string(),
1672    attributes: if table_attrs.is_empty() {
1673      None
1674    } else {
1675      Some(table_attrs)
1676    },
1677    children:   rows,
1678  }
1679}
1680
1681// ======================================================================
1682// Layout helpers
1683
1684/// Simple sub/superscript.
1685fn pmml_script_simple(doc: &PostDocument, tag: &str, base: &Node, script: &Node) -> NodeData {
1686  NodeData::Element {
1687    tag:        tag.to_string(),
1688    attributes: None,
1689    children:   vec![pmml(doc, base), pmml_scriptsize(doc, script)],
1690  }
1691}
1692
1693/// Convert node at script size (sub/superscripts). Port of Perl `pmml_scriptsize`:
1694/// steps the style to scriptstyle (β†’ scriptscript when already in a script) for the
1695/// duration of the recursion, so contained tokens compare against the smaller size.
1696fn pmml_scriptsize(doc: &PostDocument, node: &Node) -> NodeData {
1697  let old = CURRENT_STYLE.with(|s| {
1698    let o = s.get();
1699    s.set(o.script_step());
1700    o
1701  });
1702  let r = pmml(doc, node);
1703  CURRENT_STYLE.with(|s| s.set(old));
1704  r
1705}
1706
1707/// Convert node at smaller size (fraction numerator/denominator). Port of Perl
1708/// `pmml_smaller`: steps the style down one level for the duration of the recursion.
1709fn pmml_smaller(doc: &PostDocument, node: &Node) -> NodeData {
1710  let old = CURRENT_STYLE.with(|s| {
1711    let o = s.get();
1712    s.set(o.step_down());
1713    o
1714  });
1715  let r = pmml(doc, node);
1716  CURRENT_STYLE.with(|s| s.set(old));
1717  r
1718}
1719
1720/// Infix operator: arg1 op arg2 op arg3 ...
1721///
1722/// Port of `pmml_infix`.
1723fn pmml_infix(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1724  // `args` is matched UNFILTERED β€” XMath's `absent` placeholders keep their
1725  // slot. They exist to satisfy the content-arm contract (every binary
1726  // application has 2 operands), and it is tempting to drop them so no empty box
1727  // reaches the output β€” but in Presentation MathML an operand slot is what
1728  // makes the operator INFIX.
1729  //
1730  // MathML infers an `<mo>`'s form from its position: first child of its
1731  // `<mrow>` β‡’ prefix, last β‡’ postfix, otherwise infix β€” and the form selects
1732  // the operator-dictionary spacing. Dropping the absent LHS of a continuation
1733  // row (`& = RHS` in an `align`, whose LHS is inherited from the row above)
1734  // makes `<mo>=</mo>` the first child, so renderers give it *prefix* spacing
1735  // and the `=` column stops lining up. That is issue #312 β€” reported against
1736  // 0.7.5-rc1 as "the alignment is all off around `=`", and visible in both
1737  // native MathML and MathJax.
1738  //
1739  // Keeping the slot costs nothing in accessibility, because `pmml_token`
1740  // renders an `absent` token as an EMPTY `<m:mphantom/>` β€” presentational
1741  // grouping with no semantic claim, zero-width and unannounced. That is a
1742  // strict improvement on Perl, which emits an empty `<m:mi/>` here
1743  // (`MathML.pm:1474` `DefMathML("Token:?:absent", …)`): same spacing, but
1744  // without asserting "here is an identifier" for content that has none.
1745  // (Task #264 proposed suppressing the placeholder; that is what regressed the
1746  // #312 spacing, so the item is closed in the other direction.)
1747  match args {
1748    [] => pmml(doc, op),
1749    // One operand is rendered PREFIX. Port of Perl `pmml_infix` L632-635:
1750    // "Infix with 1 arg is presumably Prefix! (aka Operator)" β€” genuine unary
1751    // operators (`-21`, `+x`). Perl renders the operator via
1752    // `pmml_mo($op, role => 'OPERATOR')` when `$op` is an `ltx:XMTok`, which
1753    // selects the operator dictionary's PREFIX entry (e.g. `βˆ’` gets lspace 0,
1754    // not the infix ADDOP's 0.278em). WITHOUT this, a unary minus after a
1755    // relation (`a = -b`) reaches the spacewalk with infix ADDOP spacing on the
1756    // `βˆ’` and role ADDOP; the walk then wants no TeX space (Relβ†’Bin = 0) yet
1757    // sees 0.556em of dictionary spacing and zeroes BOTH `=`.rspace and
1758    // `βˆ’`.lspace, collapsing the gap (issue #535). A non-token (embellished)
1759    // operator renders normally, exactly as Perl's ternary does.
1760    [arg] => {
1761      let op_prefix = if op.get_name() == "XMTok" {
1762        pmml_token_inner(doc, op, Some("OPERATOR"))
1763      } else {
1764        pmml(doc, op)
1765      };
1766      pmml_row(vec![op_prefix, pmml(doc, arg)])
1767    },
1768    // arg1 op arg2 op arg3 …
1769    [first, rest @ ..] => {
1770      let op_mml = pmml(doc, op);
1771      let mut items = vec![pmml(doc, first)];
1772      for arg in rest {
1773        items.push(op_mml.clone());
1774        items.push(pmml(doc, arg));
1775      }
1776      pmml_row(items)
1777    },
1778  }
1779}
1780
1781/// True iff `node` is the XMath placeholder for a structurally-absent
1782/// operand β€” an `<ltx:XMTok>` with `meaning="absent"`. The math
1783/// parser inserts these as the left operand for prefix-relop rules
1784/// (`Apply(=, absent, RHS)` for `& = ...` continuation rows) and as
1785/// the right operand for postfix-relop rules. Used by `pmml_infix`
1786/// to suppress materialization in Presentation MathML. Task #264.
1787fn is_absent_operand(node: &Node) -> bool {
1788  if node.get_name() != "XMTok" {
1789    return false;
1790  }
1791  node.get_attribute("meaning").as_deref() == Some("absent")
1792}
1793
1794/// Big operator with possible limits.
1795///
1796/// Port of `pmml_summation`.
1797fn pmml_summation(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1798  let op_mml = pmml(doc, op);
1799  // FUNCTION APPLICATION (⁑) only if the operator base is NOT an <m:mo> β€” Perl's
1800  // universal is_mo rule (MathML.pm Apply:?:?). Big operators βˆ‘/∫/⋃/∏/lim all
1801  // render as <m:mo> (incl. scripted forms like βˆ‘_i via munder), so they
1802  // juxtapose their body (βˆ‘a_i, ∫f) rather than emit βˆ‘β‘a_i β€” matching Perl.
1803  let needs_apply = !op_base_is_mo(&op_mml);
1804  let mut items = vec![op_mml];
1805  if needs_apply {
1806    items.push(pmml_mo_str("\u{2061}")); // FUNCTION APPLICATION
1807  }
1808  for arg in args {
1809    items.push(pmml(doc, arg));
1810  }
1811  pmml_row(items)
1812}
1813
1814/// Parenthesized/fenced expression.
1815///
1816/// Port of `pmml_parenthesize`.
1817fn pmml_parenthesize(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1818  let mut items = vec![pmml(doc, op)];
1819  for arg in args {
1820    items.push(pmml(doc, arg));
1821  }
1822  pmml_row(items)
1823}
1824
1825// ======================================================================
1826// Script handling
1827//
1828// Port of `pmml_script` + `pmml_script_decipher` + `pmml_script_multi_layout`.
1829// Handles complex sub/superscript positioning with pre/mid/post scripts.
1830
1831/// Script pair: (sub, sup) where either can be None.
1832type ScriptPair = (Option<Node>, Option<Node>);
1833
1834/// Full script handler: disentangles pre/mid/post scripts.
1835///
1836/// Port of `pmml_script`.
1837fn pmml_script_full(doc: &PostDocument, op: &Node, base: &Node, script: &Node) -> NodeData {
1838  let (inner_base, pre_scripts, mid_scripts, post_scripts, emb_right) =
1839    pmml_script_decipher(doc, op, base, script);
1840
1841  // Perl `pmml_script` (L876-891) + `pmml_script_mid_layout` (L899-906):
1842  // the inner base converts under ITS recorded mathstyle (blocking a nested
1843  // m:mstyle from the token/apply paths), and when that style differs from
1844  // the context the whole script layout gets one m:mstyle displaystyle wrap
1845  // β€” mstyle doesn't nest well inside scripts.
1846  let ostyle = CURRENT_STYLE.with(|s| s.get());
1847  let bstyle = inner_base
1848    .get_attribute("mathstyle")
1849    .as_deref()
1850    .and_then(MathStyle::from_attr);
1851  if let Some(b) = bstyle {
1852    CURRENT_STYLE.with(|s| s.set(b));
1853  }
1854  let base_mml = pmml(doc, &inner_base);
1855  CURRENT_STYLE.with(|s| s.set(ostyle));
1856
1857  // Apply mid scripts (under/over)
1858  let base_mml = apply_mid_scripts(doc, base_mml, &mid_scripts, emb_right.as_ref());
1859
1860  // Apply pre/post scripts
1861  let layout = apply_multi_scripts(doc, base_mml, &pre_scripts, &post_scripts);
1862  match bstyle {
1863    Some(b) if b != ostyle => NodeData::Element {
1864      tag:        "m:mstyle".to_string(),
1865      attributes: Some(HashMap::from_iter([(
1866        "displaystyle".to_string(),
1867        (if b == MathStyle::Display {
1868          "true"
1869        } else {
1870          "false"
1871        })
1872        .to_string(),
1873      )])),
1874      children:   vec![layout],
1875    },
1876    _ => layout,
1877  }
1878}
1879
1880/// Decipher nested script applications into pre/mid/post groups.
1881///
1882/// Port of `pmml_script_decipher`.
1883fn pmml_script_decipher(
1884  doc: &PostDocument,
1885  op: &Node,
1886  base: &Node,
1887  script: &Node,
1888) -> (
1889  Node,
1890  Vec<ScriptPair>,
1891  Vec<ScriptPair>,
1892  Vec<ScriptPair>,
1893  Option<Node>,
1894) {
1895  let mut pre_scripts: Vec<ScriptPair> = Vec::new();
1896  let mut mid_scripts: Vec<ScriptPair> = Vec::new();
1897  let mut post_scripts: Vec<ScriptPair> = Vec::new();
1898  // Perl's `$emb_right` β€” the base's RIGHT embellishment, used to phantom-pad
1899  // the under/over scripts (L968, L1015-1017). Perl also declares `$emb_left`
1900  // but NEVER assigns it, so that half of `pmml_scriptsize_padded` is dead code
1901  // upstream and is deliberately not represented here.
1902  let mut emb_right: Option<Node> = None;
1903  let mut saw_mid = false;
1904
1905  // Perl tracks the last level seen in each of the three groups, so that a
1906  // script at a DIFFERENT nesting level starts a new pair instead of filling the
1907  // free slot of the current one. Perl compares with `ne`, and the initials are
1908  // the number 0 β€” which stringifies to "0" and so matches a literal `post0`.
1909  let (mut pre_level, mut mid_level, mut post_level) =
1910    ("0".to_string(), "0".to_string(), "0".to_string());
1911
1912  let (pos, level) = parse_scriptpos(op);
1913  let is_sub = op.get_attribute("role").unwrap_or_default().contains("SUB");
1914
1915  // Place the first script.
1916  let pair = if is_sub {
1917    (Some(script.clone()), None)
1918  } else {
1919    (None, Some(script.clone()))
1920  };
1921  match pos {
1922    ScriptPos::Pre => {
1923      pre_scripts.push(pair);
1924      pre_level = level;
1925    },
1926    ScriptPos::Mid => {
1927      saw_mid = true;
1928      mid_scripts.push(pair);
1929      mid_level = level;
1930    },
1931    ScriptPos::Post => {
1932      post_scripts.push(pair);
1933      post_level = level;
1934    },
1935  }
1936
1937  // Walk down through nested scripts on the base.
1938  let mut current_base = base.clone();
1939  loop {
1940    // Perl `$base = realize($base, 'presentation')` β€” note it ASSIGNS, so the
1941    // base ultimately returned is the realized one, and the realization follows
1942    // XMDual as well as XMRef.
1943    let Some(realized) = doc.realize_xm_node_branch(&current_base, XMBranch::Presentation) else {
1944      break;
1945    };
1946    current_base = realized;
1947
1948    if !doc.is_qname(&current_base, "ltx:XMApp") {
1949      break;
1950    }
1951
1952    let children = element_children(&current_base);
1953    if children.len() < 3 {
1954      break;
1955    }
1956
1957    let xop = &children[0];
1958    if !doc.is_qname(xop, "ltx:XMTok") {
1959      break;
1960    }
1961
1962    let xrole = xop.get_attribute("role").unwrap_or_default();
1963    let is_script_op = xrole.contains("SUPERSCRIPTOP") || xrole.contains("SUBSCRIPTOP");
1964    if !is_script_op {
1965      break;
1966    }
1967
1968    let xbase = children[1].clone();
1969    let xscript = &children[2];
1970    let (xpos, xlevel) = parse_scriptpos(xop);
1971    let x_is_sub = xrole.contains("SUB");
1972
1973    match xpos {
1974      // Prescripts accumulate outward-in, so Perl appends (`push`) and inspects
1975      // the LAST pair; mid and post scripts accumulate inward-out, so it
1976      // prepends (`unshift`) and inspects the FIRST.
1977      ScriptPos::Pre => place_script(
1978        &mut pre_scripts,
1979        &mut pre_level,
1980        xlevel,
1981        x_is_sub,
1982        xscript.clone(),
1983        false,
1984      ),
1985      ScriptPos::Mid => {
1986        saw_mid = true;
1987        place_script(
1988          &mut mid_scripts,
1989          &mut mid_level,
1990          xlevel,
1991          x_is_sub,
1992          xscript.clone(),
1993          true,
1994        );
1995      },
1996      ScriptPos::Post => {
1997        // Perl L1015-1017: a POST script found BELOW a mid (under/over) script is
1998        // not a script of the outer construct at all β€” it is an embellishment of
1999        // the base, e.g. the prime in `\mathop{X'}\limits_{p}^{q}`. Record it for
2000        // phantom padding and STOP the walk WITHOUT descending, so `current_base`
2001        // stays the embellished `Apply(post-sup, X, ')` and renders whole. Without
2002        // this the prime is treated as an outer postscript, which inverts the
2003        // nesting (`msup` outside `munderover` instead of in) and leaves the limits
2004        // uncentred over the primed base.
2005        if saw_mid {
2006          emb_right = Some(xscript.clone());
2007          break;
2008        }
2009        place_script(
2010          &mut post_scripts,
2011          &mut post_level,
2012          xlevel,
2013          x_is_sub,
2014          xscript.clone(),
2015          true,
2016        );
2017      },
2018    }
2019
2020    current_base = xbase;
2021  }
2022
2023  (
2024    current_base,
2025    pre_scripts,
2026    mid_scripts,
2027    post_scripts,
2028    emb_right,
2029  )
2030}
2031
2032/// Where a script sits relative to its base β€” the keyword half of the
2033/// `scriptpos` attribute.
2034#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2035enum ScriptPos {
2036  /// Before the base, as an `m:mmultiscripts` prescript.
2037  Pre,
2038  /// Under/over the base, as `m:munder`/`m:mover`/`m:munderover`.
2039  Mid,
2040  /// After the base β€” the default, and the only one `m:msub`/`m:msup` express.
2041  Post,
2042}
2043
2044/// Split a `scriptpos` attribute into its position keyword and nesting level.
2045///
2046/// Port of Perl's
2047/// `($op->getAttribute('scriptpos') || 'post0') =~ /^(pre|mid|post)?(\d+)?$/`.
2048/// Both capture groups are optional, so a value with neither part (or one that
2049/// does not match at all, leaving both undef) yields no keyword β€” which Perl's
2050/// `$pos eq 'pre'` / `eq 'mid'` chain then falls through to post. The level is
2051/// compared with `ne` against the running level, never arithmetically, so it
2052/// stays a string here; a missing one is Perl's undef, i.e. `""`.
2053fn parse_scriptpos(op: &Node) -> (ScriptPos, String) {
2054  let raw = op
2055    .get_attribute("scriptpos")
2056    .unwrap_or_else(|| "post0".to_string());
2057  let (pos, rest) = if let Some(rest) = raw.strip_prefix("pre") {
2058    (ScriptPos::Pre, rest)
2059  } else if let Some(rest) = raw.strip_prefix("mid") {
2060    (ScriptPos::Mid, rest)
2061  } else if let Some(rest) = raw.strip_prefix("post") {
2062    (ScriptPos::Post, rest)
2063  } else {
2064    (ScriptPos::Post, raw.as_str())
2065  };
2066  // The regex is anchored, so a trailing remainder that is not all digits means
2067  // the whole match failed: no keyword and no level.
2068  if rest.is_empty() || rest.bytes().all(|b| b.is_ascii_digit()) {
2069    (pos, rest.to_string())
2070  } else {
2071    (ScriptPos::Post, String::new())
2072  }
2073}
2074
2075/// Add one script to a pre/mid/post group, at the given nesting level.
2076///
2077/// Port of the shared shape of Perl `pmml_script_decipher` L1005-1020:
2078/// ```text
2079/// push/unshift(@list, [undef, undef]) if ($level ne $nl) || $list[END][$spos];
2080/// $list[END][$spos] = $xscript; $level = $nl;
2081/// ```
2082/// A script starts a NEW pair when it would collide with the current pair's
2083/// occupied slot **or** when it sits at a different nesting level β€” the latter
2084/// being what keeps `{x_a}^b` a two-pair `m:mmultiscripts` (the `b` rides to the
2085/// right of the whole `x_a` box) rather than collapsing to an `m:msubsup` that
2086/// stacks the two.
2087///
2088/// `at_front` selects Perl's `unshift`/`$list[0]` (mid and post, which
2089/// accumulate inward-out) over `push`/`$list[-1]` (pre).
2090///
2091/// One deliberate divergence: on an empty list Perl's pre arm evaluates
2092/// `$pres[-1][$spos]` as undef and, if the levels happen to match, then dies
2093/// assigning to `$pres[-1]`. Creating the pair is the obvious reading of the
2094/// intent, and cannot differ from Perl anywhere Perl does not simply crash.
2095fn place_script(
2096  list: &mut Vec<ScriptPair>,
2097  level: &mut String,
2098  new_level: String,
2099  is_sub: bool,
2100  script: Node,
2101  at_front: bool,
2102) {
2103  let slot_taken = |p: &ScriptPair| if is_sub { p.0.is_some() } else { p.1.is_some() };
2104  let current = if at_front { list.first() } else { list.last() };
2105  if current.is_none_or(slot_taken) || *level != new_level {
2106    if at_front {
2107      list.insert(0, (None, None));
2108    } else {
2109      list.push((None, None));
2110    }
2111  }
2112  let pair = if at_front {
2113    list.first_mut()
2114  } else {
2115    list.last_mut()
2116  }
2117  .expect("a pair was just ensured to exist");
2118  if is_sub {
2119    pair.0 = Some(script);
2120  } else {
2121    pair.1 = Some(script);
2122  }
2123  *level = new_level;
2124}
2125
2126/// Apply mid scripts (under/over) to a base.
2127fn apply_mid_scripts(
2128  doc: &PostDocument,
2129  mut base: NodeData,
2130  mid_scripts: &[ScriptPair],
2131  emb_right: Option<&Node>,
2132) -> NodeData {
2133  for (sub_opt, sup_opt) in mid_scripts {
2134    let under = sub_opt
2135      .as_ref()
2136      .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2137    let over = sup_opt
2138      .as_ref()
2139      .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2140
2141    base = match (under, over) {
2142      (Some(u), None) => NodeData::Element {
2143        tag:        "m:munder".to_string(),
2144        attributes: None,
2145        children:   vec![base, u],
2146      },
2147      (None, Some(o)) => NodeData::Element {
2148        tag:        "m:mover".to_string(),
2149        attributes: None,
2150        children:   vec![base, o],
2151      },
2152      (Some(u), Some(o)) => NodeData::Element {
2153        tag:        "m:munderover".to_string(),
2154        attributes: None,
2155        children:   vec![base, u, o],
2156      },
2157      (None, None) => base,
2158    };
2159  }
2160  base
2161}
2162
2163/// An under/over script at scriptsize, padded by a phantom of the base's right
2164/// embellishment when there is one.
2165///
2166/// Port of `pmml_scriptsize_padded` (`MathML.pm` L925-934) β€” "This is to handle
2167/// primed sums, etc." An `\mathop{X'}\limits_{p}^{q}` centres its limits on the
2168/// whole `Xβ€²` box unless each limit is widened by an invisible copy of the `β€²`,
2169/// which shifts them back over the `X` itself.
2170///
2171/// Perl's `$emb_left` arm is NOT ported: `pmml_script_decipher` declares
2172/// `$emb_left` and never assigns it (L968 β†’ L1022), so the left phantom is
2173/// unreachable upstream. Only the right embellishment can occur, so this takes a
2174/// single `emb_right`.
2175fn pmml_scriptsize_padded(doc: &PostDocument, script: &Node, emb_right: Option<&Node>) -> NodeData {
2176  let script_mml = pmml_scriptsize(doc, script);
2177  match emb_right {
2178    None => script_mml,
2179    Some(emb) => NodeData::Element {
2180      tag:        "m:mrow".to_string(),
2181      attributes: None,
2182      children:   vec![script_mml, NodeData::Element {
2183        tag:        "m:mphantom".to_string(),
2184        attributes: None,
2185        children:   vec![pmml_scriptsize(doc, emb)],
2186      }],
2187    },
2188  }
2189}
2190
2191/// Apply pre/post scripts to a base.
2192///
2193/// Port of `pmml_script_multi_layout`.
2194fn apply_multi_scripts(
2195  doc: &PostDocument,
2196  base: NodeData,
2197  pre_scripts: &[ScriptPair],
2198  post_scripts: &[ScriptPair],
2199) -> NodeData {
2200  // An absent script slot is an empty `<m:mrow/>`, as Perl emits (`pmml_scriptsize`
2201  // of an undefined slot). MathML Core **removed** `<m:none/>`; an empty `m:mrow`
2202  // is the accepted placeholder for an omitted subtree, so this is both the
2203  // faithful and the standards-current choice.
2204  let none_mml = || NodeData::Element {
2205    tag:        "m:mrow".to_string(),
2206    attributes: None,
2207    children:   vec![],
2208  };
2209
2210  if !pre_scripts.is_empty() {
2211    // mmultiscripts with prescripts
2212    let mut children = vec![base];
2213    for (sub_opt, sup_opt) in post_scripts {
2214      children.push(
2215        sub_opt
2216          .as_ref()
2217          .map(|s| pmml_scriptsize(doc, s))
2218          .unwrap_or_else(none_mml),
2219      );
2220      children.push(
2221        sup_opt
2222          .as_ref()
2223          .map(|s| pmml_scriptsize(doc, s))
2224          .unwrap_or_else(none_mml),
2225      );
2226    }
2227    children.push(NodeData::Element {
2228      tag:        "m:mprescripts".to_string(),
2229      attributes: None,
2230      children:   vec![],
2231    });
2232    for (sub_opt, sup_opt) in pre_scripts {
2233      children.push(
2234        sub_opt
2235          .as_ref()
2236          .map(|s| pmml_scriptsize(doc, s))
2237          .unwrap_or_else(none_mml),
2238      );
2239      children.push(
2240        sup_opt
2241          .as_ref()
2242          .map(|s| pmml_scriptsize(doc, s))
2243          .unwrap_or_else(none_mml),
2244      );
2245    }
2246    NodeData::Element {
2247      tag: "m:mmultiscripts".to_string(),
2248      attributes: None,
2249      children,
2250    }
2251  } else if post_scripts.len() > 1 {
2252    // mmultiscripts with multiple postscripts
2253    let mut children = vec![base];
2254    for (sub_opt, sup_opt) in post_scripts {
2255      children.push(
2256        sub_opt
2257          .as_ref()
2258          .map(|s| pmml_scriptsize(doc, s))
2259          .unwrap_or_else(none_mml),
2260      );
2261      children.push(
2262        sup_opt
2263          .as_ref()
2264          .map(|s| pmml_scriptsize(doc, s))
2265          .unwrap_or_else(none_mml),
2266      );
2267    }
2268    NodeData::Element {
2269      tag: "m:mmultiscripts".to_string(),
2270      attributes: None,
2271      children,
2272    }
2273  } else if post_scripts.is_empty() {
2274    base
2275  } else {
2276    // Single post script pair
2277    let (sub_opt, sup_opt) = &post_scripts[0];
2278    match (sub_opt, sup_opt) {
2279      (Some(sub_node), None) => NodeData::Element {
2280        tag:        "m:msub".to_string(),
2281        attributes: None,
2282        children:   vec![base, pmml_scriptsize(doc, sub_node)],
2283      },
2284      (None, Some(sup_node)) => NodeData::Element {
2285        tag:        "m:msup".to_string(),
2286        attributes: None,
2287        children:   vec![base, pmml_scriptsize(doc, sup_node)],
2288      },
2289      (Some(sub_node), Some(sup_node)) => NodeData::Element {
2290        tag:        "m:msubsup".to_string(),
2291        attributes: None,
2292        children:   vec![
2293          base,
2294          pmml_scriptsize(doc, sub_node),
2295          pmml_scriptsize(doc, sup_node),
2296        ],
2297      },
2298      (None, None) => base,
2299    }
2300  }
2301}
2302
2303// ======================================================================
2304// Continued fractions
2305//
2306// Port of `do_cfrac`.
2307
2308/// Handle continued fraction rendering.
2309///
2310/// Port of `Apply:?:continued-fraction` + `do_cfrac`.
2311fn pmml_cfrac(doc: &PostDocument, op: &Node, numer: &Node, denom: &Node) -> NodeData {
2312  // Perl registration (L1954-1960): only the `cfrac-inline` variant unrolls
2313  // via do_cfrac; display cfrac is a plain (recursively converted) mfrac.
2314  if op.get_attribute("name").as_deref() == Some("cfrac-inline") {
2315    return pmml_row(do_cfrac(doc, numer, denom));
2316  }
2317  NodeData::Element {
2318    tag:        "m:mfrac".to_string(),
2319    attributes: None,
2320    children:   vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2321  }
2322}
2323
2324/// Port of Perl `do_cfrac` (L1930-1951): unroll an inline continued fraction β€”
2325/// when the denominator is a sum (or \cdots) its LAST summand is pulled up to
2326/// the top level (a trailing \cdots, a nested cfrac unrolled recursively, or
2327/// an invisible-times of \cdots and a factor), leaving the current fraction
2328/// with the trailing operator inside its denominator row.
2329fn do_cfrac(doc: &PostDocument, numer: &Node, denom: &Node) -> Vec<NodeData> {
2330  if doc.is_qname(denom, "ltx:XMApp") {
2331    let dchildren = element_children(denom);
2332    if dchildren.len() >= 2 {
2333      let denomop = &dchildren[0];
2334      let denomargs = &dchildren[1..];
2335      if denomop.get_attribute("role").as_deref() == Some("ADDOP")
2336        || denomop.get_content() == "\u{22EF}"
2337      {
2338        let (rest, last) = denomargs.split_at(denomargs.len() - 1);
2339        let last = &last[0];
2340        if !rest.is_empty() {
2341          let curr = NodeData::Element {
2342            tag:        "m:mfrac".to_string(),
2343            attributes: None,
2344            children:   vec![pmml_smaller(doc, numer), NodeData::Element {
2345              tag:        "m:mrow".to_string(),
2346              attributes: None,
2347              children:   vec![
2348                if rest.len() > 1 {
2349                  pmml_infix(doc, denomop, rest)
2350                } else {
2351                  pmml_smaller(doc, &rest[0])
2352                },
2353                pmml_smaller(doc, denomop),
2354              ],
2355            }],
2356          };
2357          if last.get_content() == "\u{22EF}" {
2358            // Denominator ends with \cdots: bring the dots up to toplevel.
2359            return vec![curr, pmml_smaller(doc, last)];
2360          } else if doc.is_qname(last, "ltx:XMApp") {
2361            let lchildren = element_children(last);
2362            if lchildren.len() >= 2 {
2363              let lastop = &lchildren[0];
2364              let lastargs = &lchildren[1..];
2365              if lastop.get_attribute("meaning").as_deref() == Some("continued-fraction")
2366                && lastargs.len() >= 2
2367              {
2368                // Denominator ends with a cfrac: unroll it to toplevel.
2369                let mut out = vec![curr];
2370                out.extend(do_cfrac(doc, &lastargs[0], &lastargs[1]));
2371                return out;
2372              } else if lastop.get_content() == "\u{2062}"
2373                && lastargs.len() == 2
2374                && lastargs[0].get_content() == "\u{22EF}"
2375              {
2376                // Denominator ends with (invisible-times of) \cdots Β· factor.
2377                return vec![
2378                  curr,
2379                  pmml_smaller(doc, &lastargs[0]),
2380                  pmml_smaller(doc, &lastargs[1]),
2381                ];
2382              }
2383            }
2384          }
2385        }
2386      }
2387    }
2388  }
2389  vec![NodeData::Element {
2390    tag:        "m:mfrac".to_string(),
2391    attributes: None,
2392    children:   vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2393  }]
2394}
2395
2396// ======================================================================
2397// Utility functions
2398
2399/// Wrap nodes in an mrow (or return single node unwrapped).
2400/// Perl MathML.pm `Apply:?:?`: descend through script wrappers (msub/msup/…) to
2401/// the operator's base and report whether it renders as `<m:mo>`. A generic
2402/// application inserts FUNCTION APPLICATION (⁑) only when the base is NOT an
2403/// `<m:mo>`, so an OPERATOR/DIFFOP (βˆ‡, βˆ‚) juxtaposes its argument while a
2404/// function identifier (`f`, `\sin`) gets the invisible apply char.
2405fn op_base_is_mo(node: &NodeData) -> bool {
2406  let mut cur = node;
2407  loop {
2408    let NodeData::Element { tag, children, .. } = cur else {
2409      return false;
2410    };
2411    if tag == "m:mo" {
2412      return true;
2413    }
2414    // Perl regex `^m:(?:msub|msup|munder|mover|mprescripts)` β€” a prefix match,
2415    // so it also covers msubsup/munderover; descend to the base (first child).
2416    // m:mstyle: the F7 mathstyle wrap (e.g. `\displaystyle\sum`) is transparent
2417    // embellishment too β€” Perl's summation never re-examines it (it never
2418    // emits ⁑ at all, L1796-1798).
2419    if matches!(
2420      tag.as_str(),
2421      "m:msub"
2422        | "m:msup"
2423        | "m:msubsup"
2424        | "m:munder"
2425        | "m:mover"
2426        | "m:munderover"
2427        | "m:mprescripts"
2428        | "m:mstyle"
2429    ) {
2430      match children.first() {
2431        Some(child) => cur = child,
2432        None => return false,
2433      }
2434    } else {
2435      return false;
2436    }
2437  }
2438}
2439
2440/// Port of Perl `filter_row` (L577-579): drop `_ignorable` items.
2441fn filter_row(items: Vec<NodeData>) -> Vec<NodeData> {
2442  items
2443    .into_iter()
2444    .filter(|i| {
2445      !matches!(i, NodeData::Element { attributes: Some(a), .. } if a.contains_key("_ignorable"))
2446    })
2447    .collect()
2448}
2449
2450fn pmml_row(children: Vec<NodeData>) -> NodeData {
2451  // Perl `pmml_row` (L581-584) filters `_ignorable` items (zero-width hints).
2452  let children = filter_row(children);
2453  if children.len() == 1 {
2454    children.into_iter().next().unwrap()
2455  } else {
2456    NodeData::Element {
2457      tag: "m:mrow".to_string(),
2458      attributes: None,
2459      children,
2460    }
2461  }
2462}
2463
2464/// Create an mo element from a string.
2465fn pmml_mo_str(text: &str) -> NodeData {
2466  NodeData::Element {
2467    tag:        "m:mo".to_string(),
2468    attributes: None,
2469    children:   vec![NodeData::Text(text.to_string())],
2470  }
2471}
2472
2473/// Create a MathML error element.
2474fn pmml_error(msg: &str) -> NodeData {
2475  NodeData::Element {
2476    tag:        "m:merror".to_string(),
2477    attributes: None,
2478    children:   vec![NodeData::Element {
2479      tag:        "m:mtext".to_string(),
2480      attributes: None,
2481      children:   vec![NodeData::Text(msg.to_string())],
2482    }],
2483  }
2484}
2485
2486/// Map a LaTeXML font name to a MathML mathvariant value.
2487///
2488/// Wrapper around `unicode::unicode_mathvariant` (full Perl parity).
2489/// Returns `Some(variant)` for recognized fonts, `None` only for empty input.
2490pub fn font_to_mathvariant(font: &str) -> Option<&'static str> {
2491  if font.is_empty() {
2492    return None;
2493  }
2494  Some(crate::unicode::unicode_mathvariant(font))
2495}
2496
2497// ======================================================================
2498// TeX spacing adjustment
2499//
2500// Port of Perl's `adjust_spacing` / `space_walk` / `adjust_pair`.
2501// Walks adjacent pairs in mrow and adjusts lspace/rspace to match TeX spacing.
2502
2503/// TeX spacing values: thin=3mu, med=4mu, thick=5mu (in em = mu/18)
2504const TEX_SPACING: [f64; 4] = [0.0, 0.167, 0.222, 0.2778];
2505
2506/// Spacing epsilon β€” ignore differences below this (em)
2507const SPACING_EPSILON: f64 = 0.01;
2508
2509/// Don't complain if we can't adjust less than this (em) β€” Perl `$fudge`.
2510const SPACING_FUDGE: f64 = 0.3;
2511
2512/// Map LaTeXML role to TeX atom type.
2513fn role_to_atom_type(role: &str) -> &'static str {
2514  match role {
2515    "ATOM" | "UNKNOWN" | "ID" | "NUMBER" | "POSTFIX" | "FUNCTION" | "DIFFOP" | "SUPOP"
2516    | "ELIDEOP" => "Ord",
2517    "OPFUNCTION" | "TRIGFUNCTION" | "BIGOP" | "SUMOP" | "INTOP" | "LIMITOP" | "OPERATOR" => "Op",
2518    "ADDOP" | "MULOP" | "BINOP" | "APPLYOP" | "COMPOSEOP" => "Bin",
2519    "RELOP" | "METARELOP" | "MODIFIEROP" | "MODIFIER" | "ARROW" => "Rel",
2520    "OPEN" => "Open",
2521    "CLOSE" => "Close",
2522    "PUNCT" | "VERTBAR" | "PERIOD" => "Punct",
2523    "ARRAY" | "POSTSUBSCRIPT" | "POSTSUPERSCRIPT" | "FLOATSUPERSCRIPT" | "FLOATSUBSCRIPT" => {
2524      "Inner"
2525    },
2526    "MIDDLE" => "Ord",
2527    _ => "Ord",
2528  }
2529}
2530
2531/// Get TeX spacing code for a pair of atom types.
2532fn atompair_spacing(left: &str, right: &str) -> i32 {
2533  match (left, right) {
2534    ("Ord", "Op") | ("Op", "Ord") | ("Op", "Op") | ("Close", "Op") => 1,
2535    ("Ord", "Bin")
2536    | ("Bin", "Ord")
2537    | ("Bin", "Open")
2538    | ("Bin", "Inner")
2539    | ("Close", "Bin")
2540    | ("Inner", "Bin")
2541    | ("Bin", "Op") => -2,
2542    ("Ord", "Rel")
2543    | ("Rel", "Ord")
2544    | ("Op", "Rel")
2545    | ("Rel", "Open")
2546    | ("Rel", "Inner")
2547    | ("Close", "Rel")
2548    | ("Inner", "Rel")
2549    | ("Rel", "Op") => -3,
2550    ("Ord", "Inner")
2551    | ("Op", "Inner")
2552    | ("Close", "Inner")
2553    | ("Inner", "Inner")
2554    | ("Inner", "Open")
2555    | ("Punct", "Ord")
2556    | ("Punct", "Op")
2557    | ("Punct", "Rel")
2558    | ("Punct", "Open")
2559    | ("Punct", "Close")
2560    | ("Punct", "Punct")
2561    | ("Punct", "Inner")
2562    | ("Inner", "Ord")
2563    | ("Inner", "Punct") => -1,
2564    ("Inner", "Op") => 1,
2565    _ => 0,
2566  }
2567}
2568
2569/// Map MathML tag to TeX atom type.
2570fn m_atom_type(tag: &str) -> Option<&'static str> {
2571  match tag {
2572    "m:mfrac" => Some("Ord"),
2573    "m:marray" => Some("Inner"),
2574    "m:mspace" => Some("Ord"),
2575    _ => None,
2576  }
2577}
2578
2579/// Check if a MathML tag is an embellished operator container.
2580fn is_embellisher_tag(tag: &str) -> bool {
2581  matches!(
2582    tag,
2583    "m:msub" | "m:msup" | "m:msubsup" | "m:munder" | "m:mover" | "m:munderover"
2584  )
2585}
2586
2587/// Check if a MathML tag is mrow-like.
2588fn is_mrow_like(tag: &str) -> bool {
2589  matches!(
2590    tag,
2591    "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd"
2592  )
2593}
2594
2595/// Check if text is an invisible operator.
2596fn is_invisible_op(text: &str) -> bool {
2597  !text.is_empty()
2598    && text
2599      .chars()
2600      .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2601}
2602
2603/// Format em value. Port of Perl `fmt_em` (MathML.pm L1285):
2604/// `sprintf("%.3fem")` β€” trailing zeros are KEPT ("0.330em", "1.200em"),
2605/// matching Perl byte-for-byte; zero (Perl false-y) β†’ "0em". (audit F4)
2606fn fmt_em(val: f64) -> String {
2607  if val == 0.0 {
2608    "0em".to_string()
2609  } else {
2610    format!("{val:.3}em")
2611  }
2612}
2613
2614/// Get role from a NodeData, following embellished operators.
2615fn get_node_role(node: &NodeData) -> String {
2616  match node {
2617    NodeData::Element { tag, attributes, children } => {
2618      if is_embellisher_tag(tag) {
2619        if let Some(base) = children.first() {
2620          return get_node_role(base);
2621        }
2622      }
2623      attributes
2624        .as_ref()
2625        .and_then(|a| a.get("_role"))
2626        .cloned()
2627        .unwrap_or_default()
2628    },
2629    _ => String::new(),
2630  }
2631}
2632
2633fn get_node_tag(node: &NodeData) -> &str {
2634  match node {
2635    NodeData::Element { tag, .. } => tag,
2636    _ => "",
2637  }
2638}
2639
2640fn get_node_text(node: &NodeData) -> String {
2641  match node {
2642    NodeData::Text(t) => t.clone(),
2643    NodeData::Element { children, .. } => children.iter().map(get_node_text).collect(),
2644    _ => String::new(),
2645  }
2646}
2647
2648/// Check if all text in `node` consists only of invisible-op characters,
2649/// without allocating a concatenated String like `get_node_text` does.
2650/// Used by `adjust_spacing` where we only need the boolean answer.
2651fn is_node_text_invisible_op(node: &NodeData) -> bool {
2652  fn check(node: &NodeData, seen_any: &mut bool) -> bool {
2653    match node {
2654      NodeData::Text(t) => {
2655        if !t.is_empty() {
2656          *seen_any = true;
2657        }
2658        t.chars()
2659          .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2660      },
2661      NodeData::Element { children, .. } => children.iter().all(|c| check(c, seen_any)),
2662      _ => true,
2663    }
2664  }
2665  let mut seen_any = false;
2666  let ok = check(node, &mut seen_any);
2667  ok && seen_any
2668}
2669
2670fn set_node_attr(node: &mut NodeData, key: &str, value: &str) {
2671  if let NodeData::Element { attributes, .. } = node {
2672    let attrs = attributes.get_or_insert_with(HashMap::default);
2673    attrs.insert(key.to_string(), value.to_string());
2674  }
2675}
2676
2677fn get_node_attr(node: &NodeData, key: &str) -> Option<String> {
2678  match node {
2679    NodeData::Element { attributes, .. } => attributes.as_ref().and_then(|a| a.get(key)).cloned(),
2680    _ => None,
2681  }
2682}
2683
2684fn get_node_attr_f64(node: &NodeData, key: &str) -> f64 {
2685  match node {
2686    NodeData::Element { attributes, .. } => attributes
2687      .as_ref()
2688      .and_then(|a| a.get(key))
2689      .and_then(|v| v.strip_suffix("em"))
2690      .and_then(|v| v.parse::<f64>().ok())
2691      .unwrap_or(0.0),
2692    _ => 0.0,
2693  }
2694}
2695
2696/// Perl `%tag_arg_pattern` (MathML.pm L1084-1088): how a tag participates in
2697/// the spacing walk.
2698#[derive(PartialEq, Clone, Copy)]
2699enum WalkType {
2700  Atom,
2701  Mrow,
2702  Other,
2703}
2704fn walk_type(tag: &str) -> WalkType {
2705  match tag {
2706    "m:mi" | "m:mo" | "m:mn" | "m:ms" | "m:mtext" => WalkType::Atom,
2707    "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd" => {
2708      WalkType::Mrow
2709    },
2710    _ => WalkType::Other,
2711  }
2712}
2713
2714/// Resolve a child-index path from the walk root. The walk mutates only
2715/// attributes and rewraps nodes in place (sibling indices stay stable), so
2716/// queued paths never dangle.
2717fn node_at<'a>(root: &'a NodeData, path: &[usize]) -> &'a NodeData {
2718  let mut cur = root;
2719  for &i in path {
2720    match cur {
2721      NodeData::Element { children, .. } => cur = &children[i],
2722      _ => unreachable!("spacewalk path into non-element"),
2723    }
2724  }
2725  cur
2726}
2727
2728fn node_at_mut<'a>(root: &'a mut NodeData, path: &[usize]) -> &'a mut NodeData {
2729  let mut cur = root;
2730  for &i in path {
2731    match cur {
2732      NodeData::Element { children, .. } => cur = &mut children[i],
2733      _ => unreachable!("spacewalk path into non-element"),
2734    }
2735  }
2736  cur
2737}
2738
2739fn child_path(path: &[usize], i: usize) -> Vec<usize> {
2740  let mut p = path.to_vec();
2741  p.push(i);
2742  p
2743}
2744
2745/// Descend through embellished operators (scripts) to the inner operator,
2746/// for role/opdict-spacing reads (Perl adjust_pair L1225-1227).
2747fn descend_embellishers(root: &NodeData, mut path: Vec<usize>) -> Vec<usize> {
2748  loop {
2749    match node_at(root, &path) {
2750      NodeData::Element { tag, children, .. }
2751        if is_embellisher_tag(tag) && !children.is_empty() =>
2752      {
2753        path.push(0);
2754      },
2755      _ => return path,
2756    }
2757  }
2758}
2759
2760/// Walk the MathML tree and adjust spacing. Port of Perl `adjust_spacing` /
2761/// `space_walk` (MathML.pm L1079-1133): resolves the difference between
2762/// TeX's inter-atom spacing and MathML's operator-dictionary spacing.
2763pub fn adjust_spacing(node: &mut NodeData) { space_walk(node, Vec::new()); }
2764
2765/// Port of Perl `space_walk`: pairs VISUALLY adjacent items by unwinding
2766/// nested mrows into the pair stream, streaming script bases (TeX attaches
2767/// scripts without affecting inter-atom spacing) while recursing on the
2768/// scripts themselves, and carrying invisible operators between a pair as
2769/// the preferred place to materialize an adjustment.
2770fn space_walk(root: &mut NodeData, path: Vec<usize>) {
2771  use std::collections::VecDeque;
2772  let (wt, nch) = match node_at(root, &path) {
2773    NodeData::Element { tag, children, .. } => (walk_type(tag), children.len()),
2774    _ => return,
2775  };
2776  match wt {
2777    WalkType::Atom => {},
2778    WalkType::Other => {
2779      for i in 0..nch {
2780        space_walk(root, child_path(&path, i));
2781      }
2782    },
2783    WalkType::Mrow => {
2784      let mut queue: VecDeque<Vec<usize>> = (0..nch).map(|i| child_path(&path, i)).collect();
2785      // First prev: unwrap leading nested mrows (Perl L1105-1108).
2786      let mut first = None;
2787      while let Some(p) = queue.pop_front() {
2788        let unwrap = match node_at(root, &p) {
2789          NodeData::Element { tag, children, .. } if tag == "m:mrow" => Some(children.len()),
2790          _ => None,
2791        };
2792        match unwrap {
2793          Some(n) => {
2794            for i in (0..n).rev() {
2795              queue.push_front(child_path(&p, i));
2796            }
2797          },
2798          None => {
2799            first = Some(p);
2800            break;
2801          },
2802        }
2803      }
2804      let Some(mut prev) = first else { return };
2805      space_walk(root, prev.clone());
2806      while let Some(popped) = queue.pop_front() {
2807        let mut next = popped;
2808        // Save an invisible operator as the potential target for lspace
2809        // (Perl L1111-1114).
2810        let mut invisop: Option<Vec<usize>> = None;
2811        {
2812          let n = node_at(root, &next);
2813          if get_node_tag(n) == "m:mo" && is_node_text_invisible_op(n) {
2814            invisop = Some(next);
2815            match queue.pop_front() {
2816              Some(p) => next = p,
2817              None => break,
2818            }
2819          }
2820        }
2821        enum Kind {
2822          Mrow(usize),
2823          Script(usize),
2824          Plain,
2825        }
2826        let kind = match node_at(root, &next) {
2827          NodeData::Element { tag, children, .. } => {
2828            if tag == "m:mrow" {
2829              Kind::Mrow(children.len())
2830            } else if !children.is_empty()
2831              && (tag.starts_with("m:msup")
2832                || tag.starts_with("m:msub")
2833                || tag.starts_with("m:munder")
2834                || tag.starts_with("m:mover")
2835                || tag.starts_with("m:mmultiscripts"))
2836            {
2837              // Prefix match like Perl's regex β€” covers msubsup/munderover.
2838              // A CHILDLESS script element (malformed input) is treated as
2839              // Plain β€” streaming its base would index children[0] (Perl's
2840              // undef-shift exits silently).
2841              Kind::Script(children.len())
2842            } else {
2843              Kind::Plain
2844            }
2845          },
2846          _ => Kind::Plain,
2847        };
2848        match kind {
2849          Kind::Mrow(n) => {
2850            // Unwrap into the stream; the invisible op goes back in front.
2851            for i in (0..n).rev() {
2852              queue.push_front(child_path(&next, i));
2853            }
2854            if let Some(iv) = invisop {
2855              queue.push_front(iv);
2856            }
2857            continue;
2858          },
2859          Kind::Script(n) => {
2860            // Stream the base; recurse on the scripts (Perl L1121-1128).
2861            for i in 1..n {
2862              space_walk(root, child_path(&next, i));
2863            }
2864            queue.push_front(child_path(&next, 0));
2865            if let Some(iv) = invisop {
2866              queue.push_front(iv);
2867            }
2868            continue;
2869          },
2870          Kind::Plain => {},
2871        }
2872        space_walk(root, next.clone());
2873        adjust_pair(root, &prev, &next, invisop.as_deref());
2874        prev = next;
2875      }
2876    },
2877  }
2878}
2879
2880/// Adjust the spacing between a visually adjacent pair. Port of Perl
2881/// `adjust_pair` (MathML.pm L1220-1284), all branches.
2882fn adjust_pair(root: &mut NodeData, prev: &[usize], next: &[usize], invisop: Option<&[usize]>) {
2883  let iprev = descend_embellishers(root, prev.to_vec());
2884  let inext = descend_embellishers(root, next.to_vec());
2885
2886  // Author spacing (in em) reads from the OUTER pair; role/opdict spacing
2887  // from the inner (possibly embellished) operator.
2888  let prev_req_right = get_node_attr_f64(node_at(root, prev), "_rpadding");
2889  let next_req_left = get_node_attr_f64(node_at(root, next), "_lpadding");
2890  let (iprev_tag, prev_role, prev_dict_right) = {
2891    let n = node_at(root, &iprev);
2892    (
2893      get_node_tag(n).to_string(),
2894      get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2895      get_node_attr_f64(n, "_rspace"),
2896    )
2897  };
2898  let (inext_tag, next_role, next_dict_left) = {
2899    let n = node_at(root, &inext);
2900    (
2901      get_node_tag(n).to_string(),
2902      get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2903      get_node_attr_f64(n, "_lspace"),
2904    )
2905  };
2906  let prev_type = m_atom_type(&iprev_tag).unwrap_or_else(|| role_to_atom_type(&prev_role));
2907  let next_type = m_atom_type(&inext_tag).unwrap_or_else(|| role_to_atom_type(&next_role));
2908  let tex_code = atompair_spacing(prev_type, next_type);
2909  let tex_space = TEX_SPACING[tex_code.unsigned_abs() as usize];
2910  let target = prev_req_right + next_req_left + tex_space;
2911  let default = prev_dict_right + next_dict_left;
2912  if (target - default).abs() <= SPACING_EPSILON {
2913    return;
2914  }
2915
2916  let prev_tag = get_node_tag(node_at(root, prev)).to_string();
2917  let next_tag = get_node_tag(node_at(root, next)).to_string();
2918  // In MathML Core neither mspace nor mpadded may have negative width, and
2919  // relative +/- widths are unsupported β€” so a NEGATIVE target rewraps prev
2920  // in an m:mpadded with an ADJUSTED absolute width (Perl L1252-1260,
2921  // compute_size L1135-1145: atoms only, string metrics of the default math
2922  // font, with the ridiculous-but-Perl minimum-10pt hack for mathscript).
2923  if target < 0.0 {
2924    let sizeable = match node_at(root, prev) {
2925      NodeData::Element { tag, attributes, .. } if walk_type(tag) == WalkType::Atom => Some(
2926        attributes
2927          .as_ref()
2928          .and_then(|a| a.get("class"))
2929          .cloned()
2930          .unwrap_or_default(),
2931      ),
2932      _ => None,
2933    };
2934    if let Some(class) = sizeable {
2935      let text = get_node_text(node_at(root, prev));
2936      let font = latexml_core::common::font::Font::math_default();
2937      let (w, _h, _d) = font.compute_string_size(&text, Default::default());
2938      let mut w_sp = w.0;
2939      // Perl L1140-1141: minimum of 10pt for mathscript β€” Dimension(10*65535)
2940      // (Perl's constant, one sp shy of 10pt), applied regardless of width
2941      // (Perl's $w is an always-truthy Dimension object).
2942      if class.contains("mathscript") {
2943        w_sp = w_sp.max(10 * 65535);
2944      }
2945      let mut reqw = (w_sp as f64 / 65536.0) / 10.0 + target;
2946      if reqw < 0.0 {
2947        reqw = 0.0;
2948      }
2949      let slot = node_at_mut(root, prev);
2950      let old = std::mem::replace(slot, NodeData::Text(String::new()));
2951      *slot = NodeData::Element {
2952        tag:        "m:mpadded".to_string(),
2953        attributes: Some(HashMap::from_iter([("width".to_string(), fmt_em(reqw))])),
2954        children:   vec![old],
2955      };
2956    }
2957  } else if prev_tag == "m:mspace" || next_tag == "m:mspace" {
2958    // Merge into the mspace's existing width (Perl L1261-1262).
2959    let target_path = if prev_tag == "m:mspace" { prev } else { next };
2960    let n = node_at_mut(root, target_path);
2961    let old_w = match n {
2962      NodeData::Element { attributes, .. } => attributes
2963        .as_ref()
2964        .and_then(|a| a.get("width"))
2965        .map(|w| super::get_xm_hint_spacing(w))
2966        .unwrap_or(0.0),
2967      _ => 0.0,
2968    };
2969    set_node_attr(n, "width", &fmt_em(target + old_w));
2970  } else if let Some(iv) = invisop {
2971    set_node_attr(node_at_mut(root, iv), "lspace", &fmt_em(target));
2972  } else if prev_tag == "m:mo" && next_tag == "m:mo" {
2973    // BOTH are mo: account for each one's dictionary spacing (Perl L1264-1275).
2974    let p = prev_dict_right;
2975    let n = next_dict_left;
2976    let rem = target - n;
2977    if rem >= 0.0 {
2978      let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
2979      set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(v));
2980    } else {
2981      let rem = target - p;
2982      if rem >= 0.0 {
2983        let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
2984        set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(v));
2985      } else {
2986        // Split the difference; Perl concatenates the raw number here
2987        // (`$rem . 'em'`), NOT fmt_em.
2988        let rem = target / 2.0;
2989        if rem != p {
2990          set_node_attr(
2991            node_at_mut(root, prev),
2992            "rspace",
2993            &format!("{}em", perl_num(rem)),
2994          );
2995        }
2996        if rem != n {
2997          set_node_attr(
2998            node_at_mut(root, next),
2999            "lspace",
3000            &format!("{}em", perl_num(rem)),
3001          );
3002        }
3003      }
3004    }
3005  } else if prev_tag == "m:mo" {
3006    set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(target));
3007  } else if next_tag == "m:mo" {
3008    set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(target));
3009  } else if (target - default).abs() > SPACING_FUDGE {
3010    Info!(
3011      "ignored",
3012      "spacing",
3013      "No place to set spacing to {target} (default {default})"
3014    );
3015  }
3016}
3017
3018/// Clean up internal _role/_lspace/_rspace attributes before serialization.
3019pub fn clean_internal_attrs(node: &mut NodeData) {
3020  if let NodeData::Element { attributes, children, .. } = node {
3021    if let Some(attrs) = attributes {
3022      attrs.remove("_role");
3023      attrs.remove("_lspace");
3024      attrs.remove("_rspace");
3025      attrs.remove("_largeop");
3026      attrs.remove("_lpadding");
3027      attrs.remove("_rpadding");
3028      attrs.remove("_ignorable");
3029      if attrs.is_empty() {
3030        *attributes = None;
3031      }
3032    }
3033    for child in children {
3034      clean_internal_attrs(child);
3035    }
3036  }
3037}
3038
3039#[cfg(test)]
3040mod tests {
3041  use rustc_hash::FxHashMap as HashMap;
3042
3043  use super::*;
3044
3045  #[test]
3046  fn math_style_step_down_monotone_saturates_at_scriptscript() {
3047    assert_eq!(MathStyle::Display.step_down(), MathStyle::Text);
3048    assert_eq!(MathStyle::Text.step_down(), MathStyle::Script);
3049    assert_eq!(MathStyle::Script.step_down(), MathStyle::ScriptScript);
3050    assert_eq!(MathStyle::ScriptScript.step_down(), MathStyle::ScriptScript);
3051  }
3052
3053  #[test]
3054  fn math_style_script_step_collapses_display_and_text() {
3055    assert_eq!(MathStyle::Display.script_step(), MathStyle::Script);
3056    assert_eq!(MathStyle::Text.script_step(), MathStyle::Script);
3057    assert_eq!(MathStyle::Script.script_step(), MathStyle::ScriptScript);
3058    assert_eq!(
3059      MathStyle::ScriptScript.script_step(),
3060      MathStyle::ScriptScript
3061    );
3062  }
3063
3064  #[test]
3065  fn math_style_size_percent_matches_tex_tradition() {
3066    assert_eq!(MathStyle::Display.size_percent(), "100%");
3067    assert_eq!(MathStyle::Text.size_percent(), "100%");
3068    assert_eq!(MathStyle::Script.size_percent(), "70%");
3069    assert_eq!(MathStyle::ScriptScript.size_percent(), "50%");
3070  }
3071
3072  #[test]
3073  fn invisible_times_roundtrip() {
3074    set_invisible_times(false);
3075    assert!(!get_invisible_times());
3076    set_invisible_times(true);
3077    assert!(get_invisible_times());
3078  }
3079
3080  #[test]
3081  fn embellishing_role_matches_canonical_set() {
3082    for r in [
3083      "SUPERSCRIPTOP",
3084      "SUBSCRIPTOP",
3085      "OVERACCENT",
3086      "UNDERACCENT",
3087      "MODIFIER",
3088      "MODIFIEROP",
3089    ] {
3090      assert!(is_embellishing_role(r), "{} should embellish", r);
3091    }
3092  }
3093
3094  #[test]
3095  fn embellishing_role_rejects_others() {
3096    for r in ["ADDOP", "MULOP", "ATOM", "UNKNOWN", ""] {
3097      assert!(!is_embellishing_role(r), "{} should not embellish", r);
3098    }
3099  }
3100
3101  #[test]
3102  fn default_token_content_maps_invisible_chars() {
3103    assert_eq!(default_token_content("MULOP"), Some("\u{2062}"));
3104    assert_eq!(default_token_content("ADDOP"), Some("\u{2064}"));
3105    assert_eq!(default_token_content("PUNCT"), Some("\u{2063}"));
3106  }
3107
3108  #[test]
3109  fn default_token_content_none_for_other_roles() {
3110    assert_eq!(default_token_content("ATOM"), None);
3111    assert_eq!(default_token_content(""), None);
3112    assert_eq!(default_token_content("RELOP"), None);
3113  }
3114
3115  #[test]
3116  fn clean_internal_attrs_removes_underscore_attrs() {
3117    let mut node = NodeData::Element {
3118      tag:        "mrow".to_string(),
3119      attributes: Some(HashMap::from_iter([
3120        ("_role".to_string(), "MULOP".to_string()),
3121        ("_lspace".to_string(), "4".to_string()),
3122        ("keep".to_string(), "yes".to_string()),
3123      ])),
3124      children:   vec![],
3125    };
3126    clean_internal_attrs(&mut node);
3127    if let NodeData::Element { attributes, .. } = &node {
3128      let attrs = attributes
3129        .as_ref()
3130        .expect("still has the non-internal attr");
3131      assert_eq!(attrs.len(), 1);
3132      assert_eq!(attrs.get("keep").map(String::as_str), Some("yes"));
3133    } else {
3134      panic!("expected element");
3135    }
3136  }
3137
3138  #[test]
3139  fn clean_internal_attrs_unsets_attributes_when_empty() {
3140    let mut node = NodeData::Element {
3141      tag:        "mrow".to_string(),
3142      attributes: Some(HashMap::from_iter([
3143        ("_role".to_string(), "MULOP".to_string()),
3144        ("_largeop".to_string(), "true".to_string()),
3145      ])),
3146      children:   vec![],
3147    };
3148    clean_internal_attrs(&mut node);
3149    if let NodeData::Element { attributes, .. } = &node {
3150      // All attrs were internal β†’ attributes becomes None.
3151      assert!(attributes.is_none());
3152    } else {
3153      panic!("expected element");
3154    }
3155  }
3156
3157  #[test]
3158  fn clean_internal_attrs_recurses_into_children() {
3159    let mut node = NodeData::Element {
3160      tag:        "mrow".to_string(),
3161      attributes: None,
3162      children:   vec![NodeData::Element {
3163        tag:        "mi".to_string(),
3164        attributes: Some(HashMap::from_iter([(
3165          "_rspace".to_string(),
3166          "1".to_string(),
3167        )])),
3168        children:   vec![],
3169      }],
3170    };
3171    clean_internal_attrs(&mut node);
3172    if let NodeData::Element { children, .. } = &node {
3173      if let NodeData::Element { attributes, .. } = &children[0] {
3174        assert!(attributes.is_none(), "recursion cleared child's only attr");
3175      } else {
3176        panic!("expected element child");
3177      }
3178    } else {
3179      panic!("expected element root");
3180    }
3181  }
3182
3183  #[test]
3184  fn clean_internal_attrs_ignores_text_nodes() {
3185    let mut node = NodeData::Text("x".to_string());
3186    clean_internal_attrs(&mut node);
3187    match &node {
3188      NodeData::Text(s) => assert_eq!(s, "x"),
3189      _ => panic!("expected text untouched"),
3190    }
3191  }
3192  #[test]
3193  fn test_role_to_atom_type() {
3194    // Perl MathML.pm $role_atomtype (L1150)
3195    assert_eq!(role_to_atom_type("ID"), "Ord");
3196    assert_eq!(role_to_atom_type("NUMBER"), "Ord");
3197    assert_eq!(role_to_atom_type("ADDOP"), "Bin");
3198    assert_eq!(role_to_atom_type("RELOP"), "Rel");
3199    assert_eq!(role_to_atom_type("OPEN"), "Open");
3200    assert_eq!(role_to_atom_type("CLOSE"), "Close");
3201    assert_eq!(role_to_atom_type("SUMOP"), "Op");
3202    assert_eq!(role_to_atom_type("PUNCT"), "Punct");
3203    assert_eq!(role_to_atom_type("ARRAY"), "Inner");
3204    assert_eq!(role_to_atom_type("no-such-role"), "Ord");
3205  }
3206
3207  #[test]
3208  fn test_atompair_spacing() {
3209    // Perl MathML.pm $atompair_spacing (L1196): negative = display/text-style only
3210    assert_eq!(atompair_spacing("Ord", "Op"), 1);
3211    assert_eq!(atompair_spacing("Ord", "Bin"), -2);
3212    assert_eq!(atompair_spacing("Rel", "Ord"), -3);
3213    assert_eq!(atompair_spacing("Open", "Ord"), 0);
3214    assert_eq!(atompair_spacing("Open", "Open"), 0);
3215    assert_eq!(atompair_spacing("Punct", "Bin"), 0);
3216    // The full Inner row (Perl L1207) β€” the (Inner, Punct) cell was MISSING
3217    // until 2026-07-02 (PR_READINESS review): matrix-then-comma lost its
3218    // thin space.
3219    assert_eq!(atompair_spacing("Inner", "Ord"), -1);
3220    assert_eq!(atompair_spacing("Inner", "Op"), 1);
3221    assert_eq!(atompair_spacing("Inner", "Bin"), -2);
3222    assert_eq!(atompair_spacing("Inner", "Rel"), -3);
3223    assert_eq!(atompair_spacing("Inner", "Open"), -1);
3224    assert_eq!(atompair_spacing("Inner", "Close"), 0);
3225    assert_eq!(atompair_spacing("Inner", "Punct"), -1);
3226    assert_eq!(atompair_spacing("Inner", "Inner"), -1);
3227  }
3228
3229  #[test]
3230  fn test_fmt_em() {
3231    // Perl fmt_em (L1285) byte-parity: %.3f keeps trailing zeros (audit F4).
3232    assert_eq!(fmt_em(0.0), "0em");
3233    assert_eq!(fmt_em(1.0), "1.000em");
3234    assert_eq!(fmt_em(0.167), "0.167em");
3235    assert_eq!(fmt_em(0.33), "0.330em");
3236    assert_eq!(fmt_em(1.2), "1.200em");
3237  }
3238}