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);
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      // Perl MathML.pm L456-473: fold `border` β†’ `ltx_border_*` and `thead` β†’
1613      // `ltx_th_*` (each attribute is space-separated values) together with any
1614      // explicit `class` into the `m:mtd` `class`. This is how a column rule
1615      // (`|` / `\hline` / array `!{|}`, dginev#740) and colortbl rules reach the
1616      // rendered table β€” the CSS paints `ltx_border_*`. Perl assembles the cell
1617      // from a hash literal whose LATER `class` key wins, so when a cell has BOTH
1618      // a non-center align AND a border/thead/class the border/thead/class value
1619      // REPLACES the `ltx_align_*` class set just above (the `columnalign`
1620      // attribute stays). We mirror that by overwriting the `class` key here.
1621      let bc = cell_node.get_attribute("border").and_then(|b| {
1622        let s = b
1623          .split_whitespace()
1624          .map(|p| format!("ltx_border_{p}"))
1625          .collect::<Vec<_>>()
1626          .join(" ");
1627        (!s.is_empty()).then_some(s)
1628      });
1629      let hc = cell_node.get_attribute("thead").and_then(|t| {
1630        let s = t
1631          .split_whitespace()
1632          .map(|p| format!("ltx_th_{p}"))
1633          .collect::<Vec<_>>()
1634          .join(" ");
1635        (!s.is_empty()).then_some(s)
1636      });
1637      // Perl `$c = ($bc ? ($hc ? "$bc $hc" : $bc) : $hc)`.
1638      let border_thead = match (bc, hc) {
1639        (Some(bc), Some(hc)) => Some(format!("{bc} {hc}")),
1640        (Some(bc), None) => Some(bc),
1641        (None, Some(hc)) => Some(hc),
1642        (None, None) => None,
1643      };
1644      let cl = cell_node.get_attribute("class").filter(|s| !s.is_empty());
1645      // Perl `($c || $cl ? (class => ($c && $cl ? "$c $cl" : $c || $cl)) : ())`.
1646      if let Some(class) = match (border_thead, cl) {
1647        (Some(c), Some(cl)) => Some(format!("{c} {cl}")),
1648        (Some(c), None) => Some(c),
1649        (None, Some(cl)) => Some(cl),
1650        (None, None) => None,
1651      } {
1652        td_attrs.insert("class".to_string(), class);
1653      }
1654      if let Some(cs) = colspan {
1655        td_attrs.insert("columnspan".to_string(), cs);
1656      }
1657      if let Some(rs) = rowspan {
1658        td_attrs.insert("rowspan".to_string(), rs);
1659      }
1660      // A cell's `backgroundcolor` (e.g. nicematrix `\CodeBefore` fills, #6569)
1661      // rides onto the `m:mtd` as `mathbackground`, matching how token-level
1662      // backgrounds are emitted (`mod.rs` `pmml_token`).
1663      if let Some(bg) = cell_node.get_attribute("backgroundcolor") {
1664        td_attrs.insert("mathbackground".to_string(), bg);
1665      }
1666
1667      let cell_children = element_children(&cell_node);
1668      let cell_content = if cell_children.is_empty() {
1669        vec![]
1670      } else {
1671        // Perl L468: cells filter _ignorable items too.
1672        filter_row(cell_children.iter().map(|c| pmml(doc, c)).collect())
1673      };
1674
1675      cols.push(NodeData::Element {
1676        tag:        "m:mtd".to_string(),
1677        attributes: if td_attrs.is_empty() {
1678          None
1679        } else {
1680          Some(td_attrs)
1681        },
1682        children:   cell_content,
1683      });
1684    }
1685    if nc > ncols {
1686      ncols = nc;
1687    }
1688    nrows += 1;
1689    rows.push(NodeData::Element {
1690      tag:        "m:mtr".to_string(),
1691      attributes: None,
1692      children:   cols,
1693    });
1694  }
1695
1696  // Perl L478-479: drop separators if there's only one row/column.
1697  let emit_rowsep = nrows >= 2;
1698  let emit_colsep = ncols >= 2;
1699
1700  let mut table_attrs = HashMap::default();
1701  if align != "axis" {
1702    table_attrs.insert("align".to_string(), align.to_string());
1703  }
1704  if emit_rowsep {
1705    table_attrs.insert("rowspacing".to_string(), rowsep);
1706  }
1707  if emit_colsep {
1708    table_attrs.insert("columnspacing".to_string(), colsep);
1709  }
1710  if let Some(w) = width {
1711    table_attrs.insert("width".to_string(), w);
1712  }
1713  // Perl L484-485: "Mozilla seems to need some encouragement?"
1714  if CURRENT_STYLE.with(|s| s.get()) == MathStyle::Display {
1715    table_attrs.insert("displaystyle".to_string(), "true".to_string());
1716  }
1717
1718  NodeData::Element {
1719    tag:        "m:mtable".to_string(),
1720    attributes: if table_attrs.is_empty() {
1721      None
1722    } else {
1723      Some(table_attrs)
1724    },
1725    children:   rows,
1726  }
1727}
1728
1729// ======================================================================
1730// Layout helpers
1731
1732/// Simple sub/superscript.
1733fn pmml_script_simple(doc: &PostDocument, tag: &str, base: &Node, script: &Node) -> NodeData {
1734  NodeData::Element {
1735    tag:        tag.to_string(),
1736    attributes: None,
1737    children:   vec![pmml(doc, base), pmml_scriptsize(doc, script)],
1738  }
1739}
1740
1741/// Convert node at script size (sub/superscripts). Port of Perl `pmml_scriptsize`:
1742/// steps the style to scriptstyle (β†’ scriptscript when already in a script) for the
1743/// duration of the recursion, so contained tokens compare against the smaller size.
1744fn pmml_scriptsize(doc: &PostDocument, node: &Node) -> NodeData {
1745  let old = CURRENT_STYLE.with(|s| {
1746    let o = s.get();
1747    s.set(o.script_step());
1748    o
1749  });
1750  let r = pmml(doc, node);
1751  CURRENT_STYLE.with(|s| s.set(old));
1752  r
1753}
1754
1755/// Convert node at smaller size (fraction numerator/denominator). Port of Perl
1756/// `pmml_smaller`: steps the style down one level for the duration of the recursion.
1757fn pmml_smaller(doc: &PostDocument, node: &Node) -> NodeData {
1758  let old = CURRENT_STYLE.with(|s| {
1759    let o = s.get();
1760    s.set(o.step_down());
1761    o
1762  });
1763  let r = pmml(doc, node);
1764  CURRENT_STYLE.with(|s| s.set(old));
1765  r
1766}
1767
1768/// Infix operator: arg1 op arg2 op arg3 ...
1769///
1770/// Port of `pmml_infix`.
1771fn pmml_infix(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1772  // `args` is matched UNFILTERED β€” XMath's `absent` placeholders keep their
1773  // slot. They exist to satisfy the content-arm contract (every binary
1774  // application has 2 operands), and it is tempting to drop them so no empty box
1775  // reaches the output β€” but in Presentation MathML an operand slot is what
1776  // makes the operator INFIX.
1777  //
1778  // MathML infers an `<mo>`'s form from its position: first child of its
1779  // `<mrow>` β‡’ prefix, last β‡’ postfix, otherwise infix β€” and the form selects
1780  // the operator-dictionary spacing. Dropping the absent LHS of a continuation
1781  // row (`& = RHS` in an `align`, whose LHS is inherited from the row above)
1782  // makes `<mo>=</mo>` the first child, so renderers give it *prefix* spacing
1783  // and the `=` column stops lining up. That is issue #312 β€” reported against
1784  // 0.7.5-rc1 as "the alignment is all off around `=`", and visible in both
1785  // native MathML and MathJax.
1786  //
1787  // Keeping the slot costs nothing in accessibility, because `pmml_token`
1788  // renders an `absent` token as an EMPTY `<m:mphantom/>` β€” presentational
1789  // grouping with no semantic claim, zero-width and unannounced. That is a
1790  // strict improvement on Perl, which emits an empty `<m:mi/>` here
1791  // (`MathML.pm:1474` `DefMathML("Token:?:absent", …)`): same spacing, but
1792  // without asserting "here is an identifier" for content that has none.
1793  // (Task #264 proposed suppressing the placeholder; that is what regressed the
1794  // #312 spacing, so the item is closed in the other direction.)
1795  match args {
1796    [] => pmml(doc, op),
1797    // One operand is rendered PREFIX. Port of Perl `pmml_infix` L632-635:
1798    // "Infix with 1 arg is presumably Prefix! (aka Operator)" β€” genuine unary
1799    // operators (`-21`, `+x`). Perl renders the operator via
1800    // `pmml_mo($op, role => 'OPERATOR')` when `$op` is an `ltx:XMTok`, which
1801    // selects the operator dictionary's PREFIX entry (e.g. `βˆ’` gets lspace 0,
1802    // not the infix ADDOP's 0.278em). WITHOUT this, a unary minus after a
1803    // relation (`a = -b`) reaches the spacewalk with infix ADDOP spacing on the
1804    // `βˆ’` and role ADDOP; the walk then wants no TeX space (Relβ†’Bin = 0) yet
1805    // sees 0.556em of dictionary spacing and zeroes BOTH `=`.rspace and
1806    // `βˆ’`.lspace, collapsing the gap (issue #535). A non-token (embellished)
1807    // operator renders normally, exactly as Perl's ternary does.
1808    [arg] => {
1809      let op_prefix = if op.get_name() == "XMTok" {
1810        pmml_token_inner(doc, op, Some("OPERATOR"))
1811      } else {
1812        pmml(doc, op)
1813      };
1814      pmml_row(vec![op_prefix, pmml(doc, arg)])
1815    },
1816    // arg1 op arg2 op arg3 …
1817    [first, rest @ ..] => {
1818      let op_mml = pmml(doc, op);
1819      let mut items = vec![pmml(doc, first)];
1820      for arg in rest {
1821        items.push(op_mml.clone());
1822        items.push(pmml(doc, arg));
1823      }
1824      pmml_row(items)
1825    },
1826  }
1827}
1828
1829/// True iff `node` is the XMath placeholder for a structurally-absent
1830/// operand β€” an `<ltx:XMTok>` with `meaning="absent"`. The math
1831/// parser inserts these as the left operand for prefix-relop rules
1832/// (`Apply(=, absent, RHS)` for `& = ...` continuation rows) and as
1833/// the right operand for postfix-relop rules. Used by `pmml_infix`
1834/// to suppress materialization in Presentation MathML. Task #264.
1835fn is_absent_operand(node: &Node) -> bool {
1836  if node.get_name() != "XMTok" {
1837    return false;
1838  }
1839  node.get_attribute("meaning").as_deref() == Some("absent")
1840}
1841
1842/// Big operator with possible limits.
1843///
1844/// Port of `pmml_summation`.
1845fn pmml_summation(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1846  let op_mml = pmml(doc, op);
1847  // FUNCTION APPLICATION (⁑) only if the operator base is NOT an <m:mo> β€” Perl's
1848  // universal is_mo rule (MathML.pm Apply:?:?). Big operators βˆ‘/∫/⋃/∏/lim all
1849  // render as <m:mo> (incl. scripted forms like βˆ‘_i via munder), so they
1850  // juxtapose their body (βˆ‘a_i, ∫f) rather than emit βˆ‘β‘a_i β€” matching Perl.
1851  let needs_apply = !op_base_is_mo(&op_mml);
1852  let mut items = vec![op_mml];
1853  if needs_apply {
1854    items.push(pmml_mo_str("\u{2061}")); // FUNCTION APPLICATION
1855  }
1856  for arg in args {
1857    items.push(pmml(doc, arg));
1858  }
1859  pmml_row(items)
1860}
1861
1862/// Parenthesized/fenced expression.
1863///
1864/// Port of `pmml_parenthesize`.
1865fn pmml_parenthesize(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1866  let mut items = vec![pmml(doc, op)];
1867  for arg in args {
1868    items.push(pmml(doc, arg));
1869  }
1870  pmml_row(items)
1871}
1872
1873// ======================================================================
1874// Script handling
1875//
1876// Port of `pmml_script` + `pmml_script_decipher` + `pmml_script_multi_layout`.
1877// Handles complex sub/superscript positioning with pre/mid/post scripts.
1878
1879/// Script pair: (sub, sup) where either can be None.
1880type ScriptPair = (Option<Node>, Option<Node>);
1881
1882/// Full script handler: disentangles pre/mid/post scripts.
1883///
1884/// Port of `pmml_script`.
1885fn pmml_script_full(doc: &PostDocument, op: &Node, base: &Node, script: &Node) -> NodeData {
1886  let (inner_base, pre_scripts, mid_scripts, post_scripts, emb_right) =
1887    pmml_script_decipher(doc, op, base, script);
1888
1889  // Perl `pmml_script` (L876-891) + `pmml_script_mid_layout` (L899-906):
1890  // the inner base converts under ITS recorded mathstyle (blocking a nested
1891  // m:mstyle from the token/apply paths), and when that style differs from
1892  // the context the whole script layout gets one m:mstyle displaystyle wrap
1893  // β€” mstyle doesn't nest well inside scripts.
1894  let ostyle = CURRENT_STYLE.with(|s| s.get());
1895  let bstyle = inner_base
1896    .get_attribute("mathstyle")
1897    .as_deref()
1898    .and_then(MathStyle::from_attr);
1899  if let Some(b) = bstyle {
1900    CURRENT_STYLE.with(|s| s.set(b));
1901  }
1902  let base_mml = pmml(doc, &inner_base);
1903  CURRENT_STYLE.with(|s| s.set(ostyle));
1904
1905  // Apply mid scripts (under/over)
1906  let base_mml = apply_mid_scripts(doc, base_mml, &mid_scripts, emb_right.as_ref());
1907
1908  // Apply pre/post scripts
1909  let layout = apply_multi_scripts(doc, base_mml, &pre_scripts, &post_scripts);
1910  match bstyle {
1911    Some(b) if b != ostyle => NodeData::Element {
1912      tag:        "m:mstyle".to_string(),
1913      attributes: Some(HashMap::from_iter([(
1914        "displaystyle".to_string(),
1915        (if b == MathStyle::Display {
1916          "true"
1917        } else {
1918          "false"
1919        })
1920        .to_string(),
1921      )])),
1922      children:   vec![layout],
1923    },
1924    _ => layout,
1925  }
1926}
1927
1928/// Decipher nested script applications into pre/mid/post groups.
1929///
1930/// Port of `pmml_script_decipher`.
1931fn pmml_script_decipher(
1932  doc: &PostDocument,
1933  op: &Node,
1934  base: &Node,
1935  script: &Node,
1936) -> (
1937  Node,
1938  Vec<ScriptPair>,
1939  Vec<ScriptPair>,
1940  Vec<ScriptPair>,
1941  Option<Node>,
1942) {
1943  let mut pre_scripts: Vec<ScriptPair> = Vec::new();
1944  let mut mid_scripts: Vec<ScriptPair> = Vec::new();
1945  let mut post_scripts: Vec<ScriptPair> = Vec::new();
1946  // Perl's `$emb_right` β€” the base's RIGHT embellishment, used to phantom-pad
1947  // the under/over scripts (L968, L1015-1017). Perl also declares `$emb_left`
1948  // but NEVER assigns it, so that half of `pmml_scriptsize_padded` is dead code
1949  // upstream and is deliberately not represented here.
1950  let mut emb_right: Option<Node> = None;
1951  let mut saw_mid = false;
1952
1953  // Perl tracks the last level seen in each of the three groups, so that a
1954  // script at a DIFFERENT nesting level starts a new pair instead of filling the
1955  // free slot of the current one. Perl compares with `ne`, and the initials are
1956  // the number 0 β€” which stringifies to "0" and so matches a literal `post0`.
1957  let (mut pre_level, mut mid_level, mut post_level) =
1958    ("0".to_string(), "0".to_string(), "0".to_string());
1959
1960  let (pos, level) = parse_scriptpos(op);
1961  let is_sub = op.get_attribute("role").unwrap_or_default().contains("SUB");
1962
1963  // Place the first script.
1964  let pair = if is_sub {
1965    (Some(script.clone()), None)
1966  } else {
1967    (None, Some(script.clone()))
1968  };
1969  match pos {
1970    ScriptPos::Pre => {
1971      pre_scripts.push(pair);
1972      pre_level = level;
1973    },
1974    ScriptPos::Mid => {
1975      saw_mid = true;
1976      mid_scripts.push(pair);
1977      mid_level = level;
1978    },
1979    ScriptPos::Post => {
1980      post_scripts.push(pair);
1981      post_level = level;
1982    },
1983  }
1984
1985  // Walk down through nested scripts on the base.
1986  let mut current_base = base.clone();
1987  loop {
1988    // Perl `$base = realize($base, 'presentation')` β€” note it ASSIGNS, so the
1989    // base ultimately returned is the realized one, and the realization follows
1990    // XMDual as well as XMRef.
1991    let Some(realized) = doc.realize_xm_node_branch(&current_base, XMBranch::Presentation) else {
1992      break;
1993    };
1994    current_base = realized;
1995
1996    if !doc.is_qname(&current_base, "ltx:XMApp") {
1997      break;
1998    }
1999
2000    let children = element_children(&current_base);
2001    if children.len() < 3 {
2002      break;
2003    }
2004
2005    let xop = &children[0];
2006    if !doc.is_qname(xop, "ltx:XMTok") {
2007      break;
2008    }
2009
2010    let xrole = xop.get_attribute("role").unwrap_or_default();
2011    let is_script_op = xrole.contains("SUPERSCRIPTOP") || xrole.contains("SUBSCRIPTOP");
2012    if !is_script_op {
2013      break;
2014    }
2015
2016    let xbase = children[1].clone();
2017    let xscript = &children[2];
2018    let (xpos, xlevel) = parse_scriptpos(xop);
2019    let x_is_sub = xrole.contains("SUB");
2020
2021    match xpos {
2022      // Prescripts accumulate outward-in, so Perl appends (`push`) and inspects
2023      // the LAST pair; mid and post scripts accumulate inward-out, so it
2024      // prepends (`unshift`) and inspects the FIRST.
2025      ScriptPos::Pre => place_script(
2026        &mut pre_scripts,
2027        &mut pre_level,
2028        xlevel,
2029        x_is_sub,
2030        xscript.clone(),
2031        false,
2032      ),
2033      ScriptPos::Mid => {
2034        saw_mid = true;
2035        place_script(
2036          &mut mid_scripts,
2037          &mut mid_level,
2038          xlevel,
2039          x_is_sub,
2040          xscript.clone(),
2041          true,
2042        );
2043      },
2044      ScriptPos::Post => {
2045        // Perl L1015-1017: a POST script found BELOW a mid (under/over) script is
2046        // not a script of the outer construct at all β€” it is an embellishment of
2047        // the base, e.g. the prime in `\mathop{X'}\limits_{p}^{q}`. Record it for
2048        // phantom padding and STOP the walk WITHOUT descending, so `current_base`
2049        // stays the embellished `Apply(post-sup, X, ')` and renders whole. Without
2050        // this the prime is treated as an outer postscript, which inverts the
2051        // nesting (`msup` outside `munderover` instead of in) and leaves the limits
2052        // uncentred over the primed base.
2053        if saw_mid {
2054          emb_right = Some(xscript.clone());
2055          break;
2056        }
2057        place_script(
2058          &mut post_scripts,
2059          &mut post_level,
2060          xlevel,
2061          x_is_sub,
2062          xscript.clone(),
2063          true,
2064        );
2065      },
2066    }
2067
2068    current_base = xbase;
2069  }
2070
2071  (
2072    current_base,
2073    pre_scripts,
2074    mid_scripts,
2075    post_scripts,
2076    emb_right,
2077  )
2078}
2079
2080/// Where a script sits relative to its base β€” the keyword half of the
2081/// `scriptpos` attribute.
2082#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2083enum ScriptPos {
2084  /// Before the base, as an `m:mmultiscripts` prescript.
2085  Pre,
2086  /// Under/over the base, as `m:munder`/`m:mover`/`m:munderover`.
2087  Mid,
2088  /// After the base β€” the default, and the only one `m:msub`/`m:msup` express.
2089  Post,
2090}
2091
2092/// Split a `scriptpos` attribute into its position keyword and nesting level.
2093///
2094/// Port of Perl's
2095/// `($op->getAttribute('scriptpos') || 'post0') =~ /^(pre|mid|post)?(\d+)?$/`.
2096/// Both capture groups are optional, so a value with neither part (or one that
2097/// does not match at all, leaving both undef) yields no keyword β€” which Perl's
2098/// `$pos eq 'pre'` / `eq 'mid'` chain then falls through to post. The level is
2099/// compared with `ne` against the running level, never arithmetically, so it
2100/// stays a string here; a missing one is Perl's undef, i.e. `""`.
2101fn parse_scriptpos(op: &Node) -> (ScriptPos, String) {
2102  let raw = op
2103    .get_attribute("scriptpos")
2104    .unwrap_or_else(|| "post0".to_string());
2105  let (pos, rest) = if let Some(rest) = raw.strip_prefix("pre") {
2106    (ScriptPos::Pre, rest)
2107  } else if let Some(rest) = raw.strip_prefix("mid") {
2108    (ScriptPos::Mid, rest)
2109  } else if let Some(rest) = raw.strip_prefix("post") {
2110    (ScriptPos::Post, rest)
2111  } else {
2112    (ScriptPos::Post, raw.as_str())
2113  };
2114  // The regex is anchored, so a trailing remainder that is not all digits means
2115  // the whole match failed: no keyword and no level.
2116  if rest.is_empty() || rest.bytes().all(|b| b.is_ascii_digit()) {
2117    (pos, rest.to_string())
2118  } else {
2119    (ScriptPos::Post, String::new())
2120  }
2121}
2122
2123/// Add one script to a pre/mid/post group, at the given nesting level.
2124///
2125/// Port of the shared shape of Perl `pmml_script_decipher` L1005-1020:
2126/// ```text
2127/// push/unshift(@list, [undef, undef]) if ($level ne $nl) || $list[END][$spos];
2128/// $list[END][$spos] = $xscript; $level = $nl;
2129/// ```
2130/// A script starts a NEW pair when it would collide with the current pair's
2131/// occupied slot **or** when it sits at a different nesting level β€” the latter
2132/// being what keeps `{x_a}^b` a two-pair `m:mmultiscripts` (the `b` rides to the
2133/// right of the whole `x_a` box) rather than collapsing to an `m:msubsup` that
2134/// stacks the two.
2135///
2136/// `at_front` selects Perl's `unshift`/`$list[0]` (mid and post, which
2137/// accumulate inward-out) over `push`/`$list[-1]` (pre).
2138///
2139/// One deliberate divergence: on an empty list Perl's pre arm evaluates
2140/// `$pres[-1][$spos]` as undef and, if the levels happen to match, then dies
2141/// assigning to `$pres[-1]`. Creating the pair is the obvious reading of the
2142/// intent, and cannot differ from Perl anywhere Perl does not simply crash.
2143fn place_script(
2144  list: &mut Vec<ScriptPair>,
2145  level: &mut String,
2146  new_level: String,
2147  is_sub: bool,
2148  script: Node,
2149  at_front: bool,
2150) {
2151  let slot_taken = |p: &ScriptPair| if is_sub { p.0.is_some() } else { p.1.is_some() };
2152  let current = if at_front { list.first() } else { list.last() };
2153  if current.is_none_or(slot_taken) || *level != new_level {
2154    if at_front {
2155      list.insert(0, (None, None));
2156    } else {
2157      list.push((None, None));
2158    }
2159  }
2160  let pair = if at_front {
2161    list.first_mut()
2162  } else {
2163    list.last_mut()
2164  }
2165  .expect("a pair was just ensured to exist");
2166  if is_sub {
2167    pair.0 = Some(script);
2168  } else {
2169    pair.1 = Some(script);
2170  }
2171  *level = new_level;
2172}
2173
2174/// Apply mid scripts (under/over) to a base.
2175fn apply_mid_scripts(
2176  doc: &PostDocument,
2177  mut base: NodeData,
2178  mid_scripts: &[ScriptPair],
2179  emb_right: Option<&Node>,
2180) -> NodeData {
2181  for (sub_opt, sup_opt) in mid_scripts {
2182    let under = sub_opt
2183      .as_ref()
2184      .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2185    let over = sup_opt
2186      .as_ref()
2187      .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2188
2189    base = match (under, over) {
2190      (Some(u), None) => NodeData::Element {
2191        tag:        "m:munder".to_string(),
2192        attributes: None,
2193        children:   vec![base, u],
2194      },
2195      (None, Some(o)) => NodeData::Element {
2196        tag:        "m:mover".to_string(),
2197        attributes: None,
2198        children:   vec![base, o],
2199      },
2200      (Some(u), Some(o)) => NodeData::Element {
2201        tag:        "m:munderover".to_string(),
2202        attributes: None,
2203        children:   vec![base, u, o],
2204      },
2205      (None, None) => base,
2206    };
2207  }
2208  base
2209}
2210
2211/// An under/over script at scriptsize, padded by a phantom of the base's right
2212/// embellishment when there is one.
2213///
2214/// Port of `pmml_scriptsize_padded` (`MathML.pm` L925-934) β€” "This is to handle
2215/// primed sums, etc." An `\mathop{X'}\limits_{p}^{q}` centres its limits on the
2216/// whole `Xβ€²` box unless each limit is widened by an invisible copy of the `β€²`,
2217/// which shifts them back over the `X` itself.
2218///
2219/// Perl's `$emb_left` arm is NOT ported: `pmml_script_decipher` declares
2220/// `$emb_left` and never assigns it (L968 β†’ L1022), so the left phantom is
2221/// unreachable upstream. Only the right embellishment can occur, so this takes a
2222/// single `emb_right`.
2223fn pmml_scriptsize_padded(doc: &PostDocument, script: &Node, emb_right: Option<&Node>) -> NodeData {
2224  let script_mml = pmml_scriptsize(doc, script);
2225  match emb_right {
2226    None => script_mml,
2227    Some(emb) => NodeData::Element {
2228      tag:        "m:mrow".to_string(),
2229      attributes: None,
2230      children:   vec![script_mml, NodeData::Element {
2231        tag:        "m:mphantom".to_string(),
2232        attributes: None,
2233        children:   vec![pmml_scriptsize(doc, emb)],
2234      }],
2235    },
2236  }
2237}
2238
2239/// Apply pre/post scripts to a base.
2240///
2241/// Port of `pmml_script_multi_layout`.
2242fn apply_multi_scripts(
2243  doc: &PostDocument,
2244  base: NodeData,
2245  pre_scripts: &[ScriptPair],
2246  post_scripts: &[ScriptPair],
2247) -> NodeData {
2248  // An absent script slot is an empty `<m:mrow/>`, as Perl emits (`pmml_scriptsize`
2249  // of an undefined slot). MathML Core **removed** `<m:none/>`; an empty `m:mrow`
2250  // is the accepted placeholder for an omitted subtree, so this is both the
2251  // faithful and the standards-current choice.
2252  let none_mml = || NodeData::Element {
2253    tag:        "m:mrow".to_string(),
2254    attributes: None,
2255    children:   vec![],
2256  };
2257
2258  if !pre_scripts.is_empty() {
2259    // mmultiscripts with prescripts
2260    let mut children = vec![base];
2261    for (sub_opt, sup_opt) in post_scripts {
2262      children.push(
2263        sub_opt
2264          .as_ref()
2265          .map(|s| pmml_scriptsize(doc, s))
2266          .unwrap_or_else(none_mml),
2267      );
2268      children.push(
2269        sup_opt
2270          .as_ref()
2271          .map(|s| pmml_scriptsize(doc, s))
2272          .unwrap_or_else(none_mml),
2273      );
2274    }
2275    children.push(NodeData::Element {
2276      tag:        "m:mprescripts".to_string(),
2277      attributes: None,
2278      children:   vec![],
2279    });
2280    for (sub_opt, sup_opt) in pre_scripts {
2281      children.push(
2282        sub_opt
2283          .as_ref()
2284          .map(|s| pmml_scriptsize(doc, s))
2285          .unwrap_or_else(none_mml),
2286      );
2287      children.push(
2288        sup_opt
2289          .as_ref()
2290          .map(|s| pmml_scriptsize(doc, s))
2291          .unwrap_or_else(none_mml),
2292      );
2293    }
2294    NodeData::Element {
2295      tag: "m:mmultiscripts".to_string(),
2296      attributes: None,
2297      children,
2298    }
2299  } else if post_scripts.len() > 1 {
2300    // mmultiscripts with multiple postscripts
2301    let mut children = vec![base];
2302    for (sub_opt, sup_opt) in post_scripts {
2303      children.push(
2304        sub_opt
2305          .as_ref()
2306          .map(|s| pmml_scriptsize(doc, s))
2307          .unwrap_or_else(none_mml),
2308      );
2309      children.push(
2310        sup_opt
2311          .as_ref()
2312          .map(|s| pmml_scriptsize(doc, s))
2313          .unwrap_or_else(none_mml),
2314      );
2315    }
2316    NodeData::Element {
2317      tag: "m:mmultiscripts".to_string(),
2318      attributes: None,
2319      children,
2320    }
2321  } else if post_scripts.is_empty() {
2322    base
2323  } else {
2324    // Single post script pair
2325    let (sub_opt, sup_opt) = &post_scripts[0];
2326    match (sub_opt, sup_opt) {
2327      (Some(sub_node), None) => NodeData::Element {
2328        tag:        "m:msub".to_string(),
2329        attributes: None,
2330        children:   vec![base, pmml_scriptsize(doc, sub_node)],
2331      },
2332      (None, Some(sup_node)) => NodeData::Element {
2333        tag:        "m:msup".to_string(),
2334        attributes: None,
2335        children:   vec![base, pmml_scriptsize(doc, sup_node)],
2336      },
2337      (Some(sub_node), Some(sup_node)) => NodeData::Element {
2338        tag:        "m:msubsup".to_string(),
2339        attributes: None,
2340        children:   vec![
2341          base,
2342          pmml_scriptsize(doc, sub_node),
2343          pmml_scriptsize(doc, sup_node),
2344        ],
2345      },
2346      (None, None) => base,
2347    }
2348  }
2349}
2350
2351// ======================================================================
2352// Continued fractions
2353//
2354// Port of `do_cfrac`.
2355
2356/// Handle continued fraction rendering.
2357///
2358/// Port of `Apply:?:continued-fraction` + `do_cfrac`.
2359fn pmml_cfrac(doc: &PostDocument, op: &Node, numer: &Node, denom: &Node) -> NodeData {
2360  // Perl registration (L1954-1960): only the `cfrac-inline` variant unrolls
2361  // via do_cfrac; display cfrac is a plain (recursively converted) mfrac.
2362  if op.get_attribute("name").as_deref() == Some("cfrac-inline") {
2363    return pmml_row(do_cfrac(doc, numer, denom));
2364  }
2365  NodeData::Element {
2366    tag:        "m:mfrac".to_string(),
2367    attributes: None,
2368    children:   vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2369  }
2370}
2371
2372/// Port of Perl `do_cfrac` (L1930-1951): unroll an inline continued fraction β€”
2373/// when the denominator is a sum (or \cdots) its LAST summand is pulled up to
2374/// the top level (a trailing \cdots, a nested cfrac unrolled recursively, or
2375/// an invisible-times of \cdots and a factor), leaving the current fraction
2376/// with the trailing operator inside its denominator row.
2377fn do_cfrac(doc: &PostDocument, numer: &Node, denom: &Node) -> Vec<NodeData> {
2378  if doc.is_qname(denom, "ltx:XMApp") {
2379    let dchildren = element_children(denom);
2380    if dchildren.len() >= 2 {
2381      let denomop = &dchildren[0];
2382      let denomargs = &dchildren[1..];
2383      if denomop.get_attribute("role").as_deref() == Some("ADDOP")
2384        || denomop.get_content() == "\u{22EF}"
2385      {
2386        let (rest, last) = denomargs.split_at(denomargs.len() - 1);
2387        let last = &last[0];
2388        if !rest.is_empty() {
2389          let curr = NodeData::Element {
2390            tag:        "m:mfrac".to_string(),
2391            attributes: None,
2392            children:   vec![pmml_smaller(doc, numer), NodeData::Element {
2393              tag:        "m:mrow".to_string(),
2394              attributes: None,
2395              children:   vec![
2396                if rest.len() > 1 {
2397                  pmml_infix(doc, denomop, rest)
2398                } else {
2399                  pmml_smaller(doc, &rest[0])
2400                },
2401                pmml_smaller(doc, denomop),
2402              ],
2403            }],
2404          };
2405          if last.get_content() == "\u{22EF}" {
2406            // Denominator ends with \cdots: bring the dots up to toplevel.
2407            return vec![curr, pmml_smaller(doc, last)];
2408          } else if doc.is_qname(last, "ltx:XMApp") {
2409            let lchildren = element_children(last);
2410            if lchildren.len() >= 2 {
2411              let lastop = &lchildren[0];
2412              let lastargs = &lchildren[1..];
2413              if lastop.get_attribute("meaning").as_deref() == Some("continued-fraction")
2414                && lastargs.len() >= 2
2415              {
2416                // Denominator ends with a cfrac: unroll it to toplevel.
2417                let mut out = vec![curr];
2418                out.extend(do_cfrac(doc, &lastargs[0], &lastargs[1]));
2419                return out;
2420              } else if lastop.get_content() == "\u{2062}"
2421                && lastargs.len() == 2
2422                && lastargs[0].get_content() == "\u{22EF}"
2423              {
2424                // Denominator ends with (invisible-times of) \cdots Β· factor.
2425                return vec![
2426                  curr,
2427                  pmml_smaller(doc, &lastargs[0]),
2428                  pmml_smaller(doc, &lastargs[1]),
2429                ];
2430              }
2431            }
2432          }
2433        }
2434      }
2435    }
2436  }
2437  vec![NodeData::Element {
2438    tag:        "m:mfrac".to_string(),
2439    attributes: None,
2440    children:   vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2441  }]
2442}
2443
2444// ======================================================================
2445// Utility functions
2446
2447/// Wrap nodes in an mrow (or return single node unwrapped).
2448/// Perl MathML.pm `Apply:?:?`: descend through script wrappers (msub/msup/…) to
2449/// the operator's base and report whether it renders as `<m:mo>`. A generic
2450/// application inserts FUNCTION APPLICATION (⁑) only when the base is NOT an
2451/// `<m:mo>`, so an OPERATOR/DIFFOP (βˆ‡, βˆ‚) juxtaposes its argument while a
2452/// function identifier (`f`, `\sin`) gets the invisible apply char.
2453fn op_base_is_mo(node: &NodeData) -> bool {
2454  let mut cur = node;
2455  loop {
2456    let NodeData::Element { tag, children, .. } = cur else {
2457      return false;
2458    };
2459    if tag == "m:mo" {
2460      return true;
2461    }
2462    // Perl regex `^m:(?:msub|msup|munder|mover|mprescripts)` β€” a prefix match,
2463    // so it also covers msubsup/munderover; descend to the base (first child).
2464    // m:mstyle: the F7 mathstyle wrap (e.g. `\displaystyle\sum`) is transparent
2465    // embellishment too β€” Perl's summation never re-examines it (it never
2466    // emits ⁑ at all, L1796-1798).
2467    if matches!(
2468      tag.as_str(),
2469      "m:msub"
2470        | "m:msup"
2471        | "m:msubsup"
2472        | "m:munder"
2473        | "m:mover"
2474        | "m:munderover"
2475        | "m:mprescripts"
2476        | "m:mstyle"
2477    ) {
2478      match children.first() {
2479        Some(child) => cur = child,
2480        None => return false,
2481      }
2482    } else {
2483      return false;
2484    }
2485  }
2486}
2487
2488/// Port of Perl `filter_row` (L577-579): drop `_ignorable` items.
2489fn filter_row(items: Vec<NodeData>) -> Vec<NodeData> {
2490  items
2491    .into_iter()
2492    .filter(|i| {
2493      !matches!(i, NodeData::Element { attributes: Some(a), .. } if a.contains_key("_ignorable"))
2494    })
2495    .collect()
2496}
2497
2498fn pmml_row(children: Vec<NodeData>) -> NodeData {
2499  // Perl `pmml_row` (L581-584) filters `_ignorable` items (zero-width hints).
2500  let children = filter_row(children);
2501  if children.len() == 1 {
2502    children.into_iter().next().unwrap()
2503  } else {
2504    NodeData::Element {
2505      tag: "m:mrow".to_string(),
2506      attributes: None,
2507      children,
2508    }
2509  }
2510}
2511
2512/// Create an mo element from a string.
2513fn pmml_mo_str(text: &str) -> NodeData {
2514  NodeData::Element {
2515    tag:        "m:mo".to_string(),
2516    attributes: None,
2517    children:   vec![NodeData::Text(text.to_string())],
2518  }
2519}
2520
2521/// Create a MathML error element.
2522fn pmml_error(msg: &str) -> NodeData {
2523  NodeData::Element {
2524    tag:        "m:merror".to_string(),
2525    attributes: None,
2526    children:   vec![NodeData::Element {
2527      tag:        "m:mtext".to_string(),
2528      attributes: None,
2529      children:   vec![NodeData::Text(msg.to_string())],
2530    }],
2531  }
2532}
2533
2534/// Map a LaTeXML font name to a MathML mathvariant value.
2535///
2536/// Wrapper around `unicode::unicode_mathvariant` (full Perl parity).
2537/// Returns `Some(variant)` for recognized fonts, `None` only for empty input.
2538pub fn font_to_mathvariant(font: &str) -> Option<&'static str> {
2539  if font.is_empty() {
2540    return None;
2541  }
2542  Some(crate::unicode::unicode_mathvariant(font))
2543}
2544
2545// ======================================================================
2546// TeX spacing adjustment
2547//
2548// Port of Perl's `adjust_spacing` / `space_walk` / `adjust_pair`.
2549// Walks adjacent pairs in mrow and adjusts lspace/rspace to match TeX spacing.
2550
2551/// TeX spacing values: thin=3mu, med=4mu, thick=5mu (in em = mu/18)
2552const TEX_SPACING: [f64; 4] = [0.0, 0.167, 0.222, 0.2778];
2553
2554/// Spacing epsilon β€” ignore differences below this (em)
2555const SPACING_EPSILON: f64 = 0.01;
2556
2557/// Don't complain if we can't adjust less than this (em) β€” Perl `$fudge`.
2558const SPACING_FUDGE: f64 = 0.3;
2559
2560/// Map LaTeXML role to TeX atom type.
2561fn role_to_atom_type(role: &str) -> &'static str {
2562  match role {
2563    "ATOM" | "UNKNOWN" | "ID" | "NUMBER" | "POSTFIX" | "FUNCTION" | "DIFFOP" | "SUPOP"
2564    | "ELIDEOP" => "Ord",
2565    "OPFUNCTION" | "TRIGFUNCTION" | "BIGOP" | "SUMOP" | "INTOP" | "LIMITOP" | "OPERATOR" => "Op",
2566    "ADDOP" | "MULOP" | "BINOP" | "APPLYOP" | "COMPOSEOP" => "Bin",
2567    "RELOP" | "METARELOP" | "MODIFIEROP" | "MODIFIER" | "ARROW" => "Rel",
2568    "OPEN" => "Open",
2569    "CLOSE" => "Close",
2570    "PUNCT" | "VERTBAR" | "PERIOD" => "Punct",
2571    "ARRAY" | "POSTSUBSCRIPT" | "POSTSUPERSCRIPT" | "FLOATSUPERSCRIPT" | "FLOATSUBSCRIPT" => {
2572      "Inner"
2573    },
2574    "MIDDLE" => "Ord",
2575    _ => "Ord",
2576  }
2577}
2578
2579/// Get TeX spacing code for a pair of atom types.
2580fn atompair_spacing(left: &str, right: &str) -> i32 {
2581  match (left, right) {
2582    ("Ord", "Op") | ("Op", "Ord") | ("Op", "Op") | ("Close", "Op") => 1,
2583    ("Ord", "Bin")
2584    | ("Bin", "Ord")
2585    | ("Bin", "Open")
2586    | ("Bin", "Inner")
2587    | ("Close", "Bin")
2588    | ("Inner", "Bin")
2589    | ("Bin", "Op") => -2,
2590    ("Ord", "Rel")
2591    | ("Rel", "Ord")
2592    | ("Op", "Rel")
2593    | ("Rel", "Open")
2594    | ("Rel", "Inner")
2595    | ("Close", "Rel")
2596    | ("Inner", "Rel")
2597    | ("Rel", "Op") => -3,
2598    ("Ord", "Inner")
2599    | ("Op", "Inner")
2600    | ("Close", "Inner")
2601    | ("Inner", "Inner")
2602    | ("Inner", "Open")
2603    | ("Punct", "Ord")
2604    | ("Punct", "Op")
2605    | ("Punct", "Rel")
2606    | ("Punct", "Open")
2607    | ("Punct", "Close")
2608    | ("Punct", "Punct")
2609    | ("Punct", "Inner")
2610    | ("Inner", "Ord")
2611    | ("Inner", "Punct") => -1,
2612    ("Inner", "Op") => 1,
2613    _ => 0,
2614  }
2615}
2616
2617/// Map MathML tag to TeX atom type.
2618fn m_atom_type(tag: &str) -> Option<&'static str> {
2619  match tag {
2620    "m:mfrac" => Some("Ord"),
2621    "m:marray" => Some("Inner"),
2622    "m:mspace" => Some("Ord"),
2623    _ => None,
2624  }
2625}
2626
2627/// Check if a MathML tag is an embellished operator container.
2628fn is_embellisher_tag(tag: &str) -> bool {
2629  matches!(
2630    tag,
2631    "m:msub" | "m:msup" | "m:msubsup" | "m:munder" | "m:mover" | "m:munderover"
2632  )
2633}
2634
2635/// Check if a MathML tag is mrow-like.
2636fn is_mrow_like(tag: &str) -> bool {
2637  matches!(
2638    tag,
2639    "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd"
2640  )
2641}
2642
2643/// Check if text is an invisible operator.
2644fn is_invisible_op(text: &str) -> bool {
2645  !text.is_empty()
2646    && text
2647      .chars()
2648      .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2649}
2650
2651/// Format em value. Port of Perl `fmt_em` (MathML.pm L1285):
2652/// `sprintf("%.3fem")` β€” trailing zeros are KEPT ("0.330em", "1.200em"),
2653/// matching Perl byte-for-byte; zero (Perl false-y) β†’ "0em". (audit F4)
2654fn fmt_em(val: f64) -> String {
2655  if val == 0.0 {
2656    "0em".to_string()
2657  } else {
2658    format!("{val:.3}em")
2659  }
2660}
2661
2662/// Get role from a NodeData, following embellished operators.
2663fn get_node_role(node: &NodeData) -> String {
2664  match node {
2665    NodeData::Element { tag, attributes, children } => {
2666      if is_embellisher_tag(tag) {
2667        if let Some(base) = children.first() {
2668          return get_node_role(base);
2669        }
2670      }
2671      attributes
2672        .as_ref()
2673        .and_then(|a| a.get("_role"))
2674        .cloned()
2675        .unwrap_or_default()
2676    },
2677    _ => String::new(),
2678  }
2679}
2680
2681fn get_node_tag(node: &NodeData) -> &str {
2682  match node {
2683    NodeData::Element { tag, .. } => tag,
2684    _ => "",
2685  }
2686}
2687
2688fn get_node_text(node: &NodeData) -> String {
2689  match node {
2690    NodeData::Text(t) => t.clone(),
2691    NodeData::Element { children, .. } => children.iter().map(get_node_text).collect(),
2692    _ => String::new(),
2693  }
2694}
2695
2696/// Check if all text in `node` consists only of invisible-op characters,
2697/// without allocating a concatenated String like `get_node_text` does.
2698/// Used by `adjust_spacing` where we only need the boolean answer.
2699fn is_node_text_invisible_op(node: &NodeData) -> bool {
2700  fn check(node: &NodeData, seen_any: &mut bool) -> bool {
2701    match node {
2702      NodeData::Text(t) => {
2703        if !t.is_empty() {
2704          *seen_any = true;
2705        }
2706        t.chars()
2707          .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2708      },
2709      NodeData::Element { children, .. } => children.iter().all(|c| check(c, seen_any)),
2710      _ => true,
2711    }
2712  }
2713  let mut seen_any = false;
2714  let ok = check(node, &mut seen_any);
2715  ok && seen_any
2716}
2717
2718fn set_node_attr(node: &mut NodeData, key: &str, value: &str) {
2719  if let NodeData::Element { attributes, .. } = node {
2720    let attrs = attributes.get_or_insert_with(HashMap::default);
2721    attrs.insert(key.to_string(), value.to_string());
2722  }
2723}
2724
2725fn get_node_attr(node: &NodeData, key: &str) -> Option<String> {
2726  match node {
2727    NodeData::Element { attributes, .. } => attributes.as_ref().and_then(|a| a.get(key)).cloned(),
2728    _ => None,
2729  }
2730}
2731
2732fn get_node_attr_f64(node: &NodeData, key: &str) -> f64 {
2733  match node {
2734    NodeData::Element { attributes, .. } => attributes
2735      .as_ref()
2736      .and_then(|a| a.get(key))
2737      .and_then(|v| v.strip_suffix("em"))
2738      .and_then(|v| v.parse::<f64>().ok())
2739      .unwrap_or(0.0),
2740    _ => 0.0,
2741  }
2742}
2743
2744/// Perl `%tag_arg_pattern` (MathML.pm L1084-1088): how a tag participates in
2745/// the spacing walk.
2746#[derive(PartialEq, Clone, Copy)]
2747enum WalkType {
2748  Atom,
2749  Mrow,
2750  Other,
2751}
2752fn walk_type(tag: &str) -> WalkType {
2753  match tag {
2754    "m:mi" | "m:mo" | "m:mn" | "m:ms" | "m:mtext" => WalkType::Atom,
2755    "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd" => {
2756      WalkType::Mrow
2757    },
2758    _ => WalkType::Other,
2759  }
2760}
2761
2762/// Resolve a child-index path from the walk root. The walk mutates only
2763/// attributes and rewraps nodes in place (sibling indices stay stable), so
2764/// queued paths never dangle.
2765fn node_at<'a>(root: &'a NodeData, path: &[usize]) -> &'a NodeData {
2766  let mut cur = root;
2767  for &i in path {
2768    match cur {
2769      NodeData::Element { children, .. } => cur = &children[i],
2770      _ => unreachable!("spacewalk path into non-element"),
2771    }
2772  }
2773  cur
2774}
2775
2776fn node_at_mut<'a>(root: &'a mut NodeData, path: &[usize]) -> &'a mut NodeData {
2777  let mut cur = root;
2778  for &i in path {
2779    match cur {
2780      NodeData::Element { children, .. } => cur = &mut children[i],
2781      _ => unreachable!("spacewalk path into non-element"),
2782    }
2783  }
2784  cur
2785}
2786
2787fn child_path(path: &[usize], i: usize) -> Vec<usize> {
2788  let mut p = path.to_vec();
2789  p.push(i);
2790  p
2791}
2792
2793/// Descend through embellished operators (scripts) to the inner operator,
2794/// for role/opdict-spacing reads (Perl adjust_pair L1225-1227).
2795fn descend_embellishers(root: &NodeData, mut path: Vec<usize>) -> Vec<usize> {
2796  loop {
2797    match node_at(root, &path) {
2798      NodeData::Element { tag, children, .. }
2799        if is_embellisher_tag(tag) && !children.is_empty() =>
2800      {
2801        path.push(0);
2802      },
2803      _ => return path,
2804    }
2805  }
2806}
2807
2808/// Walk the MathML tree and adjust spacing. Port of Perl `adjust_spacing` /
2809/// `space_walk` (MathML.pm L1079-1133): resolves the difference between
2810/// TeX's inter-atom spacing and MathML's operator-dictionary spacing.
2811pub fn adjust_spacing(node: &mut NodeData) { space_walk(node, Vec::new()); }
2812
2813/// Port of Perl `space_walk`: pairs VISUALLY adjacent items by unwinding
2814/// nested mrows into the pair stream, streaming script bases (TeX attaches
2815/// scripts without affecting inter-atom spacing) while recursing on the
2816/// scripts themselves, and carrying invisible operators between a pair as
2817/// the preferred place to materialize an adjustment.
2818fn space_walk(root: &mut NodeData, path: Vec<usize>) {
2819  use std::collections::VecDeque;
2820  let (wt, nch) = match node_at(root, &path) {
2821    NodeData::Element { tag, children, .. } => (walk_type(tag), children.len()),
2822    _ => return,
2823  };
2824  match wt {
2825    WalkType::Atom => {},
2826    WalkType::Other => {
2827      for i in 0..nch {
2828        space_walk(root, child_path(&path, i));
2829      }
2830    },
2831    WalkType::Mrow => {
2832      let mut queue: VecDeque<Vec<usize>> = (0..nch).map(|i| child_path(&path, i)).collect();
2833      // First prev: unwrap leading nested mrows (Perl L1105-1108).
2834      let mut first = None;
2835      while let Some(p) = queue.pop_front() {
2836        let unwrap = match node_at(root, &p) {
2837          NodeData::Element { tag, children, .. } if tag == "m:mrow" => Some(children.len()),
2838          _ => None,
2839        };
2840        match unwrap {
2841          Some(n) => {
2842            for i in (0..n).rev() {
2843              queue.push_front(child_path(&p, i));
2844            }
2845          },
2846          None => {
2847            first = Some(p);
2848            break;
2849          },
2850        }
2851      }
2852      let Some(mut prev) = first else { return };
2853      space_walk(root, prev.clone());
2854      while let Some(popped) = queue.pop_front() {
2855        let mut next = popped;
2856        // Save an invisible operator as the potential target for lspace
2857        // (Perl L1111-1114).
2858        let mut invisop: Option<Vec<usize>> = None;
2859        {
2860          let n = node_at(root, &next);
2861          if get_node_tag(n) == "m:mo" && is_node_text_invisible_op(n) {
2862            invisop = Some(next);
2863            match queue.pop_front() {
2864              Some(p) => next = p,
2865              None => break,
2866            }
2867          }
2868        }
2869        enum Kind {
2870          Mrow(usize),
2871          Script(usize),
2872          Plain,
2873        }
2874        let kind = match node_at(root, &next) {
2875          NodeData::Element { tag, children, .. } => {
2876            if tag == "m:mrow" {
2877              Kind::Mrow(children.len())
2878            } else if !children.is_empty()
2879              && (tag.starts_with("m:msup")
2880                || tag.starts_with("m:msub")
2881                || tag.starts_with("m:munder")
2882                || tag.starts_with("m:mover")
2883                || tag.starts_with("m:mmultiscripts"))
2884            {
2885              // Prefix match like Perl's regex β€” covers msubsup/munderover.
2886              // A CHILDLESS script element (malformed input) is treated as
2887              // Plain β€” streaming its base would index children[0] (Perl's
2888              // undef-shift exits silently).
2889              Kind::Script(children.len())
2890            } else {
2891              Kind::Plain
2892            }
2893          },
2894          _ => Kind::Plain,
2895        };
2896        match kind {
2897          Kind::Mrow(n) => {
2898            // Unwrap into the stream; the invisible op goes back in front.
2899            for i in (0..n).rev() {
2900              queue.push_front(child_path(&next, i));
2901            }
2902            if let Some(iv) = invisop {
2903              queue.push_front(iv);
2904            }
2905            continue;
2906          },
2907          Kind::Script(n) => {
2908            // Stream the base; recurse on the scripts (Perl L1121-1128).
2909            for i in 1..n {
2910              space_walk(root, child_path(&next, i));
2911            }
2912            queue.push_front(child_path(&next, 0));
2913            if let Some(iv) = invisop {
2914              queue.push_front(iv);
2915            }
2916            continue;
2917          },
2918          Kind::Plain => {},
2919        }
2920        space_walk(root, next.clone());
2921        adjust_pair(root, &prev, &next, invisop.as_deref());
2922        prev = next;
2923      }
2924    },
2925  }
2926}
2927
2928/// Adjust the spacing between a visually adjacent pair. Port of Perl
2929/// `adjust_pair` (MathML.pm L1220-1284), all branches.
2930fn adjust_pair(root: &mut NodeData, prev: &[usize], next: &[usize], invisop: Option<&[usize]>) {
2931  let iprev = descend_embellishers(root, prev.to_vec());
2932  let inext = descend_embellishers(root, next.to_vec());
2933
2934  // Author spacing (in em) reads from the OUTER pair; role/opdict spacing
2935  // from the inner (possibly embellished) operator.
2936  let prev_req_right = get_node_attr_f64(node_at(root, prev), "_rpadding");
2937  let next_req_left = get_node_attr_f64(node_at(root, next), "_lpadding");
2938  let (iprev_tag, prev_role, prev_dict_right) = {
2939    let n = node_at(root, &iprev);
2940    (
2941      get_node_tag(n).to_string(),
2942      get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2943      get_node_attr_f64(n, "_rspace"),
2944    )
2945  };
2946  let (inext_tag, next_role, next_dict_left) = {
2947    let n = node_at(root, &inext);
2948    (
2949      get_node_tag(n).to_string(),
2950      get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2951      get_node_attr_f64(n, "_lspace"),
2952    )
2953  };
2954  let prev_type = m_atom_type(&iprev_tag).unwrap_or_else(|| role_to_atom_type(&prev_role));
2955  let next_type = m_atom_type(&inext_tag).unwrap_or_else(|| role_to_atom_type(&next_role));
2956  let tex_code = atompair_spacing(prev_type, next_type);
2957  let tex_space = TEX_SPACING[tex_code.unsigned_abs() as usize];
2958  let target = prev_req_right + next_req_left + tex_space;
2959  let default = prev_dict_right + next_dict_left;
2960  if (target - default).abs() <= SPACING_EPSILON {
2961    return;
2962  }
2963
2964  let prev_tag = get_node_tag(node_at(root, prev)).to_string();
2965  let next_tag = get_node_tag(node_at(root, next)).to_string();
2966  // In MathML Core neither mspace nor mpadded may have negative width, and
2967  // relative +/- widths are unsupported β€” so a NEGATIVE target rewraps prev
2968  // in an m:mpadded with an ADJUSTED absolute width (Perl L1252-1260,
2969  // compute_size L1135-1145: atoms only, string metrics of the default math
2970  // font, with the ridiculous-but-Perl minimum-10pt hack for mathscript).
2971  if target < 0.0 {
2972    let sizeable = match node_at(root, prev) {
2973      NodeData::Element { tag, attributes, .. } if walk_type(tag) == WalkType::Atom => Some(
2974        attributes
2975          .as_ref()
2976          .and_then(|a| a.get("class"))
2977          .cloned()
2978          .unwrap_or_default(),
2979      ),
2980      _ => None,
2981    };
2982    if let Some(class) = sizeable {
2983      let text = get_node_text(node_at(root, prev));
2984      let font = latexml_core::common::font::Font::math_default();
2985      let (w, _h, _d) = font.compute_string_size(&text, Default::default());
2986      let mut w_sp = w.0;
2987      // Perl L1140-1141: minimum of 10pt for mathscript β€” Dimension(10*65535)
2988      // (Perl's constant, one sp shy of 10pt), applied regardless of width
2989      // (Perl's $w is an always-truthy Dimension object).
2990      if class.contains("mathscript") {
2991        w_sp = w_sp.max(10 * 65535);
2992      }
2993      let mut reqw = (w_sp as f64 / 65536.0) / 10.0 + target;
2994      if reqw < 0.0 {
2995        reqw = 0.0;
2996      }
2997      let slot = node_at_mut(root, prev);
2998      let old = std::mem::replace(slot, NodeData::Text(String::new()));
2999      *slot = NodeData::Element {
3000        tag:        "m:mpadded".to_string(),
3001        attributes: Some(HashMap::from_iter([("width".to_string(), fmt_em(reqw))])),
3002        children:   vec![old],
3003      };
3004    }
3005  } else if prev_tag == "m:mspace" || next_tag == "m:mspace" {
3006    // Merge into the mspace's existing width (Perl L1261-1262).
3007    let target_path = if prev_tag == "m:mspace" { prev } else { next };
3008    let n = node_at_mut(root, target_path);
3009    let old_w = match n {
3010      NodeData::Element { attributes, .. } => attributes
3011        .as_ref()
3012        .and_then(|a| a.get("width"))
3013        .map(|w| super::get_xm_hint_spacing(w))
3014        .unwrap_or(0.0),
3015      _ => 0.0,
3016    };
3017    set_node_attr(n, "width", &fmt_em(target + old_w));
3018  } else if let Some(iv) = invisop {
3019    set_node_attr(node_at_mut(root, iv), "lspace", &fmt_em(target));
3020  } else if prev_tag == "m:mo" && next_tag == "m:mo" {
3021    // BOTH are mo: account for each one's dictionary spacing (Perl L1264-1275).
3022    let p = prev_dict_right;
3023    let n = next_dict_left;
3024    let rem = target - n;
3025    if rem >= 0.0 {
3026      let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
3027      set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(v));
3028    } else {
3029      let rem = target - p;
3030      if rem >= 0.0 {
3031        let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
3032        set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(v));
3033      } else {
3034        // Split the difference; Perl concatenates the raw number here
3035        // (`$rem . 'em'`), NOT fmt_em.
3036        let rem = target / 2.0;
3037        if rem != p {
3038          set_node_attr(
3039            node_at_mut(root, prev),
3040            "rspace",
3041            &format!("{}em", perl_num(rem)),
3042          );
3043        }
3044        if rem != n {
3045          set_node_attr(
3046            node_at_mut(root, next),
3047            "lspace",
3048            &format!("{}em", perl_num(rem)),
3049          );
3050        }
3051      }
3052    }
3053  } else if prev_tag == "m:mo" {
3054    set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(target));
3055  } else if next_tag == "m:mo" {
3056    set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(target));
3057  } else if (target - default).abs() > SPACING_FUDGE {
3058    Info!(
3059      "ignored",
3060      "spacing",
3061      "No place to set spacing to {target} (default {default})"
3062    );
3063  }
3064}
3065
3066/// Clean up internal _role/_lspace/_rspace attributes before serialization.
3067pub fn clean_internal_attrs(node: &mut NodeData) {
3068  if let NodeData::Element { attributes, children, .. } = node {
3069    if let Some(attrs) = attributes {
3070      attrs.remove("_role");
3071      attrs.remove("_lspace");
3072      attrs.remove("_rspace");
3073      attrs.remove("_largeop");
3074      attrs.remove("_lpadding");
3075      attrs.remove("_rpadding");
3076      attrs.remove("_ignorable");
3077      if attrs.is_empty() {
3078        *attributes = None;
3079      }
3080    }
3081    for child in children {
3082      clean_internal_attrs(child);
3083    }
3084  }
3085}
3086
3087#[cfg(test)]
3088mod tests {
3089  use rustc_hash::FxHashMap as HashMap;
3090
3091  use super::*;
3092
3093  #[test]
3094  fn math_style_step_down_monotone_saturates_at_scriptscript() {
3095    assert_eq!(MathStyle::Display.step_down(), MathStyle::Text);
3096    assert_eq!(MathStyle::Text.step_down(), MathStyle::Script);
3097    assert_eq!(MathStyle::Script.step_down(), MathStyle::ScriptScript);
3098    assert_eq!(MathStyle::ScriptScript.step_down(), MathStyle::ScriptScript);
3099  }
3100
3101  #[test]
3102  fn math_style_script_step_collapses_display_and_text() {
3103    assert_eq!(MathStyle::Display.script_step(), MathStyle::Script);
3104    assert_eq!(MathStyle::Text.script_step(), MathStyle::Script);
3105    assert_eq!(MathStyle::Script.script_step(), MathStyle::ScriptScript);
3106    assert_eq!(
3107      MathStyle::ScriptScript.script_step(),
3108      MathStyle::ScriptScript
3109    );
3110  }
3111
3112  #[test]
3113  fn math_style_size_percent_matches_tex_tradition() {
3114    assert_eq!(MathStyle::Display.size_percent(), "100%");
3115    assert_eq!(MathStyle::Text.size_percent(), "100%");
3116    assert_eq!(MathStyle::Script.size_percent(), "70%");
3117    assert_eq!(MathStyle::ScriptScript.size_percent(), "50%");
3118  }
3119
3120  #[test]
3121  fn invisible_times_roundtrip() {
3122    set_invisible_times(false);
3123    assert!(!get_invisible_times());
3124    set_invisible_times(true);
3125    assert!(get_invisible_times());
3126  }
3127
3128  #[test]
3129  fn embellishing_role_matches_canonical_set() {
3130    for r in [
3131      "SUPERSCRIPTOP",
3132      "SUBSCRIPTOP",
3133      "OVERACCENT",
3134      "UNDERACCENT",
3135      "MODIFIER",
3136      "MODIFIEROP",
3137    ] {
3138      assert!(is_embellishing_role(r), "{} should embellish", r);
3139    }
3140  }
3141
3142  #[test]
3143  fn embellishing_role_rejects_others() {
3144    for r in ["ADDOP", "MULOP", "ATOM", "UNKNOWN", ""] {
3145      assert!(!is_embellishing_role(r), "{} should not embellish", r);
3146    }
3147  }
3148
3149  #[test]
3150  fn default_token_content_maps_invisible_chars() {
3151    assert_eq!(default_token_content("MULOP"), Some("\u{2062}"));
3152    assert_eq!(default_token_content("ADDOP"), Some("\u{2064}"));
3153    assert_eq!(default_token_content("PUNCT"), Some("\u{2063}"));
3154  }
3155
3156  #[test]
3157  fn default_token_content_none_for_other_roles() {
3158    assert_eq!(default_token_content("ATOM"), None);
3159    assert_eq!(default_token_content(""), None);
3160    assert_eq!(default_token_content("RELOP"), None);
3161  }
3162
3163  #[test]
3164  fn clean_internal_attrs_removes_underscore_attrs() {
3165    let mut node = NodeData::Element {
3166      tag:        "mrow".to_string(),
3167      attributes: Some(HashMap::from_iter([
3168        ("_role".to_string(), "MULOP".to_string()),
3169        ("_lspace".to_string(), "4".to_string()),
3170        ("keep".to_string(), "yes".to_string()),
3171      ])),
3172      children:   vec![],
3173    };
3174    clean_internal_attrs(&mut node);
3175    if let NodeData::Element { attributes, .. } = &node {
3176      let attrs = attributes
3177        .as_ref()
3178        .expect("still has the non-internal attr");
3179      assert_eq!(attrs.len(), 1);
3180      assert_eq!(attrs.get("keep").map(String::as_str), Some("yes"));
3181    } else {
3182      panic!("expected element");
3183    }
3184  }
3185
3186  #[test]
3187  fn clean_internal_attrs_unsets_attributes_when_empty() {
3188    let mut node = NodeData::Element {
3189      tag:        "mrow".to_string(),
3190      attributes: Some(HashMap::from_iter([
3191        ("_role".to_string(), "MULOP".to_string()),
3192        ("_largeop".to_string(), "true".to_string()),
3193      ])),
3194      children:   vec![],
3195    };
3196    clean_internal_attrs(&mut node);
3197    if let NodeData::Element { attributes, .. } = &node {
3198      // All attrs were internal β†’ attributes becomes None.
3199      assert!(attributes.is_none());
3200    } else {
3201      panic!("expected element");
3202    }
3203  }
3204
3205  #[test]
3206  fn clean_internal_attrs_recurses_into_children() {
3207    let mut node = NodeData::Element {
3208      tag:        "mrow".to_string(),
3209      attributes: None,
3210      children:   vec![NodeData::Element {
3211        tag:        "mi".to_string(),
3212        attributes: Some(HashMap::from_iter([(
3213          "_rspace".to_string(),
3214          "1".to_string(),
3215        )])),
3216        children:   vec![],
3217      }],
3218    };
3219    clean_internal_attrs(&mut node);
3220    if let NodeData::Element { children, .. } = &node {
3221      if let NodeData::Element { attributes, .. } = &children[0] {
3222        assert!(attributes.is_none(), "recursion cleared child's only attr");
3223      } else {
3224        panic!("expected element child");
3225      }
3226    } else {
3227      panic!("expected element root");
3228    }
3229  }
3230
3231  #[test]
3232  fn clean_internal_attrs_ignores_text_nodes() {
3233    let mut node = NodeData::Text("x".to_string());
3234    clean_internal_attrs(&mut node);
3235    match &node {
3236      NodeData::Text(s) => assert_eq!(s, "x"),
3237      _ => panic!("expected text untouched"),
3238    }
3239  }
3240  #[test]
3241  fn test_role_to_atom_type() {
3242    // Perl MathML.pm $role_atomtype (L1150)
3243    assert_eq!(role_to_atom_type("ID"), "Ord");
3244    assert_eq!(role_to_atom_type("NUMBER"), "Ord");
3245    assert_eq!(role_to_atom_type("ADDOP"), "Bin");
3246    assert_eq!(role_to_atom_type("RELOP"), "Rel");
3247    assert_eq!(role_to_atom_type("OPEN"), "Open");
3248    assert_eq!(role_to_atom_type("CLOSE"), "Close");
3249    assert_eq!(role_to_atom_type("SUMOP"), "Op");
3250    assert_eq!(role_to_atom_type("PUNCT"), "Punct");
3251    assert_eq!(role_to_atom_type("ARRAY"), "Inner");
3252    assert_eq!(role_to_atom_type("no-such-role"), "Ord");
3253  }
3254
3255  #[test]
3256  fn test_atompair_spacing() {
3257    // Perl MathML.pm $atompair_spacing (L1196): negative = display/text-style only
3258    assert_eq!(atompair_spacing("Ord", "Op"), 1);
3259    assert_eq!(atompair_spacing("Ord", "Bin"), -2);
3260    assert_eq!(atompair_spacing("Rel", "Ord"), -3);
3261    assert_eq!(atompair_spacing("Open", "Ord"), 0);
3262    assert_eq!(atompair_spacing("Open", "Open"), 0);
3263    assert_eq!(atompair_spacing("Punct", "Bin"), 0);
3264    // The full Inner row (Perl L1207) β€” the (Inner, Punct) cell was MISSING
3265    // until 2026-07-02 (PR_READINESS review): matrix-then-comma lost its
3266    // thin space.
3267    assert_eq!(atompair_spacing("Inner", "Ord"), -1);
3268    assert_eq!(atompair_spacing("Inner", "Op"), 1);
3269    assert_eq!(atompair_spacing("Inner", "Bin"), -2);
3270    assert_eq!(atompair_spacing("Inner", "Rel"), -3);
3271    assert_eq!(atompair_spacing("Inner", "Open"), -1);
3272    assert_eq!(atompair_spacing("Inner", "Close"), 0);
3273    assert_eq!(atompair_spacing("Inner", "Punct"), -1);
3274    assert_eq!(atompair_spacing("Inner", "Inner"), -1);
3275  }
3276
3277  #[test]
3278  fn test_fmt_em() {
3279    // Perl fmt_em (L1285) byte-parity: %.3f keeps trailing zeros (audit F4).
3280    assert_eq!(fmt_em(0.0), "0em");
3281    assert_eq!(fmt_em(1.0), "1.000em");
3282    assert_eq!(fmt_em(0.167), "0.167em");
3283    assert_eq!(fmt_em(0.33), "0.330em");
3284    assert_eq!(fmt_em(1.2), "1.200em");
3285  }
3286}