Skip to main content

latexml_post/mathml/
mod.rs

1//! MathML conversion processor.
2//!
3//! Port of `LaTeXML::Post::MathML` (2162 lines) + submodules:
4//! - `Presentation.pm` (146 lines) — Presentation MathML rendering rules
5//! - `Content.pm` (31 lines) — Content MathML rendering rules
6//! - `Linebreaker.pm` (1053 lines) — MathML line-breaking algorithm
7//! - `OperatorDictionary.pm` (252 lines) — Operator symbol table
8//!
9//! This is the primary math conversion format for web output.
10//! Converts XMath parsed math into Presentation MathML and/or Content MathML.
11
12pub mod content;
13pub mod linebreaker;
14pub mod operator_dictionary;
15pub mod presentation;
16
17use libxml::tree::Node;
18use rustc_hash::FxHashMap as HashMap;
19
20use crate::{
21  document::{NodeData, PostDocument},
22  math_processor::{MathConversion, MathProcessor, math_is_parsed, process_math},
23  processor::{ProcessResult, Processor},
24};
25
26const MML_URI: &str = "http://www.w3.org/1998/Math/MathML";
27const MML_MIMETYPE: &str = "application/mathml-presentation+xml";
28const CMML_MIMETYPE: &str = "application/mathml-content+xml";
29
30/// MathML post-processor.
31///
32/// Port of `LaTeXML::Post::MathML`.
33/// Handles both Presentation and Content MathML conversion.
34pub struct MathML {
35  name:            String,
36  is_secondary:    bool,
37  /// Whether to produce Content MathML (vs Presentation).
38  content_mathml:  bool,
39  /// Whether to remap styled alphanumerics to Unicode's Plane-1 Mathematical
40  /// Alphanumeric Symbols. Perl `$$MATHPROCESSOR{plane1}`, default on
41  /// (`preprocess` L70); `--noplane1` keeps ASCII + a `mathvariant` attribute.
42  plane1:          bool,
43  /// Perl `$$MATHPROCESSOR{hackplane1}` (`--hackplane1`): remap only the
44  /// variants in `plane1_hackable`, and to the simpler variant named there.
45  /// Implies `plane1` (Perl L71).
46  hack_plane1:     bool,
47  /// Whether to enable line-breaking.
48  linebreaking:    bool,
49  /// Line width for line-breaking.
50  line_width:      u32,
51  /// Whether to keep the XMath nodes alongside the generated MathML.
52  keep_xmath:      bool,
53  /// Whether to emit invisible times (U+2062). When false, replaces with zero-width space.
54  /// Perl: $$MATHPROCESSOR{invisibletimes} — defaults to true.
55  invisible_times: bool,
56  /// Whether to include TeX source annotation in parallel MathML.
57  /// Perl: --mathtex adds <m:annotation encoding='application/x-tex'>
58  mathtex:         bool,
59  /// Whether to add intent=":literal" on all `<math>` elements.
60  /// ar5iv.sty.ltxml monkey-patches outerWrapper to add this.
61  intent_literal:  bool,
62  /// Parallel-markup secondaries (e.g. a Content-MathML processor under a
63  /// Presentation-MathML primary). Held by the primary rather than registered
64  /// as independent chain passes: during the primary's `process_math_node`,
65  /// each secondary's `convert_node` runs against the still-live XMath and the
66  /// results are folded into one `<m:semantics>` via [`combine_parallel`].
67  /// Port of Perl `MathProcessor`'s primary→secondary parallel model
68  /// (`$$self{parallel}` / `combineParallel`). Empty for a standalone format.
69  secondaries:     Vec<Box<dyn MathProcessor>>,
70}
71
72impl MathML {
73  /// Create a Presentation MathML processor.
74  pub fn new_presentation() -> Self {
75    MathML {
76      name:            "MathML[Presentation]".to_string(),
77      is_secondary:    false,
78      content_mathml:  false,
79      plane1:          true,
80      hack_plane1:     false,
81      linebreaking:    false,
82      line_width:      80,
83      keep_xmath:      false,
84      invisible_times: true,
85      mathtex:         false,
86      intent_literal:  false,
87      secondaries:     Vec::new(),
88    }
89  }
90
91  /// Create a Content MathML processor.
92  pub fn new_content() -> Self {
93    MathML {
94      name:            "MathML[Content]".to_string(),
95      is_secondary:    false,
96      content_mathml:  true,
97      plane1:          true,
98      hack_plane1:     false,
99      linebreaking:    false,
100      line_width:      80,
101      keep_xmath:      false,
102      invisible_times: true,
103      mathtex:         false,
104      intent_literal:  false,
105      secondaries:     Vec::new(),
106    }
107  }
108
109  /// Enable intent=":literal" on all `<math>` elements.
110  /// Perl: ar5iv.sty.ltxml monkey-patches outerWrapper for this.
111  pub fn with_intent_literal(mut self, enable: bool) -> Self {
112    self.intent_literal = enable;
113    self
114  }
115
116  /// Enable line-breaking with the given width.
117  pub fn with_linebreaking(mut self, width: u32) -> Self {
118    self.linebreaking = true;
119    self.line_width = width;
120    self
121  }
122
123  /// Keep XMath nodes in the output alongside MathML.
124  pub fn with_keep_xmath(mut self, keep: bool) -> Self {
125    self.keep_xmath = keep;
126    self
127  }
128
129  /// Set whether to emit invisible times (U+2062) in MathML output.
130  /// When false, invisible times is replaced with zero-width space (U+200B).
131  /// Perl: --noinvisibletimes
132  pub fn with_invisible_times(mut self, emit: bool) -> Self {
133    self.invisible_times = emit;
134    self
135  }
136
137  /// Set the Plane-1 remapping mode. `plane1` off keeps ASCII text plus a
138  /// `mathvariant` attribute; `hack_plane1` remaps only the poorly-supported
139  /// variants and implies `plane1`. Perl `--plane1` / `--hackplane1`.
140  pub fn with_plane1(mut self, plane1: bool, hack_plane1: bool) -> Self {
141    self.plane1 = plane1;
142    self.hack_plane1 = hack_plane1;
143    self
144  }
145
146  /// Enable TeX source annotation in MathML output (--mathtex).
147  pub fn with_mathtex(mut self, enable: bool) -> Self {
148    self.mathtex = enable;
149    self
150  }
151
152  /// Mark this processor as a parallel-markup secondary (e.g. the Content-MathML
153  /// format under a Presentation primary). Secondaries get their format-specific
154  /// `id_suffix` and are folded into the primary's `<m:semantics>` rather than
155  /// emitted as a standalone `<m:math>`. Port of `MathProcessor`'s secondary role.
156  pub fn secondary(mut self) -> Self {
157    self.is_secondary = true;
158    self
159  }
160
161  /// Attach parallel-markup secondaries to this (primary) processor. Their
162  /// conversions are merged into one `<m:semantics>` by
163  /// [`combine_parallel`](MathProcessor::combine_parallel)
164  /// during the primary's pass. Mirrors Perl `MathProcessor`'s primary holding
165  /// its parallel secondaries.
166  pub fn with_secondaries(mut self, secondaries: Vec<Box<dyn MathProcessor>>) -> Self {
167    self.secondaries = secondaries;
168    self
169  }
170}
171
172impl Processor for MathML {
173  fn get_name(&self) -> &str { &self.name }
174
175  fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
176    doc.findnodes("//ltx:Math[not(ancestor::ltx:Math)]")
177  }
178
179  fn process(&mut self, mut doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
180    // Register the MathML namespace so add_nodes can create m: elements
181    doc.add_namespace("m", MML_URI);
182
183    // Process all math nodes
184    process_math(self, &mut doc, nodes, self.keep_xmath)?;
185    Ok(vec![doc])
186  }
187}
188
189impl MathProcessor for MathML {
190  fn convert_node(&self, doc: &PostDocument, xmath: &Node) -> Option<MathConversion> {
191    // Set invisible_times flag for rendering
192    presentation::set_invisible_times(self.invisible_times);
193    presentation::set_plane1(self.plane1, self.hack_plane1);
194
195    let xml = if self.content_mathml {
196      content::convert_to_cmml(doc, xmath)
197    } else {
198      presentation::convert_to_pmml(doc, xmath)
199    };
200
201    let mimetype = if self.content_mathml {
202      CMML_MIMETYPE
203    } else {
204      MML_MIMETYPE
205    };
206
207    // If mathtex is enabled, wrap in <m:semantics> with TeX annotation.
208    // Skip when this primary carries parallel secondaries: `combine_parallel`
209    // then builds the single `<m:semantics>` (primary + content annotation-xml
210    // + the x-tex annotation), so wrapping here would double-nest semantics.
211    let final_xml = if self.mathtex && self.secondaries.is_empty() {
212      let tex_str = xmath
213        .get_parent()
214        .and_then(|p| p.get_attribute("tex"))
215        .unwrap_or_default();
216      if tex_str.is_empty() {
217        xml
218      } else {
219        NodeData::Element {
220          tag:        "m:semantics".to_string(),
221          attributes: None,
222          children:   vec![xml, NodeData::Element {
223            tag:        "m:annotation".to_string(),
224            attributes: Some(HashMap::from_iter([(
225              "encoding".to_string(),
226              "application/x-tex".to_string(),
227            )])),
228            children:   vec![NodeData::Text(tex_str)],
229          }],
230        }
231      }
232    } else {
233      xml
234    };
235
236    Some(MathConversion {
237      processor_name: self.name.clone(),
238      mimetype:       Some(mimetype.to_string()),
239      xml:            Some(final_xml),
240      string:         None,
241      src:            None,
242      width:          None,
243      height:         None,
244      depth:          None,
245    })
246  }
247
248  fn combine_parallel(
249    &self,
250    _doc: &PostDocument,
251    xmath: &Node,
252    primary: MathConversion,
253    secondaries: Vec<MathConversion>,
254  ) -> MathConversion {
255    if secondaries.is_empty() {
256      return primary;
257    }
258
259    // Build m:semantics element with primary + annotation-xml for secondaries
260    let mut children = Vec::new();
261    if let Some(ref xml) = primary.xml {
262      children.push(xml.clone());
263    }
264
265    for secondary in &secondaries {
266      let mimetype = secondary.mimetype.as_deref().unwrap_or("unknown");
267      // Parallel markup names the format via the canonical encoding label
268      // (e.g. `MathML-Content`), not the raw internal mimetype. Port of
269      // `%ENCODINGS` / `encoding_for_mimetype`.
270      let encoding = encoding_for_mimetype(mimetype).to_string();
271      if let Some(ref xml) = secondary.xml {
272        children.push(NodeData::Element {
273          tag:        "m:annotation-xml".to_string(),
274          attributes: Some(HashMap::from_iter([("encoding".to_string(), encoding)])),
275          children:   vec![xml.clone()],
276        });
277      } else if let Some(ref string) = secondary.string {
278        children.push(NodeData::Element {
279          tag:        "m:annotation".to_string(),
280          attributes: Some(HashMap::from_iter([("encoding".to_string(), encoding)])),
281          children:   vec![NodeData::Text(string.clone())],
282        });
283      }
284    }
285
286    // TeX source annotation. In the standalone (no-secondary) path this is added
287    // by `convert_node`; in the parallel path that wrap is skipped, so the
288    // single combined `<m:semantics>` carries the x-tex annotation here.
289    if self.mathtex {
290      let tex_str = xmath
291        .get_parent()
292        .and_then(|p| p.get_attribute("tex"))
293        .unwrap_or_default();
294      if !tex_str.is_empty() {
295        children.push(NodeData::Element {
296          tag:        "m:annotation".to_string(),
297          attributes: Some(HashMap::from_iter([(
298            "encoding".to_string(),
299            "application/x-tex".to_string(),
300          )])),
301          children:   vec![NodeData::Text(tex_str)],
302        });
303      }
304    }
305
306    MathConversion {
307      processor_name: self.name.clone(),
308      mimetype:       Some(MML_MIMETYPE.to_string()),
309      xml:            Some(NodeData::Element {
310        tag: "m:semantics".to_string(),
311        attributes: None,
312        children,
313      }),
314      string:         None,
315      src:            None,
316      width:          None,
317      height:         None,
318      depth:          None,
319    }
320  }
321
322  fn outer_wrapper(&self, _doc: &PostDocument, xmath: &Node, conversion: NodeData) -> NodeData {
323    let mut attrs = HashMap::default();
324    // Determine display mode and alttext from parent Math element
325    // Port of MathML::outerWrapper (L77-100)
326    if let Some(math) = xmath.get_parent() {
327      let mode = math
328        .get_attribute("mode")
329        .unwrap_or_else(|| "inline".to_string());
330      attrs.insert(
331        "display".to_string(),
332        if mode == "display" {
333          "block".to_string()
334        } else {
335          "inline".to_string()
336        },
337      );
338      if let Some(tex) = math.get_attribute("tex") {
339        attrs.insert("alttext".to_string(), tex);
340      }
341      if let Some(class) = math.get_attribute("class") {
342        attrs.insert("class".to_string(), class);
343      }
344
345      // Image fallback (Perl L81-87): when the `--mathimages` post-processor has
346      // rendered this formula, advertise the bitmap so a renderer without MathML
347      // support can show it. `altimg-valign` carries the baseline offset and Perl
348      // NEGATES the depth ("Note the sign!"): `imagedepth="5"` → `-5px`.
349      if let Some(src) = math.get_attribute("imagesrc").filter(|s| !s.is_empty()) {
350        attrs.insert("altimg".to_string(), src);
351        // Perl appends 'px' unconditionally once `imagesrc` is present, so a
352        // missing `imagewidth` yields the literal `"px"` rather than omitting the
353        // attribute. Mirrored: `--mathimages` always sets both dimensions, so the
354        // quirk is unreachable in practice, and diverging here would cost
355        // byte-parity for no gain.
356        attrs.insert(
357          "altimg-width".to_string(),
358          format!("{}px", math.get_attribute("imagewidth").unwrap_or_default()),
359        );
360        attrs.insert(
361          "altimg-height".to_string(),
362          format!(
363            "{}px",
364            math.get_attribute("imageheight").unwrap_or_default()
365          ),
366        );
367        // Perl-falsy depth (absent, empty, or "0") omits the attribute entirely
368        // rather than emitting a bare "-px" or "-0px".
369        if let Some(depth) = math
370          .get_attribute("imagedepth")
371          .filter(|d| !d.is_empty() && d != "0")
372        {
373          attrs.insert("altimg-valign".to_string(), format!("-{depth}px"));
374        }
375      }
376
377      // RDFa (Perl L88-90): the Math element's own value, else the XMath's.
378      // Perl's `$math->getAttribute($_) || $xmath->getAttribute($_)` is a TRUTH
379      // test, so an EMPTY value on the Math falls through to the XMath rather
380      // than shadowing it; the trailing `$val ? … : ()` then drops the pair if
381      // neither had one.
382      for key in [
383        "about", "resource", "property", "rel", "rev", "typeof", "datatype", "content",
384      ] {
385        let non_empty = |n: &Node| n.get_attribute(key).filter(|v| !v.is_empty());
386        if let Some(val) = non_empty(&math).or_else(|| non_empty(xmath)) {
387          attrs.insert(key.to_string(), val);
388        }
389      }
390    }
391
392    // ar5iv.sty.ltxml: intent=":literal" for all math elements
393    if self.intent_literal {
394      attrs.insert("intent".to_string(), ":literal".to_string());
395    }
396
397    NodeData::Element {
398      tag:        "m:math".to_string(),
399      attributes: Some(attrs),
400      children:   vec![conversion],
401    }
402  }
403
404  fn raw_id_suffix(&self) -> &str {
405    if self.content_mathml {
406      ".cmml"
407    } else {
408      ".pmml"
409    }
410  }
411
412  fn is_secondary(&self) -> bool { self.is_secondary }
413
414  fn can_convert(&self, _doc: &PostDocument, math: &Node) -> bool {
415    // Content MathML requires parsed math
416    if self.content_mathml {
417      math_is_parsed(math)
418    } else {
419      true
420    }
421  }
422
423  fn parallel_secondaries(&self) -> &[Box<dyn MathProcessor>] { &self.secondaries }
424
425  fn preprocess(&self, _doc: &PostDocument, _nodes: &[Node]) {
426    // Register MathML namespace
427    log::trace!("MathML: would register m namespace for {}", MML_URI);
428  }
429}
430
431/// MathML encoding names for parallel markup annotation-xml.
432///
433/// Port of `%ENCODINGS`.
434pub fn encoding_for_mimetype(mimetype: &str) -> &str {
435  match mimetype {
436    "application/mathml-presentation+xml" => "MathML-Presentation",
437    "application/mathml-content+xml" => "MathML-Content",
438    "image/svg+xml" => "SVG1.1",
439    _ => mimetype,
440  }
441}
442
443/// Math style step-down table.
444///
445/// Port of `%stylestep`.
446pub fn style_step(style: &str) -> &str {
447  match style {
448    "display" => "text",
449    "text" => "script",
450    "script" => "scriptscript",
451    _ => "scriptscript",
452  }
453}
454
455/// Size percentage for math styles.
456///
457/// Port of `%stylesize`.
458pub fn style_size(style: &str) -> &str {
459  match style {
460    "display" | "text" => "100%",
461    "script" => "70%",
462    _ => "50%",
463  }
464}
465
466/// Perl's `%attr` for `pmml_text_aux` — the presentation attributes an
467/// enclosing `ltx:*` element contributes to the `m:mtext` elements below it.
468///
469/// Perl threads an open hash (`MathML.pm` L1029, L1041-1045), but only these
470/// five keys are ever written on this path, so they are named fields. The other
471/// keys `stylizeContent` consults (`role`, `class`, `cssstyle`, `href`, `title`,
472/// `stretchy`) are never set by this caller — they come off the node itself.
473#[derive(Clone, Default, Debug)]
474pub struct TextAttrs {
475  font:            Option<String>,
476  fontsize:        Option<String>,
477  color:           Option<String>,
478  backgroundcolor: Option<String>,
479  opacity:         Option<String>,
480}
481
482impl TextAttrs {
483  /// Overlay `node`'s own presentation attributes, as Perl `pmml_text_aux`
484  /// L1041-1045 does: an attribute PRESENT on the element wins over what was
485  /// inherited; an absent one leaves the inherited value in place.
486  fn overlay(&self, node: &Node) -> Self {
487    let pick = |cur: &Option<String>, name: &str| -> Option<String> {
488      node
489        .get_attribute(name)
490        .filter(|v| !v.is_empty())
491        .or_else(|| cur.clone())
492    };
493    Self {
494      font:            pick(&self.font, "font"),
495      fontsize:        pick(&self.fontsize, "fontsize"),
496      color:           pick(&self.color, "color"),
497      backgroundcolor: pick(&self.backgroundcolor, "backgroundcolor"),
498      opacity:         pick(&self.opacity, "opacity"),
499    }
500  }
501}
502
503/// The first direct element child of `node` with the given namespace URI and
504/// local name — Perl's `findnode('<prefix>:<name>', $node)` without depending on
505/// the prefix being registered in the document's XPath context.
506fn element_child_named(node: &Node, ns_uri: &str, local: &str) -> Option<Node> {
507  let mut current = node.get_first_child();
508  while let Some(c) = current {
509    if c.get_type() == Some(libxml::tree::NodeType::ElementNode)
510      && c.get_name() == local
511      && c.get_namespace().map(|ns| ns.get_href()).as_deref() == Some(ns_uri)
512    {
513      return Some(c);
514    }
515    current = c.get_next_sibling();
516  }
517  None
518}
519
520/// Perl's `\p{Format}` over the codepoints that occur in math content — the
521/// same approximation `pmml_token_inner` uses, so the two arms agree.
522fn is_format_char(c: char) -> bool {
523  matches!(c,
524    '\u{00AD}' | '\u{200B}'..='\u{200F}' | '\u{2060}'..='\u{2064}' | '\u{FEFF}')
525}
526
527/// `stylizeContent` (`MathML.pm` L672-828) for the `$tag eq 'm:mtext'` case —
528/// the styling half of the `pmml_text_aux` path.
529///
530/// **The token half of the same Perl function lives in
531/// `presentation::pmml_token_inner`**, the golden-guarded faithful copy of the
532/// `m:mi`/`m:mo`/`m:mn` branches (operator dictionary, plane-1 remapping,
533/// stretch/size interplay). Perl is one function; the Rust split is by target
534/// tag, and neither half should grow the other's branches. (This function used
535/// to be a whole second copy of `stylizeContent`, tag-generic and dead — nothing
536/// called it, so its `m:mo` arm had drifted out of parity unnoticed.)
537///
538/// What `m:mtext` reaches, and hence emits:
539/// - **no `mathvariant`** — Perl clears it unconditionally for `m:mtext`
540///   (L756-757); a font survives only as an `ltx_font_*` / `ltx_mathvariant_*`
541///   CSS class.
542/// - **no plane-1 remapping** — guarded off by `($tag ne 'm:mtext')` (L737).
543/// - **no `href`/`title`** — gated on `$istoken` (L691-692).
544/// - **no operator-dictionary attributes** — `%props` is filled only for `m:mo`
545///   (L764), and `$stretchy` is cleared for every other tag (L767). So Perl's
546///   `delete $mmlattr{stretchy}` in `pmml_text_aux` (L1069) is belt-and-braces
547///   over something already absent, and has nothing to port.
548///
549/// `node` is `None` for a text node — Perl's non-`XML_ELEMENT_NODE` `$item`,
550/// which contributes no attributes of its own (`$iselement` is false).
551///
552/// Returns Perl's `($text, %mmlattr)` pair. The text can differ from the input
553/// only via the empty-item failsafe below; the caller that discards it (the
554/// raw-markup arm, Perl's `my ($ignore, %mmlattr)`) is doing what Perl does.
555fn stylize_text_content(
556  node: Option<&Node>,
557  attrs: &TextAttrs,
558  text: &str,
559) -> (String, HashMap<String, String>) {
560  let attr_of = |name: &str| -> Option<String> {
561    node
562      .and_then(|n| n.get_attribute(name))
563      .filter(|v| !v.is_empty())
564  };
565  // Perl L677-686: the passed-in %attr wins, then the item's own attribute,
566  // then the inherited context.
567  let font = attrs
568    .font
569    .clone()
570    .or_else(|| attr_of("font"))
571    .or_else(presentation::ctx_font);
572  let size = attrs.fontsize.clone().or_else(|| attr_of("fontsize"));
573  let color = attrs
574    .color
575    .clone()
576    .or_else(|| attr_of("color"))
577    .or_else(presentation::ctx_color);
578  // NB Perl L683-684 reads `$attr{backgroundcolor} && ($iselement &&
579  // $item->getAttribute('backgroundcolor')) || $BGCOLOR`, so the item's own
580  // attribute counts only when an inherited one is ALSO set, and the result is
581  // then the item's. Mirrored, quirk included; `pmml_token_inner` carries the
582  // same note for the token arm.
583  let bgcolor = attrs
584    .backgroundcolor
585    .as_ref()
586    .and_then(|_| attr_of("backgroundcolor"))
587    .or_else(presentation::ctx_bgcolor);
588  let opacity = attrs
589    .opacity
590    .clone()
591    .or_else(|| attr_of("opacity"))
592    .or_else(presentation::ctx_opacity);
593  let mut class = attr_of("class");
594  // NB Perl reads `$attr{ccsstyle}` here (L686) — three c's, a typo for
595  // `cssstyle`, so the passed-in half never contributes and only the item's own
596  // attribute is ever seen. `%attr` carries no cssstyle on this path either way.
597  let mut cssstyle = attr_of("cssstyle");
598
599  // Perl L707-713: the failsafe for an item with nothing to show. An invisible
600  // operator supplies its own character; anything else falls back to the item's
601  // name, meaning or role — or a literal `?` for a bare text node — and is
602  // painted red, since arriving here means something upstream emitted an empty
603  // token. The red usually does NOT survive: an all-empty fallback is caught by
604  // the Format test below, which clears the color again.
605  let role = attr_of("role");
606  let mut color = color;
607  let text = if text.is_empty() {
608    match role
609      .as_deref()
610      .and_then(presentation::default_token_content)
611    {
612      Some(default) => default.to_string(),
613      None => {
614        color = Some("red".to_string());
615        if node.is_some() {
616          attr_of("name")
617            .or_else(|| attr_of("meaning"))
618            .or(role)
619            .unwrap_or_default()
620        } else {
621          "?".to_string()
622        }
623      },
624    }
625  } else {
626    text.to_string()
627  };
628
629  // Perl L744-745: purely-Format content (invisible times/apply/separator, …)
630  // needs no visual styling attributes at all.
631  let (font, color, bgcolor, opacity) = if text.chars().all(is_format_char) {
632    (None, None, None, None)
633  } else {
634    (font, color, bgcolor, opacity)
635  };
636
637  // Perl L746-756: patch up weak font translations with a CSS class. For
638  // `m:mtext` this is the ONLY channel a font has, since the mathvariant is
639  // dropped below.
640  if let Some(ref f) = font {
641    let extra = if f.contains("caligraphic") {
642      Some("ltx_font_mathcaligraphic".to_string())
643    } else if f.contains("script") {
644      Some("ltx_font_mathscript".to_string())
645    } else if f.contains("fraktur") && text.chars().all(|c| "+-0123456789.".contains(c)) {
646      Some("ltx_font_oldstyle".to_string())
647    } else if f.contains("smallcaps") {
648      Some("ltx_font_smallcaps".to_string())
649    } else {
650      Some(crate::unicode::unicode_mathvariant(f))
651        .filter(|v| *v != "normal")
652        .map(|v| format!("ltx_mathvariant_{v}"))
653    };
654    if let Some(extra) = extra {
655      class = Some(match class {
656        Some(c) if !c.is_empty() => format!("{c} {extra}"),
657        _ => extra,
658      });
659    }
660  }
661
662  // Perl L758-759: opacity folds into the css style.
663  if let Some(op) = opacity {
664    cssstyle = Some(match cssstyle {
665      Some(c) if !c.is_empty() => format!("{c};opacity:{op}"),
666      _ => format!("opacity:{op}"),
667    });
668  }
669
670  let mut out: HashMap<String, String> = HashMap::default();
671  // Perl L770-771: text that is empty or purely invisible operators gets no
672  // size (nor stretchiness, which an `m:mtext` could not carry anyway). Note
673  // this is a NARROWER class than the `\p{Format}` test above — only the three
674  // invisible operators — so the two cannot be folded together.
675  let size = size.filter(|_| !text.chars().all(|c| matches!(c, '\u{2061}'..='\u{2063}')));
676
677  // Perl L779-797: emit a size only when it differs from the style's nominal
678  // size, re-expressed relative to a script context and converted to em. The
679  // `stretchyhack` minsize/maxsize arm needs `$issymm`, which for a non-`m:mo`
680  // tag reduces to `$text eq '/'` (L703) — `$islargeop` needs a SUMOP/INTOP
681  // role and `$props{symmetric}` is `m:mo`-only.
682  if let Some(s) = size.filter(|s| s != presentation::context_size()) {
683    let s = presentation::resolve_size(s);
684    if text == "/" {
685      out.insert("minsize".to_string(), s.clone());
686      out.insert("maxsize".to_string(), s);
687    } else {
688      out.insert("mathsize".to_string(), s);
689    }
690  }
691  if let Some(c) = color {
692    out.insert("mathcolor".to_string(), c);
693  }
694  if let Some(bg) = bgcolor {
695    out.insert("mathbackground".to_string(), bg);
696  }
697  if let Some(style) = cssstyle.filter(|s| !s.is_empty()) {
698    out.insert("style".to_string(), style);
699  }
700  if let Some(c) = class.filter(|c| !c.is_empty()) {
701    out.insert("class".to_string(), c);
702  }
703  (text, out)
704}
705
706/// Convert an XMHint spacing attribute to em value.
707///
708/// Port of `getXMHintSpacing`.
709pub fn get_xm_hint_spacing(width: &str) -> f64 {
710  // Perl (MathML.pm L380-385): /^([\d\.\+\-]+)(pt|mu|em)(\s+plus\s+…)?(\s+minus\s+…)?$/
711  // — a GLUE width ("3.0pt plus 2.0pt minus 1.0pt") contributes its natural
712  // part; the stretch/shrink tails are ignored.
713  let trimmed = width.trim();
714  let base = trimmed
715    .split(" plus ")
716    .next()
717    .unwrap_or(trimmed)
718    .split(" minus ")
719    .next()
720    .unwrap_or(trimmed)
721    .trim();
722  if let Some((num_str, unit)) = base
723    .rfind(|c: char| c.is_ascii_digit() || c == '.')
724    .map(|i| (&base[..=i], base[i + 1..].trim()))
725  {
726    let num: f64 = num_str.parse().unwrap_or(0.0);
727    match unit {
728      "em" => num,
729      "mu" => num / 18.0,
730      "pt" => num / 10.0, // Assuming 10pt font
731      _ => 0.0,
732    }
733  } else {
734    0.0
735  }
736}
737
738/// Find an inherited attribute by walking up the LaTeXML ancestor chain.
739///
740/// Port of `find_inherited_attribute`.
741pub fn find_inherited_attribute(
742  _doc: &PostDocument,
743  node: &Node,
744  attribute: &str,
745) -> Option<String> {
746  let mut current = Some(node.clone());
747  while let Some(ref n) = current {
748    // Perl getQName returns undef for non-elements → stop. Also guards the
749    // FFI: reading the ns field of a Document node is a misaligned deref.
750    if n.get_type() != Some(libxml::tree::NodeType::ElementNode) {
751      break;
752    }
753    if let Some(ns) = n.get_namespace() {
754      if ns.get_href() != crate::document::LTX_NSURI {
755        break; // Stop at non-LaTeXML elements
756      }
757    }
758    if let Some(val) = n.get_attribute(attribute) {
759      return Some(val);
760    }
761    current = n.get_parent();
762  }
763  None
764}
765
766// ======================================================================
767// DefMathML converter dispatch table
768//
769// Port of the `%MMLTable_P` / `%MMLTable_C` lookup tables and the
770// 800+ lines of DefMathML declarations in MathML.pm.
771//
772// The Perl pattern is:
773//   DefMathML("Mode:Role:Meaning", \&pmml_handler, \&cmml_handler);
774// Lookup tries: "Mode:Role:Meaning", "Mode:?:Meaning", "Mode:Role:?", "Mode:?:?"
775//
776// In Rust, we encode this as a static table of known role→tag mappings
777// and meaning→element mappings, and the actual dispatch happens in
778// presentation.rs::pmml_apply() and content.rs::cmml().
779
780/// Presentation MathML tag for a token role.
781///
782/// Port of Token:ROLE:? DefMathML declarations.
783/// These map roles to their default MathML element type.
784pub fn pmml_tag_for_role(role: &str) -> &'static str {
785  match role {
786    // Operators → m:mo
787    "PUNCT" | "PERIOD" | "OPEN" | "CLOSE" | "MIDDLE" | "VERTBAR" | "ARROW" | "OVERACCENT"
788    | "UNDERACCENT" | "ADDOP" | "MULOP" | "BINOP" | "RELOP" | "METARELOP" | "MODIFIEROP"
789    | "COMPOSEOP" | "APPLYOP" | "OPERATOR" | "SUPOP" | "POSTFIX" | "DIFFOP" => "m:mo",
790    // Big operators → m:mo (with largeop)
791    "BIGOP" | "SUMOP" | "INTOP" | "LIMITOP" => "m:mo",
792    // Functions → m:mi (but rendered as operator names)
793    "FUNCTION" | "OPFUNCTION" | "TRIGFUNCTION" => "m:mi",
794    // Numbers → m:mn
795    "NUMBER" => "m:mn",
796    // Identifiers → m:mi (default)
797    _ => "m:mi",
798  }
799}
800
801/// Whether a role should use the "big operator" presentation style.
802///
803/// Port of `Token:INTOP:?` → `\&pmml_bigop`, `Token:SUMOP:?` → `\&pmml_bigop`, etc.
804pub fn is_bigop_role(role: &str) -> bool { matches!(role, "INTOP" | "SUMOP" | "BIGOP" | "LIMITOP") }
805
806/// Presentation handler type for XMApp nodes.
807///
808/// Port of the `Apply:ROLE:?` entries in DefMathML.
809/// Returns the handler category that presentation.rs should use.
810#[derive(Debug, Clone, Copy, PartialEq)]
811pub enum ApplyHandler {
812  /// Infix: op between args (ADDOP, MULOP, RELOP, etc.)
813  Infix,
814  /// Script: sub/superscript (SUPERSCRIPTOP, SUBSCRIPTOP)
815  Script,
816  /// Big operator with possible limits (SUMOP, INTOP, BIGOP, LIMITOP)
817  Summation,
818  /// Prefix: op before args (DIFFOP, default)
819  Prefix,
820  /// Postfix: args then op (POSTFIX)
821  Postfix,
822  /// Fraction (FRACOP)
823  Fraction,
824  /// Over accent (OVERACCENT)
825  OverAccent,
826  /// Under accent (UNDERACCENT)
827  UnderAccent,
828  /// Enclose (ENCLOSE)
829  Enclose,
830  /// Generic application (default)
831  Generic,
832}
833
834/// Determine the presentation handler for an XMApp based on operator role.
835///
836/// Port of the `Apply:ROLE:?` DefMathML declarations.
837pub fn apply_handler_for_role(role: &str) -> ApplyHandler {
838  match role {
839    "ADDOP" | "MULOP" | "BINOP" | "RELOP" | "METARELOP" | "ARROW" | "COMPOSEOP" | "MODIFIEROP"
840    | "MIDDLE" => ApplyHandler::Infix,
841    "SUPERSCRIPTOP" | "SUBSCRIPTOP" => ApplyHandler::Script,
842    "SUMOP" | "INTOP" | "BIGOP" | "LIMITOP" => ApplyHandler::Summation,
843    "DIFFOP" => ApplyHandler::Prefix,
844    "POSTFIX" => ApplyHandler::Postfix,
845    "FRACOP" => ApplyHandler::Fraction,
846    "OVERACCENT" => ApplyHandler::OverAccent,
847    "UNDERACCENT" => ApplyHandler::UnderAccent,
848    "ENCLOSE" => ApplyHandler::Enclose,
849    _ => ApplyHandler::Generic,
850  }
851}
852
853/// Determine the presentation handler for a specific meaning.
854///
855/// Port of the `Apply:?:meaning` DefMathML declarations.
856/// Returns Some(handler) if a meaning-specific handler exists, None for role-based fallback.
857pub fn apply_handler_for_meaning(meaning: &str) -> Option<ApplyHandler> {
858  match meaning {
859    "square-root" | "nth-root" => None, // Handled specially in pmml_apply
860    "formulae" | "multirelation" => Some(ApplyHandler::Infix),
861    "limit-from" | "annotated" => Some(ApplyHandler::Prefix),
862    "continued-fraction" => Some(ApplyHandler::Fraction),
863    _ => None,
864  }
865}
866
867/// Known Content MathML elements for specific meanings.
868///
869/// Port of the `Token:?:meaning` content DefMathML declarations.
870/// See also content.rs::meaning_to_cmml_element() for the full list.
871pub fn cmml_element_for_meaning(meaning: &str) -> Option<&'static str> {
872  content::meaning_to_cmml_element_pub(meaning)
873}
874
875/// Whether an XMApp with this meaning has a dedicated Content MathML structure.
876///
877/// Port of the `Apply:?:meaning` content DefMathML declarations.
878pub fn has_dedicated_cmml_structure(meaning: &str) -> bool {
879  matches!(
880    meaning,
881    "square-root"
882      | "nth-root"
883      | "set"
884      | "list"
885      | "open-interval"
886      | "closed-interval"
887      | "closed-open-interval"
888      | "open-closed-interval"
889      | "formulae"
890      | "multirelation"
891      | "cases"
892  )
893}
894
895// ======================================================================
896// Presentation MathML helpers
897//
898// Port of `pmml_maybe_resize`, `pmml_row`, `pmml_parenthesize`,
899// `pmml_text_aux`, `filter_row` from MathML.pm.
900
901/// Wrap items in an mrow, filtering out ignorable items.
902///
903/// Port of `pmml_row` + `filter_row`.
904pub fn pmml_row(items: Vec<NodeData>) -> NodeData {
905  // Filter out ignorable items (those with _ignorable attribute)
906  let filtered: Vec<NodeData> = items
907    .into_iter()
908    .filter(|item| match item {
909      NodeData::Element { attributes, .. } => {
910        if let Some(attrs) = attributes {
911          !attrs.contains_key("_ignorable")
912        } else {
913          true
914        }
915      },
916      _ => true,
917    })
918    .collect();
919
920  if filtered.len() == 1 {
921    filtered.into_iter().next().unwrap()
922  } else {
923    NodeData::Element {
924      tag:        "m:mrow".to_string(),
925      attributes: None,
926      children:   filtered,
927    }
928  }
929}
930
931/// Parenthesize an expression with open/close delimiters.
932///
933/// Port of `pmml_parenthesize`.
934pub fn pmml_parenthesize(item: NodeData, open: Option<&str>, close: Option<&str>) -> NodeData {
935  if open.is_none() && close.is_none() {
936    return item;
937  }
938
939  let mut children = Vec::new();
940  if let Some(o) = open {
941    children.push(NodeData::Element {
942      tag:        "m:mo".to_string(),
943      attributes: Some(HashMap::from_iter([
944        ("fence".to_string(), "true".to_string()),
945        ("stretchy".to_string(), "true".to_string()),
946      ])),
947      children:   vec![NodeData::Text(o.to_string())],
948    });
949  }
950  children.push(item);
951  if let Some(c) = close {
952    children.push(NodeData::Element {
953      tag:        "m:mo".to_string(),
954      attributes: Some(HashMap::from_iter([
955        ("fence".to_string(), "true".to_string()),
956        ("stretchy".to_string(), "true".to_string()),
957      ])),
958      children:   vec![NodeData::Text(c.to_string())],
959    });
960  }
961
962  NodeData::Element {
963    tag: "m:mrow".to_string(),
964    attributes: None,
965    children,
966  }
967}
968
969/// Punctuate a list of items with separators.
970///
971/// Port of `pmml_punctuate`.
972pub fn pmml_punctuate(separators: &str, items: Vec<NodeData>) -> NodeData {
973  if items.is_empty() {
974    return NodeData::Element {
975      tag:        "m:mrow".to_string(),
976      attributes: None,
977      children:   vec![],
978    };
979  }
980
981  let mut result = Vec::new();
982  let mut sep_chars: Vec<char> = separators.chars().collect();
983  let last_sep = if sep_chars.is_empty() {
984    ','
985  } else {
986    *sep_chars.last().unwrap()
987  };
988
989  let mut iter = items.into_iter();
990  result.push(iter.next().unwrap());
991
992  for item in iter {
993    let sep = if sep_chars.is_empty() {
994      last_sep
995    } else {
996      sep_chars.remove(0)
997    };
998    result.push(NodeData::Element {
999      tag:        "m:mo".to_string(),
1000      attributes: Some(HashMap::from_iter([(
1001        "separator".to_string(),
1002        "true".to_string(),
1003      )])),
1004      children:   vec![NodeData::Text(sep.to_string())],
1005    });
1006    result.push(item);
1007  }
1008
1009  pmml_row(result)
1010}
1011
1012/// Convert a text node within XMText to Presentation MathML.
1013///
1014/// Port of `pmml_text_aux` (`MathML.pm` L1029-1077). `attrs` is Perl's `%attr`:
1015/// the presentation attributes accumulated from the enclosing `ltx:*` elements,
1016/// which `stylize_text_content` then puts on the `m:mtext`. The top-level caller
1017/// (the `ltx:XMText` arm of `pmml_internal`, Perl L494-498) passes an empty set,
1018/// exactly as Perl's bare `pmml_text_aux($_)` does.
1019pub fn pmml_text_aux(doc: &PostDocument, node: &Node, attrs: &TextAttrs) -> Vec<NodeData> {
1020  use libxml::tree::NodeType;
1021
1022  match node.get_type() {
1023    Some(NodeType::TextNode) => {
1024      // Perl stylizes the RAW content first (L1034) and only then rewrites the
1025      // whitespace (L1035), so an empty text node reaches the failsafe as empty.
1026      let (text, attributes) = stylize_text_content(None, attrs, &node.get_content());
1027      // Perl L1035: `s/^\s+/NBSP/` then `s/\s+$/NBSP/` — a leading or trailing
1028      // whitespace RUN is REPLACED by a single NBSP, not trimmed away. (This arm
1029      // used to `trim_start()` unconditionally and only then test the
1030      // already-trimmed string with `starts_with(is_whitespace)`, which can never
1031      // be true — so leading space was silently dropped instead of becoming the
1032      // NBSP that keeps `$a \text{ and } b$` from closing up.)
1033      let head_trimmed = text.trim_start();
1034      let mut text = if head_trimmed.len() == text.len() {
1035        text.clone()
1036      } else {
1037        format!("\u{00A0}{head_trimmed}")
1038      };
1039      let tail_trimmed = text.trim_end();
1040      if tail_trimmed.len() != text.len() {
1041        text = format!("{tail_trimmed}\u{00A0}");
1042      }
1043      vec![NodeData::Element {
1044        tag:        "m:mtext".to_string(),
1045        attributes: (!attributes.is_empty()).then_some(attributes),
1046        children:   vec![NodeData::Text(text)],
1047      }]
1048    },
1049    Some(NodeType::ElementNode) => {
1050      // Perl L1041-1045: the element's own font/fontsize/color/backgroundcolor/
1051      // opacity join the inherited set for everything below it.
1052      let attrs = attrs.overlay(node);
1053      let tag = doc.get_qname(node).unwrap_or_default();
1054      match tag.as_str() {
1055        "ltx:Math" => {
1056          // Nested math: convert XMath if present
1057          match doc.findnode_at("ltx:XMath", node) {
1058            Some(xmath) => {
1059              vec![presentation::convert_to_pmml(doc, &xmath)]
1060            },
1061            // Perl L1051-1052: no XMath left means this Math was already
1062            // converted on an earlier pass — hand back the existing
1063            // `m:math`'s children rather than dropping the formula. Perl finds
1064            // it with `findnode('m:math', …)`, a DIRECT child; we scan the
1065            // children by namespace URI because the `m` prefix is not in the
1066            // document's XPath context at this point — this very processor is
1067            // what introduces MathML, so `m:` would fail to resolve and the
1068            // formula would go on being dropped silently.
1069            _ => match element_child_named(node, MML_URI, "math") {
1070              Some(mml) => {
1071                let mut out = Vec::new();
1072                let mut current = mml.get_first_child();
1073                while let Some(ref c) = current {
1074                  if let Some(nd) = rebuild_text_subtree_with_doc(c, true, Some(doc)) {
1075                    out.push(nd);
1076                  }
1077                  current = c.get_next_sibling();
1078                }
1079                out
1080              },
1081              _ => vec![],
1082            },
1083          }
1084        },
1085        // Perl L1057-1059: an `ltx:text` is transparent — recurse and let the
1086        // attributes ride down — but ONLY when it is not framed. `m:mtext`
1087        // cannot express a frame, so a framed one falls through to the
1088        // raw-markup arm below, where the XSLT can still render the box.
1089        "ltx:text" if !node.has_attribute("framed") && !node.has_attribute("framecolor") => {
1090          // Recurse on children
1091          let mut results = Vec::new();
1092          if let Some(child) = node.get_first_child() {
1093            let mut current = Some(child);
1094            while let Some(ref c) = current {
1095              results.extend(pmml_text_aux(doc, c, &attrs));
1096              current = c.get_next_sibling();
1097            }
1098          }
1099          vec![presentation::maybe_resize(doc, node, pmml_row(results))]
1100        },
1101        "ltx:picture" => {
1102          // Picture in text: wrap in mtext. Eagerly materialize the picture
1103          // subtree into owned NodeData so the result is not tied to the
1104          // source node's libxml2 lifetime. Perl: MathProcessor.pm
1105          // convertXMTextContent (Post.pm L456-489). A lazy
1106          // `NodeData::XmlNode(node.clone())` here SIGSEGVs in
1107          // `add_xml_node` once the parent XMath is unlinked (its children
1108          // are stripped into a detached document fragment and later
1109          // accesses via the stale rust-libxml wrapper dereference freed
1110          // memory — reproducible on 0710.1208 / 1110.2158 / 1605.07431).
1111          // Perl L1061-1063 passes no %attr through this arm — the picture is
1112          // its own rendering, so the surrounding math font/color does not
1113          // restyle it.
1114          vec![NodeData::Element {
1115            tag:        "m:mtext".to_string(),
1116            attributes: None,
1117            children:   convert_xm_text_content(doc, node, true),
1118          }]
1119        },
1120        _ => {
1121          // Unknown element (e.g. ltx:ref, ltx:bibref, ltx:inline-block,
1122          // …): preserve the raw subtree inside the mtext so the XSLT
1123          // can transform it (ltx:ref → HTML <a>, etc.). Perl
1124          // `pmml_text_aux` (MathML.pm L1063-1073) clones the whole
1125          // node into the returned mtext; we eagerly materialize an
1126          // owned subtree, threading `doc` through so URI→prefix
1127          // resolution recovers the canonical `ltx:` prefix on
1128          // default-namespace elements.
1129          //
1130          // Perl L1067-1072 stylizes this `m:mtext` from the accumulated %attr
1131          // and — when the raw subtree still holds an `ltx:Math` — warns
1132          // `unexpected:nested-math` and leaves the content-MathML unconverted,
1133          // which renders operator-first (garbled) in the browser. We instead
1134          // convert any nested `ltx:Math` in `rebuild_text_subtree_with_doc`
1135          // below (to a self-contained inline `<m:math>` — see
1136          // `nested_ltx_math_to_inline_mathml`), so a `\parbox`/`\mbox`-with-math
1137          // in math renders correctly (arXiv html_feedback #6847). Surpass-Perl;
1138          // see OXIDIZED_DESIGN #101.
1139          // Perl `my ($ignore, %mmlattr) = …` — the raw subtree is carried over
1140          // verbatim below, so only the attributes are wanted here.
1141          let (_, attributes) = stylize_text_content(Some(node), &attrs, &node.get_content());
1142          let cloned = rebuild_text_subtree_with_doc(node, true, Some(doc))
1143            .unwrap_or_else(|| NodeData::Text("\u{00A0}".to_string()));
1144          vec![NodeData::Element {
1145            tag:        "m:mtext".to_string(),
1146            attributes: (!attributes.is_empty()).then_some(attributes),
1147            children:   vec![cloned],
1148          }]
1149        },
1150      }
1151    },
1152    _ => vec![],
1153  }
1154}
1155
1156/// Convert a nested `ltx:Math` — a `$...$` inside a text box (`\parbox`/`\mbox`/
1157/// `\text`) that itself sits in math — into a self-contained INLINE `<m:math>`
1158/// element, or `None` if the Math is empty.
1159///
1160/// Why a full `<m:math>` rather than the bare presentation `convert_to_pmml`
1161/// returns: this node lands inside the text box's HTML (`ltx:inline-block` /
1162/// `ltx:text` → `<span>`), and per HTML5's MathML text-integration-point rules a
1163/// bare `<mrow>` inside that HTML is parsed as HTML and renders as flat text —
1164/// not math (subscripts/superscripts/calligraphic lost). The `<math>` re-enters
1165/// MathML context. Nested math is always inline (a `$...$`), so `display=inline`;
1166/// `alttext`/`class` ride from the `ltx:Math`. The top-level pass gets the same
1167/// wrapper from `MathProcessor::outer_wrapper`; this is its nested analogue
1168/// (arXiv html_feedback #6847 / OXIDIZED_DESIGN #101).
1169fn nested_ltx_math_to_inline_mathml(doc: &PostDocument, math_node: &Node) -> Option<NodeData> {
1170  let inner = match doc.findnode_at("ltx:XMath", math_node) {
1171    Some(xmath) => presentation::convert_to_pmml(doc, &xmath),
1172    // No XMath left => already converted on an earlier pass; its `<m:math>` is
1173    // already a full element, so reuse it as-is.
1174    None => {
1175      return element_child_named(math_node, MML_URI, "math")
1176        .and_then(|mml| rebuild_text_subtree_with_doc(&mml, true, Some(doc)));
1177    },
1178  };
1179  let mut attrs: HashMap<String, String> = HashMap::default();
1180  attrs.insert("display".to_string(), "inline".to_string());
1181  if let Some(tex) = math_node.get_attribute("tex") {
1182    attrs.insert("alttext".to_string(), tex);
1183  }
1184  if let Some(class) = math_node.get_attribute("class") {
1185    attrs.insert("class".to_string(), class);
1186  }
1187  Some(NodeData::Element {
1188    tag:        "m:math".to_string(),
1189    attributes: Some(attrs),
1190    children:   vec![inner],
1191  })
1192}
1193
1194/// Eagerly materialize an XMText-or-picture subtree into owned NodeData.
1195///
1196/// Port of `LaTeXML::Post::MathProcessor::convertXMTextContent`
1197/// (Post.pm L456-489). Walks `node` recursively and rebuilds the subtree
1198/// as owned NodeData, so downstream consumers do not depend on the
1199/// source node's libxml2 lifetime. Internal `_*` attributes and stray
1200/// `xml:id` are dropped (Perl mirrors this); `fragid` would be remapped
1201/// to a fresh id in Perl but MathML::Presentation does not carry a
1202/// processor-level id suffix through this path, so we drop it too and
1203/// let the surrounding MathML ids govern.
1204///
1205/// When `convert_spaces` is true, leading/trailing whitespace on text
1206/// nodes is replaced with NBSP so the rendered MathML does not collapse
1207/// the space. A nested `ltx:Math` (a `$...$` inside a text box that itself
1208/// sits in math) is CONVERTED to presentation MathML by
1209/// `rebuild_text_subtree_with_doc` rather than cloned raw — see the
1210/// reentrancy note there (arXiv html_feedback #6847); Perl leaves it raw and
1211/// warns `unexpected:nested-math`, which renders operator-first (garbled).
1212pub fn convert_xm_text_content(
1213  doc: &PostDocument,
1214  node: &Node,
1215  convert_spaces: bool,
1216) -> Vec<NodeData> {
1217  node
1218    .get_child_nodes()
1219    .iter()
1220    .filter_map(|c| rebuild_text_subtree_with_doc(c, convert_spaces, Some(doc)))
1221    .collect()
1222}
1223
1224/// Rebuild a libxml2 subtree into owned `NodeData`, dropping internal
1225/// `_*`, `xml:id`, and `fragid` attributes. Shared between
1226/// `convert_xm_text_content` (Perl `convertXMTextContent`,
1227/// Post.pm L456-489) and `pmml_text_aux` for cases where Perl
1228/// calls `cloneNode($node, 'nest')` (MathML.pm L1073) — i.e. when an
1229/// unhandled element like `ltx:ref` appears inside a text-mode
1230/// fragment and must survive into the output so the XSLT can
1231/// transform it (e.g. `ltx:ref` → `<a>`).
1232pub fn rebuild_text_subtree(node: &Node, convert_spaces: bool) -> Option<NodeData> {
1233  rebuild_text_subtree_with_doc(node, convert_spaces, None)
1234}
1235
1236/// Same as `rebuild_text_subtree`, but consults the post-document's
1237/// namespace map to resolve elements whose source `xmlns="…"` carries
1238/// an empty prefix. `add_nodes` only emits elements whose tag is
1239/// `prefix:local`; without a prefix the element is dropped with a
1240/// `malformed:namespace` warning. libxml2 reports an empty
1241/// `Namespace::get_prefix()` for the default-namespace branch even
1242/// when the doc has a `ltx:` prefix declared elsewhere, so we
1243/// reverse-lookup the URI in `PostDocument::namespaces` to recover
1244/// the canonical prefix.
1245pub fn rebuild_text_subtree_with_doc(
1246  node: &Node,
1247  convert_spaces: bool,
1248  doc: Option<&PostDocument>,
1249) -> Option<NodeData> {
1250  use libxml::tree::NodeType;
1251  match node.get_type() {
1252    Some(NodeType::TextNode) => {
1253      let mut text = node.get_content();
1254      if convert_spaces {
1255        if text.starts_with(char::is_whitespace) {
1256          text = format!("\u{00A0}{}", text.trim_start());
1257        }
1258        if text.ends_with(char::is_whitespace) {
1259          text = format!("{}\u{00A0}", text.trim_end());
1260        }
1261      }
1262      Some(NodeData::Text(text))
1263    },
1264    Some(NodeType::ElementNode) => {
1265      // A nested `ltx:Math` — a `$...$` inside a \parbox/\mbox/inline-block that
1266      // itself sits in math. The top-level pass skipped it
1267      // (`//ltx:Math[not(ancestor::ltx:Math)]`), so cloning it verbatim leaks
1268      // unconverted `<ltx:XMath>` content-MathML into the HTML, which the browser
1269      // renders in operator-first document order (garbled, arXiv html_feedback
1270      // #6847 / arXiv:2608.05024). Convert it here instead. Needs `doc` for
1271      // URI→prefix + the ancestor style/font context; the doc-less
1272      // `rebuild_text_subtree` callers keep the verbatim clone (they never carry
1273      // nested math). See OXIDIZED_DESIGN #101.
1274      if let Some(d) = doc
1275        && d.get_qname(node).as_deref() == Some("ltx:Math")
1276        && let Some(mml) = nested_ltx_math_to_inline_mathml(d, node)
1277      {
1278        return Some(mml);
1279      }
1280      let tag = {
1281        let local = node.get_name();
1282        match node.get_namespace() {
1283          Some(ns) => {
1284            let prefix = ns.get_prefix();
1285            if !prefix.is_empty() {
1286              format!("{prefix}:{local}")
1287            } else {
1288              // Default-namespace element. add_nodes won't accept a
1289              // tag without prefix — reverse-resolve URI → prefix
1290              // via the post-document's namespace map.
1291              let uri = ns.get_href();
1292              match doc.and_then(|d| {
1293                d.namespaces
1294                  .iter()
1295                  .find(|(p, u)| !p.is_empty() && **u == uri)
1296                  .map(|(p, _)| p.clone())
1297              }) {
1298                Some(p) => format!("{p}:{local}"),
1299                None => local,
1300              }
1301            }
1302          },
1303          None => local,
1304        }
1305      };
1306      // Copy attributes, skipping internal `_*`, `xml:id`, and `fragid`.
1307      // Matches Perl convertXMTextContent (Post.pm L479-483); the
1308      // `fragid → xml:id` remap requires the MathProcessor's IDSuffix
1309      // which this helper does not receive — drop both here rather
1310      // than forge a wrong id. NOTE: get_attributes() reports xml:id
1311      // under its LOCAL name "id", so that spelling must be skipped
1312      // too — it used to leak through as a plain id= duplicate. The
1313      // namespace probe keeps a GENUINE plain `id` (the model grants
1314      // one to `ltx:bib-identifier`/`ltx:bib-review`, carrying a
1315      // DOI/ISSN) from being dropped along with it.
1316      let has_xml_id = node
1317        .get_attribute_ns("id", latexml_core::common::xml::XML_NS)
1318        .is_some();
1319      let mut attrs: HashMap<String, String> = HashMap::default();
1320      for (k, v) in node.get_attributes() {
1321        if k.starts_with('_') || k == "xml:id" || k == "fragid" || (k == "id" && has_xml_id) {
1322          continue;
1323        }
1324        attrs.insert(k, v);
1325      }
1326      let children: Vec<NodeData> = node
1327        .get_child_nodes()
1328        .iter()
1329        .filter_map(|c| rebuild_text_subtree_with_doc(c, convert_spaces, doc))
1330        .collect();
1331      Some(NodeData::Element {
1332        tag,
1333        attributes: if attrs.is_empty() { None } else { Some(attrs) },
1334        children,
1335      })
1336    },
1337    _ => None,
1338  }
1339}
1340
1341/// Unwrap an mrow if it has no attributes.
1342///
1343/// Port of `pmml_unrow`.
1344pub fn pmml_unrow(mml: NodeData) -> Vec<NodeData> {
1345  match mml {
1346    NodeData::Element {
1347      ref tag,
1348      ref attributes,
1349      ref children,
1350    } if tag == "m:mrow" && attributes.as_ref().map(|a| a.is_empty()).unwrap_or(true) => {
1351      children.clone()
1352    },
1353    _ => vec![mml],
1354  }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359  use super::*;
1360
1361  #[test]
1362  fn test_style_step() {
1363    assert_eq!(style_step("display"), "text");
1364    assert_eq!(style_step("text"), "script");
1365    assert_eq!(style_step("script"), "scriptscript");
1366    assert_eq!(style_step("scriptscript"), "scriptscript");
1367  }
1368
1369  #[test]
1370  fn test_pmml_tag_for_role() {
1371    assert_eq!(pmml_tag_for_role("NUMBER"), "m:mn");
1372    assert_eq!(pmml_tag_for_role("ID"), "m:mi");
1373    assert_eq!(pmml_tag_for_role("ADDOP"), "m:mo");
1374    assert_eq!(pmml_tag_for_role("FUNCTION"), "m:mi");
1375    assert_eq!(pmml_tag_for_role("SUMOP"), "m:mo");
1376  }
1377
1378  #[test]
1379  fn test_apply_handler_for_role() {
1380    assert_eq!(apply_handler_for_role("ADDOP"), ApplyHandler::Infix);
1381    assert_eq!(
1382      apply_handler_for_role("SUPERSCRIPTOP"),
1383      ApplyHandler::Script
1384    );
1385    assert_eq!(apply_handler_for_role("FRACOP"), ApplyHandler::Fraction);
1386    assert_eq!(
1387      apply_handler_for_role("OVERACCENT"),
1388      ApplyHandler::OverAccent
1389    );
1390    assert_eq!(apply_handler_for_role("SUMOP"), ApplyHandler::Summation);
1391    assert_eq!(apply_handler_for_role("FUNCTION"), ApplyHandler::Generic);
1392  }
1393
1394  #[test]
1395  fn test_is_bigop_role() {
1396    assert!(is_bigop_role("SUMOP"));
1397    assert!(is_bigop_role("INTOP"));
1398    assert!(!is_bigop_role("ADDOP"));
1399    assert!(!is_bigop_role("ID"));
1400  }
1401
1402  #[test]
1403  fn test_encoding_for_mimetype() {
1404    assert_eq!(
1405      encoding_for_mimetype("application/mathml-presentation+xml"),
1406      "MathML-Presentation"
1407    );
1408    assert_eq!(
1409      encoding_for_mimetype("application/mathml-content+xml"),
1410      "MathML-Content"
1411    );
1412    assert_eq!(encoding_for_mimetype("image/svg+xml"), "SVG1.1");
1413    assert_eq!(encoding_for_mimetype("text/plain"), "text/plain");
1414  }
1415
1416  #[test]
1417  fn test_pmml_row_single() {
1418    let items = vec![NodeData::Text("x".to_string())];
1419    let result = pmml_row(items);
1420    match result {
1421      NodeData::Text(s) => assert_eq!(s, "x"),
1422      _ => panic!("Expected Text, got Element"),
1423    }
1424  }
1425
1426  #[test]
1427  fn test_pmml_row_multiple() {
1428    let items = vec![
1429      NodeData::Text("x".to_string()),
1430      NodeData::Text("+".to_string()),
1431      NodeData::Text("y".to_string()),
1432    ];
1433    let result = pmml_row(items);
1434    match result {
1435      NodeData::Element { tag, children, .. } => {
1436        assert_eq!(tag, "m:mrow");
1437        assert_eq!(children.len(), 3);
1438      },
1439      _ => panic!("Expected Element"),
1440    }
1441  }
1442
1443  #[test]
1444  fn test_pmml_parenthesize() {
1445    let item = NodeData::Text("x".to_string());
1446    let result = pmml_parenthesize(item.clone(), Some("("), Some(")"));
1447    match result {
1448      NodeData::Element { tag, children, .. } => {
1449        assert_eq!(tag, "m:mrow");
1450        assert_eq!(children.len(), 3); // open, item, close
1451      },
1452      _ => panic!("Expected mrow"),
1453    }
1454
1455    // No parens → pass through
1456    let result2 = pmml_parenthesize(item, None, None);
1457    match result2 {
1458      NodeData::Text(s) => assert_eq!(s, "x"),
1459      _ => panic!("Expected passthrough"),
1460    }
1461  }
1462}