Skip to main content

latexml_post/
crossref.rs

1//! Cross-reference resolution processor.
2//!
3//! Port of `LaTeXML::Post::CrossRef` (946 lines of Perl).
4//! Resolves cross-references (`ltx:ref`, `ltx:bibref`, etc.) by looking up
5//! referenced IDs in the ObjectDB and filling in the reference text,
6//! titles, and navigation links.
7
8use std::{cell::RefCell, rc::Rc};
9
10use libxml::tree::{Node, NodeType};
11use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
12
13use crate::{
14  document::{NodeData, PostDocument, get_xml_id},
15  object_db::{Entry, ObjectDB, Value},
16  processor::{ProcessResult, Processor},
17  scan::title_text_content,
18};
19
20/// Perl CrossRef.pm `$normaltoctypes` (L202-206): the sectional element types
21/// used by `gentoc_context`'s UPWARD ancestor/sibling enclosure. This is NOT
22/// the normal `gen_toc` path — that filters purely by the TOC's
23/// `select`/`inlist` (issue #291). Deliberately excludes
24/// `ltx:abstract`/`ltx:acknowledgements` (matching Perl exactly) so frontmatter
25/// does not clutter the navigation breadcrumb's sibling rows.
26const NORMAL_TOC_TYPES: &[&str] = &[
27  "ltx:document",
28  "ltx:part",
29  "ltx:chapter",
30  "ltx:section",
31  "ltx:subsection",
32  "ltx:subsubsection",
33  "ltx:paragraph",
34  "ltx:subparagraph",
35  "ltx:index",
36  "ltx:bibliography",
37  "ltx:glossary",
38  "ltx:appendix",
39];
40
41/// Memoized result of `get_child_page_ids` for one ObjectDB entry: the
42/// distinct descendant page ids, plus a position index so
43/// `find_previous_page_id`/`find_next_page_id` can locate a sibling in O(1)
44/// instead of the Perl pop/shift scan.
45struct ChildPages {
46  ids:      Vec<String>,
47  index_of: HashMap<String, usize>,
48}
49
50/// Fallback fields when a requested ref show key is not found.
51fn ref_fallbacks(key: &str) -> &'static [&'static str] {
52  match key {
53    "typerefnum" => &["refnum"],
54    "toctitle" => &["title", "toccaption"],
55    "title" => &["toccaption"],
56    "rawtoctitle" => &["toctitle", "title", "toccaption"],
57    "rawtitle" => &["title", "toccaption"],
58    _ => &[],
59  }
60}
61
62/// Derive the STRING form of a stored value (page `<title>`, `title=` tooltip).
63/// Perl `CrossRef::getTextContent` (`CrossRef.pm` L853-859). A `Value::Xml` title
64/// is flattened tag-aware and math-aware (via [`title_text_content`], which routes
65/// `ltx:Math` through `unicodemath`); any other value uses its plain string form.
66/// Either way the result is whitespace-collapsed like Perl: trim both ends, then
67/// `s/\s+/ /g` — so a multi-line math serialization cannot bloat a `title=`
68/// tooltip (issue #761).
69fn value_text(doc: &PostDocument, val: &Value) -> String {
70  let raw = match val {
71    Value::Xml(node) => title_text_content(doc, node),
72    other => other.to_string(),
73  };
74  // Perl `getTextContent`: `s/^\s+//; s/\s+$//; s/\s+/ /g`. `split_whitespace`
75  // does exactly this (drops leading/trailing runs, collapses interior runs).
76  raw.split_whitespace().collect::<Vec<_>>().join(" ")
77}
78
79/// Build the child nodes of an `<ltx:ref>` from a stored value.
80///
81/// Port of Perl `CrossRef::prepRefText` = `cloneNodes(trimChildNodes($value))`:
82/// deep-clone the title's child nodes — `<ltx:Math>` included — trimming
83/// whitespace at the two edges. Element children become [`NodeData::XmlNode`]
84/// (deep-copied at materialization by `PostDocument::add_xml_node`, which
85/// uniquifies their `xml:id`s); text children become [`NodeData::Text`]. A
86/// plain-string value keeps the single flat-text child.
87///
88/// (Perl's `fillInTitle` — resolving nested `ltx:ref`/`ltx:bibref`/`ltx:break`
89/// embedded in a title before cloning — is not ported here; those are rare in
90/// titles and were not handled by the previous flat-text path either.)
91fn ref_content_children(val: &Value) -> Vec<NodeData> {
92  let node = match val {
93    Value::Xml(node) => node,
94    other => return vec![NodeData::Text(other.to_string())],
95  };
96  let mut out: Vec<NodeData> = Vec::new();
97  let mut child = node.get_first_child();
98  while let Some(c) = child {
99    match c.get_type() {
100      Some(NodeType::TextNode) => out.push(NodeData::Text(c.get_content())),
101      Some(NodeType::ElementNode) => out.push(NodeData::XmlNode(c.clone())),
102      _ => {},
103    }
104    child = c.get_next_sibling();
105  }
106  // trimChildNodes: left-trim the first text child, right-trim the last; drop
107  // either if it becomes empty.
108  if let Some(NodeData::Text(s)) = out.first_mut() {
109    let t = s.trim_start().to_string();
110    if t.is_empty() {
111      out.remove(0);
112    } else {
113      *s = t;
114    }
115  }
116  if let Some(NodeData::Text(s)) = out.last_mut() {
117    let t = s.trim_end().to_string();
118    if t.is_empty() {
119      out.pop();
120    } else {
121      *s = t;
122    }
123  }
124  out
125}
126
127/// Strip `fragid` from everything inside an `<ltx:ref>` (TOC entries, inline
128/// refs, navigation).
129///
130/// Reference content is a non-anchor DISPLAY copy of a title, so it must not
131/// carry a `fragid` — the XSLT `add_id` template emits the HTML `id` from
132/// `fragid`, and a display copy with an `id` would spuriously duplicate the
133/// real target's anchor. Perl gets this for free: its ref content is cloned
134/// from Scan's `cleanNode` snapshot, taken before `fragid` is assigned
135/// (`Scan.pm` L290 / `CrossRef.pm` fillInFrags). We clone the live (already
136/// `fragid`'d) title, so we drop `fragid` here to match (issue #356). The
137/// uniquified `xml:id` is kept, as in Perl's snapshot.
138fn strip_ref_display_fragids(doc: &PostDocument) {
139  for mut n in doc.findnodes("//ltx:ref//*[@fragid]") {
140    let _ = n.remove_attribute("fragid");
141  }
142}
143
144/// URL style for cross-references (Perl `--urlstyle`; `Config.pm` accepts
145/// `server`, `negotiated`, `file`). Selects how [`CrossRef`]'s URL generation
146/// rewrites a generated cross-reference URL for the serving environment.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum UrlStyle {
149  /// Keep the full `file.html#fragment` — nothing stripped. Correct for
150  /// `file://` viewing and servers that do not rewrite `index.html`.
151  File,
152  /// Strip a trailing `index.ext` so a directory index links as `dir/`
153  /// (Perl's `latexml` default; for servers that serve `dir/` → `dir/index.ext`).
154  Server,
155  /// Content negotiation: strip the `.ext` extension AND a trailing `index`
156  /// (for servers that hide the extension, e.g. BookML's `--urlstyle=negotiated`).
157  Negotiated,
158}
159
160impl UrlStyle {
161  /// Parse a CLI `--urlstyle` value. Returns `None` for an unrecognized value
162  /// (the caller reports it, mirroring Perl `_checkOptionValue`).
163  pub fn from_cli(s: &str) -> Option<Self> {
164    match s {
165      "file" => Some(UrlStyle::File),
166      "server" => Some(UrlStyle::Server),
167      "negotiated" => Some(UrlStyle::Negotiated),
168      _ => None,
169    }
170  }
171
172  /// The canonical CLI tag — round-trips through [`UrlStyle::from_cli`]. Used to
173  /// serialize the style across the parallel page-render worker manifest.
174  pub fn as_cli(self) -> &'static str {
175    match self {
176      UrlStyle::File => "file",
177      UrlStyle::Server => "server",
178      UrlStyle::Negotiated => "negotiated",
179    }
180  }
181}
182
183/// Rewrite a generated cross-reference `url` for the given [`UrlStyle`], mirroring
184/// Perl `CrossRef::generateURL` (CrossRef.pm L656-663) verbatim — including its
185/// `(^|\/)` path boundary: a trailing `index[.ext]` is stripped only at the very
186/// start of the URL or right after a `/`, so a filename like `myindex.html` is
187/// left intact. `extension` is the output file extension (e.g. `html`).
188fn apply_url_style(url: &str, style: UrlStyle, extension: &str) -> String {
189  match style {
190    // Perl: s/(^|\/)index.\Q$ext\E$/($1 ? $1 : '.\/')/e
191    UrlStyle::Server => {
192      let index_suffix = format!("index.{extension}");
193      if let Some(prefix) = url.strip_suffix(&index_suffix) {
194        if prefix.is_empty() {
195          return "./".to_string(); // matched at start ($1 empty → './')
196        } else if prefix.ends_with('/') {
197          return prefix.to_string(); // matched after '/' ($1 = '/', kept)
198        } // else: no path boundary before `index` → leave url unchanged
199      }
200      url.to_string()
201    },
202    // Perl: s/\.\Q$ext\E$// then s/(^|\/)index$/$1/
203    UrlStyle::Negotiated => {
204      let stripped = url.strip_suffix(&format!(".{extension}")).unwrap_or(url);
205      if stripped == "index" {
206        String::new() // matched at start ($1 empty)
207      } else if let Some(prefix) = stripped.strip_suffix("index") {
208        if prefix.ends_with('/') {
209          prefix.to_string() // matched after '/' ($1 = '/', kept)
210        } else {
211          stripped.to_string() // no path boundary → leave (e.g. `myindex`)
212        }
213      } else {
214        stripped.to_string()
215      }
216    },
217    UrlStyle::File => url.to_string(),
218  }
219}
220
221/// CrossRef post-processor.
222///
223/// Port of `LaTeXML::Post::CrossRef`.
224pub struct CrossRef {
225  name:           String,
226  /// Reference to the shared ObjectDB.
227  pub db:         ObjectDB,
228  /// URL style for cross-references.
229  url_style:      UrlStyle,
230  /// File extension used for output (e.g. "html", "xml").
231  extension:      String,
232  /// Default show format for TOC refs.
233  toc_show:       String,
234  /// Default show format for regular refs.
235  ref_show:       String,
236  /// Minimum useful content length for refs.
237  min_ref_length: usize,
238  /// Join string between parent+child ref text.
239  ref_join:       String,
240  /// Type of navigation TOC to add (e.g. "context").
241  navigation_toc: Option<String>,
242  /// Track missing references for reporting.
243  missing:        HashMap<String, HashMap<String, HashMap<String, u32>>>,
244  /// Memoized `get_child_page_ids` results, keyed by entry id. The ObjectDB
245  /// is read-only for the whole CrossRef pass, so a given entry's child pages
246  /// never change — caching them across all split pages turns the per-page
247  /// O(siblings) recomputation (Perl's unpruned `getChildPages`) into O(1)
248  /// lookups, eliminating the `fill_in_relations` O(n²).
249  child_pages:    RefCell<HashMap<String, Rc<ChildPages>>>,
250}
251
252/// Render a natbib bibref `show` format into its visible label by interleaving
253/// the resolved `authors`/`year`/`number`/`refnum` values with the bibref's
254/// `<ltx:bibrefphrase>` children (`Phrase1`, `Phrase2`, …) and any literal
255/// characters. `\citet`/`\cite` use `show="Authors Phrase1YearPhrase2"` (phrases
256/// `(` / `)` → "Beta (2002)"); `\citep` uses `show="AuthorsPhrase1Year"` (phrase
257/// `, ` → "Beta, 2002", the surrounding macro adding the outer parens). Mirrors
258/// Perl `CrossRef.pm` `make_bibcite`'s show walk.
259///
260/// Returns `(label, resolved_ay)`: `resolved_ay` is true iff an `Authors`/
261/// `Fullauthors`/`Year` token resolved to a non-empty value, so the caller can
262/// fall back to the bare number for entries that carry no author-year metadata.
263fn render_bibref_show(
264  show: &str,
265  authors: Option<&str>,
266  fullauthors: Option<&str>,
267  year: Option<&str>,
268  number: Option<&str>,
269  refnum: Option<&str>,
270  phrases: &[String],
271) -> (String, bool) {
272  let lower = show.to_ascii_lowercase();
273  let lb = lower.as_bytes();
274  let mut out = String::new();
275  let mut resolved_ay = false;
276  let mut i = 0;
277  while i < show.len() {
278    // Phrase token: "phrase" + digits → the Nth <ltx:bibrefphrase> child.
279    if lb[i..].starts_with(b"phrase") {
280      let ds = i + "phrase".len();
281      let mut j = ds;
282      while j < lb.len() && lb[j].is_ascii_digit() {
283        j += 1;
284      }
285      if j > ds {
286        if let Ok(n) = show[ds..j].parse::<usize>() {
287          if n >= 1 && n <= phrases.len() {
288            out.push_str(&phrases[n - 1]);
289          }
290        }
291        i = j;
292        continue;
293      }
294    }
295    // Value keyword (fullauthors before authors — distinct first letters, so
296    // order is not load-bearing, but keep the longest name first for clarity).
297    let mut matched = false;
298    for (kw, val, is_ay) in [
299      ("fullauthors", fullauthors.or(authors), true),
300      ("authors", authors, true),
301      ("year", year, true),
302      ("number", number, false),
303      ("refnum", refnum, false),
304    ] {
305      if lb[i..].starts_with(kw.as_bytes()) {
306        if let Some(v) = val {
307          if !v.is_empty() {
308            out.push_str(v);
309            if is_ay {
310              resolved_ay = true;
311            }
312          }
313        }
314        i += kw.len();
315        matched = true;
316        break;
317      }
318    }
319    if matched {
320      continue;
321    }
322    // Literal character.
323    let ch = show[i..].chars().next().unwrap();
324    out.push(ch);
325    i += ch.len_utf8();
326  }
327  (out, resolved_ay)
328}
329
330impl CrossRef {
331  pub fn new(db: ObjectDB, url_style: UrlStyle, number_sections: bool) -> Self {
332    CrossRef {
333      name: "CrossRef".to_string(),
334      db,
335      url_style,
336      extension: "xml".to_string(),
337      toc_show: "toctitle".to_string(),
338      ref_show: if number_sections {
339        "refnum".to_string()
340      } else {
341        "title".to_string()
342      },
343      min_ref_length: 1,
344      ref_join: " \u{2023} ".to_string(), // TRIANGULAR BULLET
345      navigation_toc: None,
346      missing: HashMap::default(),
347      child_pages: RefCell::new(HashMap::default()),
348    }
349  }
350
351  /// Set the file extension for URL generation.
352  pub fn set_extension(&mut self, ext: &str) { self.extension = ext.to_string(); }
353
354  /// Set the navigation TOC format.
355  pub fn set_navigation_toc(&mut self, format: &str) {
356    self.navigation_toc = Some(format.to_string());
357  }
358
359  /// Note a missing reference.
360  fn note_missing(&mut self, severity: &str, ref_type: &str, key: &str) {
361    self
362      .missing
363      .entry(severity.to_string())
364      .or_default()
365      .entry(ref_type.to_string())
366      .or_default()
367      .entry(key.to_string())
368      .and_modify(|c| *c += 1)
369      .or_insert(1);
370  }
371
372  /// Generate a URL for a referenced ID.
373  ///
374  /// Port of `CrossRef::generateURL`.
375  fn generate_url(&mut self, doc: &PostDocument, id: &str) -> Option<String> {
376    let entry = self.db.lookup(&format!("ID:{}", id))?;
377    let location = entry.get_string("location")?;
378
379    let doc_location = doc.site_relative_destination().unwrap_or_default();
380    let mut url = relative_url(location, &doc_location);
381
382    url = apply_url_style(&url, self.url_style, &self.extension);
383
384    if url.is_empty() {
385      url = ".".to_string();
386    }
387
388    // Add fragment ID
389    let fragid = entry.get_string("fragid").map(String::from);
390    let loc = location.to_string();
391    if let Some(fid) = fragid {
392      if url == "." || loc == doc_location {
393        url = String::new();
394      }
395      url = format!("{}#{}", url, fid);
396    } else if loc == doc_location {
397      url = String::new();
398    }
399
400    Some(url)
401  }
402
403  /// Generate a title string for a referenced ID, traversing parents for context.
404  ///
405  /// Port of `CrossRef::generateTitle`.
406  fn generate_title(&self, doc: &PostDocument, id: &str, shown: &str) -> Option<String> {
407    let mut current_id = id.to_string();
408    let mut result = String::new();
409    let mut prefix = String::new();
410    let mut shown_so_far = shown.to_string();
411
412    while let Some(entry) = self.db.lookup(&format!("ID:{}", current_id)) {
413      let mut pieces = Vec::new();
414      let mut is_dup = false;
415
416      // Try title, then typerefnum, then refnum
417      if let Some(title_val) = entry.get_value("title") {
418        if title_val.is_truthy() {
419          is_dup = shown_so_far.contains("title");
420          // The title is stored as a NODE (`Value::Xml`) for sections; derive
421          // its string form tag-aware (Perl `getTextContent`). A plain-string
422          // title (e.g. abstract/bibliography names) is used verbatim.
423          pieces.push(value_text(doc, title_val));
424        }
425      }
426      if pieces.is_empty() {
427        let has_type = entry
428          .get_value("tag:creftypecap")
429          .or_else(|| entry.get_value("tag:creftype"));
430        let has_refnum = entry.get_value("refnum");
431        if has_type.is_some() && has_refnum.is_some() {
432          is_dup = shown_so_far.contains("type") && shown_so_far.contains("refnum");
433          if let Some(t) = has_type {
434            pieces.push(t.to_string());
435          }
436          if let Some(r) = has_refnum {
437            pieces.push(r.to_string());
438          }
439        } else if let Some(tr) = entry.get_value("typerefnum") {
440          is_dup = shown_so_far.contains("type") && shown_so_far.contains("refnum");
441          pieces.push(tr.to_string());
442        } else if let Some(r) = has_refnum {
443          is_dup = shown_so_far.contains("refnum");
444          pieces.push(r.to_string());
445        }
446      }
447
448      if is_dup {
449        prefix = "In ".to_string();
450        shown_so_far.clear();
451      } else {
452        let title = pieces.join(" ");
453        let title = title.trim();
454        if !title.is_empty() {
455          result.push_str(&prefix);
456          prefix = self.ref_join.clone();
457          result.push_str(title);
458        }
459      }
460
461      // Walk to parent for more context
462      match entry.get_string("parent").map(String::from) {
463        Some(pid) => current_id = pid,
464        None => break,
465      }
466    }
467
468    if result.is_empty() {
469      None
470    } else {
471      Some(result)
472    }
473  }
474
475  /// Generate a title for the document itself.
476  ///
477  /// Port of `CrossRef::generateDocumentTitle`.
478  fn generate_document_title(&self, doc: &PostDocument) -> Option<String> {
479    // Try to generate from the document's root ID. Use `get_xml_id` so we
480    // pick up ids stored in the xml namespace (Scan's default placement)
481    // as well as the bare "xml:id" attribute form.
482    if let Some(docid) = doc.get_document_element().as_ref().and_then(get_xml_id) {
483      // Perl `generateDocumentTile` (CrossRef.pm L809) calls
484      // `generateTitle($doc, $docid)` with NO `$shown` arg → `$shown=''`. Passing
485      // "toctitle" here is WRONG: `generate_title`'s dup test is `shown.contains("title")`
486      // (Perl `$shown =~ /title/`), and "toctitle" contains "title", so the page's OWN
487      // (deepest) title is falsely flagged a duplicate and dropped — every split section
488      // page's <title> collapsed to "In <parent>" instead of "<section> ‣ <ancestors>".
489      let title = self.generate_title(doc, &docid, "");
490      if title.as_ref().map(|t| !t.is_empty()).unwrap_or(false) {
491        return title;
492      }
493    }
494    // Fallback: look for a title element in the document
495    if let Some(node) =
496      doc.findnode("//ltx:title | //ltx:toctitle | //ltx:caption | //ltx:toccaption")
497    {
498      let text = get_text_content_node(&node);
499      if !text.is_empty() {
500        return Some(text);
501      }
502    }
503    None
504  }
505
506  /// Generate content for a glossary reference.
507  ///
508  /// Port of `CrossRef::generateGlossaryRefTitle`.
509  fn generate_glossary_ref_title(&self, entry_key: &str, show: &str) -> Vec<NodeData> {
510    let entry = match self.db.lookup(entry_key) {
511      Some(e) => e,
512      None => return vec![],
513    };
514
515    let phrase_key = format!("phrase:{}", show);
516    if let Some(val) = entry.get_value(&phrase_key) {
517      return vec![NodeData::Element {
518        tag:        "ltx:text".to_string(),
519        attributes: Some(HashMap::from_iter([(
520          "class".to_string(),
521          format!("ltx_glossary_{}", show),
522        )])),
523        children:   vec![NodeData::Text(val.to_string())],
524      }];
525    }
526
527    // Handle -plural and -indefinite suffixes
528    if let Some(base_show) = show.strip_suffix("-plural") {
529      let base_key = format!("phrase:{}", base_show);
530      if let Some(val) = entry.get_value(&base_key) {
531        return vec![NodeData::Element {
532          tag:        "ltx:text".to_string(),
533          attributes: Some(HashMap::from_iter([(
534            "class".to_string(),
535            format!("ltx_glossary_{}", show),
536          )])),
537          children:   vec![NodeData::Text(format!("{}s", val))],
538        }];
539      }
540    }
541    if let Some(base_show) = show.strip_suffix("-indefinite") {
542      let base_key = format!("phrase:{}", base_show);
543      if let Some(val) = entry.get_value(&base_key) {
544        let text = val.to_string();
545        let article = if text.starts_with(|c: char| "aeiouAEIOU".contains(c)) {
546          "an "
547        } else {
548          "a "
549        };
550        return vec![NodeData::Element {
551          tag:        "ltx:text".to_string(),
552          attributes: Some(HashMap::from_iter([(
553            "class".to_string(),
554            format!("ltx_glossary_{}", show),
555          )])),
556          children:   vec![NodeData::Text(article.to_string()), NodeData::Text(text)],
557        }];
558      }
559    }
560
561    vec![]
562  }
563
564  /// Copy linked resources (non-idref hrefs) to the destination.
565  ///
566  /// Port of `CrossRef::copy_resources`.
567  fn copy_resources(&self, doc: &PostDocument) {
568    let refs = doc.findnodes("//ltx:ref[@href and not(@idref) and not(@labelref)]");
569    for ref_node in &refs {
570      if let Some(url) = ref_node.get_attribute("href") {
571        // Only copy relative URLs (no protocol, not absolute)
572        if !url.contains("://") && !url.starts_with('/') {
573          // Would copy resource from search path to destination
574          log::trace!("CrossRef: would copy resource '{}'", url);
575        }
576      }
577    }
578  }
579
580  /// Generate reference content for a given ID and show pattern.
581  ///
582  /// Port of `CrossRef::generateRef`.
583  fn generate_ref(&mut self, _doc: &PostDocument, req_id: &str, req_show: &str) -> Vec<NodeData> {
584    let show_options = if !req_show.contains("title") {
585      vec![req_show.to_string(), "title".to_string()]
586    } else {
587      vec![req_show.to_string(), "refnum".to_string()]
588    };
589
590    for show in &show_options {
591      let mut stuff = Vec::new();
592      let mut id = req_id.to_string();
593      let mut pending = String::new();
594      loop {
595        let entry_exists = self.db.lookup(&format!("ID:{}", id)).is_some();
596        if !entry_exists {
597          break;
598        }
599        let s = self.generate_ref_aux(&id, show);
600        if !s.is_empty() {
601          if !pending.is_empty() {
602            stuff.push(NodeData::Text(pending.clone()));
603          }
604          stuff.extend(s);
605          if self.check_ref_content(&stuff) {
606            return stuff;
607          }
608          pending = self.ref_join.clone();
609        }
610        let parent = self
611          .db
612          .lookup(&format!("ID:{}", id))
613          .and_then(|e| e.get_string("parent").map(String::from));
614        match parent {
615          Some(pid) => id = pid,
616          None => break,
617        }
618      }
619      if !stuff.is_empty() {
620        return stuff;
621      }
622    }
623
624    self.note_missing("info", "Usable title for ID", req_id);
625    vec![NodeData::Text(req_id.to_string())]
626  }
627
628  /// Generate ref content from a single DB entry.
629  fn generate_ref_aux(&self, id: &str, show: &str) -> Vec<NodeData> {
630    let entry = match self.db.lookup(&format!("ID:{}", id)) {
631      Some(e) => e,
632      None => return vec![],
633    };
634
635    let mut stuff = Vec::new();
636    let mut ok = false;
637    let mut remaining = show.to_string();
638
639    while !remaining.is_empty() {
640      if remaining.starts_with(|c: char| c.is_alphanumeric()) {
641        let keyword: String = remaining
642          .chars()
643          .take_while(|c| c.is_alphanumeric())
644          .collect();
645        remaining = remaining[keyword.len()..].to_string();
646        let key = keyword.to_lowercase();
647        let class = if key.contains("title") {
648          "ltx_ref_title"
649        } else {
650          "ltx_ref_tag"
651        };
652
653        let mut keys_to_try = vec![key.clone(), format!("tag:{}", key)];
654        keys_to_try.extend(ref_fallbacks(&key).iter().map(|s| s.to_string()));
655
656        for k in &keys_to_try {
657          if let Some(val) = entry.get_value(k) {
658            if val.is_truthy() {
659              ok = true;
660              // Perl `generateRef_aux` L779: `['ltx:text', {class}, prepRefText]`
661              // where `prepRefText` = `cloneNodes(trimChildNodes($value))` — a
662              // DEEP CLONE of the title's child nodes, `<ltx:Math>` included.
663              // The CrossRef pass runs before the MathML pass, so the cloned
664              // `<ltx:Math>` is later turned into `<math>` just like the body
665              // copy (issue #356). A plain-string value keeps the flat-text
666              // rendering.
667              stuff.push(NodeData::Element {
668                tag:        "ltx:text".to_string(),
669                attributes: Some(HashMap::from_iter([(
670                  "class".to_string(),
671                  class.to_string(),
672                )])),
673                children:   ref_content_children(val),
674              });
675              break;
676            }
677          }
678        }
679      } else if remaining.starts_with('{') {
680        if let Some(end) = remaining[1..].find('}') {
681          let literal = &remaining[1..1 + end];
682          if !literal.is_empty() {
683            stuff.push(NodeData::Text(literal.to_string()));
684          }
685          remaining = remaining[2 + end..].to_string();
686        } else {
687          remaining.clear();
688        }
689      } else if remaining.starts_with('~') {
690        remaining = remaining[1..].to_string();
691        if !stuff.is_empty() {
692          stuff.push(NodeData::Text("\u{00A0}".to_string()));
693        }
694      } else if remaining.starts_with(|c: char| c.is_whitespace()) {
695        let ws: String = remaining
696          .chars()
697          .take_while(|c| c.is_whitespace())
698          .collect();
699        remaining = remaining[ws.len()..].to_string();
700        if !stuff.is_empty() {
701          stuff.push(NodeData::Text(ws));
702        }
703      } else {
704        let sym: String = remaining
705          .chars()
706          .take_while(|c| !c.is_alphanumeric() && *c != '{' && *c != '~')
707          .collect();
708        remaining = remaining[sym.len()..].to_string();
709        stuff.push(NodeData::Text(sym));
710      }
711    }
712
713    if ok { stuff } else { vec![] }
714  }
715
716  /// Check if ref content is "good enough".
717  fn check_ref_content(&self, stuff: &[NodeData]) -> bool {
718    let text = text_content(stuff);
719    let cleaned = text.replace("in ", "");
720    cleaned.chars().any(|c| c.is_alphanumeric())
721  }
722
723  // ======================================================================
724  // Fill-in methods
725
726  fn fill_in_relations(&mut self, doc: &mut PostDocument) {
727    // Same get_xml_id trick as generate_document_title: Scan stores ids
728    // in the xml namespace by default; without this, sub-docs would skip
729    // relation filling and never gain the prev/next/up navigation.
730    let page_id = match doc.get_document_element().as_ref().and_then(get_xml_id) {
731      Some(id) => id,
732      None => return,
733    };
734
735    // 1. up / "up up" / "up up up" — walk ancestors that have a title.
736    let mut current_id = page_id.clone();
737    let mut rel = "up".to_string();
738    let mut topmost = current_id.clone();
739    loop {
740      let parent_id = self
741        .db
742        .lookup(&format!("ID:{}", current_id))
743        .and_then(|e| e.get_string("parent").map(String::from));
744      match parent_id {
745        Some(pid) => {
746          let has_title = self
747            .db
748            .lookup(&format!("ID:{}", pid))
749            .and_then(|e| e.get_value("title"))
750            .map(|v| v.is_truthy())
751            .unwrap_or(false);
752          if has_title {
753            doc.add_navigation(&rel, &pid);
754            rel = format!("{} up", rel);
755          }
756          current_id = pid.clone();
757          topmost = pid;
758        },
759        None => break,
760      }
761    }
762
763    // 2. start — the topmost ancestor (root page), if different from us.
764    if topmost != page_id {
765      if let Some(top_pageid) = self
766        .db
767        .lookup(&format!("ID:{}", topmost))
768        .and_then(|e| e.get_string("pageid").map(String::from))
769      {
770        doc.add_navigation("start", &top_pageid);
771      }
772    }
773
774    // 3. prev / next — walk the page tree.
775    if let Some(prev) = self.find_previous_page_id(&page_id) {
776      doc.add_navigation("prev", &prev);
777    }
778    if let Some(next) = self.find_next_page_id(&page_id) {
779      doc.add_navigation("next", &next);
780    }
781
782    // 4. Relation-typed links (Perl CrossRef.pm L105-130). "Dig around for other
783    // interesting related documents": the sibling pages of each ancestor (walking
784    // up), then this page's own child pages. Each is keyed by the page's own
785    // element-name relation (`chapter`/`section`/`subsection`/…) if it is a
786    // primary page, else `sidebar`. This is what gives split pages their
787    // `rel="chapter"`/`rel="section"`/… head links; the whole block was unported.
788    let mut xentry = page_id.clone();
789    while let Some(parent) = self.get_parent_page_id(&xentry) {
790      for sib in self.child_pages(&parent).ids.iter() {
791        if *sib == page_id {
792          continue;
793        }
794        self.add_typed_navigation(doc, sib);
795      }
796      xentry = parent;
797    }
798    for child in self.child_pages(&page_id).ids.iter() {
799      self.add_typed_navigation(doc, child);
800    }
801  }
802
803  /// Add a navigation link to `related_id` keyed by its own element-name
804  /// relation (Perl: `$type =~ s/^(\w+)://` → `chapter`/`section`/…) when it is
805  /// a primary page, else `sidebar`. Port of the per-entry arm of Perl
806  /// `CrossRef::fill_in_relations`'s second half.
807  fn add_typed_navigation(&self, doc: &mut PostDocument, related_id: &str) {
808    if self.is_primary_page(related_id) {
809      let rel = self
810        .db
811        .lookup(&format!("ID:{}", related_id))
812        .and_then(|e| e.get_string("type").map(String::from))
813        // Strip the namespace prefix: `ltx:chapter` → `chapter`.
814        .map(|t| t.rsplit(':').next().unwrap_or(&t).to_string());
815      if let Some(rel) = rel.filter(|r| !r.is_empty()) {
816        doc.add_navigation(&rel, related_id);
817      }
818    } else {
819      doc.add_navigation("sidebar", related_id);
820    }
821  }
822
823  /// Return whether the given xml:id is registered as a primary page.
824  /// Port of `$entry->getValue('primary')`.
825  fn is_primary_page(&self, page_id: &str) -> bool {
826    self
827      .db
828      .lookup(&format!("ID:{}", page_id))
829      .and_then(|e| e.get_value("primary"))
830      .map(|v| v.is_truthy())
831      .unwrap_or(false)
832  }
833
834  /// Resolve `entry_id` to the pageid of the page that *contains* its
835  /// parent. Port of Perl `CrossRef::getParentPage`.
836  fn get_parent_page_id(&self, entry_id: &str) -> Option<String> {
837    let entry = self.db.lookup(&format!("ID:{}", entry_id))?;
838    let pageid = entry.get_string("pageid")?.to_string();
839    let page_entry = self.db.lookup(&format!("ID:{}", pageid))?;
840    let parent_id = page_entry.get_string("parent")?.to_string();
841    let parent_entry = self.db.lookup(&format!("ID:{}", parent_id))?;
842    Some(parent_entry.get_string("pageid")?.to_string())
843  }
844
845  /// Memoized `get_child_page_ids`. The ObjectDB is immutable for the whole
846  /// CrossRef pass, so a given entry's child-page list is stable and shared
847  /// across every page (see the `child_pages` field). Also records each id's
848  /// position so the sibling finders skip the Perl pop/shift scan.
849  fn child_pages(&self, entry_id: &str) -> Rc<ChildPages> {
850    if let Some(cached) = self.child_pages.borrow().get(entry_id) {
851      return cached.clone();
852    }
853    let ids = self.compute_child_page_ids(entry_id);
854    let mut index_of = HashMap::default();
855    // Last occurrence wins, matching the Perl scan that peels from the end.
856    for (i, id) in ids.iter().enumerate() {
857      index_of.insert(id.clone(), i);
858    }
859    let rc = Rc::new(ChildPages { ids, index_of });
860    self
861      .child_pages
862      .borrow_mut()
863      .insert(entry_id.to_string(), rc.clone());
864    rc
865  }
866
867  /// Recursively collect distinct child page ids under `entry_id`.
868  /// Port of Perl `CrossRef::getChildPages` (the uncached recursion body;
869  /// recursion reuses the cache via [`child_pages`](Self::child_pages)).
870  fn compute_child_page_ids(&self, entry_id: &str) -> Vec<String> {
871    let entry = match self.db.lookup(&format!("ID:{}", entry_id)) {
872      Some(e) => e,
873      None => return Vec::new(),
874    };
875    let here_pageid = entry.get_string("pageid").map(String::from);
876    let children = entry.get_children();
877    let mut out = Vec::new();
878    for ch in children {
879      let ch_entry = match self.db.lookup(&format!("ID:{}", ch)) {
880        Some(e) => e,
881        None => continue,
882      };
883      let ch_pageid = match ch_entry.get_string("pageid") {
884        Some(p) => p.to_string(),
885        None => continue,
886      };
887      if here_pageid.as_deref() != Some(&ch_pageid) {
888        out.push(ch_pageid);
889      } else {
890        out.extend(self.child_pages(&ch).ids.iter().cloned());
891      }
892    }
893    out
894  }
895
896  /// Page immediately preceding `page_id` in tree order, restricted to
897  /// `primary` pages. Port of Perl `CrossRef::findPreviousPage`: previous
898  /// sibling if any, drilled into rightmost descendant.
899  fn find_previous_page_id(&self, page_id: &str) -> Option<String> {
900    let parent_id = self.get_parent_page_id(page_id)?;
901    let siblings = self.child_pages(&parent_id);
902    // Our position among the parent's child pages (None = "broken database").
903    let pos = *siblings.index_of.get(page_id)?;
904    // Nearest primary sibling strictly before us (Perl: peel following sibs,
905    // drop self, keep primaries, take the last one). If there is NONE, Perl's
906    // `$pentry` is still the PARENT page, so the previous page is the parent
907    // itself (e.g. the first `\section` of a `\chapter` → the chapter page).
908    // The old `?` returned None here, dropping the `rel="prev"` link entirely.
909    let mut current = match siblings.ids[..pos]
910      .iter()
911      .rev()
912      .find(|s| self.is_primary_page(s))
913    {
914      Some(sib) => sib.clone(),
915      None => return Some(parent_id),
916    };
917    // Drill into the rightmost primary descendant.
918    loop {
919      let kids = self.child_pages(&current);
920      match kids.ids.iter().rev().find(|s| self.is_primary_page(s)) {
921        Some(deepest) => current = deepest.clone(),
922        None => break,
923      }
924    }
925    Some(current)
926  }
927
928  /// Page immediately following `page_id` in tree order, restricted to
929  /// `primary` pages. Port of Perl `CrossRef::findNextPage`: first child,
930  /// else walk up to find next sibling at progressively higher levels.
931  fn find_next_page_id(&self, page_id: &str) -> Option<String> {
932    // First primary child page, if any.
933    if let Some(first) = self
934      .child_pages(page_id)
935      .ids
936      .iter()
937      .find(|s| self.is_primary_page(s))
938    {
939      return Some(first.clone());
940    }
941    let mut current = page_id.to_string();
942    loop {
943      let parent = self.get_parent_page_id(&current)?;
944      let siblings = self.child_pages(&parent);
945      // Our position among the parent's child pages (None = "broken database").
946      let pos = *siblings.index_of.get(&current)?;
947      // First primary sibling strictly after us.
948      if let Some(first) = siblings.ids[pos + 1..]
949        .iter()
950        .find(|s| self.is_primary_page(s))
951      {
952        return Some(first.clone());
953      }
954      current = parent;
955    }
956  }
957
958  fn fill_in_tocs(&mut self, doc: &mut PostDocument) {
959    // Perl Post.pm L946-948: Document::findnodes defaults the XPath
960    // context to documentElement. oxide's `findnodes(None)` defaults to
961    // the XML document node, where libxml2's `descendant::` axis evaluates
962    // differently — `descendant::ltx:TOC` matches zero from the doc node
963    // even though `//ltx:TOC` matches one. Pin the root explicitly so the
964    // user's `\tableofcontents` placeholder is reachable.
965    let tocs = match doc.get_document_element() {
966      Some(root) => doc.findnodes_at("descendant::ltx:TOC[not(ltx:toclist)]", Some(&root)),
967      None => Vec::new(),
968    };
969    for toc in &tocs {
970      // Use the unified get_xml_id helper: Scan's `Document` fallback
971      // assigns xml:id via the xml namespace, which is invisible to a
972      // bare `get_attribute("xml:id")` lookup but is found by
973      // `get_attribute_ns("id", XML_NS)` (which get_xml_id tries first).
974      let mut id = doc
975        .get_document_element()
976        .as_ref()
977        .and_then(get_xml_id)
978        .unwrap_or_default();
979      // `scope="global"` retargets the TOC to the root page. Perl
980      // fill_in_tocs L227-231 resolves this via `getRootPage`, walking up the
981      // *page* hierarchy (parent → its pageid → …) and taking the root page's
982      // `pageid`. Default scope (`current` or absent) keeps the current-page id,
983      // so the inline `\tableofcontents` placeholder stays page-local.
984      if toc.get_attribute("scope").as_deref() == Some("global") {
985        id = self.get_root_page_id(&id);
986      }
987      let show = toc
988        .get_attribute("show")
989        .unwrap_or_else(|| self.toc_show.clone());
990
991      // Perl CrossRef.pm fill_in_tocs L213-233: the `select` attribute (built
992      // by `\tableofcontents` from `tocdepth`) restricts which element types
993      // reach the ToC; absent `select` ⇒ no type restriction. The `lists`
994      // attribute names which inlist buckets to draw from (default `toc`;
995      // `lof`/`lot` for the figure/table lists).
996      let select_attr = toc.get_attribute("select");
997      let types: Option<HashSet<&str>> = select_attr.as_deref().map(|s| {
998        s.split('|')
999          .map(str::trim)
1000          .filter(|t| !t.is_empty())
1001          .collect()
1002      });
1003      let lists_attr = toc.get_attribute("lists");
1004      let lists: HashSet<&str> = match lists_attr.as_deref() {
1005        Some(l) => l.split_whitespace().collect(),
1006        None => HashSet::from_iter(["toc"]),
1007      };
1008
1009      // Perl fill_in_tocs L232-236 dispatches on `format`: `normal` (or absent)
1010      // builds a plain downward TOC; `context` builds the navigation breadcrumb
1011      // (`gentoc_context`), which forces `lists={toc}`. Any other value yields
1012      // no toclist (Perl leaves `@list` empty).
1013      let format = toc.get_attribute("format").unwrap_or_default();
1014      let list = if format.is_empty() || format.starts_with("normal") {
1015        self.gen_toc(&id, &show, types.as_ref(), &lists, None, None)
1016      } else if format == "context" {
1017        let toc_lists: HashSet<&str> = HashSet::from_iter(["toc"]);
1018        self.gen_toc_context(&id, &show, types.as_ref(), &toc_lists)
1019      } else {
1020        Vec::new()
1021      };
1022      if !list.is_empty() {
1023        let toclist = NodeData::Element {
1024          tag:        "ltx:toclist".to_string(),
1025          attributes: None,
1026          children:   list,
1027        };
1028        let mut toc_mut = toc.clone();
1029        doc.add_nodes(&mut toc_mut, &[toclist]);
1030      }
1031    }
1032  }
1033
1034  /// Perl CrossRef.pm getRootPage L179-186 + its caller (fill_in_tocs L229):
1035  /// walk up the *page* hierarchy — `parent` → that parent's `pageid` → that
1036  /// page's `parent` → … — to the topmost page, and return its `pageid`. For a
1037  /// single-page document this resolves back to the document id.
1038  fn get_root_page_id(&self, start_id: &str) -> String {
1039    let mut root_id = start_id.to_string();
1040    let mut cursor = start_id.to_string();
1041    while let Some(page_id) = self.parent_page_of(&cursor) {
1042      root_id = page_id.clone();
1043      cursor = page_id;
1044    }
1045    // Caller reads `$root->getValue('pageid')`.
1046    self
1047      .db
1048      .lookup(&format!("ID:{}", root_id))
1049      .and_then(|e| e.get_string("pageid"))
1050      .map(String::from)
1051      .unwrap_or(root_id)
1052  }
1053
1054  /// One `getRootPage` step (Perl L182-184): the `pageid` of this entry's
1055  /// parent, provided that page entry exists. `None` ends the upward walk.
1056  fn parent_page_of(&self, id: &str) -> Option<String> {
1057    // $x = $x->getValue('parent')
1058    let parent_id = self
1059      .db
1060      .lookup(&format!("ID:{}", id))
1061      .and_then(|e| e.get_string("parent"))
1062      .filter(|s| !s.is_empty())?;
1063    // $x = lookup(parent)->getValue('pageid')
1064    let page_id = self
1065      .db
1066      .lookup(&format!("ID:{}", parent_id))
1067      .and_then(|e| e.get_string("pageid"))
1068      .filter(|s| !s.is_empty())?
1069      .to_string();
1070    // $x = lookup(pageid) — the page entry must exist to continue.
1071    self.db.lookup(&format!("ID:{}", page_id)).map(|_| page_id)
1072  }
1073
1074  /// Perl CrossRef.pm gentoc L246-262. Generate the TOC for `id` and its
1075  /// children. `localto` (when `Some`) restricts the downward recursion to
1076  /// entries on that page's `location` — the mechanism a context TOC uses to
1077  /// stop at the current page's boundary. `selfid` marks the matching entry
1078  /// with `ltx_ref_self` ("you are here").
1079  fn gen_toc(
1080    &self,
1081    id: &str,
1082    show: &str,
1083    types: Option<&HashSet<&str>>,
1084    lists: &HashSet<&str>,
1085    localto: Option<&str>,
1086    selfid: Option<&str>,
1087  ) -> Vec<NodeData> {
1088    let entry = match self.db.lookup(&format!("ID:{}", id)) {
1089      Some(e) => e,
1090      None => return vec![],
1091    };
1092
1093    // gentoc L250-252: recurse into children only when unrestricted, or this
1094    // entry lives on the target page.
1095    let recurse = match localto {
1096      None => true,
1097      Some(target) => entry.get_string("location").unwrap_or("") == target,
1098    };
1099    let kids: Vec<NodeData> = if recurse {
1100      entry
1101        .get_children()
1102        .iter()
1103        .flat_map(|child_id| self.gen_toc(child_id, show, types, lists, localto, selfid))
1104        .collect()
1105    } else {
1106      Vec::new()
1107    };
1108
1109    let entry_type = entry.get_string("type").unwrap_or("");
1110    // gentoc L255-256: include this entry iff its type passes the `select`
1111    // filter (no `select` ⇒ unrestricted) AND its `inlist` shares a list with
1112    // the TOC's `lists`. This is what makes `\setcounter{tocdepth}` (#291) take
1113    // effect — the level filter rides on `select`.
1114    let type_ok = types.map(|t| t.contains(entry_type)).unwrap_or(true);
1115    let in_toc = entry
1116      .get_value("inlist")
1117      .map(|v| match v {
1118        Value::Hash(h) => lists.iter().any(|l| h.contains_key(*l)),
1119        _ => false,
1120      })
1121      .unwrap_or(false);
1122
1123    if type_ok && in_toc {
1124      vec![self.gen_tocentry(entry, selfid, show, kids)]
1125    } else {
1126      kids
1127    }
1128  }
1129
1130  /// Perl CrossRef.pm gentocentry L268-283. Build one `ltx:tocentry` for an
1131  /// entry: the `before < show > after` split (`generateRef_simple` for the
1132  /// before/after halves), the `ltx:ref` body, the `ltx_ref_self` marker when
1133  /// this is the `selfid`, and a nested `ltx:toclist` of `children`.
1134  fn gen_tocentry(
1135    &self,
1136    entry: &Entry,
1137    selfid: Option<&str>,
1138    show: &str,
1139    children: Vec<NodeData>,
1140  ) -> NodeData {
1141    let id = entry
1142      .get_string("id")
1143      .or_else(|| entry.get_key().strip_prefix("ID:"))
1144      .unwrap_or("")
1145      .to_string();
1146    let entry_type = entry.get_string("type").unwrap_or("");
1147    let type_name = entry_type.strip_prefix("ltx:").unwrap_or(entry_type);
1148
1149    // gentocentry L272-273: `before < show > after`.
1150    let (mut before, mut after): (Option<&str>, Option<&str>) = (None, None);
1151    let mut show_mid = show;
1152    if let Some((b, rest)) = show_mid.split_once('<') {
1153      before = Some(b);
1154      show_mid = rest;
1155    }
1156    if let Some((mid, a)) = show_mid.split_once('>') {
1157      show_mid = mid;
1158      after = Some(a);
1159    }
1160
1161    let self_class = if selfid == Some(id.as_str()) {
1162      " ltx_ref_self"
1163    } else {
1164      ""
1165    };
1166
1167    let mut kids: Vec<NodeData> = Vec::new();
1168    if let Some(b) = before.filter(|b| !b.is_empty()) {
1169      kids.extend(self.generate_ref_simple(&id, b));
1170    }
1171    kids.push(NodeData::Element {
1172      tag:        "ltx:ref".to_string(),
1173      attributes: Some(HashMap::from_iter([
1174        ("show".to_string(), show_mid.to_string()),
1175        ("idref".to_string(), id.clone()),
1176      ])),
1177      children:   vec![],
1178    });
1179    if let Some(a) = after.filter(|a| !a.is_empty()) {
1180      kids.extend(self.generate_ref_simple(&id, a));
1181    }
1182    if !children.is_empty() {
1183      kids.push(NodeData::Element {
1184        tag: "ltx:toclist".to_string(),
1185        attributes: Some(HashMap::from_iter([(
1186          "class".to_string(),
1187          format!("ltx_toclist_{}", type_name),
1188        )])),
1189        children,
1190      });
1191    }
1192
1193    NodeData::Element {
1194      tag:        "ltx:tocentry".to_string(),
1195      attributes: Some(HashMap::from_iter([(
1196        "class".to_string(),
1197        format!("ltx_tocentry_{}{}", type_name, self_class),
1198      )])),
1199      children:   kids,
1200    }
1201  }
1202
1203  /// Perl CrossRef.pm generateRef_simple L...: look the entry up and, if found,
1204  /// render `req_show` against it. Used only by `gentocentry`'s before/after.
1205  fn generate_ref_simple(&self, req_id: &str, req_show: &str) -> Vec<NodeData> {
1206    if !req_show.is_empty()
1207      && !req_id.is_empty()
1208      && self.db.lookup(&format!("ID:{}", req_id)).is_some()
1209    {
1210      self.generate_ref_aux(req_id, req_show)
1211    } else {
1212      Vec::new()
1213    }
1214  }
1215
1216  /// Perl CrossRef.pm gentoc_context L288-311. A "context" TOC: the current
1217  /// page's own contents (downward, page-local), enclosed upward within its
1218  /// ancestors and their sibling sections — the navigation-bar breadcrumb.
1219  fn gen_toc_context(
1220    &self,
1221    id: &str,
1222    show: &str,
1223    types: Option<&HashSet<&str>>,
1224    lists: &HashSet<&str>,
1225  ) -> Vec<NodeData> {
1226    let start = match self.db.lookup(&format!("ID:{}", id)) {
1227      Some(e) => e,
1228      None => return vec![],
1229    };
1230
1231    // Downward TOC covering items WITHIN the current page (localto = this page's
1232    // location; selfid = this id so the current entry is marked ltx_ref_self).
1233    let location = start.get_string("location").unwrap_or("").to_string();
1234    let mut navtoc = self.gen_toc(id, show, types, lists, Some(&location), Some(id));
1235
1236    // Enclose it upward, along with siblings & ancestors. `came_from` is the id
1237    // of the child we ascended through; its slot in each parent's sibling row is
1238    // replaced by the accumulated `navtoc` subtree.
1239    let mut came_from = id.to_string();
1240    let mut parent_id = start.get_string("parent").map(String::from);
1241
1242    while let Some(pid) = parent_id {
1243      let parent = match self.db.lookup(&format!("ID:{}", pid)) {
1244        Some(e) => e,
1245        None => break,
1246      };
1247
1248      // gentoc_context L297-303: the parent's normal-type children become plain
1249      // tocentries, except the one we came from (spliced with `navtoc`).
1250      let mut row: Vec<NodeData> = Vec::new();
1251      for child_id in parent.get_children() {
1252        let child = match self.db.lookup(&format!("ID:{}", child_id)) {
1253          Some(e) => e,
1254          None => continue,
1255        };
1256        if !NORMAL_TOC_TYPES.contains(&child.get_string("type").unwrap_or("")) {
1257          continue;
1258        }
1259        let child_id_val = child.get_string("id").unwrap_or(&child_id);
1260        if child_id_val == came_from {
1261          row.append(&mut navtoc);
1262        } else {
1263          row.push(self.gen_tocentry(child, None, show, Vec::new()));
1264        }
1265      }
1266      navtoc = row;
1267
1268      // gentoc_context L304-306: wrap in the parent's own tocentry, but only if
1269      // the parent passes the type filter AND is itself nested (never wrap the
1270      // top-level document).
1271      let parent_type = parent.get_string("type").unwrap_or("");
1272      let parent_ok = types.map(|t| t.contains(parent_type)).unwrap_or(true);
1273      let parent_has_parent = parent
1274        .get_string("parent")
1275        .map(|s| !s.is_empty())
1276        .unwrap_or(false);
1277      if parent_ok && parent_has_parent {
1278        navtoc = vec![self.gen_tocentry(parent, None, show, navtoc)];
1279      }
1280
1281      came_from = pid;
1282      parent_id = parent.get_string("parent").map(String::from);
1283    }
1284
1285    navtoc
1286  }
1287
1288  fn fill_in_frags(&self, doc: &PostDocument) {
1289    // Perl (CrossRef.pm L312-324) walks the page's own `//@xml:id` nodes and
1290    // sets `fragid` on any that have a DB entry. Iterating the ObjectDB
1291    // instead (one lookup per DB key) wins ONLY when a single page has far
1292    // more id-nodes than the DB has entries (math-heavy single documents:
1293    // ~60K XM* ids vs ~1K DB entries). On a *split* document the DB holds
1294    // every page's entries (tens of thousands) while each page has a handful
1295    // of id-nodes, so that inverted loop is O(db_keys) per page = O(n²)
1296    // overall. Pick whichever loop is bounded by the smaller set — both
1297    // assign fragids to exactly the same nodes, so the output is identical.
1298    if doc.idcache_len() <= self.db.len() {
1299      // Perl semantics: iterate the page's id-nodes (bounded by page size).
1300      for (id, node) in doc.idcache_iter() {
1301        if let Some(entry) = self.db.lookup(&format!("ID:{}", id)) {
1302          if let Some(fragid) = entry.get_string("fragid") {
1303            let mut n = node.clone();
1304            n.set_attribute("fragid", fragid).ok();
1305          }
1306        }
1307      }
1308    } else {
1309      // Inverted loop: fewer DB entries than page id-nodes. `find_node_by_id`
1310      // restricts to this page's nodes, so only page-local fragids are set.
1311      for key in self.db.keys_iter() {
1312        let id = match key.strip_prefix("ID:") {
1313          Some(rest) => rest,
1314          None => continue,
1315        };
1316        let entry = match self.db.lookup(key) {
1317          Some(e) => e,
1318          None => continue,
1319        };
1320        let fragid = match entry.get_string("fragid") {
1321          Some(f) => f,
1322          None => continue,
1323        };
1324        if let Some(node) = doc.find_node_by_id(id) {
1325          let mut n = node.clone();
1326          n.set_attribute("fragid", fragid).ok();
1327        }
1328      }
1329    }
1330  }
1331
1332  fn fill_in_refs(&mut self, doc: &mut PostDocument) {
1333    let mut refs = doc.findnodes("//*[@idref]");
1334    refs.extend(doc.findnodes("//*[@labelref]"));
1335    for ref_node in &refs {
1336      let tag = doc.get_qname(ref_node).unwrap_or_default();
1337      if tag == "ltx:XMRef" {
1338        continue;
1339      }
1340
1341      let mut ref_mut = ref_node.clone();
1342      let mut id = ref_node.get_attribute("idref");
1343      let show = ref_node
1344        .get_attribute("show")
1345        .unwrap_or_else(|| self.ref_show.clone());
1346
1347      if id.is_none() {
1348        if let Some(label) = ref_node.get_attribute("labelref") {
1349          if let Some(entry) = self.db.lookup(&label) {
1350            if let Some(resolved_id) = entry.get_string("id") {
1351              ref_mut.set_attribute("idref", resolved_id).ok();
1352              id = Some(resolved_id.to_string());
1353            }
1354          }
1355          if id.is_none() {
1356            self.note_missing("warn", "Target for Label", &label);
1357            PostDocument::add_class(&mut ref_mut, "ltx_missing_label");
1358          }
1359        }
1360      }
1361
1362      if let Some(ref id_str) = id {
1363        if ref_mut.get_attribute("href").is_none() {
1364          if let Some(url) = self.generate_url(doc, id_str) {
1365            ref_mut.set_attribute("href", &url).ok();
1366          }
1367        }
1368        if ref_mut.get_attribute("title").is_none() {
1369          if let Some(titlestring) = self.generate_title(doc, id_str, &show) {
1370            ref_mut.set_attribute("title", &titlestring).ok();
1371          }
1372          // Perl CrossRef.pm L358-361: a ref carrying a `rel` (a navigation ref)
1373          // ALSO gets a `fulltitle` — `generateTitle($doc, $id)` with an EMPTY
1374          // `show`, i.e. the full contextual breadcrumb with NO "In <context>"
1375          // dup-collapse (that collapse only fires for `show=~/title/`). The XSLT
1376          // `head-links` template prefers `@fulltitle` over `@title` for the head
1377          // `<link rel=… title=…>` entries, so without this split pages emitted
1378          // empty (or "In X") nav-link titles.
1379          if let Some(rel) = ref_mut.get_attribute("rel") {
1380            if !rel.is_empty() {
1381              if let Some(fulltitle) = self.generate_title(doc, id_str, "") {
1382                ref_mut.set_attribute("fulltitle", &fulltitle).ok();
1383              }
1384            }
1385          }
1386        }
1387        if ref_mut.get_first_child().is_none() && tag != "ltx:graphics" && tag != "ltx:picture" {
1388          let content = self.generate_ref(doc, id_str, &show);
1389          doc.add_nodes(&mut ref_mut, &content);
1390        }
1391      }
1392    }
1393  }
1394
1395  fn fill_in_glossaryrefs(&mut self, doc: &mut PostDocument) {
1396    // Mirrors Perl CrossRef.pm L454-481 fill_in_glossaryrefs:
1397    //   - resolve `<ltx:glossaryref key=… inlist=…>` against the GLOSSARY:list:key DB entry
1398    //     registered by Scan + MakeIndex,
1399    //   - copy the entry's id into `idref` so a later fill_in_refs pass converts it to `href`,
1400    //   - copy `phrase:description` into `title` so the XSLT inline template renders a tooltip,
1401    //   - fall back to the bare key + `ltx_missing` class when the entry is not in the DB or has no
1402    //     displayable content.
1403    for ref_node in &doc.findnodes("descendant::ltx:glossaryref") {
1404      let mut ref_mut = ref_node.clone();
1405      let key = ref_node.get_attribute("key").unwrap_or_default();
1406      let list = ref_node.get_attribute("inlist").unwrap_or_default();
1407
1408      let gkey = format!("GLOSSARY:{}:{}", list, key);
1409      if let Some(entry) = self.db.lookup(&gkey) {
1410        if let Some(id) = entry.get_string("id") {
1411          ref_mut.set_attribute("idref", id).ok();
1412        }
1413        // Perl L465-467: copy phrase:definition (Rust schema uses
1414        // phrase:description) into `title` if not already set.
1415        if ref_mut.get_attribute("title").is_none() {
1416          if let Some(desc) = entry.get_string("phrase:description") {
1417            if !desc.is_empty() {
1418              ref_mut.set_attribute("title", desc).ok();
1419            }
1420          }
1421        }
1422      } else {
1423        self.note_missing("warn", "Glossary Entry for key", &key);
1424      }
1425
1426      if ref_mut.get_first_child().is_none() {
1427        doc.add_nodes(&mut ref_mut, &[NodeData::Text(key.clone())]);
1428        PostDocument::add_class(&mut ref_mut, "ltx_missing");
1429      }
1430    }
1431  }
1432
1433  /// Resolve the RDFa subject/object references — `aboutidref`/`aboutlabelref`
1434  /// into `about`, and `resourceidref`/`resourcelabelref` into `resource`.
1435  ///
1436  /// Port of `CrossRef.pm::fill_in_RDFa_refs` (L372-398), and runs in Perl's
1437  /// position: after `fill_in_refs`, before `fill_in_bibrefs`.
1438  ///
1439  /// `lxRDFa.sty` deliberately records an intra-document RDFa subject as an
1440  /// `…idref`/`…labelref` pair rather than a URL, because the URL is not knowable
1441  /// until the document has been split and paginated — see the
1442  /// `LaTeXML-common.rnc` L301 note, "it will be converted to `aboutidref` and
1443  /// `about` during post-processing". Without this pass that conversion never
1444  /// happened, so `\lxRDFa{about=#thm1}` produced an `aboutidref` that no
1445  /// consumer reads and **no `about` at all** — the RDFa triple lost its subject.
1446  /// Visible on math once `outer_wrapper` began copying `about` onto `<m:math>`:
1447  /// Perl emits `about="#thm1"` there and Rust emitted nothing.
1448  ///
1449  /// An id that the ObjectDB knows becomes a real (possibly cross-page) URL via
1450  /// `generate_url`; an id it does not know still becomes a bare `#id` fragment,
1451  /// because — as Perl's comment puts it — "RDF 'id' need not be real, valid,
1452  /// ids!!!": an author may name a subject that is not a document node at all.
1453  ///
1454  /// Perl also re-runs `set_RDFa_prefixes` at the end. Not ported, and it cannot
1455  /// matter here: this pass only ever writes `about`/`resource` values that are
1456  /// absolute URLs or `#id` fragments, never prefixed CURIEs, so there is no new
1457  /// prefix to declare. (Prefix management itself already happens core-side, in
1458  /// `latexml_core::document::set_rdfa_prefixes`, as it does in Perl's
1459  /// `Core/Document.pm:366`.)
1460  fn fill_in_rdfa_refs(&mut self, doc: &mut PostDocument) {
1461    for key in ["about", "resource"] {
1462      // One query with `or`, as Perl has it — two queries concatenated would
1463      // visit a node carrying BOTH attributes twice and in the wrong order.
1464      let refs = doc.findnodes(&format!("//*[@{key}idref or @{key}labelref]"));
1465      for ref_node in &refs {
1466        let mut ref_mut = ref_node.clone();
1467        let idref_attr = format!("{key}idref");
1468        // Perl's `if (!$id)` and `if ($id)` are truth tests, so an empty
1469        // `aboutidref=""` counts as absent rather than resolving to `about="#"`.
1470        let mut id = ref_node
1471          .get_attribute(&idref_attr)
1472          .filter(|v| !v.is_empty());
1473
1474        // A label reference resolves through the ObjectDB to an id, which is
1475        // written back so the `if let Some(id)` below treats both spellings
1476        // alike (Perl L379-387).
1477        if id.is_none()
1478          && let Some(label) = ref_node.get_attribute(&format!("{key}labelref"))
1479        {
1480          if let Some(entry) = self.db.lookup(&label)
1481            && let Some(resolved) = entry.get_string("id")
1482          {
1483            ref_mut.set_attribute(&idref_attr, resolved).ok();
1484            id = Some(resolved.to_string());
1485          }
1486          if id.is_none() {
1487            self.note_missing("warn", &format!("Target for {key} Label"), &label);
1488          }
1489        }
1490
1491        // Never overwrite an `about`/`resource` the author gave outright.
1492        if let Some(ref id_str) = id
1493          && ref_mut.get_attribute(key).is_none()
1494        {
1495          let value = if self.db.lookup(&format!("ID:{id_str}")).is_some() {
1496            self.generate_url(doc, id_str)
1497          } else {
1498            Some(format!("#{id_str}"))
1499          };
1500          if let Some(value) = value {
1501            ref_mut.set_attribute(key, &value).ok();
1502          }
1503        }
1504      }
1505    }
1506  }
1507
1508  fn fill_in_bibrefs(&mut self, doc: &mut PostDocument) {
1509    let bibrefs = doc.findnodes("//ltx:bibref");
1510    for bibref in &bibrefs {
1511      let keys_str = bibref.get_attribute("bibrefs").unwrap_or_default();
1512      let show = bibref
1513        .get_attribute("show")
1514        .unwrap_or_else(|| "refnum".to_string());
1515      // natbib emits show patterns like:
1516      //   "AuthorsPhrase1Year"           → \citep{X} → "Author (Year)"
1517      //   "Authors Phrase1YearPhrase2"  → \citet{X}/\cite{X} → "Author (Year)"
1518      //   "refnum"                       → numeric / default
1519      // Anything containing "Author" or "Year" wants the author-year
1520      // text built from the bibentry's `authors`/`year` fields; the
1521      // legacy refnum-only path serves the numeric case.
1522      let show_wants_ay = show.contains("Author") || show.contains("Year");
1523      // The lists to search, most-specific first. Perl CrossRef.pm L515 reads
1524      // `inlist || 'bibliography'` — an *exclusive* choice, which strands every
1525      // citation of a document that loads `bibunits`/`chapterbib` but keeps a
1526      // single main `\bibliography`: `\cite` stamps CITE_UNIT=bu0 onto the
1527      // bibref, while the bibitems register under the default `bibliography`
1528      // list, so the unit-only lookup never matches (witness 2303.06077: 93
1529      // bibitems, 93 dangling keys, 0 links).
1530      //
1531      // Perl's own Scan.pm L379-380 spells the intended chain — unit lists
1532      // PLUS the main one ("Citation specifies main 'bibliography', as well as
1533      // any specific others (eg. per chapter)") — and registers the reference
1534      // under both. We follow Scan's convention here so the two agree; the unit
1535      // list still wins, since the search breaks on the first list that yields
1536      // an id. OXIDIZED_DESIGN #59, KNOWN_PERL_ERRORS #50.
1537      let inlist = bibref.get_attribute("inlist").unwrap_or_default();
1538      let mut lists: Vec<&str> = inlist.split_whitespace().collect();
1539      if !lists.contains(&"bibliography") {
1540        lists.push("bibliography");
1541      }
1542      // \NAT@force@numbers (natbib): a numeric `.bbl` — plain `\bibitem{key}`
1543      // with no `[author(year)]` label — forces numbers mode globally, so every
1544      // `\cite` prints the bracketed number `[N]`/`[N, M]` even when a numeric
1545      // `\bibliographystyle{unsrt}` sits AFTER the cites (witness arXiv:2308.06262
1546      // / html_feedback#62). Single-pass LaTeXML froze this bibref's author-year
1547      // `show`; Perl `CrossRef.pm:542` keeps it because its `|| $keytag` guard is
1548      // always satisfied, so both engines render the raw key. When the show wants
1549      // author-year yet EVERY cited entry is numeric-only (has a number, no real
1550      // author/year), collapse to natbib's numeric form. SURPASS-PERL:
1551      // OXIDIZED_DESIGN #123, KNOWN_PERL_ERRORS #89.
1552      let force_numeric = show_wants_ay && {
1553        let keys: Vec<&str> = keys_str.split(',').filter(|k| !k.is_empty()).collect();
1554        !keys.is_empty()
1555          && keys.iter().all(|key| {
1556            let mut id = None;
1557            for list in &lists {
1558              if let Some(be) = self.db.lookup(&format!("BIBLABEL:{}:{}", list, key)) {
1559                id = be.get_string("id").map(String::from);
1560                if id.is_some() {
1561                  break;
1562                }
1563              }
1564            }
1565            let Some(id) = id else { return false };
1566            match self.db.lookup(&format!("ID:{}", id)) {
1567              Some(e) => {
1568                let nonempty = |k: &str| {
1569                  e.get_value(k)
1570                    .is_some_and(|v| !v.to_string().trim().is_empty())
1571                };
1572                !nonempty("authors")
1573                  && !nonempty("fullauthors")
1574                  && !nonempty("year")
1575                  && (nonempty("number") || nonempty("refnum"))
1576              },
1577              None => false,
1578            }
1579          })
1580      };
1581      // Do the frozen author-year delimiters sit INSIDE the bibref (a Phrase
1582      // after Year — `\cite`/`\citet` carry their own `( )`) or as sibling text
1583      // (`\citep`, whose macro adds the parens outside the bibref)? Only bracket
1584      // our numeric group in the former case, else `\citep`'s parens double up.
1585      let internal_delims = show
1586        .find("Year")
1587        .is_some_and(|yp| show[yp + "Year".len()..].contains("Phrase"));
1588      // Numeric collapse joins with ", " (natbib numbers mode) and drops the
1589      // author-year path; otherwise the loop keeps the bibref's own separator.
1590      let want_authoryear = show_wants_ay && !force_numeric;
1591      let sep = if force_numeric {
1592        ",".to_string()
1593      } else {
1594        bibref
1595          .get_attribute("separator")
1596          .unwrap_or_else(|| ",".to_string())
1597      };
1598
1599      let mut refs: Vec<NodeData> = Vec::new();
1600      for key in keys_str.split(',').filter(|k| !k.is_empty()) {
1601        let mut found_id = None;
1602        for list in &lists {
1603          let bkey = format!("BIBLABEL:{}:{}", list, key);
1604          if let Some(bentry) = self.db.lookup(&bkey) {
1605            found_id = bentry.get_string("id").map(String::from);
1606            if found_id.is_some() {
1607              break;
1608            }
1609          }
1610        }
1611        if !refs.is_empty() {
1612          refs.push(NodeData::Text(format!("{} ", sep)));
1613        }
1614        if let Some(id) = found_id {
1615          let mut attrs = HashMap::default();
1616          attrs.insert("idref".to_string(), id.clone());
1617          if let Some(url) = self.generate_url(doc, &id) {
1618            attrs.insert("href".to_string(), url);
1619          }
1620          // Build the display text: author-year when natbib's `show`
1621          // requests it AND the bibentry has the author/year metadata;
1622          // otherwise fall back to the numeric `number`/`refnum`
1623          // (matches the legacy path).
1624          // Perl: use 'number' field for numeric citations (bare number without brackets).
1625          // The 'refnum' field includes brackets like "[13]", causing double brackets [[13]].
1626          let entry = self.db.lookup(&format!("ID:{}", id));
1627          // Pull the entry's citation metadata into owned strings (ends the
1628          // `entry` borrow before we touch `doc`/`bibref` below).
1629          let get = |k: &str| {
1630            entry
1631              .and_then(|e| e.get_value(k))
1632              .map(|v| v.to_string())
1633              .map(|s| s.trim().to_string())
1634              .filter(|s| !s.is_empty())
1635          };
1636          let authors = get("authors");
1637          let fullauthors = get("fullauthors");
1638          let keytag = get("keytag");
1639          let year = get("year");
1640          let typetag = get("typetag");
1641          let number = get("number");
1642          let refnum = get("refnum");
1643          let number_or_refnum = || {
1644            number
1645              .clone()
1646              .or_else(|| refnum.clone())
1647              .unwrap_or_else(|| key.to_string())
1648          };
1649          let display = if want_authoryear {
1650            // The `<ltx:bibrefphrase>` children supply Phrase1/Phrase2 (the
1651            // `(`/`)` for \citet, the `, ` for \citep). Interleave them with the
1652            // authors/year per the `show` format — Perl CrossRef.pm make_bibcite.
1653            let phrases: Vec<String> = crate::document::element_children(bibref)
1654              .iter()
1655              .filter(|c| doc.get_qname(c).as_deref() == Some("ltx:bibrefphrase"))
1656              .map(|c| c.get_content())
1657              .collect();
1658            let a = authors
1659              .as_deref()
1660              .or(fullauthors.as_deref())
1661              .or(keytag.as_deref());
1662            let y = year.as_deref().or(typetag.as_deref());
1663            let (text, resolved) = render_bibref_show(
1664              &show,
1665              a,
1666              fullauthors.as_deref(),
1667              y,
1668              number.as_deref(),
1669              refnum.as_deref(),
1670              &phrases,
1671            );
1672            // No author/year metadata (e.g. an entry with only a number) → fall
1673            // back to the bare number so the inline label still resolves.
1674            if resolved && !text.trim().is_empty() {
1675              text
1676            } else {
1677              number_or_refnum()
1678            }
1679          } else {
1680            number_or_refnum()
1681          };
1682          refs.push(NodeData::Element {
1683            tag:        "ltx:ref".to_string(),
1684            attributes: Some(attrs),
1685            children:   vec![NodeData::Text(display)],
1686          });
1687        } else {
1688          self.note_missing("warn", "Entry for citation", key);
1689          refs.push(NodeData::Element {
1690            tag:        "ltx:ref".to_string(),
1691            attributes: Some(HashMap::from_iter([
1692              ("idref".to_string(), key.to_string()),
1693              ("class".to_string(), "ltx_missing_citation".to_string()),
1694            ])),
1695            children:   vec![NodeData::Text(key.to_string())],
1696          });
1697        }
1698      }
1699      // Bracket the numeric group as a whole ([1, 2]), matching natbib's numbers
1700      // mode; \citep-style external parens (no internal delimiter) are left be.
1701      if force_numeric && internal_delims && !refs.is_empty() {
1702        refs.insert(0, NodeData::Text("[".to_string()));
1703        refs.push(NodeData::Text("]".to_string()));
1704      }
1705      if !refs.is_empty() {
1706        doc.replace_node(bibref, &refs);
1707      }
1708    }
1709  }
1710
1711  fn fill_in_mathlinks(&mut self, doc: &PostDocument) {
1712    for sym in &doc.findnodes("descendant::*[@decl_id or @meaning]") {
1713      let tag = doc.get_qname(sym).unwrap_or_default();
1714      if tag == "ltx:XMRef" || sym.get_attribute("href").is_some() {
1715        continue;
1716      }
1717      let entry_key = sym
1718        .get_attribute("decl_id")
1719        .map(|did| format!("DECLARATION:local:{}", did))
1720        .or_else(|| {
1721          sym
1722            .get_attribute("meaning")
1723            .map(|m| format!("DECLARATION:global:{}", m))
1724        });
1725      let parent_id = entry_key
1726        .as_ref()
1727        .and_then(|ek| self.db.lookup(ek))
1728        .and_then(|entry| entry.get_string("parent").map(String::from));
1729      if let Some(pid) = parent_id {
1730        if let Some(url) = self.generate_url(doc, &pid) {
1731          let mut sym_mut = sym.clone();
1732          sym_mut.set_attribute("href", &url).ok();
1733        }
1734      }
1735    }
1736  }
1737
1738  fn report_missing(&self) {
1739    for (severity, types) in &self.missing {
1740      for (ref_type, items) in types {
1741        let keys: Vec<&String> = items.keys().collect();
1742        let msg = format!(
1743          "Missing {}: {}",
1744          ref_type,
1745          keys
1746            .iter()
1747            .map(|s| s.as_str())
1748            .collect::<Vec<_>>()
1749            .join(",")
1750        );
1751        // Perl CrossRef.pm L72-75: structured Error/Warn/Info with
1752        // class='expected', object='ids'. Use harness-friendly target.
1753        match severity.as_str() {
1754          "error" => Error!("expected", "ids", "{}", msg),
1755          "warn" => Warn!("expected", "ids", "{}", msg),
1756          _ => Info!("expected", "ids", "{}", msg),
1757        }
1758      }
1759    }
1760  }
1761}
1762
1763impl Processor for CrossRef {
1764  fn get_name(&self) -> &str { &self.name }
1765
1766  fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
1767    match doc.get_document_element() {
1768      Some(el) => vec![el],
1769      None => vec![],
1770    }
1771  }
1772
1773  fn process(&mut self, mut doc: PostDocument, _nodes: Vec<Node>) -> ProcessResult {
1774    self.missing.clear();
1775
1776    // Generate document title and add navigation
1777    let doc_title = self.generate_document_title(&doc);
1778    let navtoc = self.navigation_toc.clone();
1779
1780    if (navtoc.is_some() || doc_title.is_some()) && doc.findnode("//ltx:navigation").is_none() {
1781      if let Some(mut root) = doc.get_document_element() {
1782        doc.add_nodes(&mut root, &[NodeData::Element {
1783          tag:        "ltx:navigation".to_string(),
1784          attributes: None,
1785          children:   vec![],
1786        }]);
1787      }
1788    }
1789    if let Some(ref format) = navtoc {
1790      if let Some(mut nav) = doc.findnode("//ltx:navigation") {
1791        // Perl CrossRef.pm L50: `['ltx:TOC', {format => $navtoc}]` — format
1792        // ONLY, no `scope`. `fill_in_tocs` then defaults scope to `current`, so
1793        // this TOC is built relative to THIS page's document element. On a split
1794        // document that yields a per-page navigation breadcrumb via
1795        // `gen_toc_context` (each page's own contents enclosed within its
1796        // ancestors + their siblings), rather than one identical global tree.
1797        doc.add_nodes(&mut nav, &[NodeData::Element {
1798          tag:        "ltx:TOC".to_string(),
1799          attributes: Some(HashMap::from_iter([("format".to_string(), format.clone())])),
1800          children:   vec![],
1801        }]);
1802      }
1803    }
1804    if let Some(ref title) = doc_title {
1805      if let Some(mut nav) = doc.findnode("//ltx:navigation") {
1806        doc.add_nodes(&mut nav, &[NodeData::Element {
1807          tag:        "ltx:title".to_string(),
1808          attributes: None,
1809          children:   vec![NodeData::Text(title.clone())],
1810        }]);
1811      }
1812    }
1813
1814    self.fill_in_relations(&mut doc);
1815    self.fill_in_tocs(&mut doc);
1816    self.fill_in_frags(&doc);
1817    self.fill_in_glossaryrefs(&mut doc);
1818    self.fill_in_refs(&mut doc);
1819    self.fill_in_rdfa_refs(&mut doc);
1820    self.fill_in_bibrefs(&mut doc);
1821    self.fill_in_mathlinks(&doc);
1822    self.copy_resources(&doc);
1823    strip_ref_display_fragids(&doc);
1824    self.report_missing();
1825    Ok(vec![doc])
1826  }
1827}
1828
1829// ======================================================================
1830// Helpers
1831
1832fn relative_url(target: &str, base: &str) -> String {
1833  if target == base {
1834    return ".".to_string();
1835  }
1836  let target_parts: Vec<&str> = target.split('/').collect();
1837  let base_parts: Vec<&str> = base.split('/').collect();
1838  let common = target_parts
1839    .iter()
1840    .zip(base_parts.iter())
1841    .take_while(|(a, b)| a == b)
1842    .count();
1843  let mut result = String::new();
1844  for _ in common..base_parts.len().saturating_sub(1) {
1845    result.push_str("../");
1846  }
1847  result.push_str(&target_parts[common..].join("/"));
1848  if result.is_empty() {
1849    ".".to_string()
1850  } else {
1851    result
1852  }
1853}
1854
1855/// Get text content from an XML node, normalizing whitespace.
1856///
1857/// Port of `getTextContent`.
1858fn get_text_content_node(node: &Node) -> String {
1859  let text = node.get_content();
1860  let trimmed = text.trim();
1861  // Normalize whitespace
1862  trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
1863}
1864
1865fn text_content(nodes: &[NodeData]) -> String {
1866  nodes
1867    .iter()
1868    .map(|n| match n {
1869      NodeData::Text(s) => s.clone(),
1870      NodeData::Element { children, .. } => text_content(children),
1871      NodeData::XmlNode(n) => n.get_content(),
1872    })
1873    .collect::<Vec<_>>()
1874    .join("")
1875}
1876
1877#[cfg(test)]
1878mod tests {
1879  use super::*;
1880
1881  #[test]
1882  fn relative_url_identical_paths_is_dot() {
1883    assert_eq!(relative_url("a/b.html", "a/b.html"), ".");
1884  }
1885
1886  #[test]
1887  fn relative_url_same_dir() {
1888    // Both live under a/, so target becomes simply the sibling filename.
1889    assert_eq!(relative_url("a/other.html", "a/index.html"), "other.html");
1890  }
1891
1892  #[test]
1893  fn relative_url_sibling_dir() {
1894    // From a/index.html to b/x.html: up once, then down.
1895    assert_eq!(relative_url("b/x.html", "a/index.html"), "../b/x.html");
1896  }
1897
1898  #[test]
1899  fn relative_url_deeply_nested_base() {
1900    // Up past each intermediate dir of the base, then into the new path.
1901    assert_eq!(
1902      relative_url("top/sibling.html", "top/deep/nested/page.html"),
1903      "../../sibling.html"
1904    );
1905  }
1906
1907  #[test]
1908  fn relative_url_same_prefix_different_file() {
1909    assert_eq!(
1910      relative_url("a/b/c/target.html", "a/b/c/source.html"),
1911      "target.html"
1912    );
1913  }
1914
1915  #[test]
1916  fn ref_fallbacks_typerefnum_goes_to_refnum() {
1917    assert_eq!(ref_fallbacks("typerefnum"), &["refnum"]);
1918  }
1919
1920  #[test]
1921  fn ref_fallbacks_title_chain() {
1922    assert_eq!(ref_fallbacks("title"), &["toccaption"]);
1923    assert_eq!(ref_fallbacks("toctitle"), &["title", "toccaption"]);
1924    assert_eq!(ref_fallbacks("rawtoctitle"), &[
1925      "toctitle",
1926      "title",
1927      "toccaption"
1928    ]);
1929    assert_eq!(ref_fallbacks("rawtitle"), &["title", "toccaption"]);
1930  }
1931
1932  #[test]
1933  fn ref_fallbacks_unknown_key_is_empty() {
1934    let empty: &[&str] = &[];
1935    assert_eq!(ref_fallbacks("nonexistent"), empty);
1936    assert_eq!(ref_fallbacks(""), empty);
1937  }
1938
1939  #[test]
1940  fn text_content_flattens_text() {
1941    let nodes = vec![
1942      NodeData::Text("hello ".to_string()),
1943      NodeData::Text("world".to_string()),
1944    ];
1945    assert_eq!(text_content(&nodes), "hello world");
1946  }
1947
1948  #[test]
1949  fn text_content_recurses_into_elements() {
1950    let nodes = vec![NodeData::Element {
1951      tag:        "span".to_string(),
1952      attributes: None,
1953      children:   vec![
1954        NodeData::Text("inner ".to_string()),
1955        NodeData::Text("text".to_string()),
1956      ],
1957    }];
1958    assert_eq!(text_content(&nodes), "inner text");
1959  }
1960
1961  #[test]
1962  fn text_content_empty_list_is_empty_string() {
1963    assert_eq!(text_content(&[]), "");
1964  }
1965
1966  #[test]
1967  fn text_content_mixed_text_and_nested_element() {
1968    let nodes = vec![
1969      NodeData::Text("outer ".to_string()),
1970      NodeData::Element {
1971        tag:        "em".to_string(),
1972        attributes: None,
1973        children:   vec![NodeData::Text("inner".to_string())],
1974      },
1975      NodeData::Text(" tail".to_string()),
1976    ];
1977    assert_eq!(text_content(&nodes), "outer inner tail");
1978  }
1979
1980  // --- URL-style transform (Perl CrossRef::generateURL L656-663) -------------
1981
1982  #[test]
1983  fn url_style_file_is_identity() {
1984    // `file` keeps the full path untouched, whatever it is.
1985    for url in ["a/b.html", "index.html", "sub/index.html", "index", ""] {
1986      assert_eq!(apply_url_style(url, UrlStyle::File, "html"), url);
1987    }
1988  }
1989
1990  #[test]
1991  fn url_style_server_strips_trailing_index() {
1992    // At start → "./"; after a slash → keep the directory (with slash).
1993    assert_eq!(
1994      apply_url_style("index.html", UrlStyle::Server, "html"),
1995      "./"
1996    );
1997    assert_eq!(
1998      apply_url_style("dir/index.html", UrlStyle::Server, "html"),
1999      "dir/"
2000    );
2001    assert_eq!(
2002      apply_url_style("a/b/index.html", UrlStyle::Server, "html"),
2003      "a/b/"
2004    );
2005    // A non-index page is untouched.
2006    assert_eq!(
2007      apply_url_style("dir/page.html", UrlStyle::Server, "html"),
2008      "dir/page.html"
2009    );
2010    // Boundary: `index.html` NOT preceded by start-or-`/` must NOT be stripped
2011    // (Perl's `(^|\/)`); the old `ends_with` check wrongly mangled this.
2012    assert_eq!(
2013      apply_url_style("myindex.html", UrlStyle::Server, "html"),
2014      "myindex.html"
2015    );
2016  }
2017
2018  #[test]
2019  fn url_style_negotiated_strips_extension_and_index() {
2020    // Extension goes; a plain page keeps its stem.
2021    assert_eq!(
2022      apply_url_style("dir/page.html", UrlStyle::Negotiated, "html"),
2023      "dir/page"
2024    );
2025    // Trailing `index` after the extension strip: at start → ""; after "/" → keep dir.
2026    assert_eq!(
2027      apply_url_style("index.html", UrlStyle::Negotiated, "html"),
2028      ""
2029    );
2030    assert_eq!(
2031      apply_url_style("dir/index.html", UrlStyle::Negotiated, "html"),
2032      "dir/"
2033    );
2034    // Boundary: a bare `index` NOT after start-or-`/` stays (Perl's `(^|\/)index$`).
2035    assert_eq!(
2036      apply_url_style("myindex.html", UrlStyle::Negotiated, "html"),
2037      "myindex"
2038    );
2039    // Honors a non-html extension.
2040    assert_eq!(
2041      apply_url_style("dir/index.xml", UrlStyle::Negotiated, "xml"),
2042      "dir/"
2043    );
2044  }
2045}