Skip to main content

latexml_post/
collector.rs

1//! Abstract collector base for content generation processors.
2//!
3//! Port of `LaTeXML::Post::Collector` (113 lines of Perl).
4//! Base class for processors that collect information from multiple documents
5//! and build derived content (indexes, bibliographies, etc.).
6//! Supports splitting collected content into sub-documents by initial letter.
7
8use std::path::Path;
9
10use libxml::tree::Node;
11use rustc_hash::FxHashMap as HashMap;
12
13use crate::{
14  document::{NodeData, PostDocument, get_xml_id},
15  processor::{ProcessResult, Processor},
16};
17
18/// Abstract collector post-processor.
19///
20/// Port of `LaTeXML::Post::Collector`.
21/// Subclasses (MakeIndex, MakeBibliography) implement the actual `process` method.
22pub struct Collector {
23  name:               String,
24  resource_directory: Option<String>,
25  resource_prefix:    Option<String>,
26  /// Optional scanner to rescan generated content.
27  /// In Perl: `$$self{scanner}->process($doc, $$self{scanner}->toProcess($doc))`
28  has_scanner:        bool,
29}
30
31impl Collector {
32  pub fn new(name: &str) -> Self {
33    Collector {
34      name:               name.to_string(),
35      resource_directory: None,
36      resource_prefix:    None,
37      has_scanner:        false,
38    }
39  }
40
41  /// Set whether this collector has an attached scanner for rescanning.
42  pub fn with_scanner(mut self) -> Self {
43    self.has_scanner = true;
44    self
45  }
46}
47
48/// Given collected content broken into portions by initial letter,
49/// fill in the main document with the first sub-collection,
50/// and create new documents for the rest.
51///
52/// Port of `Collector::makeSubCollectionDocuments`.
53///
54/// The `collections` map has: initial → XML data for that section.
55/// The first sub-collection fills the existing `root` element;
56/// each subsequent one gets a new sub-document.
57pub fn make_sub_collection_documents(
58  doc: &mut PostDocument,
59  root: &Node,
60  collections: &HashMap<String, Vec<NodeData>>,
61) -> Vec<PostDocument> {
62  let mut initials: Vec<&String> = collections.keys().collect();
63  initials.sort();
64
65  if initials.is_empty() {
66    return vec![];
67  }
68
69  let _root_tag = doc
70    .get_qname(root)
71    .unwrap_or_else(|| "ltx:index".to_string());
72  // NS-aware read — the bare form always returned None, so every
73  // sub-collection page id was built from an EMPTY root id (".B"-style).
74  let root_id = get_xml_id(root).unwrap_or_default();
75
76  // Build (id, initial) pairs for each sub-collection
77  let ids: Vec<(String, &str)> = initials
78    .iter()
79    .enumerate()
80    .map(|(i, init)| {
81      if i == 0 {
82        (root_id.clone(), init.as_str())
83      } else {
84        (format!("{}.{}", root_id, init), init.as_str())
85      }
86    })
87    .collect();
88
89  // For the first sub-collection, fill the main document's root element
90  if let Some(first_init) = initials.first() {
91    if let Some(data) = collections.get(*first_init) {
92      // Build TOC linking all sub-collections
93      let toc_entries: Vec<NodeData> = ids
94        .iter()
95        .enumerate()
96        .map(|(i, (id, init))| {
97          if i == 0 {
98            NodeData::Element {
99              tag:        "ltx:tocentry".to_string(),
100              attributes: None,
101              children:   vec![NodeData::Text(init.to_string())],
102            }
103          } else {
104            NodeData::Element {
105              tag:        "ltx:tocentry".to_string(),
106              attributes: None,
107              children:   vec![NodeData::Element {
108                tag:        "ltx:ref".to_string(),
109                attributes: Some(HashMap::from_iter([
110                  ("idref".to_string(), id.clone()),
111                  ("show".to_string(), "refnum".to_string()),
112                ])),
113                children:   vec![NodeData::Text(init.to_string())],
114              }],
115            }
116          }
117        })
118        .collect();
119
120      let toc = NodeData::Element {
121        tag:        "ltx:TOC".to_string(),
122        attributes: Some(HashMap::from_iter([(
123          "format".to_string(),
124          "veryshort".to_string(),
125        )])),
126        children:   vec![NodeData::Element {
127          tag:        "ltx:toclist".to_string(),
128          attributes: None,
129          children:   toc_entries,
130        }],
131      };
132
133      let mut root_mut = root.clone();
134      let mut content = vec![toc];
135      content.extend(data.clone());
136      doc.add_nodes(&mut root_mut, &content);
137    }
138  }
139
140  // For subsequent sub-collections, we'd create new documents
141  // This requires PostDocument::newDocument which needs more infrastructure
142  Info!(
143    "collector",
144    "subcollections",
145    "Collector: {} sub-collections by initial: {:?}",
146    initials.len(),
147    initials
148  );
149
150  // NOTE: Sub-documents for initials[1..] require PostDocument::newDocument
151  //   which creates XML documents from element roots with proper ID remapping.
152  vec![]
153}
154
155/// Compute a page name for a sub-collection document.
156///
157/// If the main document is "index.html", use just the initial as the name.
158/// Otherwise, append the initial to the document name.
159///
160/// Port of `Collector::getPageName`.
161pub fn get_page_name(doc: &PostDocument, initial: &str) -> String {
162  if let Some(dest) = doc.get_destination() {
163    let path = Path::new(dest);
164    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("doc");
165    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("xml");
166    let dir = path.parent().and_then(|p| p.to_str()).unwrap_or(".");
167    let name = if stem == "index" {
168      initial.to_string()
169    } else {
170      format!("{}.{}", stem, initial)
171    };
172    format!("{}/{}.{}", dir, name, ext)
173  } else {
174    format!("{}.xml", initial)
175  }
176}
177
178impl Processor for Collector {
179  fn get_name(&self) -> &str { &self.name }
180
181  fn process(&mut self, doc: PostDocument, _nodes: Vec<Node>) -> ProcessResult {
182    // Mirrors Perl Post.pm:177 `Fatal("misdefined", $self, $doc, "abstract; ...")`
183    // but at Warn severity (Rust trait can't fatal here without changing the
184    // signature). A concrete subtype reaching this branch is a misconfig.
185    Warn!(
186      "misdefined",
187      "Collector",
188      "Abstract Collector::process called — concrete subclass should override"
189    );
190    Ok(vec![doc])
191  }
192}
193
194#[cfg(test)]
195mod tests {
196  use super::*;
197  use crate::document::PostDocumentOptions;
198
199  fn doc_with_dest(dest: Option<&str>) -> PostDocument {
200    let opts = PostDocumentOptions {
201      destination: dest.map(|s| s.to_string()),
202      ..PostDocumentOptions::default()
203    };
204    PostDocument::new_from_string("<root/>", opts).expect("parse")
205  }
206
207  #[test]
208  fn new_sets_name_defaults() {
209    let c = Collector::new("MyCollector");
210    assert_eq!(c.get_name(), "MyCollector");
211    assert!(c.resource_directory.is_none());
212    assert!(c.resource_prefix.is_none());
213    assert!(!c.has_scanner);
214  }
215
216  #[test]
217  fn with_scanner_flips_flag() {
218    let c = Collector::new("X").with_scanner();
219    assert!(c.has_scanner);
220  }
221
222  #[test]
223  fn get_page_name_no_destination_is_bare_initial_xml() {
224    let doc = doc_with_dest(None);
225    assert_eq!(get_page_name(&doc, "A"), "A.xml");
226  }
227
228  #[test]
229  fn get_page_name_index_uses_just_initial() {
230    let doc = doc_with_dest(Some("/tmp/index.html"));
231    // stem == "index" → name becomes just the initial.
232    assert_eq!(get_page_name(&doc, "A"), "/tmp/A.html");
233  }
234
235  #[test]
236  fn get_page_name_non_index_appends_initial() {
237    let doc = doc_with_dest(Some("/tmp/doc.html"));
238    assert_eq!(get_page_name(&doc, "A"), "/tmp/doc.A.html");
239  }
240
241  #[test]
242  fn get_page_name_preserves_extension() {
243    let doc = doc_with_dest(Some("/tmp/foo.xml"));
244    assert_eq!(get_page_name(&doc, "B"), "/tmp/foo.B.xml");
245  }
246
247  #[test]
248  fn make_sub_collection_documents_empty_map_returns_empty() {
249    let mut doc = doc_with_dest(None);
250    let root = doc.get_document_element().expect("root");
251    let collections: HashMap<String, Vec<NodeData>> = HashMap::default();
252    let result = make_sub_collection_documents(&mut doc, &root, &collections);
253    assert!(result.is_empty());
254  }
255
256  #[test]
257  fn make_sub_collection_documents_populated_map_currently_returns_empty() {
258    // The implementation notes it doesn't yet create sub-documents for
259    // initials[1..] (needs PostDocument::newDocument infra). Lock that in.
260    let mut doc = doc_with_dest(None);
261    let root = doc.get_document_element().expect("root");
262    let mut collections: HashMap<String, Vec<NodeData>> = HashMap::default();
263    collections.insert("A".to_string(), vec![NodeData::Text("entry-a".to_string())]);
264    collections.insert("B".to_string(), vec![NodeData::Text("entry-b".to_string())]);
265    let result = make_sub_collection_documents(&mut doc, &root, &collections);
266    assert!(result.is_empty());
267  }
268}