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}