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}