Skip to main content

latexml_post/
split.rs

1//! Document splitting processor.
2//!
3//! Port of `LaTeXML::Post::Split`.
4//! Splits a document into multiple pages based on an XPath expression
5//! that identifies section-level elements to extract as separate documents.
6
7use std::path::Path;
8
9use libxml::tree::Node;
10use rustc_hash::FxHashMap as HashMap;
11
12use crate::{
13  document::{NodeData, PostDocument, get_xml_id},
14  processor::{ProcessResult, Processor},
15};
16
17/// Page naming strategy for split documents.
18#[derive(Debug, Clone)]
19pub enum SplitNaming {
20  /// Use xml:id attribute
21  Id,
22  /// Use xml:id, relative to parent
23  IdRelative,
24  /// Use labels attribute
25  Label,
26  /// Use labels, relative to parent
27  LabelRelative,
28}
29
30/// A tree node used to track the hierarchy of pages during splitting.
31struct PageEntry {
32  node:     Node,
33  id:       Option<String>,
34  upid:     Option<String>,
35  name:     String,
36  children: Vec<PageEntry>,
37  document: Option<PostDocument>,
38}
39
40/// Split post-processor: splits a document into multiple pages.
41///
42/// Port of `LaTeXML::Post::Split`.
43pub struct Split {
44  name:                 String,
45  /// XPath expression to find elements that become pages.
46  split_xpath:          String,
47  /// Naming strategy for page files.
48  split_naming:         SplitNaming,
49  /// Whether to suppress navigation links.
50  no_navigation:        bool,
51  /// Counter for unnamed pages.
52  unnamed_page_counter: u32,
53}
54
55impl Split {
56  pub fn new(split_xpath: &str, split_naming: SplitNaming, no_navigation: bool) -> Self {
57    Split {
58      name: "Split".to_string(),
59      split_xpath: split_xpath.to_string(),
60      split_naming,
61      no_navigation,
62      unnamed_page_counter: 0,
63    }
64  }
65
66  /// Get the nodes that will become separate pages, via the limit-safe
67  /// [`PostDocument::find_split_pages`] walk (not XPath, which silently splits
68  /// nothing on very large documents).
69  fn get_pages(&self, doc: &PostDocument) -> Vec<Node> { doc.find_split_pages(&self.split_xpath) }
70
71  /// Generate a name for an unnamed page.
72  fn generate_unnamed_page_name(&mut self) -> String {
73    self.unnamed_page_counter += 1;
74    format!("FOO{}", self.unnamed_page_counter)
75  }
76
77  /// Sort pages into a tree hierarchy.
78  ///
79  /// Port of Perl `presortPages`.
80  /// If a page is a descendant of another page, it becomes a child in the tree.
81  fn presort_pages(
82    tree: &mut PageEntry,
83    haschildren: &mut HashMap<String, bool>,
84    pages: Vec<Node>,
85  ) {
86    // We track the "current" position in the tree by maintaining a path of ancestors.
87    // Since we can't have mutable borrows at multiple tree levels simultaneously,
88    // we use an index-based approach to walk the tree.
89    let mut path: Vec<usize> = Vec::new(); // indices into children arrays
90
91    for page in pages {
92      // Walk back up the tree until we find an ancestor of `page`
93      loop {
94        let current_node = Self::get_node_at(tree, &path);
95        if is_child(&page, &current_node) {
96          break;
97        }
98        if path.is_empty() {
99          break;
100        }
101        path.pop();
102      }
103
104      let current_node = Self::get_node_at(tree, &path);
105      let current_id = get_xml_id(&current_node);
106      let localname = current_node.get_name();
107      haschildren.insert(localname, true);
108
109      let page_id = get_xml_id(&page);
110      let entry = PageEntry {
111        node:     page,
112        id:       page_id,
113        upid:     current_id,
114        name:     String::new(),
115        children: Vec::new(),
116        document: None,
117      };
118
119      // Add as child of current position
120      let parent = Self::get_entry_at_mut(tree, &path);
121      parent.children.push(entry);
122      let new_idx = parent.children.len() - 1;
123
124      // Go "down" — following pages may be children of this one
125      path.push(new_idx);
126    }
127  }
128
129  /// Get the node at a given path in the tree.
130  fn get_node_at(tree: &PageEntry, path: &[usize]) -> Node {
131    let mut current = tree;
132    for &idx in path {
133      current = &current.children[idx];
134    }
135    current.node.clone()
136  }
137
138  /// Get a mutable reference to the entry at a given path.
139  fn get_entry_at_mut<'a>(tree: &'a mut PageEntry, path: &[usize]) -> &'a mut PageEntry {
140    let mut current = tree;
141    for &idx in path {
142      current = &mut current.children[idx];
143    }
144    current
145  }
146
147  /// Compute destination pathnames for each page in the tree.
148  ///
149  /// Port of Perl `prenamePages`.
150  fn prename_pages(
151    &mut self,
152    doc: &PostDocument,
153    tree: &mut PageEntry,
154    haschildren: &HashMap<String, bool>,
155  ) {
156    for i in 0..tree.children.len() {
157      let (parent_name, parent_node) = (tree.name.clone(), tree.node.clone());
158      let child = &tree.children[i];
159      let child_localname = child.node.get_name();
160      let recursive = haschildren.get(&child_localname).copied().unwrap_or(false);
161      let name = self.get_page_name(doc, &child.node, &parent_node, &parent_name, recursive);
162      tree.children[i].name = name;
163    }
164    // Recurse into children
165    for child in &mut tree.children {
166      self.prename_pages(doc, child, haschildren);
167    }
168  }
169
170  /// Process a sequence of page entries, removing them from the document
171  /// and generating sub-documents for each.
172  ///
173  /// Port of Perl `processPages`.
174  fn process_pages(
175    &mut self,
176    doc: &mut PostDocument,
177    entries: &mut Vec<PageEntry>,
178  ) -> Vec<PostDocument> {
179    // Before any document surgery, copy inheritable attributes.
180    let mut intoc = false;
181    for entry in entries.iter() {
182      let node = &entry.node;
183      if let Some(inlist) = node.get_attribute("inlist") {
184        if inlist.contains("toc") {
185          intoc = true;
186        }
187      }
188      // Copy xml:lang and backgroundcolor from ancestors (Perl Split.pm's
189      // inheritable-attribute loop). `get_attribute("xml:lang")` alone can
190      // miss the namespaced form (`xml:` attributes live in the XML
191      // namespace — the same trap `get_xml_id` guards), which silently
192      // skipped the copy: the XPath found the ancestor, the read returned
193      // None, and split pages lost their inherited language. Caught by the
194      // streaming-split parity gate (118_streaming_split_parity).
195      for attr in &["xml:lang", "backgroundcolor"] {
196        let xpath = format!("ancestor-or-self::*[@{}][1]", attr);
197        if let Some(anc) = doc.findnode_at(&xpath, node) {
198          let val = anc.get_attribute(attr).or_else(|| {
199            attr
200              .strip_prefix("xml:")
201              .and_then(|local| anc.get_attribute_ns(local, "http://www.w3.org/XML/1998/namespace"))
202          });
203          if let Some(val) = val {
204            let mut node_mut = node.clone();
205            node_mut.set_attribute(attr, &val).ok();
206          }
207        }
208      }
209    }
210
211    let mut docs = Vec::new();
212    while !entries.is_empty() {
213      let parent = match entries[0].node.get_parent() {
214        Some(p) => p,
215        None => {
216          entries.remove(0);
217          continue;
218        },
219      };
220
221      // Remove page & ALL following siblings (backwards).
222      let mut removed: Vec<Node> = Vec::new();
223      loop {
224        let last = parent.get_last_child();
225        match last {
226          Some(mut sib) => {
227            sib.unlink_node();
228            removed.insert(0, sib.clone());
229            if sib == entries[0].node {
230              break;
231            }
232          },
233          None => break,
234        }
235      }
236
237      // Build TOC from adjacent nodes being extracted.
238      let mut toc: Vec<NodeData> = Vec::new();
239
240      // Process a sequence of adjacent pages that share the same parent.
241      while !entries.is_empty() && !removed.is_empty() && entries[0].node == removed[0] {
242        let mut entry = entries.remove(0);
243        let page = entry.node.clone();
244
245        // If any pages go in toc, assume siblings should too
246        if intoc && page.get_attribute("inlist").is_none() {
247          let mut page_mut = page.clone();
248          page_mut.set_attribute("inlist", "toc").ok();
249        }
250
251        // Remove this page from the removed list and from the document's ID cache
252        let removed_node = removed.remove(0);
253        doc.remove_nodes(&[removed_node]);
254
255        // Build TOC entry
256        if let Some(id) = get_xml_id(&page) {
257          let mut toc_attrs = HashMap::default();
258          toc_attrs.insert("idref".to_string(), id);
259          toc_attrs.insert("show".to_string(), "toctitle".to_string());
260          let tocentry = NodeData::Element {
261            tag:        "ltx:tocentry".to_string(),
262            attributes: None,
263            children:   vec![NodeData::Element {
264              tag:        "ltx:ref".to_string(),
265              attributes: Some(toc_attrs),
266              children:   vec![],
267            }],
268          };
269          toc.push(tocentry);
270        }
271
272        // Process children pages BEFORE this page (Perl: "Due to the way document building works")
273        let mut child_docs = self.process_pages(doc, &mut entry.children);
274
275        // Create sub-document from the extracted page
276        let subdoc = doc.new_document(page, &entry.name);
277        entry.document = Some(subdoc);
278        // Take the document out to push to the results
279        docs.push(entry.document.take().unwrap());
280        docs.append(&mut child_docs);
281      }
282
283      // Add TOC to reflect the extracted pages
284      if !toc.is_empty() {
285        // Only add if parent doesn't already have a TOC with lists='toc'
286        let has_toc = !doc
287          .findnodes_at("descendant::ltx:TOC[@lists='toc']", Some(&parent))
288          .is_empty();
289        if !has_toc {
290          let parent_type = parent.get_name();
291          let mut toclist_attrs = HashMap::default();
292          toclist_attrs.insert("class".to_string(), format!("ltx_toclist_{}", parent_type));
293          let toc_node = NodeData::Element {
294            tag:        "ltx:TOC".to_string(),
295            attributes: None,
296            children:   vec![NodeData::Element {
297              tag:        "ltx:toclist".to_string(),
298              attributes: Some(toclist_attrs),
299              children:   toc,
300            }],
301          };
302          let mut parent_mut = parent.clone();
303          doc.add_nodes(&mut parent_mut, &[toc_node]);
304        }
305      }
306
307      // Re-add remaining siblings
308      let mut parent_mut = parent;
309      for mut child in removed {
310        parent_mut.add_child(&mut child).ok();
311      }
312    }
313    docs
314  }
315
316  /// Add navigation elements to all documents in the tree.
317  ///
318  /// Port of Perl `addNavigation`.
319  fn add_navigation(entry: &mut PageEntry, nav_nodes: &[Node]) {
320    if let Some(ref mut doc) = entry.document {
321      if let Some(mut root) = doc.get_document_element() {
322        let nav_data: Vec<NodeData> = nav_nodes
323          .iter()
324          .map(|n| NodeData::XmlNode(n.clone()))
325          .collect();
326        doc.add_nodes(&mut root, &nav_data);
327      }
328    }
329    for child in &mut entry.children {
330      Self::add_navigation(child, nav_nodes);
331    }
332  }
333
334  /// Compute the destination pathname for a page.
335  ///
336  /// Port of `Split::getPageName`.
337  fn get_page_name(
338    &mut self,
339    doc: &PostDocument,
340    page: &Node,
341    parent: &Node,
342    parent_path: &str,
343    recursive: bool,
344  ) -> String {
345    let attr = match self.split_naming {
346      SplitNaming::Id | SplitNaming::IdRelative => "xml:id",
347      SplitNaming::Label | SplitNaming::LabelRelative => "labels",
348    };
349
350    let mut name = if attr == "xml:id" {
351      get_xml_id(page).unwrap_or_default()
352    } else {
353      page.get_attribute(attr).unwrap_or_default()
354    };
355
356    // Truncate to first label, strip LABEL: prefix
357    if let Some(first) = name.split_whitespace().next() {
358      name = first.to_string();
359    }
360    if let Some(stripped) = name.strip_prefix("LABEL:") {
361      name = stripped.to_string();
362    }
363
364    if name.is_empty() {
365      if attr == "labels" {
366        if let Some(id) = get_xml_id(page) {
367          Info!(
368            "split",
369            "pathname",
370            "Using '{}' to create page pathname, instead of missing '{}'",
371            id,
372            attr
373          );
374          name = id;
375        } else {
376          name = self.generate_unnamed_page_name();
377          Info!(
378            "split",
379            "pathname",
380            "Using '{}' to create page pathname, instead of missing '{}'",
381            name,
382            attr
383          );
384        }
385      } else {
386        name = self.generate_unnamed_page_name();
387        Info!(
388          "split",
389          "pathname",
390          "Using '{}' to create page pathname, instead of missing '{}'",
391          name,
392          attr
393        );
394      }
395    }
396
397    // Relative naming: strip parent prefix
398    let as_dir = match self.split_naming {
399      SplitNaming::IdRelative | SplitNaming::LabelRelative => {
400        let parent_attr = if attr == "xml:id" {
401          get_xml_id(parent)
402        } else {
403          parent.get_attribute(attr)
404        };
405        if let Some(pname) = parent_attr {
406          let pname = pname.split_whitespace().next().unwrap_or("");
407          let pname = pname.strip_prefix("LABEL:").unwrap_or(pname);
408          if let Some(rest) = name.strip_prefix(pname) {
409            let rest = rest.trim_start_matches(['.', '_', ':']);
410            if !rest.is_empty() {
411              name = rest.to_string();
412            }
413          }
414        }
415        recursive
416      },
417      _ => false,
418    };
419
420    // Sanitize colons
421    name = name.replace(':', "_");
422
423    let ext = doc
424      .get_destination_extension()
425      .unwrap_or_else(|| "xml".to_string());
426    let parent_dir = Path::new(parent_path)
427      .parent()
428      .and_then(|p| p.to_str())
429      .unwrap_or(".");
430
431    // Normalize empty parent_dir to "."
432    let parent_dir = if parent_dir.is_empty() {
433      "."
434    } else {
435      parent_dir
436    };
437
438    if as_dir {
439      format!("{}/{}/index.{}", parent_dir, name, ext)
440    } else {
441      format!("{}/{}.{}", parent_dir, name, ext)
442    }
443  }
444}
445
446impl Processor for Split {
447  fn get_name(&self) -> &str { &self.name }
448
449  fn process(&mut self, mut doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
450    let root = match nodes.into_iter().next() {
451      Some(r) => r,
452      None => return Ok(vec![doc]),
453    };
454
455    // Ensure root has an ID (Writer will remove TEMPORARY_DOCUMENT_ID)
456    let mut root_mut = root;
457    if get_xml_id(&root_mut).is_none() {
458      root_mut
459        .set_attribute("xml:id", "TEMPORARY_DOCUMENT_ID")
460        .ok();
461    }
462
463    let pages = self.get_pages(&doc);
464    // Filter out the root node itself (Perl: grep { $_->parentNode->parentNode })
465    let pages: Vec<Node> = pages
466      .into_iter()
467      .filter(|p| p.get_parent().and_then(|pp| pp.get_parent()).is_some())
468      .collect();
469
470    if pages.is_empty() {
471      Info!("split", "result", "[not split]");
472      return Ok(vec![doc]);
473    }
474
475    // Save and remove navigation elements
476    let nav_nodes: Vec<Node> = doc.findnodes("descendant::ltx:navigation");
477    if !nav_nodes.is_empty() {
478      doc.remove_nodes(&nav_nodes);
479    }
480
481    // Build the page tree
482    let root_id = get_xml_id(&root_mut);
483    let root_dest = doc.get_destination().unwrap_or("").to_string();
484    let mut tree = PageEntry {
485      node:     root_mut,
486      id:       root_id,
487      upid:     None,
488      name:     root_dest,
489      children: Vec::new(),
490      document: Some(doc),
491    };
492
493    let mut haschildren = HashMap::default();
494    Self::presort_pages(&mut tree, &mut haschildren, pages);
495
496    // Take doc out so we can pass &PostDocument and &mut tree without borrow conflict
497    let doc_tmp = tree.document.take().unwrap();
498    self.prename_pages(&doc_tmp, &mut tree, &haschildren);
499    tree.document = Some(doc_tmp);
500
501    // Process pages: extract and create sub-documents
502    let mut doc = tree.document.take().unwrap();
503    let mut docs = vec![];
504    let mut child_docs = self.process_pages(&mut doc, &mut tree.children);
505
506    // Restore navigation to all documents
507    if !nav_nodes.is_empty() && !self.no_navigation {
508      // Put doc back into tree for navigation distribution
509      tree.document = Some(doc);
510      Self::add_navigation(&mut tree, &nav_nodes);
511      doc = tree.document.take().unwrap();
512
513      // Also add nav to child docs
514      for child_doc in &mut child_docs {
515        if let Some(mut root) = child_doc.get_document_element() {
516          let nav_data: Vec<NodeData> = nav_nodes
517            .iter()
518            .map(|n| NodeData::XmlNode(n.clone()))
519            .collect();
520          child_doc.add_nodes(&mut root, &nav_data);
521        }
522      }
523    }
524
525    docs.insert(0, doc);
526    docs.append(&mut child_docs);
527
528    let n = docs.len();
529    Info!(
530      "split",
531      "result",
532      "{}",
533      if n > 1 {
534        format!(" [Split into {} pages]", n)
535      } else {
536        "[not split]".to_string()
537      }
538    );
539
540    Ok(docs)
541  }
542}
543
544/// Check if `child` is a descendant of `ancestor`.
545fn is_child(child: &Node, ancestor: &Node) -> bool {
546  let mut parent = child.get_parent();
547  while let Some(ref p) = parent {
548    if *p == *ancestor {
549      return true;
550    }
551    parent = p.get_parent();
552  }
553  false
554}