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