Skip to main content

latexml_post/
lib.rs

1#![allow(dead_code)] // Library in progress — many APIs not yet consumed externally
2#![allow(clippy::collapsible_match, clippy::collapsible_if)]
3#![allow(clippy::if_same_then_else)] // Intentional per-type branches for FMT_SPEC clarity
4#![allow(clippy::cloned_ref_to_slice_refs)] // Node::clone() is Rc clone (cheap), needed for &[Node] APIs
5//! Post-processing pipeline for latexml_oxide.
6//!
7//! Rust port of `LaTeXML::Post` — the driver that orchestrates
8//! post-processors (Scan, CrossRef, MathML conversion, XSLT, Writer, etc.)
9//! on a converted LaTeXML XML document.
10//!
11//! # Architecture
12//!
13//! The processing pipeline follows the Perl original:
14//!
15//! 1. **Input**: An XML document produced by the core LaTeXML conversion
16//! 2. **ProcessChain**: Each `Processor` in sequence gets:
17//!    - `to_process(doc)` → nodes relevant to this processor
18//!    - `process(doc, nodes)` → the (possibly split) result document(s)
19//! 3. **Output**: One or more processed XML documents
20//!
21//! # Modules
22//!
23//! - [`document`] — `PostDocument`: XML wrapper with ID management, XPath, caching
24//! - [`processor`] — `Processor` trait: abstract base for all post-processors
25//! - [`math_processor`] — `MathProcessor` trait: abstract base for math converters
26//! - [`radix`] — Radix utilities for ID generation (a,b,...,z,aa,ab,...)
27
28// Crate-wide diagnostic emission macros
29// (`Error!`, `Warn!`, `Info!`,
30// `Fatal!`). Loaded first via #[macro_use] so every
31// post-processor module can use them without explicit imports —
32// matching how `latexml_engine`'s prelude makes the engine-level
33// `Error!`/`Warn!` macros visible.
34#[macro_use]
35pub mod diag;
36
37// Core infrastructure
38pub mod document;
39pub mod math_processor;
40pub mod object_db;
41pub mod processor;
42pub mod radix;
43
44// Concrete post-processors (alphabetical)
45pub mod collector;
46pub mod crossref;
47pub mod extract;
48pub mod graphics;
49pub mod graphics_cache; // Content-addressed cache for graphics phase subprocs.
50pub mod latex_images;
51pub mod lex_math;
52pub mod make_bibliography;
53pub mod make_index;
54pub mod manifest;
55pub mod math_images;
56pub mod mathml;
57pub mod open_math;
58pub mod pack;
59pub mod picture_images;
60pub mod scan;
61pub mod schema_docs;
62pub mod split;
63pub mod stream_split;
64pub mod svg;
65pub mod tex_math;
66pub mod unicode;
67pub mod unicode_math;
68pub mod writer;
69pub mod xmath;
70pub mod xslt;
71
72/// Shared helpers for the process-global state (`env`, `/tmp`) that this
73/// crate's multi-threaded unit tests contend over.
74#[cfg(test)]
75mod test_env;
76
77use std::sync::LazyLock;
78
79use document::PostDocument;
80use processor::{PostError, Processor};
81
82// Process-once cached env var (see WISDOM #56 — getenv hot-path race).
83static POST_AUDIT: LazyLock<bool> = LazyLock::new(|| std::env::var("LATEXML_POST_AUDIT").is_ok());
84
85/// The post-processing pipeline driver.
86///
87/// Port of `LaTeXML::Post`.
88/// Manages a chain of processors and orchestrates their execution.
89pub struct Post {
90  /// Status tracking.
91  pub status: PostStatus,
92}
93
94/// Status of post-processing.
95#[derive(Debug, Default)]
96pub struct PostStatus {
97  pub warning_count: u32,
98  pub error_count:   u32,
99  pub fatal_count:   u32,
100  pub info_count:    u32,
101}
102
103impl Post {
104  /// Create a new post-processing driver.
105  pub fn new() -> Self { Post { status: PostStatus::default() } }
106
107  /// Run the processing chain on a document.
108  ///
109  /// Each processor in order gets the current document(s),
110  /// finds nodes to process via `to_process()`, and transforms them
111  /// via `process()`. Documents may be split (producing multiple outputs).
112  ///
113  /// Port of `Post::ProcessChain` + `ProcessChain_internal`.
114  pub fn process_chain(
115    &mut self,
116    docs: Vec<PostDocument>,
117    processors: &mut [Box<dyn Processor>],
118  ) -> Result<Vec<PostDocument>, PostError> {
119    let mut docs = docs;
120
121    Note!("post-processing");
122    let audit = *POST_AUDIT;
123
124    for processor in processors.iter_mut() {
125      // Map processor names to telemetry phases. See docs/performance/TELEMETRY.md.
126      // Names come from each Processor's get_name() — same bracket-
127      // classifier shape as XSLT (`XSLT[using ...]`): MathML uses
128      // `MathML[Presentation]` / `MathML[Content]`. Anything
129      // unrecognised attributes to Xslt as a coarse fallback.
130      let pname = processor.get_name();
131      let phase = if pname.starts_with("MathML[Presentation]") {
132        latexml_core::telemetry::Phase::MathmlPres
133      } else if pname.starts_with("MathML[Content]") {
134        latexml_core::telemetry::Phase::MathmlCont
135      } else if pname.starts_with("XSLT") {
136        latexml_core::telemetry::Phase::Xslt
137      } else if pname.contains("Image") || pname.contains("image") {
138        // math_images / picture_images / latex_images all share the
139        // external-tool-rendering semantics of Graphics; classify them
140        // as MathImages when they are wired up to process_chain.
141        latexml_core::telemetry::Phase::MathImages
142      } else {
143        // Unknown processor — fall through to Xslt (least surprising
144        // catch-all for the post-XSLT-ish region).
145        latexml_core::telemetry::Phase::Xslt
146      };
147      let _gp = latexml_core::telemetry::phase(phase);
148      let mut new_docs = Vec::new();
149      for doc in docs {
150        let nodes = processor.to_process(&doc);
151        if !nodes.is_empty() {
152          let n = nodes.len();
153          let msg = format!(
154            "{} {} {}",
155            processor.get_name(),
156            doc.site_relative_destination().unwrap_or_default(),
157            if n > 1 {
158              format!("{} to process", n)
159            } else {
160              "processing".to_string()
161            }
162          );
163          Note!(msg);
164          let t0 = if audit {
165            Some(std::time::Instant::now())
166          } else {
167            None
168          };
169          let result_docs = processor.process(doc, nodes)?;
170          if let Some(t0) = t0 {
171            let ms = t0.elapsed().as_millis();
172            Note!(format!(
173              "POST_AUDIT stage {} took {}ms ({} nodes)",
174              processor.get_name(),
175              ms,
176              n
177            ));
178          }
179          new_docs.extend(result_docs);
180        } else {
181          new_docs.push(doc);
182        }
183      }
184      docs = new_docs;
185    }
186
187    // Drain any XMath subtrees that the math-format processors queued
188    // for deferred unlink. The defer-then-drain split is what lets the
189    // CorTeX pmml+cmml chain share one XMath subtree across both
190    // formats; eager unlink in the first processor was the root cause
191    // of the dominant `Error:expected:id` cluster (~63% of CONVERR
192    // papers on second-500K stages) — the second processor's
193    // `mark_xm_node_visibility` walked stale XMRefs into a freed
194    // subtree. See `PostDocument::defer_xmath_unlink`.
195    for doc in docs.iter_mut() {
196      doc.drain_pending_xmath_unlinks();
197    }
198
199    Note!("post-processing complete");
200    Ok(docs)
201  }
202}
203
204impl Default for Post {
205  fn default() -> Self { Self::new() }
206}
207
208#[cfg(test)]
209mod tests {
210  use super::*;
211  use crate::{
212    document::PostDocumentOptions,
213    writer::{OutputFormat, Writer},
214  };
215
216  #[test]
217  fn test_empty_pipeline() {
218    let mut post = Post::new();
219    let doc = PostDocument::new_from_string(
220      "<document xmlns='http://dlmf.nist.gov/LaTeXML'/>",
221      PostDocumentOptions::default(),
222    )
223    .unwrap();
224
225    let mut processors: Vec<Box<dyn Processor>> = vec![];
226    let result = post.process_chain(vec![doc], &mut processors);
227    assert!(result.is_ok());
228    assert_eq!(result.unwrap().len(), 1);
229  }
230
231  #[test]
232  fn test_writer_pipeline() {
233    let mut post = Post::new();
234    let doc = PostDocument::new_from_string(
235      "<document xmlns='http://dlmf.nist.gov/LaTeXML'><title>Test</title></document>",
236      PostDocumentOptions::default(),
237    )
238    .unwrap();
239
240    // Writer without destination prints to stdout (we just test it doesn't crash)
241    let writer = Writer::new(Some(OutputFormat::Xml), false, false);
242    let mut processors: Vec<Box<dyn Processor>> = vec![Box::new(writer)];
243    let result = post.process_chain(vec![doc], &mut processors);
244    assert!(result.is_ok());
245  }
246
247  #[test]
248  fn test_pmml_pipeline() {
249    let mut post = Post::new();
250    let doc = PostDocument::new_from_string(
251      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
252         <para xml:id='p1'><p>Inline <Math mode='inline' tex='a+b' text='a + b' xml:id='p1.m1'>\
253           <XMath><XMApp>\
254             <XMTok meaning='plus' role='ADDOP'>+</XMTok>\
255             <XMTok font='italic' role='ID'>a</XMTok>\
256             <XMTok font='italic' role='ID'>b</XMTok>\
257           </XMApp></XMath></Math></p></para>\
258       </document>",
259      PostDocumentOptions::default(),
260    )
261    .unwrap();
262
263    let pmml = mathml::MathML::new_presentation().with_keep_xmath(true);
264    let mut processors: Vec<Box<dyn Processor>> = vec![Box::new(pmml)];
265    let result = post.process_chain(vec![doc], &mut processors);
266    assert!(result.is_ok());
267    let docs = result.unwrap();
268    let output = docs[0].to_xml_string();
269    eprintln!("PMML output:\n{}", output);
270    // Should contain both XMath and m:math
271    assert!(
272      output.contains("<XMath>") || output.contains("<XMath "),
273      "XMath should be preserved"
274    );
275    assert!(
276      output.contains("m:math"),
277      "m:math element should be present"
278    );
279    assert!(output.contains("m:mi"), "m:mi element should be present");
280    assert!(output.contains("m:mo"), "m:mo element should be present");
281  }
282
283  /// Parallel Presentation+Content markup must merge into ONE `<m:semantics>`
284  /// with the content tree wrapped in `<m:annotation-xml encoding="MathML-Content">`
285  /// — never left as an orphan `<m:apply>` sibling of `<m:semantics>` (which
286  /// browsers render as stray text after the formula). Exercises
287  /// `MathProcessor::combine_parallel` via the primary→secondary chain that
288  /// `latexml_oxide::post` builds when both `pmml` and `cmml` are requested.
289  /// Regression guard for the cortex-preview "broken MathML" bug.
290  #[test]
291  fn test_parallel_pmml_cmml_combine_parallel() {
292    let mut post = Post::new();
293    let doc = PostDocument::new_from_string(
294      "<document xmlns='http://dlmf.nist.gov/LaTeXML'>\
295         <para xml:id='p1'><p>Inline <Math mode='inline' tex='a+b' text='a + b' xml:id='p1.m1'>\
296           <XMath><XMApp>\
297             <XMTok meaning='plus' role='ADDOP'>+</XMTok>\
298             <XMTok font='italic' role='ID'>a</XMTok>\
299             <XMTok font='italic' role='ID'>b</XMTok>\
300           </XMApp></XMath></Math></p></para>\
301       </document>",
302      PostDocumentOptions::default(),
303    )
304    .unwrap();
305
306    // Presentation primary holding a Content secondary — the same parallel-markup
307    // shape `latexml_oxide::post` builds for `pmml && cmml`.
308    let content = mathml::MathML::new_content().secondary();
309    let pmml = mathml::MathML::new_presentation()
310      .with_mathtex(true)
311      .with_secondaries(vec![Box::new(content)]);
312    let mut processors: Vec<Box<dyn Processor>> = vec![Box::new(pmml)];
313    let result = post.process_chain(vec![doc], &mut processors);
314    assert!(result.is_ok());
315    let docs = result.unwrap();
316    let output = docs[0].to_xml_string();
317    eprintln!("parallel P+C output:\n{output}");
318
319    // Single semantics holding presentation + content + tex source.
320    assert!(
321      output.contains("m:semantics"),
322      "m:semantics wrapper missing"
323    );
324    assert!(
325      output.contains("m:apply"),
326      "content m:apply should be generated"
327    );
328    assert!(
329      output.contains(r#"m:annotation-xml encoding="MathML-Content""#),
330      "content must be folded into <m:annotation-xml encoding=\"MathML-Content\">"
331    );
332    assert!(
333      output.contains(r#"encoding="application/x-tex""#),
334      "x-tex source annotation should be present in the parallel semantics"
335    );
336    // THE regression assertion: no content markup may appear AFTER the
337    // semantics closes (i.e. as an orphan sibling under <m:math>).
338    let after_semantics = output.rsplit("</m:semantics>").next().unwrap_or("");
339    assert!(
340      !after_semantics.contains("m:apply") && !after_semantics.contains("m:ci"),
341      "orphan Content MathML found after </m:semantics> — combine_parallel not applied:\n{output}"
342    );
343  }
344
345  #[test]
346  fn test_scan_pipeline() {
347    let mut post = Post::new();
348    let doc = PostDocument::new_from_string(
349      "<document xmlns='http://dlmf.nist.gov/LaTeXML' xml:id='doc'>\
350         <section xml:id='s1'><title>First</title></section>\
351         <section xml:id='s2'><title>Second</title></section>\
352       </document>",
353      PostDocumentOptions::default(),
354    )
355    .unwrap();
356
357    let db = object_db::ObjectDB::new();
358    let scanner = scan::Scan::new(db);
359    let mut processors: Vec<Box<dyn Processor>> = vec![Box::new(scanner)];
360    let result = post.process_chain(vec![doc], &mut processors);
361    assert!(result.is_ok());
362  }
363}