Skip to main content

latexml_post/
extract.rs

1//! Document-subtree extraction helpers — port of `LaTeXML::Util::Pack`'s
2//! `get_math` and `get_embeddable` (Perl Pack.pm L247-313).
3//!
4//! These are the implementations behind the `--whatsout fragment` and
5//! `--whatsout math` CLI modes. They run AFTER all post-processing on
6//! the final XML/HTML document and return the subtree the user actually
7//! wanted (an embeddable inline snippet, or just the math). The
8//! `--whatsout archive` mode keeps the full document (no extraction) but
9//! flags the caller to wrap it into a zip — see [`Whatsout::is_archive`]
10//! and [`crate::pack::pack_archive`].
11//!
12//! Companion modules:
13//! * [`crate::pack`] bundles the chosen output into a zip archive.
14//! * [`crate::writer`] serializes it to a file or stdout.
15//!
16//! ## What's not ported (intentional gaps)
17//!
18//! Perl `get_math` falls through to inline an SVG when the math node's
19//! `imagesrc` ends in `.svg` — for our pipeline the SVG is already a
20//! referenced resource bundled by `pack_archive`, so the inline path
21//! has no caller. Doc string flags the gap so a future visitor knows
22//! it's intentional.
23//!
24//! Perl `get_embeddable` copies namespace declarations and RDFa
25//! attributes from the document root onto the extracted node. libxml-rs
26//! exposes `Node::set_attribute` for the RDFa half cheaply; namespace
27//! re-binding requires FFI into `xmlSetNs` and is deferred (the
28//! extracted subtree usually inherits its namespaces fine as long as
29//! the consumer doesn't re-parse it standalone).
30
31use libxml::tree::Node;
32
33use crate::document::PostDocument;
34
35/// Output extraction mode — port of Perl `LaTeXML::Util::Pack`'s
36/// `whatsout` option (Pack.pm L320-345). Selects which subtree of the
37/// post-processed document to serialize and ship to the user.
38///
39/// * [`Whatsout::Document`] — full document, no extraction (default).
40/// * [`Whatsout::Fragment`] — embeddable HTML snippet via [`get_embeddable`].
41/// * [`Whatsout::Math`] — math subtree (or fallback) via [`get_math`].
42/// * [`Whatsout::Archive`] — full document, but bundled into a zip by [`crate::pack::pack_archive`]
43///   (Pack.pm L326-331 + `Pack/Zip.pm::get_archive`). Extraction is a no-op; the archiving happens
44///   in the binary's output stage.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum Whatsout {
47  #[default]
48  Document,
49  Fragment,
50  Math,
51  Archive,
52}
53
54impl Whatsout {
55  /// Parse a CLI string into the matching variant. Returns `None` for
56  /// unrecognized values; callers typically fall back to `Document`.
57  /// Mirrors Perl `pack_collection`'s string-tag dispatch. Perl matches
58  /// archive with `/^archive/`, so the `archive::zip` /
59  /// `archive::zip::perl` long forms (Pack/Zip.pm L118-122) also map to
60  /// [`Whatsout::Archive`].
61  pub fn from_cli(s: &str) -> Option<Self> {
62    match s {
63      "document" => Some(Whatsout::Document),
64      "fragment" => Some(Whatsout::Fragment),
65      "math" => Some(Whatsout::Math),
66      _ if s.starts_with("archive") => Some(Whatsout::Archive),
67      _ => None,
68    }
69  }
70
71  /// The canonical CLI tag for this variant — round-trips through
72  /// [`Whatsout::from_cli`]. Used to serialize the mode across a process
73  /// boundary (the parallel page-render worker manifest).
74  pub fn as_cli(self) -> &'static str {
75    match self {
76      Whatsout::Document => "document",
77      Whatsout::Fragment => "fragment",
78      Whatsout::Math => "math",
79      Whatsout::Archive => "archive",
80    }
81  }
82
83  /// Whether this mode bundles the output into a zip archive (Perl
84  /// `pack_collection` `whatsout =~ /^archive/`). The binary's output
85  /// stage branches on this to call [`crate::pack::pack_archive`].
86  pub fn is_archive(self) -> bool { matches!(self, Whatsout::Archive) }
87
88  /// Whether selecting this mode forces post-processing. Perl
89  /// `Config.pm` L454: any `whatsout` other than `document` implies
90  /// `post = 1` (fragment/math need the extraction stage; archive needs
91  /// the full post-processed document to bundle).
92  pub fn requires_post(self) -> bool { !matches!(self, Whatsout::Document) }
93}
94
95/// Apply the requested [`Whatsout`] extraction to `doc` and return the
96/// serialized subtree. Returns the full document for
97/// [`Whatsout::Document`] (the default no-op) or when extraction finds
98/// no candidate node.
99///
100/// Wraps the two `get_*` helpers + libxml `node_to_string` so callers
101/// don't have to thread the inner [`libxml::tree::Document`] through.
102pub fn serialize_whatsout(doc: &PostDocument, mode: Whatsout) -> String {
103  match mode {
104    // Archive ships the FULL document; the zip wrapping is a separate
105    // output-stage concern (`pack::pack_archive`), not extraction.
106    Whatsout::Document | Whatsout::Archive => doc.to_xml_string(),
107    Whatsout::Fragment => get_embeddable(doc)
108      .map(|n| doc.get_document().node_to_string(&n))
109      .unwrap_or_else(|| doc.to_xml_string()),
110    Whatsout::Math => get_math(doc)
111      .map(|n| doc.get_document().node_to_string(&n))
112      .unwrap_or_else(|| doc.to_xml_string()),
113  }
114}
115
116/// XPath that matches the `<math>` (HTML5 / MathML) and `<Math>`
117/// (legacy LaTeXML pre-MathML) elements anywhere in the document,
118/// regardless of namespace prefix.
119const MATH_XPATH: &str = "//*[local-name()='math' or local-name()='Math']";
120
121/// XPath for math-as-image fallback: `<img class="ltx_Math …">`.
122const MATH_IMG_XPATH: &str = "//*[local-name()='img' and contains(@class,'ltx_Math')]";
123
124/// XPath for embeddable fragment root: `<div class="ltx_document">`.
125/// Perl literally uses `contains(@class, "ltx_document")` so any
126/// element whose `class` includes the substring matches.
127const EMBEDDABLE_XPATH: &str = "//*[contains(@class,'ltx_document')]";
128
129/// RDFa attributes that Perl `get_embeddable` copies from the document
130/// root onto the extracted node (Perl Pack.pm L309).
131const RDFA_ATTRS: &[&str] = &[
132  "prefix", "property", "content", "resource", "about", "typeof", "rel", "rev", "datatype",
133];
134
135/// Class-name pattern that a single-child `<div>` must match for the
136/// unwrap loop to descend into it. Perl regex `/^ltx_(page_(main|content)|document|para|header)$/`.
137fn is_unwrappable_div_class(class: &str) -> bool {
138  matches!(
139    class,
140    "ltx_page_main" | "ltx_page_content" | "ltx_document" | "ltx_para" | "ltx_header"
141  )
142}
143
144/// Local-name predicate for the "this <p> is purely inline content"
145/// check in `get_embeddable`: child names matching `math|text|span`.
146fn is_inline_child(name: &str) -> bool {
147  // Perl uses `=~ /math|text|span/` (substring match anywhere in the
148  // node name). Match the substring semantics exactly.
149  name.contains("math") || name.contains("text") || name.contains("span")
150}
151
152/// Extract the math subtree(s) from a post-processed document, mirroring
153/// Perl `LaTeXML::Util::Pack::get_math` (Pack.pm L247-280).
154///
155/// * If exactly one `<math>` (or `<Math>`) is present, return it.
156/// * If multiple, return their **least common ancestor** by walking up from the first one until its
157///   descendant-count matches the document's total. Unwraps trailing `<tr>` / `<td>` so the LCA
158///   isn't a table cell.
159/// * If no math nodes at all, fall through to a math-image (`<img class="ltx_Math">`) XPath; if
160///   that's also empty, fall through to [`get_embeddable`] so callers get a useful node either way.
161pub fn get_math(doc: &PostDocument) -> Option<Node> {
162  let math_nodes = doc.findnodes(MATH_XPATH);
163  let math_count = math_nodes.len();
164
165  if math_count == 0 {
166    let img_nodes = doc.findnodes(MATH_IMG_XPATH);
167    if img_nodes.is_empty() {
168      return get_embeddable(doc);
169    }
170    return img_nodes.into_iter().next();
171  }
172
173  let mut math = math_nodes.into_iter().next()?;
174  if math_count > 1 {
175    // Walk up until the subtree under `math` contains every math node.
176    // Perl re-runs the same XPath relative to the current candidate
177    // and adds 1 when the candidate itself is a math node. We use
178    // `findnodes_at` (libxml's `node_evaluate`) for context-scoped
179    // XPath — `findnodes_foreign` falls back to a manual traverser
180    // that doesn't support `local-name()` predicates.
181    let descendant_math_xpath = format!(".{MATH_XPATH}");
182    let mut found = 0;
183    while found != math_count {
184      found = doc.findnodes_at(&descendant_math_xpath, Some(&math)).len();
185      if math.get_name().eq_ignore_ascii_case("math") {
186        found += 1;
187      }
188      if found != math_count {
189        match math.get_parent() {
190          Some(p) => math = p,
191          None => break,
192        }
193      }
194    }
195    // Don't anchor on a table cell — climb out of `<tr>` / `<td>`
196    // (Perl `while ($math->nodeName =~ '^t[rd]$')`).
197    while is_table_row_or_cell(&math.get_name()) {
198      match math.get_parent() {
199        Some(p) => math = p,
200        None => break,
201      }
202    }
203  }
204
205  Some(math)
206}
207
208/// Extract an embeddable HTML fragment from a post-processed document,
209/// mirroring Perl `LaTeXML::Util::Pack::get_embeddable` (Pack.pm L282-313).
210///
211/// * Find the first `<div class="ltx_document">`.
212/// * Unwrap as long as the current node is a `<div>` with exactly one child, an unwrappable wrapper
213///   class (`ltx_page_main`, `ltx_page_content`, `ltx_document`, `ltx_para`, `ltx_header`), and no
214///   inline `style` attribute.
215/// * If the resulting node is a `<p>` whose every child is inline- compatible (local-name contains
216///   `math` / `text` / `span`), rename it to `<span class="text">` so it stays inline-embeddable.
217/// * Copy RDFa attributes (`prefix`, `property`, `content`, `resource`, `about`, `typeof`, `rel`,
218///   `rev`, `datatype`) from the document root onto the extracted node so the snippet retains its
219///   semantic annotations.
220/// * Namespace declarations from the root are NOT propagated (libxml-rs gap — see module docs).
221///
222/// If no `ltx_document` element is found, returns the document root,
223/// matching Perl's `return $embeddable || $doc`.
224pub fn get_embeddable(doc: &PostDocument) -> Option<Node> {
225  let root = doc.get_document_element()?;
226  let mut embeddable = doc
227    .findnodes(EMBEDDABLE_XPATH)
228    .into_iter()
229    .next()
230    .unwrap_or_else(|| root.clone());
231
232  // Unwrap nested single-child div wrappers.
233  loop {
234    if embeddable.get_name() != "div" {
235      break;
236    }
237    let children = embeddable.get_child_nodes();
238    if children.len() != 1 {
239      break;
240    }
241    let class = embeddable.get_attribute("class").unwrap_or_default();
242    if !is_unwrappable_div_class(&class) {
243      break;
244    }
245    if embeddable.get_attribute("style").is_some() {
246      break;
247    }
248    match embeddable.get_first_child() {
249      Some(c) => embeddable = c,
250      None => break,
251    }
252  }
253
254  // `<p>` with all-inline children → rename to `<span class="text">`.
255  if embeddable.get_name() == "p" {
256    let children = embeddable.get_child_nodes();
257    if !children.is_empty() && children.iter().all(|c| is_inline_child(&c.get_name())) {
258      let _ = embeddable.set_name("span");
259      let _ = embeddable.set_attribute("class", "text");
260    }
261  }
262
263  // Copy RDFa attributes from doc root.
264  for attr in RDFA_ATTRS {
265    if let Some(value) = root.get_attribute(attr) {
266      let _ = embeddable.set_attribute(attr, &value);
267    }
268  }
269
270  Some(embeddable)
271}
272
273fn is_table_row_or_cell(name: &str) -> bool { matches!(name, "tr" | "td" | "TR" | "TD") }
274
275#[cfg(test)]
276mod tests {
277  use super::*;
278  use crate::document::PostDocumentOptions;
279
280  fn doc(xml: &str) -> PostDocument {
281    PostDocument::new_from_string(xml, PostDocumentOptions::default()).expect("parse test fixture")
282  }
283
284  #[test]
285  fn get_embeddable_returns_root_when_no_ltx_document() {
286    let d = doc("<html><body><p>hello</p></body></html>");
287    let node = get_embeddable(&d).expect("some node");
288    assert_eq!(node.get_name(), "html");
289  }
290
291  // Fixtures use compact XML (no inter-element whitespace). libxml
292  // preserves whitespace text nodes as children, which would break
293  // the single-child unwrap check — Perl XML::LibXML behaves the same
294  // way, and real LaTeXML post-processing emits compact HTML by the
295  // time get_embeddable is called.
296
297  #[test]
298  fn get_embeddable_unwraps_single_child_wrappers_to_inline_span() {
299    // ltx_document (1 child div) → ltx_page_main (1 child p) → p
300    // (terminal: not in unwrap-class allowlist). The <p>'s single
301    // child is the text "Hello world" whose node-name is "text"
302    // (matches inline pattern) → final promotion to span.
303    let xml = r#"<html><body><div class="ltx_document"><div class="ltx_page_main"><p>Hello world</p></div></div></body></html>"#;
304    let d = doc(xml);
305    let node = get_embeddable(&d).expect("some node");
306    assert_eq!(node.get_name(), "span");
307    assert_eq!(node.get_attribute("class").as_deref(), Some("text"));
308  }
309
310  #[test]
311  fn get_embeddable_stops_at_multi_child() {
312    // ltx_document has TWO <p> children → unwrap halts; return it as-is.
313    let xml =
314      r#"<html><body><div class="ltx_document"><p>first</p><p>second</p></div></body></html>"#;
315    let d = doc(xml);
316    let node = get_embeddable(&d).expect("some node");
317    assert_eq!(node.get_name(), "div");
318    assert_eq!(node.get_attribute("class").as_deref(), Some("ltx_document"));
319  }
320
321  #[test]
322  fn get_embeddable_keeps_p_when_child_is_non_inline_block() {
323    // Single-child path lands on the <p>; child is a <table>, whose
324    // name doesn't match math|text|span → no promotion to span.
325    let xml = r#"<html><body><div class="ltx_document"><div class="ltx_para"><p><table>x</table></p></div></div></body></html>"#;
326    let d = doc(xml);
327    let node = get_embeddable(&d).expect("some node");
328    assert_eq!(node.get_name(), "p");
329  }
330
331  #[test]
332  fn get_math_returns_lone_math_node() {
333    let xml = r#"<html><body><p>some text</p><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>x</mi></math></body></html>"#;
334    let d = doc(xml);
335    let node = get_math(&d).expect("some node");
336    assert_eq!(node.get_name(), "math");
337  }
338
339  #[test]
340  fn get_math_returns_lca_for_multiple_math() {
341    let xml = r#"<html><body><div id="container"><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>a</mi></math><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>b</mi></math></div></body></html>"#;
342    let d = doc(xml);
343    let node = get_math(&d).expect("some node");
344    // Both math nodes are children of `<div id="container">` — the
345    // LCA should be that div.
346    assert_eq!(node.get_name(), "div");
347    assert_eq!(node.get_attribute("id").as_deref(), Some("container"));
348  }
349
350  #[test]
351  fn get_math_falls_through_to_img_when_no_math_elements() {
352    let xml = r#"<html><body><p>before</p><img class="ltx_Math" alt="x"/></body></html>"#;
353    let d = doc(xml);
354    let node = get_math(&d).expect("some node");
355    assert_eq!(node.get_name(), "img");
356  }
357
358  #[test]
359  fn get_math_falls_through_to_embeddable_when_no_math_at_all() {
360    // No math + no math-img → fall through to get_embeddable. The
361    // embeddable result for a single-text-child <p> is the promoted
362    // <span class="text">.
363    let xml = r#"<html><body><div class="ltx_document"><p>just prose</p></div></body></html>"#;
364    let d = doc(xml);
365    let node = get_math(&d).expect("some node");
366    assert_eq!(node.get_name(), "span");
367  }
368
369  #[test]
370  fn whatsout_from_cli_recognized() {
371    assert_eq!(Whatsout::from_cli("document"), Some(Whatsout::Document));
372    assert_eq!(Whatsout::from_cli("fragment"), Some(Whatsout::Fragment));
373    assert_eq!(Whatsout::from_cli("math"), Some(Whatsout::Math));
374    // `--whatsout=archive` (Perl Pack.pm `pack_collection` `whatsout`
375    // tag): bundle the full document into a zip. The `archive::zip`
376    // / `archive::zip::perl` long forms (Zip.pm L118-122) also count
377    // as archive — Perl matches `/^archive/`.
378    assert_eq!(Whatsout::from_cli("archive"), Some(Whatsout::Archive));
379    assert_eq!(Whatsout::from_cli("archive::zip"), Some(Whatsout::Archive));
380    assert_eq!(Whatsout::from_cli("nonsense"), None);
381  }
382
383  #[test]
384  fn whatsout_default_is_document() {
385    assert_eq!(Whatsout::default(), Whatsout::Document);
386  }
387
388  #[test]
389  fn whatsout_is_archive_predicate() {
390    assert!(Whatsout::Archive.is_archive());
391    assert!(!Whatsout::Document.is_archive());
392    assert!(!Whatsout::Fragment.is_archive());
393    assert!(!Whatsout::Math.is_archive());
394  }
395
396  #[test]
397  fn whatsout_requires_post_for_non_document() {
398    // Perl Config.pm L454: any non-`document` whatsout forces post=1.
399    assert!(!Whatsout::Document.requires_post());
400    assert!(Whatsout::Fragment.requires_post());
401    assert!(Whatsout::Math.requires_post());
402    assert!(Whatsout::Archive.requires_post());
403  }
404
405  #[test]
406  fn serialize_whatsout_archive_returns_full_document() {
407    // Archive bundles the FULL post-processed document into the zip;
408    // the serialized HTML payload is the whole document, identical to
409    // `Whatsout::Document`. The zip wrapping is `pack::pack_archive`'s
410    // job, not extraction's.
411    let xml = r#"<html><body><div class="ltx_document"><p>hi</p></div></body></html>"#;
412    let d = doc(xml);
413    let archive = serialize_whatsout(&d, Whatsout::Archive);
414    let full = serialize_whatsout(&d, Whatsout::Document);
415    assert_eq!(archive, full);
416    assert!(archive.contains("<html>") && archive.contains("</html>"));
417  }
418
419  #[test]
420  fn serialize_whatsout_document_matches_full_xml() {
421    let xml = r#"<html><body><div class="ltx_document"><p>hi</p></div></body></html>"#;
422    let d = doc(xml);
423    let full = serialize_whatsout(&d, Whatsout::Document);
424    assert!(full.contains("<html>") && full.contains("</html>"));
425  }
426
427  #[test]
428  fn serialize_whatsout_fragment_strips_html_wrapper() {
429    let xml = r#"<html><body><div class="ltx_document"><p>hi</p></div></body></html>"#;
430    let d = doc(xml);
431    let frag = serialize_whatsout(&d, Whatsout::Fragment);
432    // Fragment unwraps ltx_document and promotes the lone-text <p>
433    // to <span class="text"> — the result should NOT carry the
434    // <html>/<body> wrapper.
435    assert!(
436      !frag.contains("<html>"),
437      "frag contains html wrapper: {frag}"
438    );
439    assert!(frag.contains("hi"));
440  }
441
442  #[test]
443  fn serialize_whatsout_math_returns_math_subtree() {
444    let xml = r#"<html><body><p>txt</p><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>z</mi></math></body></html>"#;
445    let d = doc(xml);
446    let m = serialize_whatsout(&d, Whatsout::Math);
447    assert!(m.contains("<mi>z</mi>"));
448    assert!(!m.contains("<html>"), "math contains html wrapper: {m}");
449    assert!(
450      !m.contains("<p>txt</p>"),
451      "math contains unrelated text: {m}"
452    );
453  }
454
455  #[test]
456  fn get_embeddable_copies_rdfa_from_root() {
457    let xml = r#"<html prefix="dc: http://purl.org/dc/terms/" typeof="ScholarlyArticle"><body><div class="ltx_document"><p>text</p></div></body></html>"#;
458    let d = doc(xml);
459    let node = get_embeddable(&d).expect("some node");
460    // Unwrap reaches the <p>, then promotes to <span>; either way
461    // the RDFa attrs should land on the result.
462    assert_eq!(
463      node.get_attribute("prefix").as_deref(),
464      Some("dc: http://purl.org/dc/terms/")
465    );
466    assert_eq!(
467      node.get_attribute("typeof").as_deref(),
468      Some("ScholarlyArticle")
469    );
470  }
471
472  #[test]
473  fn whatsout_cli_tag_round_trips() {
474    for w in [
475      Whatsout::Document,
476      Whatsout::Fragment,
477      Whatsout::Math,
478      Whatsout::Archive,
479    ] {
480      assert_eq!(
481        Whatsout::from_cli(w.as_cli()),
482        Some(w),
483        "as_cli must round-trip through from_cli for {w:?}"
484      );
485    }
486  }
487}