Skip to main content

latexml_post/
scan.rs

1//! Document structure scanning processor.
2//!
3//! Port of `LaTeXML::Post::Scan`.
4//! Scans the document for structural elements (sections, figures, equations, etc.)
5//! and records their IDs, labels, titles, and relationships in the ObjectDB.
6//! This data is used by later processors (CrossRef, MakeIndex, etc.).
7
8use libxml::tree::{Node, NodeType};
9use rustc_hash::FxHashMap as HashMap;
10
11use crate::{
12  document::PostDocument,
13  object_db::{ObjectDB, Value},
14  processor::{ProcessResult, Processor},
15};
16
17/// Scan post-processor: collects structural information into ObjectDB.
18///
19/// Port of `LaTeXML::Post::Scan`.
20pub struct Scan {
21  name:             String,
22  /// Reference to the shared ObjectDB.
23  pub db:           ObjectDB,
24  /// Root document id for the current scan.
25  page_id:          Option<String>,
26  /// Object-count threshold at which the next `Scan: DBStatus:` line is due —
27  /// the line logs when the count PASSES a power of two, not per page. A
28  /// per-page line (Perl's verbosity-gated `NoteProgressDetailed`) wrote
29  /// 115k+ near-identical lines on a book-scale split; exponential backoff
30  /// keeps the growth curve visible in ~20 lines (user directive 2026-08-03).
31  db_status_due_at: usize,
32}
33
34/// Collected properties for a scanned element, ready for DB registration.
35struct ScannedProps {
36  /// Properties to register.
37  props:  Vec<(String, Value)>,
38  /// Labels to register separately.
39  labels: Vec<String>,
40  /// The xml:id of the scanned element.
41  id:     Option<String>,
42}
43
44impl ScannedProps {
45  fn push(&mut self, key: &str, val: Value) { self.props.push((key.to_string(), val)); }
46}
47
48impl Scan {
49  pub fn new(db: ObjectDB) -> Self {
50    Scan {
51      name: "Scan".to_string(),
52      db,
53      page_id: None,
54      db_status_due_at: 1,
55    }
56  }
57
58  /// Recursively scan a node and its children.
59  ///
60  /// Port of `Scan::scan`.
61  pub fn scan(&mut self, doc: &PostDocument, node: &Node, parent_id: Option<&str>) {
62    if let Some(qname) = doc.get_qname(node) {
63      self.dispatch(doc, node, &qname, parent_id);
64    }
65  }
66
67  /// Dispatch to the appropriate handler based on tag name.
68  fn dispatch(&mut self, doc: &PostDocument, node: &Node, tag: &str, parent_id: Option<&str>) {
69    match tag {
70      "ltx:document" | "ltx:part" | "ltx:chapter" | "ltx:section" | "ltx:appendix"
71      | "ltx:subsection" | "ltx:subsubsection" | "ltx:paragraph" | "ltx:subparagraph"
72      | "ltx:bibliography" | "ltx:index" | "ltx:glossary" | "ltx:theorem" | "ltx:proof" => {
73        self.section_handler(doc, node, tag, parent_id)
74      },
75      // arXiv-fork (Post/Scan.pm abstract_handler / ack_handler): register
76      // abstract + acknowledgements as primary TOC-able entries whose title
77      // comes from @name (or a default) since they carry no ltx:title.
78      "ltx:abstract" => self.named_handler(doc, node, tag, parent_id, "Abstract"),
79      "ltx:acknowledgements" => self.named_handler(doc, node, tag, parent_id, "Acknowledgements"),
80      "ltx:table" | "ltx:figure" | "ltx:float" | "ltx:listing" => {
81        self.captioned_handler(doc, node, tag, parent_id)
82      },
83      "ltx:equation" | "ltx:equationgroup" | "ltx:item" | "ltx:listingline" => {
84        self.labelled_handler(doc, node, tag, parent_id)
85      },
86      "ltx:anchor" => self.anchor_handler(doc, node, tag, parent_id),
87      // Math subtrees contain thousands of XMTok/XMApp/XMRef/XMWrap/XMDual
88      // nodes with xml:ids that serve only local math-tree navigation —
89      // they're not targets for cross-reference and do not need to appear
90      // in the Scan ObjectDB. Register the outer Math element's id,
91      // then skip descent. This drops Scan time on arXiv:0705.0790
92      // from 11.4 s → <1 s (the 65K XM* nodes were dominating).
93      //
94      // Rust-side intentional divergence from Perl Scan.pm, which
95      // descends blindly — but Perl doesn't emit xml:id on XM*
96      // descendants in the first place, so its default_handler short-
97      // circuits naturally. The ar5iv.sty preload in Rust populates
98      // xml:id everywhere via _ID_counter__, making this skip necessary
99      // for performance parity with Perl.
100      "ltx:Math" => {
101        let id = get_xml_id(node);
102        if let Some(ref id_str) = id {
103          let sp = self.collect_common(doc, node, tag, parent_id);
104          let key = format!("ID:{}", id_str);
105          self.register_scanned(&key, sp);
106          self.add_as_child(id_str, parent_id);
107        }
108        // No scan_children — XM* descendants are skipped.
109      },
110      "ltx:note" => self.note_handler(doc, node, tag, parent_id),
111      "ltx:bibitem" => self.bibitem_handler(doc, node, parent_id),
112      "ltx:bibentry" => {},
113      "ltx:indexmark" => self.indexmark_handler(doc, node, parent_id),
114      "ltx:glossaryentry" | "ltx:glossarydefinition" => {
115        self.glossaryentry_handler(doc, node, tag, parent_id)
116      },
117      "ltx:ref" => self.ref_handler(doc, node, tag, parent_id),
118      "ltx:bibref" => self.bibref_handler(doc, node, tag, parent_id),
119      "ltx:glossaryref" => self.glossaryref_handler(doc, node, tag, parent_id),
120      "ltx:navigation" | "ltx:rawhtml" => {},
121      "ltx:rdf" => self.rdf_handler(node, parent_id),
122      "ltx:declare" => self.declare_handler(doc, node, tag, parent_id),
123      _ => self.default_handler(doc, node, tag, parent_id),
124    }
125  }
126
127  /// Scan all element children of a node.
128  pub fn scan_children(&mut self, doc: &PostDocument, node: &Node, parent_id: Option<&str>) {
129    let children = collect_element_children(node);
130    for child in &children {
131      self.scan(doc, child, parent_id);
132    }
133  }
134
135  /// Compute the page ID for the current document.
136  fn page_id(&self, doc: &PostDocument) -> Option<String> {
137    self.page_id.clone().or_else(|| {
138      doc
139        .get_document_element()
140        .and_then(|root| get_xml_id(&root))
141    })
142  }
143
144  /// Compute the fragment ID for a node within its page.
145  fn in_page_id(&self, doc: &PostDocument, node: &Node) -> Option<String> {
146    let id = get_xml_id(node)?;
147    let base_id = self.page_id(doc).unwrap_or_default();
148
149    if id == base_id {
150      None
151    } else if !base_id.is_empty() {
152      if let Some(rest) = id.strip_prefix(&base_id).and_then(|r| r.strip_prefix('.')) {
153        Some(rest.to_string())
154      } else {
155        Some(id)
156      }
157    } else {
158      Some(id)
159    }
160  }
161
162  /// Build common properties for a scanned element WITHOUT mutating self.
163  /// Labels and tag nodes are collected but not yet registered.
164  fn collect_common(
165    &self,
166    doc: &PostDocument,
167    node: &Node,
168    tag: &str,
169    parent_id: Option<&str>,
170  ) -> ScannedProps {
171    let id = get_xml_id(node);
172    let labels_str = node.get_attribute("labels");
173    let labels: Vec<String> = labels_str
174      .map(|s| s.split_whitespace().map(String::from).collect())
175      .unwrap_or_default();
176
177    let mut sp = ScannedProps { props: Vec::new(), labels, id };
178
179    sp.push("type", Value::from(tag));
180    if let Some(ref id_str) = sp.id {
181      sp.push("id", Value::from(id_str.as_str()));
182    }
183    if let Some(pid) = parent_id {
184      sp.push("parent", Value::from(pid));
185    }
186    if !sp.labels.is_empty() {
187      sp.push("labels", Value::from(sp.labels.clone()));
188    }
189    if let Some(loc) = doc.site_relative_destination() {
190      sp.push("location", Value::from(loc));
191    }
192    if let Some(pageid) = self.page_id(doc) {
193      sp.push("pageid", Value::from(pageid));
194    }
195    if sp.id.is_some() {
196      if let Some(fragid) = self.in_page_id(doc, node) {
197        sp.push("fragid", Value::from(fragid));
198      }
199    }
200
201    // inlist
202    if let Some(listnames) = node.get_attribute("inlist") {
203      let mut inlist = HashMap::default();
204      for name in listnames.split_whitespace() {
205        inlist.insert(name.to_string(), Value::Bool(true));
206      }
207      sp.push("inlist", Value::Hash(inlist));
208    }
209
210    // tag nodes (refnum, typerefnum, etc.)
211    // Store as String (not Xml) to avoid dangling node references.
212    // Perl uses cloneNode(1) deep copy; our libxml bindings only do ref copies.
213    let mut has_refnum = false;
214    for tagnode in child_tag_nodes(node) {
215      let key = if let Some(role) = tagnode.get_attribute("role") {
216        if role.ends_with("refnum") {
217          role
218        } else {
219          format!("tag:{}", role)
220        }
221      } else {
222        "refnum".to_string()
223      };
224      if key == "refnum" {
225        has_refnum = true;
226      }
227      let text = tagnode.get_content();
228      sp.push(&key, Value::from(text));
229    }
230
231    // pdflatex parity (surpass-Perl): a `\label` placed at `\begin{eqnarray}` (or
232    // on a `\nonumber` row) captures the equation counter value at that point.
233    // LaTeX steps `equation` once at `\begin`, so `\@currentlabel` is "1" before
234    // any `\nonumber` retraction, and `\ref` yields "1" — the number the group's
235    // numbered row shows. LaTeXML binds the label to that unnumbered row, which
236    // carries no refnum, so `\ref` fell through to the document title (shared bug
237    // with Perl; witness arXiv 2308.06222 / html_feedback#94). When a *labelled*
238    // equation row inside an `<ltx:equationgroup>` has no refnum of its own,
239    // inherit it from the nearest numbered sibling — following-first (the counter
240    // points at the next number to be shown), else preceding. Only the ObjectDB
241    // entry gains the refnum; the row still shows no number in the document.
242    if tag == "ltx:equation" && !sp.labels.is_empty() && !has_refnum {
243      if let Some(inherited) = group_sibling_refnum(node) {
244        sp.push("refnum", Value::from(inherited));
245      }
246    }
247
248    sp
249  }
250
251  /// Register a ScannedProps into the DB, including labels.
252  fn register_scanned(&mut self, db_key: &str, sp: ScannedProps) {
253    // Register labels
254    if let Some(ref id) = sp.id {
255      for label in &sp.labels {
256        self
257          .db
258          .register(label, vec![("id", Value::from(id.as_str()))]);
259      }
260    }
261    // Register main entry
262    let owned_props: Vec<(&str, Value)> = Vec::new();
263    let entry = self.db.register(db_key, owned_props);
264    for (k, v) in sp.props {
265      entry.set_value(&k, v);
266    }
267  }
268
269  /// Add an ID as a child of a parent entry.
270  fn add_as_child(&mut self, id: &str, parent_id: Option<&str>) {
271    let mut current_parent = parent_id.map(String::from);
272    while let Some(ref pid) = current_parent {
273      let key = format!("ID:{}", pid);
274      let has_children = self
275        .db
276        .lookup(&key)
277        .map(|e| e.has_value("children"))
278        .unwrap_or(false);
279      if has_children {
280        if let Some(entry_mut) = self.db.lookup_mut(&key) {
281          entry_mut.push_new("children", vec![Value::from(id)]);
282        }
283        return;
284      }
285      let parent = self
286        .db
287        .lookup(&key)
288        .and_then(|e| e.get_string("parent").map(String::from));
289      current_parent = parent;
290    }
291  }
292
293  // ======================================================================
294  // Handlers
295
296  fn default_handler(
297    &mut self,
298    doc: &PostDocument,
299    node: &Node,
300    tag: &str,
301    parent_id: Option<&str>,
302  ) {
303    // Mirror Perl Scan.pm default_handler (L272-283): only build ScannedProps
304    // when the node actually carries an xml:id. For typical papers with large
305    // <Math> subtrees, the XMTok/XMApp/XMRef/XMWrap/XMDual descendants have
306    // no id and `collect_common`'s attribute fetches + labels parsing are
307    // pure waste. arXiv:0705.0790 has 65K nodes (37K XMTok alone) and only
308    // ~1K carry ids — skipping collect_common on the other 64K drops Scan
309    // from 11.4 s → sub-second on that paper.
310    let id = get_xml_id(node);
311    if let Some(ref id_str) = id {
312      let sp = self.collect_common(doc, node, tag, parent_id);
313      let key = format!("ID:{}", id_str);
314      self.register_scanned(&key, sp);
315      // Keep the ID entry addressable for refs/URLs, but do not add generic
316      // layout/math/text nodes to section children. TOCs only need primary
317      // structural children, and adding tens of thousands of table cells here
318      // turns Scan into quadratic duplicate checking.
319    }
320    let effective_id = id.as_deref().or(parent_id);
321    self.scan_children(doc, node, effective_id);
322  }
323
324  fn section_handler(
325    &mut self,
326    doc: &PostDocument,
327    node: &Node,
328    tag: &str,
329    parent_id: Option<&str>,
330  ) {
331    let mut sp = self.collect_common(doc, node, tag, parent_id);
332    let id = sp.id.clone();
333    if let Some(ref id_str) = id {
334      sp.push("primary", Value::Bool(true));
335      sp.push("children", Value::List(Vec::new()));
336      // Store the title/toctitle NODES (not flattened text), mirroring Perl
337      // Scan (`$$entry{title} = $node`). CrossRef later deep-clones them into
338      // `<ltx:ref>` content via `prepRefText`, so math/markup in a section
339      // title survives into the table of contents (issue #356). The string
340      // form used for the page `<title>`/tooltip is derived on demand via
341      // `title_text_content` in CrossRef::generate_title.
342      // `adopt_xml`, not a bare handle: the stored node must outlive this
343      // page's DOM (freed per-page under streaming). A failed copy degrades
344      // to the flattened text — a poorer TOC entry, never a dangling one.
345      if let Some(title_node) = doc.findnode_at("ltx:title", node) {
346        let value = self
347          .db
348          .adopt_xml(&title_node)
349          .unwrap_or_else(|| Value::from(title_text_content(&title_node).as_str()));
350        sp.push("title", value);
351      }
352      if let Some(toctitle_node) = doc.findnode_at("ltx:toctitle", node) {
353        let value = self
354          .db
355          .adopt_xml(&toctitle_node)
356          .unwrap_or_else(|| Value::from(title_text_content(&toctitle_node).as_str()));
357        sp.push("toctitle", value);
358      }
359      if let Some(stub) = node.get_attribute("stub") {
360        sp.push("stub", Value::from(stub));
361      }
362      let key = format!("ID:{}", id_str);
363      self.register_scanned(&key, sp);
364      self.add_as_child(id_str, parent_id);
365    }
366    let effective_id = id.as_deref().or(parent_id);
367    self.scan_children(doc, node, effective_id);
368  }
369
370  /// arXiv-fork Scan.pm `abstract_handler`/`ack_handler` (one body, two
371  /// defaults): register the element as a primary entry titled from its
372  /// `name` attribute (set by the frontmatter machinery, e.g. "Abstract" /
373  /// "Acknowledgments") or the given default. Unlike the fork we still
374  /// scan children, so labels/ids inside the abstract keep registering
375  /// (the fork's handlers skip descent — an apparent oversight there).
376  fn named_handler(
377    &mut self,
378    doc: &PostDocument,
379    node: &Node,
380    tag: &str,
381    parent_id: Option<&str>,
382    default_name: &str,
383  ) {
384    let mut sp = self.collect_common(doc, node, tag, parent_id);
385    let id = sp.id.clone();
386    if let Some(ref id_str) = id {
387      let name = node
388        .get_attribute("name")
389        .unwrap_or_else(|| default_name.to_string());
390      sp.push("primary", Value::Bool(true));
391      sp.push("children", Value::List(Vec::new()));
392      sp.push("title", Value::from(name.as_str()));
393      sp.push("toctitle", Value::from(name.as_str()));
394      let key = format!("ID:{}", id_str);
395      self.register_scanned(&key, sp);
396      self.add_as_child(id_str, parent_id);
397    }
398    let effective_id = id.as_deref().or(parent_id);
399    self.scan_children(doc, node, effective_id);
400  }
401
402  fn captioned_handler(
403    &mut self,
404    doc: &PostDocument,
405    node: &Node,
406    tag: &str,
407    parent_id: Option<&str>,
408  ) {
409    let mut sp = self.collect_common(doc, node, tag, parent_id);
410    let id = sp.id.clone();
411    if let Some(ref id_str) = id {
412      if let Some(role) = node.get_attribute("role") {
413        sp.push("role", Value::from(role));
414      }
415      let caption = doc
416        .findnode_at("child::ltx:caption", node)
417        .or_else(|| doc.findnode_at("descendant::ltx:caption", node));
418      if let Some(ref cap) = caption {
419        sp.push("caption", Value::from(cap.get_content()));
420      }
421      let toccaption = doc
422        .findnode_at("child::ltx:toccaption", node)
423        .or_else(|| doc.findnode_at("descendant::ltx:toccaption", node));
424      if let Some(ref tc) = toccaption {
425        sp.push("toccaption", Value::from(tc.get_content()));
426      }
427      let key = format!("ID:{}", id_str);
428      self.register_scanned(&key, sp);
429      self.add_as_child(id_str, parent_id);
430    }
431    let effective_id = id.as_deref().or(parent_id);
432    self.scan_children(doc, node, effective_id);
433  }
434
435  fn labelled_handler(
436    &mut self,
437    doc: &PostDocument,
438    node: &Node,
439    tag: &str,
440    parent_id: Option<&str>,
441  ) {
442    let mut sp = self.collect_common(doc, node, tag, parent_id);
443    let id = sp.id.clone();
444    if let Some(ref id_str) = id {
445      if let Some(role) = node.get_attribute("role") {
446        sp.push("role", Value::from(role));
447      }
448      let key = format!("ID:{}", id_str);
449      self.register_scanned(&key, sp);
450      self.add_as_child(id_str, parent_id);
451    }
452    let effective_id = id.as_deref().or(parent_id);
453    self.scan_children(doc, node, effective_id);
454  }
455
456  fn anchor_handler(
457    &mut self,
458    doc: &PostDocument,
459    node: &Node,
460    tag: &str,
461    parent_id: Option<&str>,
462  ) {
463    let mut sp = self.collect_common(doc, node, tag, parent_id);
464    let id = sp.id.clone();
465    if let Some(ref id_str) = id {
466      sp.push("title", Value::from(node.get_content()));
467      let key = format!("ID:{}", id_str);
468      self.register_scanned(&key, sp);
469      self.add_as_child(id_str, parent_id);
470    }
471    let effective_id = id.as_deref().or(parent_id);
472    self.scan_children(doc, node, effective_id);
473  }
474
475  fn note_handler(&mut self, doc: &PostDocument, node: &Node, tag: &str, parent_id: Option<&str>) {
476    let mut sp = self.collect_common(doc, node, tag, parent_id);
477    let id = sp.id.clone();
478    if let Some(ref id_str) = id {
479      if let Some(role) = node.get_attribute("role") {
480        sp.push("role", Value::from(role));
481      }
482      // Store note text content, not XML node reference (avoids dangling refs)
483      sp.push("note", Value::from(node.get_content()));
484      let key = format!("ID:{}", id_str);
485      self.register_scanned(&key, sp);
486      self.add_as_child(id_str, parent_id);
487    }
488    let effective_id = id.as_deref().or(parent_id);
489    self.scan_children(doc, node, effective_id);
490  }
491
492  fn bibitem_handler(&mut self, doc: &PostDocument, node: &Node, parent_id: Option<&str>) {
493    let id = match get_xml_id(node) {
494      Some(id) => id,
495      None => {
496        self.scan_children(doc, node, parent_id);
497        return;
498      },
499    };
500
501    let key = node.get_attribute("key");
502    let bib = doc.findnode_at("ancestor-or-self::ltx:bibliography", node);
503    let lists_str = bib
504      .and_then(|b| b.get_attribute("lists"))
505      .unwrap_or_else(|| "bibliography".to_string());
506
507    // Register BIBLABEL entries
508    if let Some(ref bibkey) = key {
509      for list in lists_str.split_whitespace() {
510        let label_key = format!("BIBLABEL:{}:{}", list, bibkey);
511        self
512          .db
513          .register(&label_key, vec![("id", Value::from(id.as_str()))]);
514      }
515    }
516
517    // Build props for bibitem
518    let mut props: Vec<(String, Value)> = Vec::new();
519    props.push(("id".to_string(), Value::from(id.as_str())));
520    props.push(("type".to_string(), Value::from("ltx:bibitem")));
521    if let Some(pid) = parent_id {
522      props.push(("parent".to_string(), Value::from(pid)));
523    }
524    if let Some(ref k) = key {
525      props.push(("bibkey".to_string(), Value::from(k.as_str())));
526    }
527    if let Some(loc) = doc.site_relative_destination() {
528      props.push(("location".to_string(), Value::from(loc)));
529    }
530    if let Some(pageid) = self.page_id(doc) {
531      props.push(("pageid".to_string(), Value::from(pageid)));
532    }
533    if let Some(fragid) = self.in_page_id(doc, node) {
534      props.push(("fragid".to_string(), Value::from(fragid)));
535    }
536
537    props.extend(bibitem_tag_props(doc, node));
538
539    let db_key = format!("ID:{}", id);
540    let entry = self.db.register(&db_key, vec![]);
541    for (k, v) in props {
542      entry.set_value(&k, v);
543    }
544
545    self.scan_children(doc, node, Some(&id));
546  }
547
548  fn ref_handler(&mut self, doc: &PostDocument, node: &Node, tag: &str, parent_id: Option<&str>) {
549    if let Some(label) = node.get_attribute("labelref") {
550      let in_toc = !doc
551        .findnodes_at(
552          "ancestor::ltx:tocentry | ancestor::ltx:bibblock[contains(@class,'ltx_bib_cited')]",
553          Some(node),
554        )
555        .is_empty();
556      if !in_toc {
557        self.db.register(&label, vec![]);
558        if let Some(pid) = parent_id {
559          if let Some(entry) = self.db.lookup_mut(&label) {
560            entry.note_association(&["referrers", pid]);
561          }
562        }
563      }
564    }
565    self.default_handler(doc, node, tag, parent_id);
566  }
567
568  fn bibref_handler(
569    &mut self,
570    doc: &PostDocument,
571    node: &Node,
572    tag: &str,
573    parent_id: Option<&str>,
574  ) {
575    let in_cited = !doc
576      .findnodes_at(
577        "ancestor::ltx:bibblock[contains(@class,'ltx_bib_cited')]",
578        Some(node),
579      )
580      .is_empty();
581    if !in_cited {
582      if let Some(keys) = node.get_attribute("bibrefs") {
583        let inlist = node.get_attribute("inlist").unwrap_or_default();
584        let mut lists: Vec<&str> = inlist.split_whitespace().collect();
585        lists.push("bibliography");
586        let label_keys: Vec<String> = keys
587          .split(',')
588          .filter(|k| !k.is_empty())
589          .flat_map(|bibkey| {
590            lists
591              .iter()
592              .map(move |list| format!("BIBLABEL:{}:{}", list, bibkey))
593          })
594          .collect();
595        for label_key in &label_keys {
596          self.db.register(label_key, vec![]);
597        }
598        if let Some(pid) = parent_id {
599          for label_key in &label_keys {
600            if let Some(entry) = self.db.lookup_mut(label_key) {
601              entry.note_association(&["referrers", pid]);
602            }
603          }
604        }
605      }
606    }
607    self.default_handler(doc, node, tag, parent_id);
608  }
609
610  fn glossaryref_handler(
611    &mut self,
612    doc: &PostDocument,
613    node: &Node,
614    tag: &str,
615    parent_id: Option<&str>,
616  ) {
617    if let (Some(k), Some(l)) = (node.get_attribute("key"), node.get_attribute("inlist")) {
618      let gkey = format!("GLOSSARY:{}:{}", l, k);
619      self.db.register(&gkey, vec![]);
620      if let Some(pid) = parent_id {
621        if let Some(entry) = self.db.lookup_mut(&gkey) {
622          entry.note_association(&["referrers", pid]);
623        }
624      }
625    }
626    self.default_handler(doc, node, tag, parent_id);
627  }
628
629  fn indexmark_handler(&mut self, doc: &PostDocument, node: &Node, parent_id: Option<&str>) {
630    let phrases = doc.findnodes_at("ltx:indexphrase", Some(node));
631    let see_also = doc.findnodes_at("ltx:indexsee", Some(node));
632
633    let key_parts: Vec<String> = phrases
634      .iter()
635      .filter_map(|p| p.get_attribute("key"))
636      .collect();
637    let key = format!("INDEX:{}", key_parts.join(":"));
638
639    let inlist = node.get_attribute("inlist").map(|listnames| {
640      let mut h = HashMap::default();
641      for name in listnames.split_whitespace() {
642        h.insert(name.to_string(), Value::Bool(true));
643      }
644      Value::Hash(h)
645    });
646
647    let exists = self.db.lookup(&key).is_some();
648    if !exists {
649      // Perl registers `phrases => [@phrases]` — the ltx:indexphrase
650      // NODES — so MakeIndex can key the tree off their `key`
651      // attributes and re-render the phrases with their markup
652      // (math inside an index phrase survives into the index).
653      // Adopted BEFORE `register` borrows the entry: `adopt_xml` needs the
654      // DB itself, and the copies must outlive this page's DOM anyway. A
655      // failed copy degrades to the phrase's flattened text.
656      let adopted: Vec<Value> = phrases
657        .iter()
658        .map(|n| {
659          self
660            .db
661            .adopt_xml(n)
662            .unwrap_or_else(|| Value::from(n.get_content().as_str()))
663        })
664        .collect();
665      let mut props = vec![("phrases", Value::List(adopted))];
666      if let Some(il) = inlist {
667        props.push(("inlist", il));
668      }
669      self.db.register(&key, props);
670    }
671
672    if !see_also.is_empty() {
673      // Store the ltx:indexsee NODES (not their flattened text): MakeIndex
674      // needs the `name` attribute ("see"/"see also") and the phrase's inline
675      // markup to render the cross-reference. Adopted for the same
676      // lifetime reason as the phrases above.
677      let nodes: Vec<Value> = see_also
678        .iter()
679        .map(|n| {
680          self
681            .db
682            .adopt_xml(n)
683            .unwrap_or_else(|| Value::from(n.get_content().as_str()))
684        })
685        .collect();
686      if let Some(entry) = self.db.lookup_mut(&key) {
687        entry.push_new("see_also", nodes);
688      }
689    } else if let Some(pid) = parent_id {
690      let style = node
691        .get_attribute("style")
692        .unwrap_or_else(|| "normal".to_string());
693      if let Some(entry) = self.db.lookup_mut(&key) {
694        entry.note_association(&["referrers", pid, &style]);
695      }
696    }
697  }
698
699  fn glossaryentry_handler(
700    &mut self,
701    doc: &PostDocument,
702    node: &Node,
703    tag: &str,
704    parent_id: Option<&str>,
705  ) {
706    let id = if tag == "ltx:glossaryentry" {
707      get_xml_id(node)
708    } else {
709      None
710    };
711    let lists = node.get_attribute("inlist").unwrap_or_else(|| {
712      doc
713        .findnode_at(
714          "ancestor::ltx:glossarylist[@lists] | ancestor::ltx:glossary[@lists]",
715          node,
716        )
717        .and_then(|p| p.get_attribute("lists"))
718        .unwrap_or_else(|| "glossary".to_string())
719    });
720    let key = node.get_attribute("key").unwrap_or_default();
721    let phrases = doc.findnodes_at("ltx:glossaryphrase", Some(node));
722
723    for list in lists.split_whitespace() {
724      let gkey = format!("GLOSSARY:{}:{}", list, key);
725      let entry = self.db.register(&gkey, vec![]);
726      for phrase in &phrases {
727        let role = phrase
728          .get_attribute("role")
729          .unwrap_or_else(|| "label".to_string());
730        let prop_key = format!("phrase:{}", role);
731        entry.set_value(&prop_key, Value::from(phrase.get_content()));
732      }
733      if let Some(ref id_str) = id {
734        entry.set_value("id", Value::from(id_str.as_str()));
735      }
736    }
737
738    if let Some(ref id_str) = id {
739      let sp = self.collect_common(doc, node, tag, parent_id);
740      let db_key = format!("ID:{}", id_str);
741      self.register_scanned(&db_key, sp);
742    }
743    let effective_id = id.as_deref().or(parent_id);
744    self.scan_children(doc, node, effective_id);
745  }
746
747  fn rdf_handler(&mut self, node: &Node, parent_id: Option<&str>) {
748    let mut id = node.get_attribute("about");
749    if let Some(ref about) = id {
750      if let Some(stripped) = about.strip_prefix('#') {
751        id = Some(stripped.to_string());
752      }
753    }
754    let id = id.or_else(|| parent_id.map(String::from));
755    let property = node.get_attribute("property");
756    let value = node
757      .get_attribute("resource")
758      .or_else(|| node.get_attribute("content"));
759
760    if let (Some(prop), Some(val), Some(id_str)) = (property, value, id) {
761      let db_key = format!("ID:{}", id_str);
762      let entry = self.db.register(&db_key, vec![]);
763      entry.set_value(&prop, Value::from(val));
764    }
765  }
766
767  fn declare_handler(
768    &mut self,
769    doc: &PostDocument,
770    node: &Node,
771    tag: &str,
772    parent_id: Option<&str>,
773  ) {
774    let decl_type = node.get_attribute("type");
775    let sort = node.get_attribute("sortkey");
776    let decl_id = get_xml_id(node);
777    let definiens = node.get_attribute("definiens");
778
779    let term = doc.findnode_at("child::ltx:tags/ltx:tag[@role='term']", node);
780    let description = doc.findnode_at("child::ltx:text", node);
781
782    if decl_type.as_deref() == Some("definition") {
783      let mut def = definiens.clone();
784      if def.is_none() {
785        if let Some(ref term_node) = term {
786          let syms = doc.findnodes_at("descendant-or-self::ltx:XMTok[@meaning]", Some(term_node));
787          let mut non_rel = Vec::new();
788          let mut rel = Vec::new();
789          for sym in &syms {
790            let meaning = sym.get_attribute("meaning").unwrap_or_default();
791            if meaning.starts_with("delimited-") {
792              continue;
793            }
794            if sym.get_attribute("role").as_deref() == Some("RELOP") {
795              rel.push(meaning);
796            } else {
797              non_rel.push(meaning);
798            }
799          }
800          non_rel.extend(rel);
801          def = non_rel.into_iter().next();
802        }
803      }
804      if let Some(ref def_name) = def {
805        let dkey = format!("DECLARATION:global:{}", def_name);
806        let mut sp = self.collect_common(doc, node, tag, parent_id);
807        if let Some(ref desc) = description {
808          sp.push("description", Value::from(desc.get_content()));
809        }
810        self.register_scanned(&dkey, sp);
811      }
812    } else if decl_type.is_none() && parent_id.is_some() {
813      if let Some(ref did) = decl_id {
814        let has_content =
815          description.is_some() || doc.findnode_at("ltx:tags/ltx:tag", node).is_some();
816        if has_content {
817          let dkey = format!("DECLARATION:local:{}", did);
818          let mut sp = self.collect_common(doc, node, tag, parent_id);
819          if let Some(ref desc) = description {
820            sp.push("description", Value::from(desc.get_content()));
821          }
822          self.register_scanned(&dkey, sp);
823        }
824      }
825    }
826
827    if let Some(ref sk) = sort {
828      let name = definiens.as_deref().or(decl_id.as_deref()).unwrap_or(sk);
829      let nkey = format!("NOTATION:{}", name);
830      let mut sp = self.collect_common(doc, node, tag, parent_id);
831      sp.push("sortkey", Value::from(sk.as_str()));
832      if let Some(ref desc) = description {
833        sp.push("description", Value::from(desc.get_content()));
834      }
835      self.register_scanned(&nkey, sp);
836    }
837  }
838}
839
840impl Processor for Scan {
841  fn get_name(&self) -> &str { &self.name }
842
843  fn process(&mut self, doc: PostDocument, _nodes: Vec<Node>) -> ProcessResult {
844    let root = match doc.get_document_element() {
845      Some(r) => r,
846      None => return Ok(vec![doc]),
847    };
848
849    let id = get_xml_id(&root).unwrap_or_else(|| {
850      let mut root_mut = root.clone();
851      root_mut.set_attribute("xml:id", "Document").ok();
852      "Document".to_string()
853    });
854
855    if self.db.lookup("SITE_ROOT").is_none() {
856      self
857        .db
858        .register("SITE_ROOT", vec![("id", Value::from(id.as_str()))]);
859    }
860
861    self.page_id = Some(id.clone());
862    self.scan(&doc, &root, None);
863    self.page_id = None;
864
865    let loc = doc.site_relative_destination().unwrap_or_default();
866    let doc_key = format!("DOCUMENT:{}", loc);
867    self
868      .db
869      .register(&doc_key, vec![("id", Value::from(id.as_str()))]);
870
871    // Perl Post::Scan L108-133: when scanning a doc that's not itself
872    // the site root and whose own entry has no parent yet, infer one.
873    // Without this step, the cross-document TOC produced by Split has
874    // the SITE_ROOT (e.g. "Document") with no `children` pointing at
875    // the per-page roots ("Ch1", "Ch1.S1", …) — CrossRef::fill_in_tocs
876    // then walks an empty children list and emits an empty TOC, so the
877    // index page's `\tableofcontents` placeholder collapses to a 27-
878    // line title-only page.
879    let site_id = self
880      .db
881      .lookup("SITE_ROOT")
882      .and_then(|e| e.get_string("id").map(String::from))
883      .unwrap_or_default();
884    let id_key = format!("ID:{}", id);
885    let needs_parent = self
886      .db
887      .lookup(&id_key)
888      .map(|e| !e.has_value("parent"))
889      .unwrap_or(false);
890    if !site_id.is_empty() && id != site_id && needs_parent {
891      // 1) Strip ".suffix" iteratively to find an ancestor id already in DB.
892      let mut parent_id: Option<String> = None;
893      let mut upid = id.clone();
894      while let Some(dot) = upid.rfind('.') {
895        upid.truncate(dot);
896        if !upid.is_empty() && self.db.lookup(&format!("ID:{}", upid)).is_some() {
897          parent_id = Some(upid.clone());
898          break;
899        }
900      }
901      // 2) Fallback to the site root.
902      if parent_id.is_none() {
903        parent_id = Some(site_id);
904      }
905      if let Some(pid) = parent_id {
906        if pid != id {
907          if let Some(entry_mut) = self.db.lookup_mut(&id_key) {
908            entry_mut.set_values(vec![("parent", Value::from(pid.as_str()))]);
909          }
910          self.add_as_child(&id, Some(&pid));
911        }
912      }
913    }
914
915    // Exponential backoff: log only when the object count passes the next
916    // power of two (see the `db_status_due_at` field docs).
917    if self.db.len() >= self.db_status_due_at {
918      Info!("scan", "db_status", "Scan: DBStatus: {}", self.db.status());
919      self.db_status_due_at = (self.db.len() + 1).next_power_of_two();
920    }
921    Ok(vec![doc])
922  }
923}
924
925// ======================================================================
926// Helpers
927
928/// Read a `<ltx:bibitem>`'s `<ltx:tags>/<ltx:tag role="…">` children into the
929/// ObjectDB props CrossRef's fill phase (`make_bibcite`) reads: `authors`,
930/// `fullauthors`, `year`, `number`, `refnum`, `title`, plus `keytag` (role
931/// `key`) and `typetag` (role `bibtype`). Port of Perl `Scan::bibitem_handler`
932/// (Scan.pm L475-483). Shared by `Scan::bibitem_handler` (the initial scan of
933/// authored `\bibitem`s) and `MakeBibliography`'s rescan of the bibitems it
934/// generates from `.bib`/`.bbl` — the same values, from whichever pass first
935/// produced the formatted entry, so an author-year inline citation resolves to
936/// the SAME author-year label the References list shows.
937pub fn bibitem_tag_props(doc: &PostDocument, node: &Node) -> Vec<(String, Value)> {
938  let mut props = Vec::new();
939  for role in &[
940    "authors",
941    "fullauthors",
942    "year",
943    "number",
944    "refnum",
945    "title",
946    "key",
947    "bibtype",
948  ] {
949    let xpath = format!("ltx:tags/ltx:tag[@role='{}']", role);
950    if let Some(tagnode) = doc.findnode_at(&xpath, node) {
951      let prop_name = match *role {
952        "key" => "keytag",
953        "bibtype" => "typetag",
954        _ => *role,
955      };
956      props.push((prop_name.to_string(), Value::from(tagnode.get_content())));
957    }
958  }
959  props
960}
961
962fn collect_element_children(node: &Node) -> Vec<Node> {
963  let mut result = Vec::new();
964  if let Some(child) = node.get_first_child() {
965    let mut current = Some(child);
966    while let Some(ref c) = current {
967      if c.get_type() == Some(NodeType::ElementNode) {
968        result.push(c.clone());
969      }
970      current = c.get_next_sibling();
971    }
972  }
973  result
974}
975
976/// The plain `refnum` text of an equation row (its `<ltx:tag role="refnum">`),
977/// if it carries one. Used to let a labelled but unnumbered eqnarray row inherit
978/// its group's number (html_feedback#94).
979fn equation_refnum_text(node: &Node) -> Option<String> {
980  for t in child_tag_nodes(node) {
981    if t.get_attribute("role").as_deref() == Some("refnum") {
982      let txt = t.get_content();
983      if !txt.trim().is_empty() {
984        return Some(txt);
985      }
986    }
987  }
988  None
989}
990
991/// For a labelled `<ltx:equation>` with no refnum of its own, find the number it
992/// should reference: the nearest numbered sibling equation in the same
993/// `<ltx:equationgroup>`, scanning following siblings first (the equation
994/// counter, captured at `\label` time, points at the next number to be shown),
995/// then preceding. Returns None when the parent is not an equationgroup or no
996/// sibling is numbered. See `collect_common` (html_feedback#94).
997fn group_sibling_refnum(node: &Node) -> Option<String> {
998  let parent = node.get_parent()?;
999  if parent.get_name() != "equationgroup" {
1000    return None;
1001  }
1002  let is_equation =
1003    |n: &Node| n.get_type() == Some(NodeType::ElementNode) && n.get_name() == "equation";
1004  let mut fwd = node.get_next_sibling();
1005  while let Some(sib) = fwd {
1006    if is_equation(&sib) {
1007      if let Some(r) = equation_refnum_text(&sib) {
1008        return Some(r);
1009      }
1010    }
1011    fwd = sib.get_next_sibling();
1012  }
1013  let mut back = node.get_prev_sibling();
1014  while let Some(sib) = back {
1015    if is_equation(&sib) {
1016      if let Some(r) = equation_refnum_text(&sib) {
1017        return Some(r);
1018      }
1019    }
1020    back = sib.get_prev_sibling();
1021  }
1022  None
1023}
1024
1025fn child_tag_nodes(node: &Node) -> Vec<Node> {
1026  let mut result = Vec::new();
1027  let mut child = node.get_first_child();
1028  while let Some(c) = child {
1029    if c.get_type() == Some(NodeType::ElementNode) && c.get_name() == "tags" {
1030      let mut tag_child = c.get_first_child();
1031      while let Some(t) = tag_child {
1032        if t.get_type() == Some(NodeType::ElementNode) && t.get_name() == "tag" {
1033          result.push(t.clone());
1034        }
1035        tag_child = t.get_next_sibling();
1036      }
1037    }
1038    child = c.get_next_sibling();
1039  }
1040  result
1041}
1042
1043/// Get xml:id from a node, trying both attribute forms.
1044fn get_xml_id(node: &Node) -> Option<String> {
1045  node
1046    .get_attribute("xml:id")
1047    .or_else(|| node.get_attribute_ns("id", "http://www.w3.org/XML/1998/namespace"))
1048}
1049
1050/// Extract text content from a node tree, honoring `open`/`close` attributes on `ltx:tag`.
1051///
1052/// Perl uses cloneNode(1) + full DOM rendering, so `<tag close=" ">A</tag>GPT-4o`
1053/// renders as "A GPT-4o". Our get_content() would give "AGPT-4o".
1054/// This function inserts the `open`/`close` attribute values around tag elements.
1055///
1056/// This is the derived STRING form of a title (page `<title>`, `title=`
1057/// tooltip). The rich node form is kept in the ObjectDB (`Value::Xml`) so
1058/// CrossRef can deep-clone it into `<ltx:ref>` content. Mirrors Perl
1059/// `CrossRef::getTextContent_rec`'s `ltx:tag` open/close handling (its
1060/// `ltx:Math → unicodemath` branch is not yet ported — math in a title still
1061/// flattens to its token text here, unchanged from before).
1062pub(crate) fn title_text_content(node: &Node) -> String {
1063  let mut result = String::new();
1064  let mut child = node.get_first_child();
1065  while let Some(c) = child {
1066    match c.get_type() {
1067      Some(NodeType::TextNode) => {
1068        result.push_str(&c.get_content());
1069      },
1070      Some(NodeType::ElementNode) => {
1071        let name = c.get_name();
1072        if name == "tag" {
1073          // Honor open/close attributes on <ltx:tag>
1074          if let Some(open) = c.get_attribute("open") {
1075            result.push_str(&open);
1076          }
1077          result.push_str(&title_text_content(&c));
1078          if let Some(close) = c.get_attribute("close") {
1079            result.push_str(&close);
1080          }
1081        } else {
1082          result.push_str(&title_text_content(&c));
1083        }
1084      },
1085      _ => {},
1086    }
1087    child = c.get_next_sibling();
1088  }
1089  result
1090}
1091
1092// NOTE: Perl uses cloneNode(1) for deep DOM copies. Every node stored in the
1093// ObjectDB is ADOPTED first (`ObjectDB::adopt_xml` — a deep copy into a
1094// DB-owned document), so stored values live exactly as long as the DB and
1095// never dangle into a page document that streaming has freed. Consumers still
1096// deep-copy on materialization (`PostDocument::add_xml_node`, with cross-doc
1097// namespace reconciliation + xml:id uniquification). Section titles are kept
1098// as nodes (see `section_handler`); the derived string form is
1099// `title_text_content`. Index `phrases`/`see_also` values are also stored as
1100// (adopted) `Value::Xml`.