Skip to main content

latexml_core/sxml/
fragment_reader.rs

1//! Streaming iteration over an XML file, one top-level subtree at a time.
2//!
3//! Wraps rust-libxml's `TextReader` (our fork; `xmlTextReaderExpand`
4//! underneath): the reader walks the file event-by-event, and
5//! [`FragmentReader::next_fragment`] materializes ONLY the current top-level
6//! element's subtree as an owned, mutable [`Document`], then skips past it —
7//! the rest of the file is never parsed into memory at once. This is the
8//! "partially warm DOM" substrate of pass 2: each fragment gets real XPath,
9//! rewrites, math parsing and finalize, at a peak cost of one fragment.
10//!
11//! XPath over a fragment document must use a context built from THAT
12//! document's nodes (`Context::from_node`) — evaluating a node against
13//! another document's context is the cross-doc trap recorded in
14//! `xpath-cross-doc-context-node`.
15
16use libxml::{reader::TextReader, readonly::RoNode, tree::Document};
17
18use crate::common::error::{Error, ErrorCategory, ErrorTarget, Result};
19
20/// Iterates the top-level subtrees of an XML file (the children of its root
21/// element), materializing one owned [`Document`] at a time.
22pub struct FragmentReader {
23  reader:  TextReader,
24  /// Depth of the fragments to materialize: children of the document root sit
25  /// at reader depth 1.
26  entered: bool,
27}
28
29impl FragmentReader {
30  /// Open `path` for streaming. The file's root element (e.g. the
31  /// `_lxfragment` wrapper of a spilled segment, or `ltx:document` of a full
32  /// core-XML file) is consumed as the container; fragments are its children.
33  pub fn open(path: &std::path::Path) -> Result<Self> {
34    let reader = TextReader::from_file(&path.to_string_lossy(), 0)
35      .map_err(|()| reader_error(format!("cannot open {}", path.display())))?;
36    Ok(FragmentReader { reader, entered: false })
37  }
38
39  /// Advance to the next top-level element and materialize its whole subtree
40  /// as an owned, mutable [`Document`]. Returns `Ok(None)` at the end of the
41  /// container. Non-element content between fragments (whitespace, comments,
42  /// PIs) is skipped.
43  pub fn next_fragment(&mut self) -> Result<Option<Document>> {
44    loop {
45      let advanced = if self.entered && self.at_fragment() {
46        // We are positioned ON a fragment we already materialized: skip its
47        // whole subtree without parsing it again.
48        self.reader.read_next()
49      } else {
50        self.reader.read()
51      }
52      .map_err(|()| reader_error(String::from("parse error while streaming")))?;
53      if !advanced {
54        return Ok(None);
55      }
56      if !self.entered {
57        // The first element event is the container root; descend into it.
58        if self.reader.is_element() && self.reader.depth() == 0 {
59          self.entered = true;
60        }
61        continue;
62      }
63      if self.at_fragment() {
64        let doc = self
65          .reader
66          .expand_to_document()
67          .ok_or_else(|| reader_error(String::from("expand_to_document failed on a fragment")))?;
68        return Ok(Some(doc));
69      }
70      // depth 0 again = the container's end-element event; anything deeper
71      // than 1 cannot happen here (read_next skips whole subtrees).
72    }
73  }
74
75  /// Positioned on a top-level element (a fragment root)?
76  fn at_fragment(&self) -> bool { self.reader.is_element() && self.reader.depth() == 1 }
77
78  /// Borrow the current subtree read-only without copying (valid only until
79  /// the next advance) — for peeking at a fragment (name, attributes) before
80  /// deciding to materialize it.
81  pub fn peek(&self) -> Option<RoNode> { self.reader.expand() }
82}
83
84fn reader_error(details: String) -> Error {
85  Error {
86    target:   ErrorTarget::Internal,
87    category: ErrorCategory::Libxml,
88    message:  format!("fragment-reader: {details}"),
89  }
90}
91
92#[cfg(test)]
93mod tests {
94  use super::*;
95
96  fn write_fixture(name: &str, content: &str) -> std::path::PathBuf {
97    let path = std::env::temp_dir().join(format!("lxsxml-reader-{}-{name}", std::process::id()));
98    std::fs::write(&path, content).unwrap();
99    path
100  }
101
102  #[test]
103  fn iterates_fragments_one_at_a_time() {
104    let path = write_fixture(
105      "basic.xml",
106      "<_lxfragment xmlns:ltx=\"http://dlmf.nist.gov/LaTeXML\">\
107       <ltx:para xml:id=\"p1\"><ltx:p>first Gr\u{00fc}\u{00df}e</ltx:p></ltx:para>\
108       <!-- a comment between fragments -->\
109       <ltx:para xml:id=\"p2\"><ltx:p>second \u{6570}\u{5b66}</ltx:p></ltx:para>\
110       <ltx:pagination role=\"newpage\"/>\
111       </_lxfragment>",
112    );
113    let mut reader = FragmentReader::open(&path).expect("open");
114
115    let mut seen = Vec::new();
116    while let Some(doc) = reader.next_fragment().expect("stream") {
117      let root = doc.get_root_element().expect("fragment root");
118      // The fragment document is real and owned: namespace resolved, content
119      // intact, XPath-able from its own context.
120      assert_eq!(
121        root.get_namespace().map(|ns| ns.get_href()),
122        Some(String::from("http://dlmf.nist.gov/LaTeXML")),
123        "fragment root keeps the wrapper-declared namespace"
124      );
125      seen.push((root.get_name(), root.get_content()));
126    }
127    let _ = std::fs::remove_file(&path);
128
129    assert_eq!(seen.len(), 3, "three fragments, comment skipped: {seen:?}");
130    assert_eq!(seen[0].0, "para");
131    assert!(seen[0].1.contains("first Gr\u{00fc}\u{00df}e"));
132    assert_eq!(seen[1].0, "para");
133    assert!(seen[1].1.contains("second \u{6570}\u{5b66}"));
134    assert_eq!(seen[2].0, "pagination", "empty-element fragment survives");
135  }
136
137  #[test]
138  fn fragment_documents_are_independent_and_mutable() {
139    let path = write_fixture(
140      "mutable.xml",
141      "<root><a n=\"1\"><child/></a><b n=\"2\"/></root>",
142    );
143    let mut reader = FragmentReader::open(&path).expect("open");
144
145    let doc_a = reader.next_fragment().expect("stream").expect("fragment a");
146    let mut root_a = doc_a.get_root_element().unwrap();
147    // Mutating fragment A must not disturb streaming of fragment B.
148    root_a.set_attribute("mutated", "yes").expect("mutable");
149
150    let doc_b = reader.next_fragment().expect("stream").expect("fragment b");
151    let root_b = doc_b.get_root_element().unwrap();
152    assert_eq!(root_b.get_name(), "b");
153    assert_eq!(root_b.get_attribute("n").as_deref(), Some("2"));
154    assert_eq!(root_a.get_attribute("mutated").as_deref(), Some("yes"));
155
156    assert!(
157      reader.next_fragment().expect("stream").is_none(),
158      "exhausted"
159    );
160    let _ = std::fs::remove_file(&path);
161  }
162
163  #[test]
164  fn open_missing_file_is_an_error() {
165    let missing = std::env::temp_dir().join("lxsxml-definitely-not-here.xml");
166    assert!(FragmentReader::open(&missing).is_err());
167  }
168
169  #[test]
170  fn segment_store_round_trip_streams_back() {
171    // The integration the substrate exists for: spill via SegmentStore, read
172    // back via FragmentReader.
173    use crate::sxml::{SegmentMeta, SegmentStore};
174    let tmp = std::env::temp_dir().join(format!("lxsxml-integ-{}", std::process::id()));
175    std::fs::create_dir_all(&tmp).unwrap();
176    let mut store = SegmentStore::create(&tmp).expect("store");
177    let meta = SegmentMeta {
178      depth:      1,
179      noindent:   false,
180      section_id: None,
181      parent:     None,
182      ancestors:  vec![],
183      font:       None,
184      namespaces: vec![(
185        String::from("ltx"),
186        String::from("http://dlmf.nist.gov/LaTeXML"),
187      )],
188    };
189    let id = store
190      .write_segment(
191        "<ltx:section xml:id=\"S1\"><ltx:title>One</ltx:title></ltx:section>\
192         <ltx:section xml:id=\"S2\"><ltx:title>Two</ltx:title></ltx:section>",
193        meta,
194      )
195      .expect("write");
196
197    // Segment files are raw (splice-ready); streaming reads the wrapped form.
198    let wrapped_path = tmp.join("wrapped.xml");
199    std::fs::write(&wrapped_path, store.wrapped_segment(id).expect("wrap")).unwrap();
200    let mut reader = FragmentReader::open(&wrapped_path).expect("open segment");
201    let mut titles = Vec::new();
202    while let Some(doc) = reader.next_fragment().expect("stream") {
203      let root = doc.get_root_element().unwrap();
204      assert_eq!(root.get_name(), "section");
205      titles.push(root.get_content());
206    }
207    assert_eq!(titles, vec![String::from("One"), String::from("Two")]);
208    drop(store);
209    let _ = std::fs::remove_dir_all(&tmp);
210  }
211}