Skip to main content

latexml_post/
math_processor.rs

1//! Abstract math processor base.
2//!
3//! Port of `LaTeXML::Post::MathProcessor`.
4//! Extends [`Processor`] with math-specific conversion infrastructure:
5//! - Parallel markup support (primary + secondary formats)
6//! - Cross-referencing between math formats
7//! - XMath node visibility and realization
8//! - XMText content conversion
9
10use std::sync::LazyLock;
11
12use libxml::tree::Node;
13use rustc_hash::FxHashMap as HashMap;
14
15use crate::{
16  document::{NodeData, PostDocument},
17  processor::{PostError, Processor},
18};
19
20// Process-once cached env var (see WISDOM #56 — getenv hot-path race).
21static POST_AUDIT: LazyLock<bool> = LazyLock::new(|| std::env::var("LATEXML_POST_AUDIT").is_ok());
22
23/// Result of converting a math node.
24#[derive(Debug, Clone)]
25pub struct MathConversion {
26  /// The processor that produced this conversion.
27  pub processor_name: String,
28  /// MIME type of the conversion (e.g., "application/mathml+xml").
29  pub mimetype:       Option<String>,
30  /// The converted XML (as a NodeData tree).
31  pub xml:            Option<NodeData>,
32  /// String representation (for non-XML formats).
33  pub string:         Option<String>,
34  /// Image source path (for image-based conversions).
35  pub src:            Option<String>,
36  /// Image width.
37  pub width:          Option<String>,
38  /// Image height.
39  pub height:         Option<String>,
40  /// Image depth (baseline offset).
41  pub depth:          Option<String>,
42}
43
44/// Abstract base trait for math-processing post-processors.
45///
46/// Port of `LaTeXML::Post::MathProcessor`.
47///
48/// Implementors must define:
49/// - [`convert_node`](MathProcessor::convert_node) — convert an XMath node to the target format
50/// - [`raw_id_suffix`](MathProcessor::raw_id_suffix) — suffix for generated IDs (e.g., ".pmml")
51///
52/// For parallel markup, also implement:
53/// - [`combine_parallel`](MathProcessor::combine_parallel) — merge primary + secondary conversions
54pub trait MathProcessor: Processor {
55  /// Convert an XMath node to this processor's format.
56  ///
57  /// Port of `MathProcessor::convertNode` (abstract in Perl).
58  fn convert_node(&self, doc: &PostDocument, xmath: &Node) -> Option<MathConversion>;
59
60  /// Combine parallel markup from primary conversion + secondaries.
61  ///
62  /// Port of `MathProcessor::combineParallel` (abstract in Perl).
63  /// Default implementation just returns the primary, dropping secondaries.
64  fn combine_parallel(
65    &self,
66    _doc: &PostDocument,
67    _xmath: &Node,
68    primary: MathConversion,
69    secondaries: Vec<MathConversion>,
70  ) -> MathConversion {
71    if !secondaries.is_empty() {
72      // No direct Perl counterpart — `combine_parallel` is the trait's
73      // default impl; concrete overrides handle their own merging.
74      // Reaching this base impl with secondaries means a misconfigured
75      // chain. Use class=`misdefined`, object=`combineParallel` per
76      // the wider `Error('misdefined', …)` convention (Post.pm:177/434).
77      Error!(
78        "misdefined",
79        "combineParallel",
80        "Abstract combineParallel: dropping extra markup from: {}",
81        secondaries
82          .iter()
83          .map(|s| s.processor_name.as_str())
84          .collect::<Vec<_>>()
85          .join(", ")
86      );
87    }
88    primary
89  }
90
91  /// Raw ID suffix for this format (e.g., ".pmml", ".cmml", ".om").
92  /// Primary format returns empty string; secondaries return their suffix.
93  ///
94  /// Port of `MathProcessor::rawIDSuffix`.
95  fn raw_id_suffix(&self) -> &str { "" }
96
97  /// Whether this processor is a secondary (parallel) processor.
98  fn is_secondary(&self) -> bool { false }
99
100  /// Parallel-markup secondaries held by this (primary) processor. During the
101  /// primary's `process_math_node`, each secondary's `convert_node` runs against
102  /// the still-live XMath and the results are folded into one `<m:semantics>`
103  /// via [`combine_parallel`](MathProcessor::combine_parallel). Empty by
104  /// default (standalone, single-format).
105  /// Port of Perl `MathProcessor`'s primary→secondary parallel model.
106  fn parallel_secondaries(&self) -> &[Box<dyn MathProcessor>] { &[] }
107
108  /// ID suffix: empty for primary, raw_id_suffix for secondary.
109  ///
110  /// Port of `MathProcessor::IDSuffix`.
111  fn id_suffix(&self) -> &str {
112    if self.is_secondary() {
113      self.raw_id_suffix()
114    } else {
115      ""
116    }
117  }
118
119  /// Whether this processor can convert the given math node.
120  /// Default: always true.
121  ///
122  /// Port of `MathProcessor::canConvert`.
123  fn can_convert(&self, _doc: &PostDocument, _math: &Node) -> bool { true }
124
125  /// Optional preprocessing before conversion begins.
126  ///
127  /// Port of `MathProcessor::preprocess`.
128  fn preprocess(&self, _doc: &PostDocument, _nodes: &[Node]) {}
129
130  /// Wrap the converted XML in the appropriate outer element (e.g., `m:math`).
131  ///
132  /// Port of `MathProcessor::outerWrapper`.
133  fn outer_wrapper(&self, _doc: &PostDocument, _xmath: &Node, conversion: NodeData) -> NodeData {
134    conversion
135  }
136}
137
138/// Check if a Math element was successfully parsed (not marked as unparsed).
139///
140/// Port of `MathProcessor::mathIsParsed`.
141pub fn math_is_parsed(math: &Node) -> bool {
142  math
143    .get_attribute("class")
144    .map(|c| !c.contains("ltx_math_unparsed"))
145    .unwrap_or(true)
146}
147
148/// Process all top-level Math nodes in the document using a math processor.
149///
150/// This is the main orchestration function that handles:
151/// - Finding top-level Math nodes (not nested inside other Math)
152/// - Parallel markup coordination
153/// - Cross-referencing between formats
154///
155/// When `keep_xmath` is true, the XMath elements are preserved in the output
156/// alongside the generated MathML.
157///
158/// Port of `MathProcessor::process`.
159pub fn process_math(
160  processor: &dyn MathProcessor,
161  doc: &mut PostDocument,
162  maths: Vec<Node>,
163  keep_xmath: bool,
164) -> Result<(), PostError> {
165  doc.mark_xm_node_visibility();
166  processor.preprocess(doc, &maths);
167
168  // Re-fetch once after preprocess in case it restructured things (matches
169  // Perl Post.pm L307-308 "# Re-Fetch the math nodes, in case preprocessing
170  // has messed them up!!!"). Then iterate in reverse so nested math is
171  // converted first and carried along with the enclosing math.
172  let maths = doc.findnodes("//ltx:Math[not(ancestor::ltx:Math)]");
173  let n = maths.len();
174  // LATEXML_POST_AUDIT=1 records per-node wall-clock for the math
175  // post-processing loop — diagnosis aid for MathML::Presentation perf.
176  let audit = *POST_AUDIT;
177  let mut total_ns: u128 = 0;
178  let mut max_ns: u128 = 0;
179  let mut max_idx: usize = 0;
180  for (i, math) in maths.into_iter().rev().enumerate() {
181    let t0 = if audit {
182      Some(std::time::Instant::now())
183    } else {
184      None
185    };
186    process_math_node(processor, doc, &math, keep_xmath)?;
187    if let Some(t0) = t0 {
188      let ns = t0.elapsed().as_nanos();
189      total_ns += ns;
190      if ns > max_ns {
191        max_ns = ns;
192        max_idx = i;
193      }
194    }
195  }
196  if audit {
197    Info!(
198      "audit",
199      "math",
200      "{} math nodes in {}ms (max {}µs at index {})",
201      n,
202      total_ns / 1_000_000,
203      max_ns / 1_000,
204      max_idx
205    );
206  }
207
208  // Clean up _cvis/_pvis internal visibility markers from XMath nodes
209  for mut node in doc.findnodes("//*[@_cvis or @_pvis]") {
210    let _ = node.remove_attribute("_cvis");
211    let _ = node.remove_attribute("_pvis");
212  }
213
214  Info!("math", "converted", "converted {} Maths", n);
215  Ok(())
216}
217
218/// Process a single Math node: convert XMath and add result to the Math element.
219///
220/// Port of `MathProcessor::processNode`.
221fn process_math_node(
222  processor: &dyn MathProcessor,
223  doc: &mut PostDocument,
224  math: &Node,
225  keep_xmath: bool,
226) -> Result<(), PostError> {
227  let xmath = match doc.findnode_at("ltx:XMath", math) {
228    Some(x) => x,
229    None => return Ok(()), // Nothing to convert
230  };
231
232  // Convert
233  let mut conversion = processor
234    .convert_node(doc, &xmath)
235    .unwrap_or(MathConversion {
236      processor_name: processor.get_name().to_string(),
237      mimetype:       None,
238      xml:            None,
239      string:         None,
240      src:            None,
241      width:          None,
242      height:         None,
243      depth:          None,
244    });
245
246  // Parallel markup: convert each secondary against the still-live XMath and
247  // fold the results into the primary via `combine_parallel` (→ a single
248  // `<m:semantics>` with the primary plus `<m:annotation-xml>` per secondary).
249  // Mirrors Perl `MathProcessor::process`, which runs the primary then its
250  // parallel secondaries and combines — rather than emitting each format as an
251  // independent sibling of the math element. Empty for single-format processors.
252  let secondaries: Vec<MathConversion> = processor
253    .parallel_secondaries()
254    .iter()
255    .filter_map(|sec| sec.convert_node(doc, &xmath))
256    .collect();
257  if !secondaries.is_empty() {
258    conversion = processor.combine_parallel(doc, &xmath, conversion, secondaries);
259  }
260
261  // Apply outer wrapper if we got XML
262  match conversion.xml.take() {
263    Some(xml) => {
264      conversion.xml = Some(processor.outer_wrapper(doc, &xmath, xml));
265    },
266    _ => {
267      if let Some(ref string) = conversion.string {
268        // Wrap string in ltx:text
269        let mimetype = conversion.mimetype.as_deref().unwrap_or("unknown");
270        conversion.xml = Some(NodeData::Element {
271          tag:        "ltx:text".to_string(),
272          attributes: Some(HashMap::from_iter([(
273            "class".to_string(),
274            format!("ltx_math_{}", mimetype),
275          )])),
276          children:   vec![NodeData::Text(string.clone())],
277        });
278      }
279    },
280  }
281
282  if !keep_xmath {
283    // Mark XMath IDs as reusable (it will be removed)
284    doc.preremove_nodes(&[xmath.clone()]);
285    // Defer the actual unlink to a final pass. Parallel-format
286    // chains (the CorTeX canvas configures both pmml + cmml) need
287    // the XMath subtree to survive across processors so each
288    // processor's `mark_xm_node_visibility` walk finds live
289    // `XMRef` targets. The first processor's eager unlink left
290    // the second processor reading freed children and emitting
291    // `Error:expected:id Cannot find a node with xml:id=…`
292    // (the dominant CONVERR cluster on the second-500K canvas).
293    // Mirrors Perl `Post.pm` L373-393's "XMath will be removed
294    // (LATER!), but mark its ids as reusable" — the actual
295    // `unlink` happens in `PostDocument::drain_pending_xmath_unlinks`
296    // after every math-format processor in the chain has finished.
297    //
298    // `DocOwnedNode` wrapping is performed inside `drain_…` to
299    // keep the SIGSEGV-suppression policy from cycle-236's
300    // `$X$` + ar5iv reproducer (`docs/known_crashes/README.md`)
301    // intact across the deferred path.
302    doc.defer_xmath_unlink(xmath);
303  }
304
305  // Remove blank text nodes from Math
306  doc.remove_blank_nodes(math);
307
308  // Add the converted content
309  if let Some(xml) = &conversion.xml {
310    let mut math_mut = math.clone();
311    doc.add_nodes(&mut math_mut, &[xml.clone()]);
312  }
313
314  // Copy image attributes if applicable
315  maybe_set_math_image(math, &conversion);
316
317  Ok(())
318}
319
320/// Set image attributes on the Math element if the conversion produced an image.
321///
322/// Port of `MathProcessor::maybeSetMathImage`.
323fn maybe_set_math_image(math: &Node, conversion: &MathConversion) {
324  if let Some(ref mimetype) = conversion.mimetype {
325    if mimetype.starts_with("image/") && math.get_attribute("imagesrc").is_none() {
326      if let Some(ref src) = conversion.src {
327        let mut math_mut = math.clone();
328        math_mut.set_attribute("imagesrc", src).ok();
329        if let Some(ref w) = conversion.width {
330          math_mut.set_attribute("imagewidth", w).ok();
331        }
332        if let Some(ref h) = conversion.height {
333          math_mut.set_attribute("imageheight", h).ok();
334        }
335        if let Some(ref d) = conversion.depth {
336          math_mut.set_attribute("imagedepth", d).ok();
337        }
338      }
339    }
340  }
341}
342
343/// Find top-level Math nodes (not nested within other Math nodes).
344///
345/// Port of `MathProcessor::toProcess`.
346pub fn find_top_level_math(doc: &PostDocument) -> Vec<Node> {
347  doc.findnodes("//ltx:Math[not(ancestor::ltx:Math)]")
348}
349
350#[cfg(test)]
351mod tests {
352  use super::*;
353
354  #[test]
355  fn math_conversion_clone_preserves() {
356    let c = MathConversion {
357      processor_name: "test".to_string(),
358      mimetype:       Some("application/mathml+xml".to_string()),
359      xml:            None,
360      string:         Some("x+y".to_string()),
361      src:            None,
362      width:          Some("5em".to_string()),
363      height:         None,
364      depth:          None,
365    };
366    let d = c.clone();
367    assert_eq!(d.processor_name, "test");
368    assert_eq!(d.mimetype.as_deref(), Some("application/mathml+xml"));
369    assert_eq!(d.string.as_deref(), Some("x+y"));
370    assert_eq!(d.width.as_deref(), Some("5em"));
371  }
372
373  #[test]
374  fn math_conversion_all_none_is_valid() {
375    // A MathConversion with nothing set is well-formed; it's just not
376    // useful output.
377    let c = MathConversion {
378      processor_name: "noop".to_string(),
379      mimetype:       None,
380      xml:            None,
381      string:         None,
382      src:            None,
383      width:          None,
384      height:         None,
385      depth:          None,
386    };
387    assert!(c.mimetype.is_none());
388    assert!(c.xml.is_none());
389    assert!(c.string.is_none());
390    assert!(c.src.is_none());
391    assert!(c.width.is_none());
392    assert!(c.height.is_none());
393    assert!(c.depth.is_none());
394  }
395
396  #[test]
397  fn math_conversion_debug_is_non_empty() {
398    let c = MathConversion {
399      processor_name: "pmml".to_string(),
400      mimetype:       None,
401      xml:            None,
402      string:         None,
403      src:            None,
404      width:          None,
405      height:         None,
406      depth:          None,
407    };
408    let s = format!("{c:?}");
409    assert!(s.contains("pmml"));
410  }
411}