Skip to main content

latexml_post/
make_bibliography.rs

1//! Bibliography generation processor.
2//!
3//! Port of `LaTeXML::Post::MakeBibliography` (818 lines of Perl).
4//! Collects bibliographic entries from `.bib.xml` files and the ObjectDB,
5//! formats them according to the bibliography style (numeric, author-year, alpha),
6//! and fills in `ltx:bibliography` elements with `ltx:biblist` + `ltx:bibitem`.
7//!
8//! Pipeline:
9//! 1. Find bibliography sources (xml files, bib files, literals)
10//! 2. Scan bibentry elements for cited keys (via BIBLABEL:* in ObjectDB)
11//! 3. Transitively include entries cited from within included entries
12//! 4. Extract names, dates, titles for sorting
13//! 5. Detect duplicate author+year pairs → assign suffixes (a, b, c...)
14//! 6. Format each entry into ltx:bibitem with ltx:tags + ltx:bibblock sections
15//! 7. Optionally split by initial letter
16
17use libxml::tree::Node;
18use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
19
20use crate::{
21  document::{NodeData, PostDocument, PostDocumentOptions},
22  object_db::ObjectDB,
23  processor::{ProcessResult, Processor, find_documentclass_and_packages},
24  radix::radix_alpha,
25};
26
27// ================================================================================
28// The injected raw-`.bib` converter
29// ================================================================================
30
31/// A raw bibliography source that still needs converting.
32///
33/// Perl accumulates these across all `\bibliography{}` names and converts them
34/// in ONE pass (`MakeBibliography.pm` L146-165), so an `@string` macro defined in
35/// the first file is in scope for the last.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum RawBibSource {
38  /// A resolved path to a `.bib` file on disk.
39  Path(String),
40  /// Literal BibTeX data, without the `literal:` protocol prefix.
41  Literal(String),
42}
43
44/// Everything the recursive BibTeX session needs, gathered post-side.
45///
46/// Port of the inputs `convertBibliography` (`MakeBibliography.pm` L180-242)
47/// assembles before calling `LaTeXML->get_converter`.
48#[derive(Debug, Clone)]
49pub struct BibConversionRequest {
50  /// The raw sources, in document order.
51  pub sources:      Vec<RawBibSource>,
52  /// `$$doc{searchpaths}` — the inner session's `paths`.
53  pub search_paths: Vec<String>,
54  /// `[options]name.cls` / `[options]name.sty` specs recovered from the
55  /// document's `<?latexml?>` processing instructions (Perl L186-199): the
56  /// bibliography's fields are the article's TeX, so they need the article's
57  /// class and packages to mean anything.
58  pub preloads:     Vec<String>,
59  /// The keys the document cites, or `None` to convert every entry.
60  ///
61  /// A `.bib` is a library: the converter should digest the cited entries (and
62  /// their `crossref`/`\cite` closure) rather than the whole file, exactly as
63  /// `bibtex(1)` does from the `.aux`'s `\citation` records. `None` means no
64  /// citation record was available, or the document said `\nocite{*}`.
65  pub wanted_keys:  Option<Vec<String>>,
66}
67
68/// Convert raw BibTeX into a document containing `ltx:bibentry` elements.
69///
70/// `latexml_post` cannot run this itself: a conversion needs the model loader
71/// and `convert_document`, which live in `latexml_oxide` (depending the other
72/// way would be a cycle). The orchestrating binary installs an implementation
73/// with [`set_bib_converter`] before running the pipeline.
74pub type BibConverterFn = fn(&BibConversionRequest) -> Option<PostDocument>;
75
76thread_local! {
77  static BIB_CONVERTER: std::cell::Cell<Option<BibConverterFn>> =
78    const { std::cell::Cell::new(None) };
79}
80
81/// Install the recursive-BibTeX-session implementation for this thread.
82pub fn set_bib_converter(convert: BibConverterFn) {
83  BIB_CONVERTER.with(|slot| slot.set(Some(convert)));
84}
85
86fn bib_converter() -> Option<BibConverterFn> { BIB_CONVERTER.with(|slot| slot.get()) }
87
88/// Citation style.
89#[derive(Debug, Clone, PartialEq)]
90pub enum CitationStyle {
91  /// Numeric: `[1]`, `[2]`, ...
92  Numbers,
93  /// Author-Year: Author (Year)
94  AuthorYear,
95  /// Alphabetic: `[ABC24]`
96  Alpha,
97}
98
99/// A collected bibliography entry with metadata.
100///
101/// Port of the `%entries` hash entries in `getBibEntries`.
102#[derive(Debug)]
103struct BibEntryData {
104  bib_key:       String,
105  cited_key:     Option<String>,
106  sort_key:      String,
107  initial:       String,
108  author_year:   String,
109  suffix:        Option<String>,
110  /// Author names for display (short form: "Smith et al").
111  authors_short: String,
112  /// Full author names.
113  authors_full:  String,
114  /// Sort-form of author names.
115  sort_names:    String,
116  year:          String,
117  title:         String,
118  /// BibTeX type (article, book, inproceedings, etc.).
119  entry_type:    String,
120  /// Reference style (number within bibliography).
121  number:        u32,
122  /// IDs that cite this entry (from outside bibliography).
123  referrers:     HashSet<String>,
124  /// Bib keys that cite this entry (from other bib entries).
125  bibreferrers:  HashSet<String>,
126  /// Keys cited from within this entry.
127  citations:     Vec<String>,
128  /// The bibentry XML node (from .bib.xml), if available.
129  bibentry:      Option<Node>,
130}
131
132impl BibEntryData {
133  /// Get the normalized bibliography type for CSS class.
134  fn bib_type(&self) -> &str {
135    if self.entry_type.is_empty() {
136      "misc"
137    } else {
138      &self.entry_type
139    }
140  }
141
142  /// Get the canonical format type (for FMT_SPEC mapping).
143  ///
144  /// Port of `%FMT_SPEC` aliases.
145  fn format_type(&self) -> &str {
146    match self.entry_type.as_str() {
147      "article" => "article",
148      "book" | "periodical" | "collection" | "proceedings" | "manual" | "misc" | "unpublished"
149      | "booklet" => "book",
150      "incollection"
151      | "collection.article"
152      | "proceedings.article"
153      | "inproceedings"
154      | "inbook" => "incollection",
155      "report" | "techreport" => "report",
156      "thesis" | "mastersthesis" | "phdthesis" => "thesis",
157      "website" | "online" => "website",
158      "software" => "software",
159      _ => "book", // Default fallback
160    }
161  }
162}
163
164/// MakeBibliography post-processor.
165///
166/// Port of `LaTeXML::Post::MakeBibliography`.
167pub struct MakeBibliography {
168  name:           String,
169  pub db:         ObjectDB,
170  split:          bool,
171  bibliographies: Vec<String>,
172}
173
174impl MakeBibliography {
175  pub fn new(db: ObjectDB, split: bool) -> Self {
176    MakeBibliography {
177      name: "MakeBibliography".to_string(),
178      db,
179      split,
180      bibliographies: Vec::new(),
181    }
182  }
183
184  pub fn set_bibliographies(&mut self, bibs: Vec<String>) { self.bibliographies = bibs; }
185
186  /// Load bibliography source documents.
187  ///
188  /// Port of `getBibliographies`.
189  /// Locates .bib.xml files from:
190  ///   - Command-line options (overrides)
191  ///   - //ltx:bibliography[@files] attribute
192  ///
193  /// `wanted_keys` is forwarded to the recursive raw-`.bib` conversion so it
194  /// digests only the cited entries; see [`BibConversionRequest::wanted_keys`].
195  fn get_bibliographies(
196    &self,
197    doc: &PostDocument,
198    wanted_keys: Option<&Vec<String>>,
199  ) -> Vec<PostDocument> {
200    let mut bibnames: Vec<String> = Vec::new();
201    let mut from_bibliography = false;
202
203    // Use command-line bibliographies if explicitly given
204    if !self.bibliographies.is_empty() {
205      bibnames = self.bibliographies.clone();
206    } else {
207      // Otherwise, read from the bibliography element's files attribute
208      if let Some(bibnode) = doc.findnode("//ltx:bibliography") {
209        let files = bibnode
210          .get_attribute("files")
211          .or_else(|| bibnode.get_parent().and_then(|p| p.get_attribute("files")));
212        if let Some(f) = files {
213          from_bibliography = true;
214          bibnames = f.split(',').map(|s| s.trim().to_string()).collect();
215        }
216      }
217    }
218
219    let search_paths = doc.get_search_paths();
220    let mut bibs: Vec<PostDocument> = Vec::new();
221    // Raw sources accumulate rather than converting one-by-one, so that a
222    // single recursive session sees them all (Perl L110, L146-165): `@string`
223    // macros are file-scoped in BibTeX but shared across a combined payload,
224    // and one session is also far cheaper than N.
225    let mut rawbibs: Vec<RawBibSource> = Vec::new();
226
227    for bib in &bibnames {
228      let mut loaded = false;
229      // Perl reaches the raw-`.bib` lookup only from inside the
230      // `.bib`/`\bibliography` branch (`MakeBibliography.pm` L126-140), and it
231      // is that branch alone whose failure is an `Error`. Hoisting the lookup
232      // out of the branch (below) is what lost the raise, so carry the branch
233      // condition along instead.
234      let is_bib_style = bib.ends_with(".bib") || bib.ends_with(".bib.xml") || from_bibliography;
235
236      // Literal BibTeX handed in on the command line (Perl L121-123).
237      if let Some(data) = bib.strip_prefix("literal:") {
238        rawbibs.push(RawBibSource::Literal(data.to_string()));
239        continue;
240      }
241
242      // Try as .xml file
243      if bib.ends_with(".xml") {
244        if let Some(path) = find_file(bib, search_paths) {
245          match PostDocument::new_from_file(&path, PostDocumentOptions {
246            source_directory: Some(".".to_string()),
247            ..PostDocumentOptions::default()
248          }) {
249            Ok(bibdoc) => {
250              bibs.push(bibdoc);
251              loaded = true;
252            },
253            Err(e) => Warn!("I/O", bib, "Failed to load bibliography '{}': {}", bib, e),
254          }
255        }
256      }
257      // Try as .bib or from \bibliography command
258      else if is_bib_style {
259        let xmlbib = if from_bibliography && !bib.ends_with(".bib") {
260          format!("{}.bib", bib)
261        } else {
262          bib.clone()
263        };
264        // Look for pre-compiled .bib.xml
265        let xml_candidate = if xmlbib.ends_with(".xml") {
266          xmlbib.clone()
267        } else {
268          format!("{}.xml", xmlbib)
269        };
270        if let Some(path) = find_file(&xml_candidate, search_paths) {
271          match PostDocument::new_from_file(&path, PostDocumentOptions {
272            source_directory: Some(".".to_string()),
273            ..PostDocumentOptions::default()
274          }) {
275            Ok(bibdoc) => {
276              bibs.push(bibdoc);
277              loaded = true;
278            },
279            Err(e) => Warn!("I/O", path, "Failed to load bibliography '{}': {}", path, e),
280          }
281        }
282      }
283
284      // If not loaded yet, queue the raw `.bib` for the recursive session.
285      // `kpsewhich` is the last resort, matching Perl's
286      // `pathname_find(...) || pathname_kpsewhich($bib)` (L133-134) — a `.bib`
287      // installed in the host texmf tree (revtex's `apsrev`, say) resolves
288      // there and nowhere else.
289      if !loaded {
290        let bib_file = if from_bibliography && !bib.ends_with(".bib") {
291          format!("{}.bib", bib)
292        } else {
293          bib.clone()
294        };
295        if let Some(bib_path) = find_file(&bib_file, search_paths)
296          .or_else(|| latexml_core::util::pathname::kpsewhich(&[bib_file.as_str()]))
297        {
298          rawbibs.push(RawBibSource::Path(bib_path));
299          loaded = true;
300        } else if is_bib_style {
301          // Perl L138-140 raises this BEFORE falling through to the Info
302          // below, and both reach the log. Dropping it is what let a whole
303          // family of lost bibliographies report telemetry `ok`: the shipped
304          // `bu1.bbl`/`bu2.bbl` of a bibunits paper are never consulted, the
305          // named `.bib` does not exist, and the only trace was an Info.
306          // 14 silent papers in the 2605+2606 sandboxes, 691 corpus-wide;
307          // witness 2606.04416 (same loss under Perl, which DOES raise here).
308          // Audit `docs/parity/BIB_ABSENCE_AUDIT_2026-07-29.md` family F2.
309          Error!(
310            "missing_file",
311            bib,
312            "Couldn't find Bibliography '{}'\nSearchpaths were {}",
313            bib,
314            search_paths.join(",")
315          );
316        }
317      }
318
319      if !loaded {
320        Info!(
321          "bibliography",
322          "missing",
323          "Couldn't find usable bibliography for '{}'",
324          bib
325        );
326      }
327    }
328
329    // Lastly, if we found any raw .bib files / literal data, convert them —
330    // in ONE pass (Perl L146-165).
331    if !rawbibs.is_empty() {
332      match bib_converter() {
333        Some(convert) => {
334          let (class, packages) = find_documentclass_and_packages(doc);
335          // Perl L188-199: `[$classoptions]$class.cls` then `[$options]$pkg.sty`.
336          let mut preloads = Vec::with_capacity(1 + packages.len());
337          preloads.push(if class.options.is_empty() {
338            format!("{}.cls", class.name)
339          } else {
340            format!("[{}]{}.cls", class.options, class.name)
341          });
342          for pkg in &packages {
343            preloads.push(if pkg.options.is_empty() {
344              format!("{}.sty", pkg.name)
345            } else {
346              format!("[{}]{}.sty", pkg.options, pkg.name)
347            });
348          }
349          let request = BibConversionRequest {
350            sources: rawbibs,
351            search_paths: search_paths.to_vec(),
352            preloads,
353            wanted_keys: wanted_keys.cloned(),
354          };
355          match convert(&request) {
356            Some(bibdoc) => bibs.push(bibdoc),
357            None => Error!(
358              "bibliography",
359              "convert",
360              "Recursive BibTeX conversion produced no bibliography"
361            ),
362          }
363        },
364        // Never silently drop the sources: an uninstalled converter would
365        // otherwise render an empty References section with no diagnostic,
366        // which is the exact fail-open shape this codebase forbids.
367        None => Error!(
368          "bibliography",
369          "converter",
370          "No BibTeX converter installed; cannot convert {} raw bibliography source(s) \
371           (call make_bibliography::set_bib_converter before post-processing)",
372          rawbibs.len()
373        ),
374      }
375    }
376
377    Info!(
378      "bibliography",
379      "using",
380      "MakeBibliography: using {} bibliographies",
381      bibs.len()
382    );
383    bibs
384  }
385
386  /// Collect all cited bibliography entries.
387  ///
388  /// Port of `getBibEntries`.
389  /// Scans BIBLABEL:* entries in ObjectDB, resolves to ID:* entries,
390  /// extracts author/year/title, transitively includes cited-from-cited entries,
391  /// assigns suffixes for duplicate author+year pairs.
392  /// Fold every `ltx:bibentry` of one source document into `entries`, keyed by
393  /// the lowercased bibkey. Later sources win for a repeated key, matching the
394  /// upstream `getBibEntries` loop.
395  fn scan_bibentries(entries: &mut HashMap<String, BibEntryData>, srcdoc: &PostDocument) {
396    for bibentry in srcdoc.findnodes("//ltx:bibentry") {
397      let bibkey = match bibentry.get_attribute("key") {
398        Some(k) => k,
399        None => continue,
400      };
401      let lc_key = bibkey.to_lowercase();
402      // Extract citations from within this bibentry
403      let citations: Vec<String> = srcdoc
404        .findnodes_at(".//@bibrefs", Some(&bibentry))
405        .iter()
406        .filter_map(|n| {
407          let val = n.get_content();
408          if val.is_empty() { None } else { Some(val) }
409        })
410        .flat_map(|s| s.split(',').map(String::from).collect::<Vec<_>>())
411        .filter(|s| !s.is_empty())
412        .collect();
413
414      entries.insert(lc_key, BibEntryData {
415        bib_key: bibkey,
416        cited_key: None,
417        sort_key: String::new(),
418        initial: String::new(),
419        author_year: String::new(),
420        suffix: None,
421        authors_short: String::new(),
422        authors_full: String::new(),
423        sort_names: String::new(),
424        year: String::new(),
425        title: String::new(),
426        entry_type: String::new(),
427        number: 0,
428        referrers: HashSet::default(),
429        bibreferrers: HashSet::default(),
430        citations,
431        bibentry: Some(bibentry.clone()),
432      });
433    }
434  }
435
436  /// The bib keys this document cites, for the recursive raw-`.bib` conversion
437  /// to digest instead of the whole library.
438  ///
439  /// Reads the same `BIBLABEL:<list>:<key>` ObjectDB records that Step 2 of
440  /// [`Self::get_bib_entries`] scans — they are written during the document
441  /// conversion, so they are already complete by the time post-processing asks.
442  /// Returns `None` (= convert everything) for `\nocite{*}`, and also when no
443  /// `BIBLABEL` record exists at all: an empty filter and "no citation data"
444  /// are indistinguishable here, and dropping every entry on a missing record
445  /// would be a silent, unrecoverable loss.
446  fn cited_keys(&self, lists: &[&str]) -> Option<Vec<String>> {
447    let mut keys: Vec<String> = Vec::new();
448    for db_key in self.db.get_keys() {
449      let Some(rest) = db_key.strip_prefix("BIBLABEL:") else {
450        continue;
451      };
452      let Some((list, bibkey)) = rest.split_once(':') else {
453        continue;
454      };
455      if !lists.contains(&list) {
456        continue;
457      }
458      if bibkey == "*" {
459        return None; // `\nocite{*}` — the document wants the whole library.
460      }
461      keys.push(bibkey.to_string());
462    }
463    (!keys.is_empty()).then_some(keys)
464  }
465
466  fn get_bib_entries(
467    &self,
468    doc: &PostDocument,
469    bib_node: &Node,
470  ) -> (HashMap<String, BibEntryData>, Vec<PostDocument>) {
471    let lists_str = bib_node
472      .get_attribute("lists")
473      .unwrap_or_else(|| "bibliography".to_string());
474    let lists: Vec<&str> = lists_str.split_whitespace().collect();
475
476    // Step 1: Scan bibliography source documents for ltx:bibentry elements.
477    // Build a map: lc(bibkey) → { bibkey, bibentry, citations }
478    // Import bibentry nodes into the main document so that XPath queries
479    // (which use the main document's namespace context) work correctly.
480    let mut entries: HashMap<String, BibEntryData> = HashMap::default();
481    let bib_docs = self.get_bibliographies(doc, self.cited_keys(&lists).as_ref());
482    for bibdoc in &bib_docs {
483      Self::scan_bibentries(&mut entries, bibdoc);
484    }
485    // Also scan the MAIN document for INLINE `ltx:bibentry` elements.
486    // OXIDIZED_DESIGN #57 (beyond-Perl). amsrefs writes its bibliography
487    // straight into the document — `\begin{bibdiv}\begin{biblist}
488    // \bib{key}{article}{...}` — instead of into an external `.bib`, so the
489    // entries are already children of `ltx:biblist` and there is no `@files`
490    // for `getBibliographies` to resolve. Upstream `getBibEntries` only ever
491    // scans `getBibliographies()`, and `process` then deletes every
492    // `//ltx:bibentry`, so an amsrefs bibliography is silently dropped WHOLE:
493    // empty References plus every `\cite` left dangling, with zero errors
494    // reported. Confirmed identical on installed AND vendored Perl 0.8.8
495    // (rev 51fea96a) — a shared upstream bug (KNOWN_PERL_ERRORS #49), fixed
496    // here rather than reproduced. Papers with an external `.bib` carry no
497    // inline `ltx:bibentry`, so this scan is a no-op for them.
498    // Witness 2605.01646 (AIPFa.tex, 23 entries), 2605.00783, 2605.03852.
499    Self::scan_bibentries(&mut entries, doc);
500
501    // Step 2: Collect all cited bibliography keys from BIBLABEL entries in ObjectDB.
502    // Note referrers (from outside the bibliography).
503    let cite_star = lists
504      .iter()
505      .any(|list| self.db.lookup(&format!("BIBLABEL:{}:*", list)).is_some());
506
507    let mut queue: Vec<String> = Vec::new();
508    for db_key in self.db.get_keys() {
509      if !db_key.starts_with("BIBLABEL:") {
510        continue;
511      }
512      let parts: Vec<&str> = db_key.splitn(3, ':').collect();
513      if parts.len() < 3 {
514        continue;
515      }
516      let (list, bibkey) = (parts[1], parts[2]);
517      if !lists.contains(&list) {
518        continue;
519      }
520
521      let lc_key = bibkey.to_lowercase();
522      if let Some(bentry) = self.db.lookup(db_key) {
523        let has_refs = bentry
524          .get_value("referrers")
525          .map(|v| v.is_truthy())
526          .unwrap_or(false);
527        if has_refs {
528          // Filter referrers: walk up parent chain, skip if inside ltx:bibitem
529          if let Some(crate::object_db::Value::Hash(refs)) = bentry.get_value("referrers") {
530            for ref_id in refs.keys() {
531              let mut rid = ref_id.clone();
532              let mut is_from_bib = false;
533              while let Some(entry) = self.db.lookup(&format!("ID:{}", rid)) {
534                let entry_type = entry.get_string("type").unwrap_or("");
535                if entry_type == "ltx:bibitem" {
536                  is_from_bib = true;
537                  break;
538                }
539                match entry.get_string("parent").map(String::from) {
540                  Some(parent) => rid = parent,
541                  None => break,
542                }
543              }
544              if !is_from_bib {
545                // Check for case mismatch
546                if let Some(existing) = entries.get(&lc_key) {
547                  if let Some(ref prev_key) = existing.cited_key {
548                    if prev_key != bibkey {
549                      Warn!(
550                        "bibliography",
551                        "case_mismatch",
552                        "Case mismatch in bib key '{}' vs '{}'",
553                        prev_key,
554                        bibkey
555                      );
556                    }
557                  }
558                }
559                let entry = entries
560                  .entry(lc_key.clone())
561                  .or_insert_with(|| BibEntryData {
562                    bib_key:       bibkey.to_string(),
563                    cited_key:     None,
564                    sort_key:      String::new(),
565                    initial:       String::new(),
566                    author_year:   String::new(),
567                    suffix:        None,
568                    authors_short: String::new(),
569                    authors_full:  String::new(),
570                    sort_names:    String::new(),
571                    year:          String::new(),
572                    title:         String::new(),
573                    entry_type:    String::new(),
574                    number:        0,
575                    referrers:     HashSet::default(),
576                    bibreferrers:  HashSet::default(),
577                    citations:     Vec::new(),
578                    bibentry:      None,
579                  });
580                entry.cited_key = Some(bibkey.to_string());
581                entry.referrers.insert(ref_id.clone());
582              }
583            }
584          }
585          if entries
586            .get(&lc_key)
587            .map(|e| !e.referrers.is_empty())
588            .unwrap_or(false)
589          {
590            queue.push(bibkey.to_string());
591          }
592        }
593      }
594    }
595
596    // `\nocite{*}` asks for the WHOLE library — that is bibtex's own semantic
597    // for the star key, and `cited_keys` above already returns None for it so
598    // every entry is read. Queue them all.
599    //
600    // Queueing per BIBLABEL record cannot do this: in a document whose only
601    // citation is `\nocite{*}` the sole record IS `*`, Step 3 skips that key by
602    // name, and the result was "N bibentries, **0 cited**" over an empty
603    // References list — while `pdflatex`+`bibtex` on the same source prints the
604    // full list. 7 papers of the 2605+2606 residual are `\nocite{*}`-only;
605    // 6-line reproducer: `\nocite{*}` + `\bibliography{t}` with a 2-entry
606    // `.bib` gave 0 items against bibtex's 2. Perl skips the star key the same
607    // way (`MakeBibliography.pm` L279-313), so this is deliberately BEYOND Perl.
608    if cite_star {
609      let mut all: Vec<String> = entries.values().map(|e| e.bib_key.clone()).collect();
610      all.sort();
611      queue.extend(all);
612    }
613
614    // Step 3: Process queue — transitively include cited entries.
615    // For each key, extract names/year/title from bibentry XML.
616    let mut seen: HashSet<String> = HashSet::default();
617    let mut included: HashMap<String, BibEntryData> = HashMap::default();
618    let mut missing_keys: Vec<String> = Vec::new();
619
620    while let Some(bibkey) = queue.pop() {
621      if seen.contains(&bibkey) || bibkey == "*" {
622        continue;
623      }
624      seen.insert(bibkey.clone());
625      let lc_key = bibkey.to_lowercase();
626
627      match entries.remove(&lc_key) {
628        Some(mut entry) => {
629          // Extract metadata from bibentry XML node (if available)
630          if let Some(ref bibentry) = entry.bibentry {
631            // Perl L318-328 computes TWO name strings and uses them for
632            // different things: `$sortnames` (every "Surname Givenname",
633            // joined) keys the sort, while `$names` (the SHORT form — "A and
634            // B", "A et al") keys `{ay}` and `{initial}`. `extract_names`
635            // already carries Perl's bib-key → bib-title fallback (both
636            // strings then hold the same text), so no second fallback chain is
637            // needed here.
638            let (sort_names, short_names, _full_names) = extract_names(doc, bibentry);
639            entry.sort_names = sort_names.clone();
640            entry.authors_short = short_names.clone();
641
642            // Year
643            let date_content =
644              PostDocument::findnodes_foreign("ltx:bib-date[@role='publication']", bibentry)
645                .into_iter()
646                .next()
647                .map(|n| n.get_content())
648                .unwrap_or_default();
649            let year = extract_four_digit_year(&date_content);
650            entry.year = year.clone();
651
652            // The {ay}/sortkey date is the publication date ALONE, on purpose.
653            // Perl L330 unions it with `ltx:bib-type`
654            // (`ltx:bib-date[@role="publication"] | ltx:bib-type`, whichever
655            // comes first in document order); querying the two separately is a
656            // settled decision here, so that a `type` field cannot displace the
657            // year — see BIBLIOGRAPHY_WORKLIST, "bib-type is safe to emit".
658            // A "date else type" stand-in was written while porting the sortkey
659            // and REMOVED on review: it contradicts that decision, it is not
660            // Perl's rule anyway for an entry carrying both, and it would move
661            // sort keys (and with them numbering) with no test or witness.
662
663            // Title
664            let title = PostDocument::findnodes_foreign("ltx:bib-title", bibentry)
665              .into_iter()
666              .next()
667              .map(|n| n.get_content())
668              .unwrap_or_default();
669            entry.title = title.clone();
670
671            // Type
672            let entry_type = bibentry
673              .get_attribute("type")
674              .unwrap_or_else(|| "misc".to_string());
675            entry.entry_type = entry_type;
676
677            // Author+year for suffix detection — Perl L336 `"$names.$date"`,
678            // i.e. the SHORT names. Using the full sort-names here silently
679            // disabled disambiguation for every 3+-author paper: two entries
680            // that Perl groups as "Smith et al.1999" carry different coauthor
681            // lists, so their full-name keys never collide and neither entry
682            // ever got its `a`/`b` suffix.
683            entry.author_year = format!("{}.{}", short_names, year);
684            entry.initial = PostDocument::initial(&short_names, true);
685
686            // Sort key — Perl L338, from the FULL sort-names.
687            let sort_key = format!("{}.{}.{}.{}", sort_names, year, title, bibkey).to_lowercase();
688            entry.sort_key = sort_key.clone();
689
690            // Enqueue transitive citations
691            let citations = entry.citations.clone();
692            for c in &citations {
693              queue.push(c.clone());
694            }
695            included.insert(sort_key, entry);
696          } else {
697            // No bibentry XML — use ObjectDB metadata
698            let id = self.find_bib_id(&bibkey, &lists);
699            if let Some(id) = id {
700              let id_key = format!("ID:{}", id);
701              let authors = self
702                .db
703                .lookup(&id_key)
704                .and_then(|e| e.get_value("authors").map(|v| v.to_string()))
705                .unwrap_or_default();
706              let full_authors = self
707                .db
708                .lookup(&id_key)
709                .and_then(|e| e.get_value("fullauthors").map(|v| v.to_string()))
710                .unwrap_or_else(|| authors.clone());
711              let year = self
712                .db
713                .lookup(&id_key)
714                .and_then(|e| e.get_value("year").map(|v| v.to_string()))
715                .unwrap_or_default();
716              let title = self
717                .db
718                .lookup(&id_key)
719                .and_then(|e| e.get_value("title").map(|v| v.to_string()))
720                .unwrap_or_default();
721              let entry_type = self
722                .db
723                .lookup(&id_key)
724                .and_then(|e| e.get_value("type").map(|v| v.to_string()))
725                .unwrap_or_else(|| "misc".to_string());
726
727              let year_short = extract_four_digit_year(&year);
728              let names = if authors.is_empty() {
729                bibkey.clone()
730              } else {
731                authors.clone()
732              };
733              let author_year = format!("{}.{}", names, year_short);
734              let initial = PostDocument::initial(&names, true);
735              let sort_key =
736                format!("{}.{}.{}.{}", names, year_short, title, bibkey).to_lowercase();
737
738              entry.authors_short = authors;
739              entry.authors_full = full_authors;
740              entry.sort_names = names;
741              entry.year = year_short;
742              entry.title = title;
743              entry.entry_type = entry_type;
744              entry.author_year = author_year;
745              entry.initial = initial;
746              entry.sort_key = sort_key.clone();
747
748              included.insert(sort_key, entry);
749            } else {
750              missing_keys.push(bibkey);
751            }
752          }
753        },
754        _ => {
755          // Not found in entries map
756          missing_keys.push(bibkey);
757        },
758      }
759    }
760
761    if !missing_keys.is_empty() {
762      Warn!(
763        "bibliography",
764        "missing_keys",
765        "Missing bibkeys: {}",
766        missing_keys.join(", ")
767      );
768    }
769
770    // Step 4: Note bibreferrers — for each included entry's citations,
771    // mark the cited entry as having this entry as a bibreferrer.
772    let citations_map: Vec<(String, Vec<String>)> = included
773      .values()
774      .map(|e| (e.bib_key.clone(), e.citations.clone()))
775      .collect();
776    for (bibkey, citations) in &citations_map {
777      for cited in citations {
778        let lc = cited.to_lowercase();
779        // Find which sort_key corresponds to this lc bibkey
780        for entry in included.values_mut() {
781          if entry.bib_key.to_lowercase() == lc {
782            entry.bibreferrers.insert(bibkey.clone());
783          }
784        }
785      }
786    }
787
788    Info!(
789      "bibliography",
790      "count",
791      "MakeBibliography: {} bibentries, {} cited",
792      entries.len() + included.len(),
793      included.len()
794    );
795
796    // Step 5: Sort and detect duplicate author+year → assign suffixes.
797    // Perl L357 `$doc->unisort(keys %$included)`.
798    let mut sorted_keys: Vec<String> = included.keys().cloned().collect();
799    unisort(&mut sorted_keys);
800
801    // Port of suffix detection: track by author_year, assign suffixes when duplicated.
802    let mut ay_last: HashMap<String, String> = HashMap::default(); // ay → last sort_key with this ay
803    for key in &sorted_keys {
804      if let Some(entry) = included.get(key) {
805        let ay = entry.author_year.clone();
806        if let Some(prev_key) = ay_last.get(&ay) {
807          let prev_key = prev_key.clone();
808          // Previous entry with same ay needs a suffix too
809          if let Some(prev) = included.get_mut(&prev_key) {
810            if prev.suffix.is_none() {
811              prev.suffix = Some(radix_alpha(1));
812            }
813          }
814          let prev_counter = included
815            .get(&prev_key)
816            .and_then(|p| p.suffix.as_ref())
817            .map(|s| suffix_to_counter(s))
818            .unwrap_or(1);
819          if let Some(e) = included.get_mut(key) {
820            e.suffix = Some(radix_alpha(prev_counter + 1));
821          }
822        }
823        ay_last.insert(ay, key.clone());
824      }
825    }
826
827    // Step 6: Remove sort ERROR nodes from bibentries
828    for entry in included.values() {
829      if let Some(ref bibentry) = entry.bibentry {
830        let sort_errors = PostDocument::findnodes_foreign(".//ltx:ERROR[@class='sort']", bibentry);
831        for mut sortnode in sort_errors {
832          sortnode.unlink();
833        }
834      }
835    }
836
837    // Numbering is NOT done here: Perl assigns it in FORMAT order
838    // (`++$NUMBER` inside `formatBibEntry` L418), which is initial-major once
839    // `--splitbibliography` is on. It happens in `process`, alongside the walk
840    // that builds the biblists.
841
842    (included, bib_docs)
843  }
844
845  /// Find the ID for a bibliography key in the ObjectDB.
846  fn find_bib_id(&self, bibkey: &str, lists: &[&str]) -> Option<String> {
847    for list in lists {
848      let bkey = format!("BIBLABEL:{}:{}", list, bibkey);
849      if let Some(bentry) = self.db.lookup(&bkey) {
850        if let Some(id) = bentry.get_string("id") {
851          return Some(id.to_string());
852        }
853      }
854    }
855    None
856  }
857
858  /// Format a bibliography list.
859  ///
860  /// Port of `makeBibliographyList`.
861  fn make_bibliography_list(
862    &self,
863    doc: &PostDocument,
864    bib_id: &str,
865    initial: Option<&str>,
866    entries: &HashMap<String, BibEntryData>,
867    style: &CitationStyle,
868  ) -> NodeData {
869    let id = if let Some(init) = initial {
870      format!("{}.L1.{}", bib_id, init)
871    } else {
872      format!("{}.L1", bib_id)
873    };
874
875    // Order the rendered list by the number already assigned in `process`. Perl
876    // re-`unisort`s here (L398), but the number was likewise assigned in
877    // `unisort` order, so for a sorted style the result is identical; for a
878    // citation-order style (`sort='false'`, html_feedback #6294) the number
879    // carries the citation order and the list must follow it, not re-alphabetize.
880    // Keying on the number makes "list order == numbering order" an invariant.
881    let mut ordered: Vec<&BibEntryData> = entries.values().collect();
882    ordered.sort_by_key(|e| e.number);
883    let items: Vec<NodeData> = ordered
884      .iter()
885      .map(|entry| self.format_bib_entry(doc, bib_id, entry, style))
886      .collect();
887
888    NodeData::Element {
889      tag:        "ltx:biblist".to_string(),
890      attributes: Some(HashMap::from_iter([("xml:id".to_string(), id)])),
891      children:   items,
892    }
893  }
894
895  /// Format a single bibentry into a bibitem.
896  ///
897  /// Port of `formatBibEntry`.
898  fn format_bib_entry(
899    &self,
900    doc: &PostDocument,
901    bib_id: &str,
902    entry: &BibEntryData,
903    style: &CitationStyle,
904  ) -> NodeData {
905    // ID generation: match Perl's $id =~ s/^bib//; $id = $bibid . $id;
906    // (MakeBibliography.pm L407-415; NS-aware read — the bare form always
907    // returned None, so every bibitem fell to the .bibN numbering fallback)
908    let id = if let Some(ref bibentry) = entry.bibentry {
909      let orig_id = crate::document::get_xml_id(bibentry).unwrap_or_default();
910      if orig_id.is_empty() {
911        // No xml:id on bibentry (e.g. from raw .bib parsing) — use number
912        format!("{}.bib{}", bib_id, entry.number)
913      } else {
914        let stripped = orig_id.strip_prefix("bib").unwrap_or(&orig_id);
915        format!("{}{}", bib_id, stripped)
916      }
917    } else {
918      format!("{}.bib{}", bib_id, entry.number)
919    };
920
921    let cited_key = entry.cited_key.as_deref().unwrap_or(&entry.bib_key);
922    let mut children = Vec::new();
923
924    // --- Tags ---
925    let mut tags = Vec::new();
926
927    // Number tag
928    tags.push(NodeData::Element {
929      tag:        "ltx:tag".to_string(),
930      attributes: Some(HashMap::from_iter([
931        ("role".to_string(), "number".to_string()),
932        ("class".to_string(), "ltx_bib_number".to_string()),
933      ])),
934      children:   vec![NodeData::Text(entry.number.to_string())],
935    });
936
937    // Authors/fullauthors tags — extracted from bibentry XML if available
938    let (author_tag_nodes, has_names, has_key, has_year, has_typetag) =
939      self.build_author_year_tags(doc, entry);
940    tags.extend(author_tag_nodes);
941
942    // Refnum tag: depends on citation style
943    let mut effective_style = style.clone();
944    // Perl: $style = 'numbers' unless (@names || $keytag) && (@year || $typetag)
945    if !((has_names || has_key) && (has_year || has_typetag)) {
946      effective_style = CitationStyle::Numbers;
947    }
948
949    let mut skip_first_block = false;
950    // Author-year only: keep the first block but drop its (redundant) year field.
951    let mut drop_first_block_year = false;
952    match effective_style {
953      CitationStyle::Numbers => {
954        tags.push(NodeData::Element {
955          tag:        "ltx:tag".to_string(),
956          attributes: Some(HashMap::from_iter([
957            ("role".to_string(), "refnum".to_string()),
958            ("class".to_string(), "ltx_bib_key".to_string()),
959            ("open".to_string(), "[".to_string()),
960            ("close".to_string(), "]".to_string()),
961          ])),
962          children:   vec![NodeData::Text(entry.number.to_string())],
963        });
964      },
965      CitationStyle::Alpha => {
966        // AY-style: abbreviation from author names + 2-digit year
967        let aa = self.make_alpha_label(doc, entry);
968        let yy = if entry.year.len() >= 4 {
969          entry.year[2..4].to_string()
970        } else {
971          entry.year.clone()
972        };
973        let suffix = entry.suffix.as_deref().unwrap_or("");
974        tags.push(NodeData::Element {
975          tag:        "ltx:tag".to_string(),
976          attributes: Some(HashMap::from_iter([
977            ("role".to_string(), "refnum".to_string()),
978            ("class".to_string(), "ltx_bib_abbrv".to_string()),
979            ("open".to_string(), "[".to_string()),
980            ("close".to_string(), "]".to_string()),
981          ])),
982          children:   vec![NodeData::Text(format!("{}{}{}", aa, yy, suffix))],
983        });
984      },
985      CitationStyle::AuthorYear => {
986        // Author-year refnum. Perl (MakeBibliography.pm else-branch L505-517)
987        // builds this label from `do_authors`→`do_names`, i.e. EVERY author,
988        // with "et al." only for a literal BibTeX "and others"; and it then
989        // drops the first block ("Skip redundant 1st block!!") because the
990        // authors are already in the label.
991        //
992        // We use the SHORT form instead — intentional divergence
993        // OXIDIZED_DESIGN #71. On a collaboration paper Perl's rule yields a
994        // 5104-character citation label (witness arXiv 2607.21432, reported as
995        // arXiv/html_feedback#6797) and, with the block skipped, that label is
996        // essentially the whole entry.
997        //
998        // pdflatex is the ground truth for the shape. `aa.bst` over the witness
999        // emits
1000        //   \bibitem[{Abitbol {et~al.}(2025)Abitbol, Abril-Cabezas, …}]{…}
1001        //   Abitbol, M., Abril-Cabezas, I., Adachi, S., {et~al.} 2025, JCAP …
1002        // — natbib's SHORT form is the citation label, the long surname list is
1003        // only natbib's optional full-author form (never printed), and the
1004        // authors live in the entry BODY. So: short label, and keep the block.
1005        //
1006        // Perl already carries the right helper — `do_names_short`
1007        // (MakeBibliography.pm L586) — defined and never called; `do_names_short`
1008        // below is its port. Perl's own role="authors" tag likewise truncates at
1009        // >2 (L433-437), so the full-list label was internally inconsistent.
1010        skip_first_block = false;
1011        drop_first_block_year = true;
1012        let suffix = entry.suffix.as_deref().unwrap_or("");
1013        let mut refnum_children: Vec<NodeData> = if let Some(ref bibentry) = entry.bibentry {
1014          let authors = PostDocument::findnodes_foreign("ltx:bib-name[@role='author']", bibentry);
1015          if !authors.is_empty() {
1016            do_names_short(authors)
1017          } else {
1018            let editors = PostDocument::findnodes_foreign("ltx:bib-name[@role='editor']", bibentry);
1019            if !editors.is_empty() {
1020              do_editors_a(editors)
1021            } else {
1022              // Perl: $keytag->childNodes.
1023              let key = PostDocument::findnodes_foreign("ltx:bib-key", bibentry)
1024                .into_iter()
1025                .next()
1026                .map(|k| k.get_content())
1027                .unwrap_or_else(|| entry.bib_key.clone());
1028              vec![NodeData::Text(key)]
1029            }
1030          }
1031        } else {
1032          // No bibentry XML (ObjectDB path): the pre-computed full-authors
1033          // string, else the abbreviated string, else the key.
1034          let s = if !entry.authors_full.is_empty() {
1035            entry.authors_full.clone()
1036          } else if !entry.authors_short.is_empty() {
1037            entry.authors_short.clone()
1038          } else {
1039            entry.bib_key.clone()
1040          };
1041          vec![NodeData::Text(s)]
1042        };
1043        // Perl always wraps the year part in " ( … )": @year+suffix, else the
1044        // type tag (the L482 style guard guarantees one of them is present).
1045        let year_text = if !entry.year.is_empty() {
1046          format!("{}{}", entry.year, suffix)
1047        } else if let Some(ref bibentry) = entry.bibentry {
1048          PostDocument::findnodes_foreign("ltx:bib-type", bibentry)
1049            .into_iter()
1050            .next()
1051            .map(|t| t.get_content())
1052            .unwrap_or_default()
1053        } else {
1054          String::new()
1055        };
1056        refnum_children.push(NodeData::Text(format!(" ({})", year_text)));
1057        tags.push(NodeData::Element {
1058          tag:        "ltx:tag".to_string(),
1059          attributes: Some(HashMap::from_iter([
1060            ("role".to_string(), "refnum".to_string()),
1061            ("class".to_string(), "ltx_bib_author-year".to_string()),
1062          ])),
1063          children:   refnum_children,
1064        });
1065      },
1066    }
1067
1068    if !tags.is_empty() {
1069      children.push(NodeData::Element {
1070        tag:        "ltx:tags".to_string(),
1071        attributes: None,
1072        children:   tags,
1073      });
1074    }
1075
1076    // --- Content blocks ---
1077    let blocks = self.format_blocks(doc, entry, skip_first_block, drop_first_block_year);
1078    children.extend(blocks);
1079
1080    // --- Cited-by block ---
1081    let mut citedby: Vec<NodeData> = Vec::new();
1082    let mut sorted_referrers: Vec<&String> = entry.referrers.iter().collect();
1083    sorted_referrers.sort();
1084    for ref_id in &sorted_referrers {
1085      citedby.push(NodeData::Element {
1086        tag:        "ltx:ref".to_string(),
1087        attributes: Some(HashMap::from_iter([
1088          ("idref".to_string(), (*ref_id).clone()),
1089          ("show".to_string(), "typerefnum".to_string()),
1090        ])),
1091        children:   vec![],
1092      });
1093    }
1094    if !entry.bibreferrers.is_empty() {
1095      let mut sorted_bibrefs: Vec<&String> = entry.bibreferrers.iter().collect();
1096      sorted_bibrefs.sort();
1097      citedby.push(NodeData::Element {
1098        tag:        "ltx:bibref".to_string(),
1099        attributes: Some(HashMap::from_iter([
1100          (
1101            "bibrefs".to_string(),
1102            sorted_bibrefs
1103              .iter()
1104              .map(|s| s.as_str())
1105              .collect::<Vec<_>>()
1106              .join(","),
1107          ),
1108          ("show".to_string(), "refnum".to_string()),
1109        ])),
1110        children:   vec![],
1111      });
1112    }
1113    if !citedby.is_empty() {
1114      let conjoined = PostDocument::conjoin(
1115        crate::document::Conjunction::Simple(",\n".to_string()),
1116        citedby,
1117      );
1118      let mut block_children = vec![NodeData::Text("Cited by: ".to_string())];
1119      block_children.extend(conjoined);
1120      block_children.push(NodeData::Text(".".to_string()));
1121      children.push(NodeData::Element {
1122        tag:        "ltx:bibblock".to_string(),
1123        attributes: Some(HashMap::from_iter([(
1124          "class".to_string(),
1125          "ltx_bib_cited".to_string(),
1126        )])),
1127        children:   block_children,
1128      });
1129    }
1130
1131    NodeData::Element {
1132      tag: "ltx:bibitem".to_string(),
1133      attributes: Some(HashMap::from_iter([
1134        ("xml:id".to_string(), id),
1135        ("key".to_string(), cited_key.to_string()),
1136        ("type".to_string(), entry.entry_type.clone()),
1137        ("class".to_string(), format!("ltx_bib_{}", entry.bib_type())),
1138      ])),
1139      children,
1140    }
1141  }
1142
1143  /// Build author/year/key/title/type tags from bibentry XML.
1144  ///
1145  /// Returns: (tag nodes, has_names, has_key, has_year, has_typetag)
1146  fn build_author_year_tags(
1147    &self,
1148    doc: &PostDocument,
1149    entry: &BibEntryData,
1150  ) -> (Vec<NodeData>, bool, bool, bool, bool) {
1151    let mut tags = Vec::new();
1152    let mut has_names = false;
1153    let mut has_key = false;
1154    let mut has_year = false;
1155    let mut has_typetag = false;
1156
1157    if let Some(ref bibentry) = entry.bibentry {
1158      // Author surnames from bibentry XML
1159      let mut surnames: Vec<Node> =
1160        doc.findnodes_at("ltx:bib-name[@role='author']/ltx:surname", Some(bibentry));
1161      if surnames.is_empty() {
1162        surnames = doc.findnodes_at("ltx:bib-name[@role='editor']/ltx:surname", Some(bibentry));
1163      }
1164
1165      if surnames.len() > 2 {
1166        has_names = true;
1167        // Short: first author + et al.
1168        let first_text = surnames[0].get_content();
1169        tags.push(NodeData::Element {
1170          tag:        "ltx:tag".to_string(),
1171          attributes: Some(HashMap::from_iter([
1172            ("role".to_string(), "authors".to_string()),
1173            ("class".to_string(), "ltx_bib_author".to_string()),
1174          ])),
1175          children:   vec![NodeData::Text(first_text), NodeData::Element {
1176            tag:        "ltx:text".to_string(),
1177            attributes: Some(HashMap::from_iter([(
1178              "class".to_string(),
1179              "ltx_bib_etal".to_string(),
1180            )])),
1181            children:   vec![NodeData::Text(" et al.".to_string())],
1182          }],
1183        });
1184        // Full: all names
1185        let mut full_children: Vec<NodeData> = Vec::new();
1186        for (i, surname) in surnames.iter().enumerate() {
1187          if i > 0 && i < surnames.len() - 1 {
1188            full_children.push(NodeData::Text(", ".to_string()));
1189          } else if i == surnames.len() - 1 {
1190            full_children.push(NodeData::Text(" and ".to_string()));
1191          }
1192          full_children.push(NodeData::Text(surname.get_content()));
1193        }
1194        tags.push(NodeData::Element {
1195          tag:        "ltx:tag".to_string(),
1196          attributes: Some(HashMap::from_iter([
1197            ("role".to_string(), "fullauthors".to_string()),
1198            ("class".to_string(), "ltx_bib_author".to_string()),
1199          ])),
1200          children:   full_children,
1201        });
1202      } else if surnames.len() == 2 {
1203        has_names = true;
1204        tags.push(NodeData::Element {
1205          tag:        "ltx:tag".to_string(),
1206          attributes: Some(HashMap::from_iter([
1207            ("role".to_string(), "authors".to_string()),
1208            ("class".to_string(), "ltx_bib_author".to_string()),
1209          ])),
1210          children:   vec![
1211            NodeData::Text(surnames[0].get_content()),
1212            NodeData::Text(" and ".to_string()),
1213            NodeData::Text(surnames[1].get_content()),
1214          ],
1215        });
1216      } else if !surnames.is_empty() {
1217        has_names = true;
1218        tags.push(NodeData::Element {
1219          tag:        "ltx:tag".to_string(),
1220          attributes: Some(HashMap::from_iter([
1221            ("role".to_string(), "authors".to_string()),
1222            ("class".to_string(), "ltx_bib_author".to_string()),
1223          ])),
1224          children:   vec![NodeData::Text(surnames[0].get_content())],
1225        });
1226      }
1227
1228      // Key tag
1229      if let Some(key_node) = PostDocument::findnodes_foreign("ltx:bib-key", bibentry)
1230        .into_iter()
1231        .next()
1232      {
1233        has_key = true;
1234        tags.push(NodeData::Element {
1235          tag:        "ltx:tag".to_string(),
1236          attributes: Some(HashMap::from_iter([
1237            ("role".to_string(), "key".to_string()),
1238            ("class".to_string(), "ltx_bib_key".to_string()),
1239          ])),
1240          children:   vec![NodeData::Text(key_node.get_content())],
1241        });
1242      }
1243
1244      // Year tag
1245      if let Some(date_node) =
1246        PostDocument::findnodes_foreign("ltx:bib-date[@role='publication']", bibentry)
1247          .into_iter()
1248          .next()
1249      {
1250        has_year = true;
1251        let year_text = extract_four_digit_year(&date_node.get_content());
1252        let suffix = entry.suffix.as_deref().unwrap_or("");
1253        tags.push(NodeData::Element {
1254          tag:        "ltx:tag".to_string(),
1255          attributes: Some(HashMap::from_iter([
1256            ("role".to_string(), "year".to_string()),
1257            ("class".to_string(), "ltx_bib_year".to_string()),
1258          ])),
1259          children:   vec![NodeData::Text(format!("{}{}", year_text, suffix))],
1260        });
1261      }
1262
1263      // Type tag
1264      if let Some(type_node) = PostDocument::findnodes_foreign("ltx:bib-type", bibentry)
1265        .into_iter()
1266        .next()
1267      {
1268        has_typetag = true;
1269        tags.push(NodeData::Element {
1270          tag:        "ltx:tag".to_string(),
1271          attributes: Some(HashMap::from_iter([
1272            ("role".to_string(), "bibtype".to_string()),
1273            ("class".to_string(), "ltx_bib_type".to_string()),
1274          ])),
1275          children:   vec![NodeData::Text(type_node.get_content())],
1276        });
1277      }
1278
1279      // Title tag
1280      if let Some(title_node) = PostDocument::findnodes_foreign("ltx:bib-title", bibentry)
1281        .into_iter()
1282        .next()
1283      {
1284        tags.push(NodeData::Element {
1285          tag:        "ltx:tag".to_string(),
1286          attributes: Some(HashMap::from_iter([
1287            ("role".to_string(), "title".to_string()),
1288            ("class".to_string(), "ltx_bib_title".to_string()),
1289          ])),
1290          children:   vec![NodeData::Text(title_node.get_content())],
1291        });
1292      }
1293    } else {
1294      // No bibentry XML — use ObjectDB metadata strings
1295      if !entry.authors_short.is_empty() {
1296        has_names = true;
1297        tags.push(NodeData::Element {
1298          tag:        "ltx:tag".to_string(),
1299          attributes: Some(HashMap::from_iter([
1300            ("role".to_string(), "authors".to_string()),
1301            ("class".to_string(), "ltx_bib_author".to_string()),
1302          ])),
1303          children:   vec![NodeData::Text(entry.authors_short.clone())],
1304        });
1305        if entry.authors_full != entry.authors_short {
1306          tags.push(NodeData::Element {
1307            tag:        "ltx:tag".to_string(),
1308            attributes: Some(HashMap::from_iter([
1309              ("role".to_string(), "fullauthors".to_string()),
1310              ("class".to_string(), "ltx_bib_author".to_string()),
1311            ])),
1312            children:   vec![NodeData::Text(entry.authors_full.clone())],
1313          });
1314        }
1315      }
1316      if !entry.year.is_empty() {
1317        has_year = true;
1318        let suffix = entry.suffix.as_deref().unwrap_or("");
1319        tags.push(NodeData::Element {
1320          tag:        "ltx:tag".to_string(),
1321          attributes: Some(HashMap::from_iter([
1322            ("role".to_string(), "year".to_string()),
1323            ("class".to_string(), "ltx_bib_year".to_string()),
1324          ])),
1325          children:   vec![NodeData::Text(format!("{}{}", entry.year, suffix))],
1326        });
1327      }
1328      if !entry.title.is_empty() {
1329        tags.push(NodeData::Element {
1330          tag:        "ltx:tag".to_string(),
1331          attributes: Some(HashMap::from_iter([
1332            ("role".to_string(), "title".to_string()),
1333            ("class".to_string(), "ltx_bib_title".to_string()),
1334          ])),
1335          children:   vec![NodeData::Text(entry.title.clone())],
1336        });
1337      }
1338    }
1339
1340    (tags, has_names, has_key, has_year, has_typetag)
1341  }
1342
1343  /// Generate alphabetic label for AY/alpha citation style.
1344  ///
1345  /// Port of the alpha refnum logic in `formatBibEntry`.
1346  fn make_alpha_label(&self, doc: &PostDocument, entry: &BibEntryData) -> String {
1347    if let Some(ref bibentry) = entry.bibentry {
1348      let mut surnames: Vec<Node> =
1349        doc.findnodes_at("ltx:bib-name[@role='author']/ltx:surname", Some(bibentry));
1350      if surnames.is_empty() {
1351        surnames = doc.findnodes_at("ltx:bib-name[@role='editor']/ltx:surname", Some(bibentry));
1352      }
1353      if surnames.len() > 1 {
1354        // Perl L497-500: `join('', map { substr($_->textContent, 0, 1) })`,
1355        // truncated to `substr($aa, 0, 3) . "+"` past three. Both are
1356        // CHARACTER operations, and neither uppercases — only the single-name
1357        // branch below carries Perl's `uc`. Byte indexing here used to panic
1358        // outright on a multi-byte initial (`Ångström`), which the citestyle
1359        // repair makes reachable for every `\bibliographystyle{alpha}`
1360        // document rather than the handful that spelled the style `alpha`.
1361        let initials: Vec<char> = surnames
1362          .iter()
1363          .map(|n| n.get_content().chars().next().unwrap_or('?'))
1364          .collect();
1365        if initials.len() > 3 {
1366          format!("{}+", initials[..3].iter().collect::<String>())
1367        } else {
1368          initials.iter().collect::<String>()
1369        }
1370      } else if !surnames.is_empty() {
1371        let text = surnames[0].get_content();
1372        text.chars().take(3).collect::<String>().to_uppercase()
1373      } else {
1374        entry
1375          .bib_key
1376          .chars()
1377          .take(3)
1378          .collect::<String>()
1379          .to_uppercase()
1380      }
1381    } else {
1382      // Fallback: use author short name
1383      if !entry.authors_short.is_empty() {
1384        entry
1385          .authors_short
1386          .split_whitespace()
1387          .filter_map(|w| w.chars().next())
1388          .map(|c| c.to_uppercase().to_string())
1389          .collect::<Vec<_>>()
1390          .join("")
1391      } else {
1392        entry
1393          .bib_key
1394          .chars()
1395          .take(3)
1396          .collect::<String>()
1397          .to_uppercase()
1398      }
1399    }
1400  }
1401
1402  /// Format content blocks using the FMT_SPEC table.
1403  ///
1404  /// Port of the block formatting loop in `formatBibEntry` + `%FMT_SPEC`.
1405  fn format_blocks(
1406    &self,
1407    doc: &PostDocument,
1408    entry: &BibEntryData,
1409    skip_first: bool,
1410    drop_first_year: bool,
1411  ) -> Vec<NodeData> {
1412    let format_type = entry.format_type();
1413    let block_specs = get_fmt_spec(format_type);
1414    let mut blocks = Vec::new();
1415
1416    for (i, block_spec) in block_specs.iter().enumerate() {
1417      if skip_first && i == 0 {
1418        continue;
1419      }
1420
1421      let mut items: Vec<NodeData> = Vec::new();
1422      for field_spec in block_spec {
1423        // Author-year: the refnum label already reads "Author (Year)", so the
1424        // first block repeating the year would print it twice in the one entry.
1425        // Perl avoids that by dropping the whole block (and with it the author
1426        // list); we keep the block and drop only the redundant field. Matches
1427        // the biblatex author-year path already shipped here, whose entries
1428        // render as `[Smith (2020)]  John Smith  “A study of things” …` —
1429        // label carries the year, author block does not. See OXIDIZED_DESIGN #71.
1430        if drop_first_year && i == 0 && field_spec.class == "year" {
1431          continue;
1432        }
1433        let (nodes_found, negated) = if let Some(ref bibentry) = entry.bibentry {
1434          let xpath = field_spec.xpath.trim_start_matches('!').trim();
1435          let negated = field_spec.xpath.starts_with('!');
1436          if xpath == "true" {
1437            (true, false)
1438          } else {
1439            let found = !PostDocument::findnodes_foreign(xpath, bibentry).is_empty();
1440            (found, negated)
1441          }
1442        } else {
1443          // No bibentry — try to match from metadata
1444          let found = match_metadata_field(field_spec.xpath, entry);
1445          (found, field_spec.xpath.starts_with('!'))
1446        };
1447
1448        // Check condition
1449        if field_spec.xpath != "true" {
1450          if negated {
1451            if nodes_found {
1452              continue;
1453            }
1454          } else {
1455            if !nodes_found {
1456              continue;
1457            }
1458          }
1459        }
1460
1461        // Add punctuation if there are preceding items
1462        if !field_spec.punct.is_empty() && !items.is_empty() {
1463          items.push(NodeData::Text(field_spec.punct.to_string()));
1464        }
1465        // Pre-text
1466        if !field_spec.pre.is_empty() {
1467          items.push(NodeData::Text(field_spec.pre.to_string()));
1468        }
1469        // Content (wrapped in ltx:text with class)
1470        if !field_spec.class.is_empty() {
1471          let content = if let Some(ref bibentry) = entry.bibentry {
1472            let xpath = field_spec.xpath.trim_start_matches('!').trim();
1473            if xpath == "true" {
1474              Vec::new()
1475            } else {
1476              let nodes = PostDocument::findnodes_foreign(xpath, bibentry);
1477              apply_formatter(doc, field_spec.formatter, &nodes)
1478            }
1479          } else {
1480            get_metadata_content(field_spec.xpath, entry)
1481          };
1482          if !content.is_empty() {
1483            items.push(NodeData::Element {
1484              tag:        "ltx:text".to_string(),
1485              attributes: Some(HashMap::from_iter([(
1486                "class".to_string(),
1487                format!("ltx_bib_{}", field_spec.class),
1488              )])),
1489              children:   content,
1490            });
1491          }
1492        }
1493        // Post-text
1494        if !field_spec.post.is_empty() {
1495          items.push(NodeData::Text(field_spec.post.to_string()));
1496        }
1497      }
1498
1499      if !items.is_empty() {
1500        blocks.push(make_bibblock("", &items));
1501      }
1502    }
1503
1504    // Note + External Links are part of every type's FMT_SPEC via the
1505    // `meta_block` appended in get_fmt_spec, so the loop above already emits
1506    // them. (A second, hard-coded copy here previously duplicated every
1507    // entry's final Note/External-Links bibblock.)
1508
1509    blocks
1510  }
1511}
1512
1513impl Processor for MakeBibliography {
1514  fn get_name(&self) -> &str { &self.name }
1515
1516  fn to_process(&self, doc: &PostDocument) -> Vec<Node> { doc.findnodes("//ltx:bibliography") }
1517
1518  fn process(&mut self, mut doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
1519    for bib in &nodes {
1520      // Skip if already populated
1521      if !doc.findnodes_at(".//ltx:bibitem", Some(bib)).is_empty() {
1522        continue;
1523      }
1524
1525      // Read citation style from element attributes
1526      // Perl L481 is `$STYLE{citestyle} || 'numbers'`, and `||` is falsy on the
1527      // EMPTY string as well as on absent — so an empty attribute must reach
1528      // `numbers`, not the `else` branch. Before the mapping repair below an
1529      // empty value fell through to `_ => Numbers` by luck; with `_` now
1530      // meaning author-year it has to be excluded explicitly, or the repair
1531      // would introduce an unfaithfulness of its own. Our own engine cannot
1532      // emit `citestyle=""` (`Document::set_attribute` skips empty values), but
1533      // `latexml_post` also consumes XML it did not produce.
1534      let citestyle_str = bib
1535        .get_attribute("citestyle")
1536        .filter(|s| !s.is_empty())
1537        .unwrap_or_else(|| "numbers".to_string());
1538      // Perl L481-517 branches on exactly three cases, and the mapping was
1539      // inverted here: `AY` is the ABBREVIATED `[AS64]` label (L485, class
1540      // `ltx_bib_abbrv`), NOT the spelled-out author-year one, and ANY other
1541      // non-`numbers` value takes the author-year `else` branch rather than
1542      // falling back to numbers. `\bibliographystyle{alpha}` sets
1543      // `CITE_STYLE=AY` (`latex_constructs.rs::lookup_bibstyle_params`, Perl
1544      // `$BIBSTYLES` L3953-3961), so every alpha-styled document was getting
1545      // author-year refnums where Perl — and the `.bst` the author chose —
1546      // give `[AS64]`.
1547      let style = match citestyle_str.as_str() {
1548        "numbers" => CitationStyle::Numbers,
1549        "AY" => CitationStyle::Alpha,
1550        _ => CitationStyle::AuthorYear,
1551      };
1552
1553      // bib_docs must be kept alive as long as entries (bibentry Nodes reference them)
1554      let (mut entries, _bib_docs) = self.get_bib_entries(&doc, bib);
1555      if entries.is_empty() {
1556        Info!(
1557          "bibliography",
1558          "empty",
1559          "MakeBibliography: no entries to process"
1560        );
1561        continue;
1562      }
1563
1564      // Perl MakeBibliography.pm L393-394: bib's id, else the document
1565      // element's id, else the literal 'bib'. NS-aware reads — the bare
1566      // forms always fell through to the literal.
1567      let bib_id = crate::document::get_xml_id(bib)
1568        .or_else(|| {
1569          doc
1570            .get_document_element()
1571            .as_ref()
1572            .and_then(crate::document::get_xml_id)
1573        })
1574        .unwrap_or_else(|| "bib".to_string());
1575
1576      // Perl `local $LaTeXML::Post::MakeBibliography::NUMBER = 0` (L55) —
1577      // reset per `ltx:bibliography`, then incremented once per entry as it is
1578      // FORMATTED (L418). Under `--splitbibliography` that walk is
1579      // initial-major, so the counter follows the initial groups rather than
1580      // the document-global sortkey order; the two coincide whenever an
1581      // entry's `initial` is the first letter of its sortkey, which is the
1582      // common case but not guaranteed (`initial` skips leading non-letters).
1583      //
1584      // NOTE ON REACH: `self.split` is currently always false — `post.rs`
1585      // constructs this processor with `split = false` and
1586      // `--splitbibliography` sits in the deferred CLI cluster. So moving the
1587      // counter here changes NO production output today; the non-split walk
1588      // numbers in the same order the old in-`get_bib_entries` pass did. It is
1589      // done for when that flag lands, and because the counter belongs with
1590      // the walk it counts.
1591      let mut number = 0u32;
1592
1593      // Citation-order numbering for an UNSORTED style: the References are
1594      // numbered by first citation, not alphabetically — matching the `.bst` and
1595      // the published PDF (html_feedback #6294). Perl always `unisort`s (it reads
1596      // the sort flag into `%STYLE` but ignores it), so this is a deliberate
1597      // surpass-Perl. Detected from the `bibstyle` NAME — the reliable signal on
1598      // the main node (Perl's `beginBibliography` never emits `sort`) — plus an
1599      // explicit `sort='false'` for the bibunits `\bibstyle` path / external XML.
1600      // Only the non-split walk uses it; `--splitbibliography` is inherently
1601      // initial-major (alphabetical) and never combines with it.
1602      //
1603      // Gated on a NUMERIC list: an author-year bibliography is always ordered
1604      // alphabetically by author regardless of the `.bst` sort flag, so a numeric
1605      // style is a precondition. This matters for the natbib/revtex arm
1606      // (#5930/#6095): natbib now records `bibstyle` even when it stays in
1607      // author-year mode (no `[numbers]`), and reordering that list by citation
1608      // would be wrong. An absent `citestyle` defaults to numeric (Perl L481).
1609      let is_numeric = bib
1610        .get_attribute("citestyle")
1611        .as_deref()
1612        .map(|s| s.is_empty() || s == "numbers")
1613        .unwrap_or(true);
1614      let unsorted_style = bib.get_attribute("sort").as_deref() == Some("false")
1615        || bib
1616          .get_attribute("bibstyle")
1617          .as_deref()
1618          .is_some_and(is_citation_order_style);
1619      let cite_order = (is_numeric && unsorted_style).then(|| citation_order(&doc));
1620
1621      if self.split {
1622        // Split by initial letter
1623        let mut by_initial: HashMap<String, Vec<String>> = HashMap::default();
1624        for (key, entry) in &entries {
1625          by_initial
1626            .entry(entry.initial.clone())
1627            .or_default()
1628            .push(key.clone());
1629        }
1630        let mut initials: Vec<String> = by_initial.keys().cloned().collect();
1631        initials.sort();
1632        for group in by_initial.values_mut() {
1633          unisort(group);
1634        }
1635        for initial in &initials {
1636          // Number this group, then format it — the order Perl's single
1637          // `++$NUMBER`-as-you-format walk produces.
1638          // The keys came out of `entries` a few lines above, so a miss is a
1639          // logic error, not input-dependent — `debug_assert` makes it loud in
1640          // dev/test without risking a production abort (`maxperf` sets
1641          // `panic = "abort"`). Incrementing INSIDE the lookup also means a
1642          // miss could never silently burn a number and shift the whole list.
1643          for key in &by_initial[initial] {
1644            debug_assert!(entries.contains_key(key), "grouped key {key} left entries");
1645            if let Some(entry) = entries.get_mut(key) {
1646              number += 1;
1647              entry.number = number;
1648            }
1649          }
1650          // Build a subset HashMap for this initial
1651          let subset: HashMap<String, BibEntryData> = by_initial[initial]
1652            .iter()
1653            .filter_map(|k| entries.get(k).map(|e| (k.clone(), clone_entry(e))))
1654            .collect();
1655          let biblist = self.make_bibliography_list(&doc, &bib_id, Some(initial), &subset, &style);
1656          let mut bib_mut = bib.clone();
1657          doc.add_nodes(&mut bib_mut, &[biblist]);
1658        }
1659      } else {
1660        let sorted_keys = order_entry_keys(&entries, cite_order.as_ref());
1661        for key in &sorted_keys {
1662          debug_assert!(entries.contains_key(key), "sorted key {key} left entries");
1663          if let Some(entry) = entries.get_mut(key) {
1664            number += 1;
1665            entry.number = number;
1666          }
1667        }
1668        let biblist = self.make_bibliography_list(&doc, &bib_id, None, &entries, &style);
1669        let mut bib_mut = bib.clone();
1670        doc.add_nodes(&mut bib_mut, &[biblist]);
1671      }
1672
1673      Info!(
1674        "bibliography",
1675        "formatted",
1676        "MakeBibliography: formatted {} entries",
1677        entries.len()
1678      );
1679
1680      // Register formatted bibitems in ObjectDB so CrossRef can resolve citations.
1681      // Port of Perl's approach where bibitems are registered during Scan,
1682      // but here we must register them after MakeBibliography creates them.
1683      let lists_str = bib
1684        .get_attribute("lists")
1685        .unwrap_or_else(|| "bibliography".to_string());
1686      for entry in entries.values() {
1687        let cited_key = entry.cited_key.as_deref().unwrap_or(&entry.bib_key);
1688        // Compute the same ID as format_bib_entry (NS-aware, same as there)
1689        let bibitem_id = if let Some(ref bibentry) = entry.bibentry {
1690          let orig_id = crate::document::get_xml_id(bibentry).unwrap_or_default();
1691          if orig_id.is_empty() {
1692            format!("{}.bib{}", bib_id, entry.number)
1693          } else {
1694            let stripped = orig_id.strip_prefix("bib").unwrap_or(&orig_id);
1695            format!("{}{}", bib_id, stripped)
1696          }
1697        } else {
1698          format!("{}.bib{}", bib_id, entry.number)
1699        };
1700
1701        // Register BIBLABEL:{list}:{key} → id
1702        for list in lists_str.split_whitespace() {
1703          let label_key = format!("BIBLABEL:{}:{}", list, cited_key);
1704          self.db.register(&label_key, vec![(
1705            "id",
1706            crate::object_db::Value::from(bibitem_id.as_str()),
1707          )]);
1708        }
1709
1710        // Register ID:{id} with type, location, and number for CrossRef URL
1711        // generation. The author-year metadata (`authors`/`year`/`refnum`/…) the
1712        // fill phase needs is registered by the rescan below, read from the
1713        // bibitem's own `<ltx:tag>` children — see the rescan comment.
1714        let location = doc.site_relative_destination().unwrap_or_default();
1715        self.db.register(&format!("ID:{}", bibitem_id), vec![
1716          ("type", crate::object_db::Value::from("ltx:bibitem")),
1717          ("location", crate::object_db::Value::from(location.as_str())),
1718          ("fragid", crate::object_db::Value::from(bibitem_id.as_str())),
1719          (
1720            "number",
1721            crate::object_db::Value::from(entry.number.to_string().as_str()),
1722          ),
1723        ]);
1724      }
1725    }
1726
1727    // Stand in for Perl's rescan of the generated subtree.
1728    //
1729    // Perl `Collector::rescan` (Collector.pm L97) re-runs the WHOLE Scan over
1730    // the post-MakeBibliography document (called at MakeBibliography.pm L71,
1731    // L78), so every id-bearing node in the generated bibliography — the
1732    // enclosing `ltx:biblist`, and any id'd markup CLONED IN from a bibentry
1733    // (an `ltx:Math` in a title, a styled `ltx:text`, …) — lands in the
1734    // ObjectDB. That entry is what `CrossRef::fill_in_frags` ("Any nodes with
1735    // an ID will get a fragid", CrossRef.pm L312-324) needs before it will
1736    // stamp `fragid`, and the HTML5 XSLT's `add_id` emits the HTML `id` from
1737    // `@fragid` ALONE. So an unregistered node reaches HTML with NO id.
1738    //
1739    // This port hand-registers the bibitems just above (Perl's Scan is what
1740    // registers them there) but nothing else, so both classes were lost:
1741    // Perl's `<ul id="bib.L1">` was a bare `<ul>` here, and a `$…$` inside a
1742    // bib title lost the `bib.bib1.m1a` id Perl emits (measured on same-host
1743    // 0.8.8). Registering the whole generated subtree covers both.
1744    //
1745    // Deliberately narrower than Perl's rescan: this restores the id/fragid
1746    // half only. It does NOT re-derive `labels`, relations or the richer
1747    // per-type values a full Scan would, and it never overwrites an entry
1748    // that already exists — the bibitem registrations above (which carry
1749    // `type`/`number`) win. Wiring a real `Collector::rescan` is tracked in
1750    // SYNC_STATUS.
1751    let location = doc.site_relative_destination().unwrap_or_default();
1752    for node in doc.findnodes("//ltx:bibliography//*[@xml:id]") {
1753      let Some(id) = crate::document::get_xml_id(&node) else {
1754        continue;
1755      };
1756      let key = format!("ID:{}", id);
1757      let qname = doc
1758        .get_qname(&node)
1759        .unwrap_or_else(|| "ltx:text".to_string());
1760      if qname == "ltx:bibitem" {
1761        // Faithful to Perl's rescan (MakeBibliography.pm L78 re-runs Scan, whose
1762        // `bibitem_handler` reads the bibitem's `<ltx:tag role="…">` children
1763        // into the DB): augment this bibitem's already-registered ID entry with
1764        // its author-year metadata, read from its OWN generated tags via the
1765        // handler oxide's Scan also uses. CrossRef's fill phase (make_bibcite /
1766        // `fill_in_bibrefs`) then renders an author-year inline citation matching
1767        // the References list; without these values it fell back to the bare
1768        // number (`\cite{beta}` → `2` vs the list's `Beta (2002)`).
1769        // html_feedback #6276/#6302 (inline-vs-list label mismatch).
1770        let props = crate::scan::bibitem_tag_props(&doc, &node);
1771        if !props.is_empty() {
1772          let entry = self.db.register(&key, vec![]);
1773          for (k, v) in props {
1774            entry.set_value(&k, v);
1775          }
1776        }
1777      } else if self.db.lookup(&key).is_none() {
1778        // A non-bibitem id-bearing node (an ltx:Math in a title, a styled
1779        // ltx:text, …) cloned into the generated bibliography: register its
1780        // id/fragid so CrossRef can stamp it (Perl's rescan covers these too).
1781        self.db.register(&key, vec![
1782          ("type", crate::object_db::Value::from(qname.as_str())),
1783          ("location", crate::object_db::Value::from(location.as_str())),
1784          ("fragid", crate::object_db::Value::from(id.as_str())),
1785        ]);
1786      }
1787    }
1788
1789    // Remove any remaining bibentry elements (they've been converted to bibitems)
1790    let bibentries = doc.findnodes("//ltx:bibentry");
1791    if !bibentries.is_empty() {
1792      doc.remove_nodes(&bibentries);
1793    }
1794
1795    // Remove empty biblists
1796    let biblists = doc.findnodes("//ltx:biblist");
1797    let empty_lists: Vec<Node> = biblists
1798      .into_iter()
1799      .filter(|n| {
1800        n.get_first_child()
1801          .map(|c| {
1802            let mut has_element = false;
1803            let mut current = Some(c);
1804            while let Some(ref node) = current {
1805              if node.get_type() == Some(libxml::tree::NodeType::ElementNode) {
1806                has_element = true;
1807                break;
1808              }
1809              current = node.get_next_sibling();
1810            }
1811            !has_element
1812          })
1813          .unwrap_or(true)
1814      })
1815      .collect();
1816    if !empty_lists.is_empty() {
1817      doc.remove_nodes(&empty_lists);
1818    }
1819
1820    Ok(vec![doc])
1821  }
1822}
1823
1824// ======================================================================
1825// FMT_SPEC table — defines the block structure for each bibliography type.
1826//
1827// Port of the `%FMT_SPEC` table from MakeBibliography.pm.
1828// Each type has a sequence of blocks.
1829// Each block has a sequence of field specifications.
1830
1831/// A field specification for bibliography formatting.
1832#[derive(Clone)]
1833struct FieldSpec {
1834  /// XPath expression (or "true" for unconditional). Prefix "!" for negation.
1835  xpath:     &'static str,
1836  /// Punctuation to insert before this field (if preceding content exists).
1837  punct:     &'static str,
1838  /// Text prefix.
1839  pre:       &'static str,
1840  /// CSS class (without ltx_bib_ prefix).
1841  class:     &'static str,
1842  /// Formatter function name.
1843  formatter: Formatter,
1844  /// Text suffix.
1845  post:      &'static str,
1846}
1847
1848#[derive(Clone, Copy)]
1849enum Formatter {
1850  Any,
1851  Authors,
1852  EditorsA,
1853  EditorsB,
1854  Year,
1855  Type,
1856  Title,
1857  ThesisType,
1858  Edition,
1859  Pages,
1860  CrossRef,
1861  Links,
1862  None,
1863}
1864
1865/// Get the FMT_SPEC block specifications for a bibliography type.
1866fn get_fmt_spec(format_type: &str) -> Vec<Vec<FieldSpec>> {
1867  let meta_block: Vec<Vec<FieldSpec>> = vec![
1868    vec![FieldSpec {
1869      xpath:     "ltx:bib-note",
1870      punct:     "",
1871      pre:       "Note: ",
1872      class:     "note",
1873      formatter: Formatter::Any,
1874      post:      "",
1875    }],
1876    vec![FieldSpec {
1877      xpath:     "ltx:bib-links | ltx:bib-review | ltx:bib-identifier | ltx:bib-url",
1878      punct:     "",
1879      pre:       "External Links: ",
1880      class:     "links",
1881      formatter: Formatter::Links,
1882      post:      "",
1883    }],
1884  ];
1885
1886  let mut blocks = match format_type {
1887    "article" => vec![
1888      // Block 1: authors + year
1889      vec![
1890        FieldSpec {
1891          xpath:     "ltx:bib-name[@role='author']",
1892          punct:     "",
1893          pre:       "",
1894          class:     "author",
1895          formatter: Formatter::Authors,
1896          post:      "",
1897        },
1898        FieldSpec {
1899          xpath:     "ltx:bib-date[@role='publication']",
1900          punct:     "",
1901          pre:       "",
1902          class:     "year",
1903          formatter: Formatter::Year,
1904          post:      "",
1905        },
1906      ],
1907      // Block 2: title
1908      vec![FieldSpec {
1909        xpath:     "ltx:bib-title",
1910        punct:     "",
1911        pre:       "",
1912        class:     "title",
1913        formatter: Formatter::Title,
1914        post:      ".",
1915      }],
1916      // Block 3: journal details
1917      vec![
1918        FieldSpec {
1919          xpath:     "ltx:bib-part[@role='part']",
1920          punct:     "",
1921          pre:       "",
1922          class:     "part",
1923          formatter: Formatter::Any,
1924          post:      "",
1925        },
1926        FieldSpec {
1927          xpath:     "ltx:bib-related/ltx:bib-title",
1928          punct:     ", ",
1929          pre:       "",
1930          class:     "journal",
1931          formatter: Formatter::Any,
1932          post:      "",
1933        },
1934        FieldSpec {
1935          xpath:     "ltx:bib-part[@role='volume']",
1936          punct:     " ",
1937          pre:       "",
1938          class:     "volume",
1939          formatter: Formatter::Any,
1940          post:      "",
1941        },
1942        FieldSpec {
1943          xpath:     "ltx:bib-part[@role='number']",
1944          punct:     " ",
1945          pre:       "(",
1946          class:     "number",
1947          formatter: Formatter::Any,
1948          post:      ")",
1949        },
1950        FieldSpec {
1951          xpath:     "ltx:bib-status",
1952          punct:     ", ",
1953          pre:       "(",
1954          class:     "status",
1955          formatter: Formatter::Any,
1956          post:      ")",
1957        },
1958        FieldSpec {
1959          xpath:     "ltx:bib-part[@role='pages']",
1960          punct:     ", ",
1961          pre:       "",
1962          class:     "pages",
1963          formatter: Formatter::Pages,
1964          post:      "",
1965        },
1966        FieldSpec {
1967          xpath:     "ltx:bib-language",
1968          punct:     " ",
1969          pre:       "(",
1970          class:     "language",
1971          formatter: Formatter::Any,
1972          post:      ")",
1973        },
1974        FieldSpec {
1975          xpath:     "true",
1976          punct:     ".",
1977          pre:       "",
1978          class:     "",
1979          formatter: Formatter::None,
1980          post:      "",
1981        },
1982      ],
1983    ],
1984    "book" => vec![
1985      vec![
1986        FieldSpec {
1987          xpath:     "ltx:bib-name[@role='author']",
1988          punct:     "",
1989          pre:       "",
1990          class:     "author",
1991          formatter: Formatter::Authors,
1992          post:      "",
1993        },
1994        FieldSpec {
1995          xpath:     "ltx:bib-name[@role='editor']",
1996          punct:     "",
1997          pre:       "",
1998          class:     "editor",
1999          formatter: Formatter::EditorsA,
2000          post:      "",
2001        },
2002        FieldSpec {
2003          xpath:     "ltx:bib-date[@role='publication']",
2004          punct:     "",
2005          pre:       "",
2006          class:     "year",
2007          formatter: Formatter::Year,
2008          post:      "",
2009        },
2010      ],
2011      vec![FieldSpec {
2012        xpath:     "ltx:bib-title",
2013        punct:     "",
2014        pre:       "",
2015        class:     "title",
2016        formatter: Formatter::Title,
2017        post:      ".",
2018      }],
2019      vec![
2020        FieldSpec {
2021          xpath:     "ltx:bib-type",
2022          punct:     "",
2023          pre:       "",
2024          class:     "type",
2025          formatter: Formatter::Any,
2026          post:      "",
2027        },
2028        FieldSpec {
2029          xpath:     "ltx:bib-edition",
2030          punct:     ", ",
2031          pre:       "",
2032          class:     "edition",
2033          formatter: Formatter::Edition,
2034          post:      "",
2035        },
2036        FieldSpec {
2037          xpath:     "ltx:bib-part[@role='series']",
2038          punct:     ", ",
2039          pre:       "",
2040          class:     "series",
2041          formatter: Formatter::Any,
2042          post:      "",
2043        },
2044        FieldSpec {
2045          xpath:     "ltx:bib-part[@role='volume']",
2046          punct:     ", ",
2047          pre:       "Vol. ",
2048          class:     "volume",
2049          formatter: Formatter::Any,
2050          post:      "",
2051        },
2052        FieldSpec {
2053          xpath:     "ltx:bib-part[@role='part']",
2054          punct:     ", ",
2055          pre:       "Part ",
2056          class:     "part",
2057          formatter: Formatter::Any,
2058          post:      "",
2059        },
2060        FieldSpec {
2061          xpath:     "ltx:bib-publisher",
2062          punct:     ", ",
2063          pre:       " ",
2064          class:     "publisher",
2065          formatter: Formatter::Any,
2066          post:      "",
2067        },
2068        FieldSpec {
2069          xpath:     "ltx:bib-organization",
2070          punct:     ", ",
2071          pre:       " ",
2072          class:     "publisher",
2073          formatter: Formatter::Any,
2074          post:      "",
2075        },
2076        FieldSpec {
2077          xpath:     "ltx:bib-place",
2078          punct:     ", ",
2079          pre:       "",
2080          class:     "place",
2081          formatter: Formatter::Any,
2082          post:      "",
2083        },
2084        FieldSpec {
2085          xpath:     "ltx:bib-status",
2086          punct:     " ",
2087          pre:       "(",
2088          class:     "status",
2089          formatter: Formatter::Any,
2090          post:      ")",
2091        },
2092        FieldSpec {
2093          xpath:     "ltx:bib-language",
2094          punct:     " ",
2095          pre:       "(",
2096          class:     "language",
2097          formatter: Formatter::Any,
2098          post:      ")",
2099        },
2100        FieldSpec {
2101          xpath:     "true",
2102          punct:     ".",
2103          pre:       "",
2104          class:     "",
2105          formatter: Formatter::None,
2106          post:      "",
2107        },
2108      ],
2109    ],
2110    "incollection" => vec![
2111      vec![
2112        FieldSpec {
2113          xpath:     "ltx:bib-name[@role='author']",
2114          punct:     "",
2115          pre:       "",
2116          class:     "author",
2117          formatter: Formatter::Authors,
2118          post:      "",
2119        },
2120        FieldSpec {
2121          xpath:     "ltx:bib-date[@role='publication']",
2122          punct:     "",
2123          pre:       "",
2124          class:     "year",
2125          formatter: Formatter::Year,
2126          post:      "",
2127        },
2128      ],
2129      vec![FieldSpec {
2130        xpath:     "ltx:bib-title",
2131        punct:     "",
2132        pre:       "",
2133        class:     "title",
2134        formatter: Formatter::Title,
2135        post:      ".",
2136      }],
2137      vec![
2138        FieldSpec {
2139          xpath:     "ltx:bib-type",
2140          punct:     "",
2141          pre:       "",
2142          class:     "type",
2143          formatter: Formatter::Any,
2144          post:      "",
2145        },
2146        FieldSpec {
2147          xpath:     "ltx:bib-related[@bibrefs]",
2148          punct:     " ",
2149          pre:       "See ",
2150          class:     "crossref",
2151          formatter: Formatter::CrossRef,
2152          post:      ",",
2153        },
2154        FieldSpec {
2155          xpath:     "ltx:bib-related[@type][not(../ltx:bib-related[@bibrefs])]/ltx:bib-title",
2156          punct:     " ",
2157          pre:       "In ",
2158          class:     "inbook",
2159          formatter: Formatter::Title,
2160          post:      ",",
2161        },
2162        FieldSpec {
2163          xpath:     "ltx:bib-related[@type][not(../ltx:bib-related[@bibrefs])]/ltx:bib-name[@role='editor']",
2164          punct:     " ",
2165          pre:       " ",
2166          class:     "editor",
2167          formatter: Formatter::EditorsA,
2168          post:      ",",
2169        },
2170      ],
2171      vec![
2172        FieldSpec {
2173          xpath:     "ltx:bib-edition",
2174          punct:     "",
2175          pre:       "",
2176          class:     "edition",
2177          formatter: Formatter::Edition,
2178          post:      "",
2179        },
2180        FieldSpec {
2181          xpath:     "ltx:bib-name[@role='editor']",
2182          punct:     ", ",
2183          pre:       "",
2184          class:     "editor",
2185          formatter: Formatter::EditorsB,
2186          post:      "",
2187        },
2188        FieldSpec {
2189          xpath:     "ltx:bib-related/ltx:bib-part[@role='series']",
2190          punct:     ", ",
2191          pre:       "",
2192          class:     "series",
2193          formatter: Formatter::Any,
2194          post:      "",
2195        },
2196        FieldSpec {
2197          xpath:     "ltx:bib-related/ltx:bib-part[@role='volume']",
2198          punct:     ", ",
2199          pre:       "Vol. ",
2200          class:     "volume",
2201          formatter: Formatter::Any,
2202          post:      "",
2203        },
2204        FieldSpec {
2205          xpath:     "ltx:bib-related/ltx:bib-part[@role='part']",
2206          punct:     ", ",
2207          pre:       "Part ",
2208          class:     "part",
2209          formatter: Formatter::Any,
2210          post:      "",
2211        },
2212        FieldSpec {
2213          xpath:     "ltx:bib-publisher",
2214          punct:     ", ",
2215          pre:       " ",
2216          class:     "publisher",
2217          formatter: Formatter::Any,
2218          post:      "",
2219        },
2220        FieldSpec {
2221          xpath:     "ltx:bib-organization",
2222          punct:     ", ",
2223          pre:       "",
2224          class:     "publisher",
2225          formatter: Formatter::Any,
2226          post:      "",
2227        },
2228        FieldSpec {
2229          xpath:     "ltx:bib-place",
2230          punct:     ", ",
2231          pre:       "",
2232          class:     "place",
2233          formatter: Formatter::Any,
2234          post:      "",
2235        },
2236        FieldSpec {
2237          xpath:     "ltx:bib-part[@role='pages']",
2238          punct:     ", ",
2239          pre:       "",
2240          class:     "pages",
2241          formatter: Formatter::Pages,
2242          post:      "",
2243        },
2244        FieldSpec {
2245          xpath:     "ltx:bib-status",
2246          punct:     " ",
2247          pre:       "(",
2248          class:     "status",
2249          formatter: Formatter::Any,
2250          post:      ")",
2251        },
2252        FieldSpec {
2253          xpath:     "ltx:bib-language",
2254          punct:     " ",
2255          pre:       "(",
2256          class:     "language",
2257          formatter: Formatter::Any,
2258          post:      ")",
2259        },
2260        FieldSpec {
2261          xpath:     "true",
2262          punct:     ".",
2263          pre:       "",
2264          class:     "",
2265          formatter: Formatter::None,
2266          post:      "",
2267        },
2268      ],
2269    ],
2270    "report" => vec![
2271      vec![
2272        FieldSpec {
2273          xpath:     "ltx:bib-name[@role='author']",
2274          punct:     "",
2275          pre:       "",
2276          class:     "author",
2277          formatter: Formatter::Authors,
2278          post:      "",
2279        },
2280        FieldSpec {
2281          xpath:     "ltx:bib-name[@role='editor']",
2282          punct:     "",
2283          pre:       "",
2284          class:     "editor",
2285          formatter: Formatter::EditorsA,
2286          post:      "",
2287        },
2288        FieldSpec {
2289          xpath:     "ltx:bib-date[@role='publication']",
2290          punct:     "",
2291          pre:       "",
2292          class:     "year",
2293          formatter: Formatter::Year,
2294          post:      "",
2295        },
2296      ],
2297      vec![FieldSpec {
2298        xpath:     "ltx:bib-title",
2299        punct:     "",
2300        pre:       "",
2301        class:     "title",
2302        formatter: Formatter::Title,
2303        post:      ".",
2304      }],
2305      vec![FieldSpec {
2306        xpath:     "ltx:bib-type",
2307        punct:     "",
2308        pre:       "",
2309        class:     "type",
2310        formatter: Formatter::Any,
2311        post:      "",
2312      }],
2313      vec![
2314        FieldSpec {
2315          xpath:     "ltx:bib-part[@role='number']",
2316          punct:     "",
2317          pre:       "Technical Report ",
2318          class:     "number",
2319          formatter: Formatter::Any,
2320          post:      "",
2321        },
2322        FieldSpec {
2323          xpath:     "ltx:bib-part[@role='series']",
2324          punct:     ", ",
2325          pre:       "",
2326          class:     "series",
2327          formatter: Formatter::Any,
2328          post:      "",
2329        },
2330        FieldSpec {
2331          xpath:     "ltx:bib-part[@role='volume']",
2332          punct:     ", ",
2333          pre:       "Vol. ",
2334          class:     "volume",
2335          formatter: Formatter::Any,
2336          post:      "",
2337        },
2338        FieldSpec {
2339          xpath:     "ltx:bib-part[@role='part']",
2340          punct:     ", ",
2341          pre:       "Part ",
2342          class:     "part",
2343          formatter: Formatter::Any,
2344          post:      "",
2345        },
2346        FieldSpec {
2347          xpath:     "ltx:bib-publisher",
2348          punct:     ", ",
2349          pre:       " ",
2350          class:     "publisher",
2351          formatter: Formatter::Any,
2352          post:      "",
2353        },
2354        FieldSpec {
2355          xpath:     "ltx:bib-organization",
2356          punct:     ", ",
2357          pre:       " ",
2358          class:     "publisher",
2359          formatter: Formatter::Any,
2360          post:      "",
2361        },
2362        FieldSpec {
2363          xpath:     "ltx:bib-place",
2364          punct:     ", ",
2365          pre:       " ",
2366          class:     "place",
2367          formatter: Formatter::Any,
2368          post:      "",
2369        },
2370        FieldSpec {
2371          xpath:     "ltx:bib-status",
2372          punct:     ", ",
2373          pre:       "(",
2374          class:     "status",
2375          formatter: Formatter::Any,
2376          post:      ")",
2377        },
2378        FieldSpec {
2379          xpath:     "ltx:bib-language",
2380          punct:     " ",
2381          pre:       "(",
2382          class:     "language",
2383          formatter: Formatter::Any,
2384          post:      ")",
2385        },
2386        FieldSpec {
2387          xpath:     "true",
2388          punct:     ".",
2389          pre:       "",
2390          class:     "",
2391          formatter: Formatter::None,
2392          post:      "",
2393        },
2394      ],
2395    ],
2396    "thesis" => vec![
2397      vec![
2398        FieldSpec {
2399          xpath:     "ltx:bib-name[@role='author']",
2400          punct:     "",
2401          pre:       "",
2402          class:     "author",
2403          formatter: Formatter::Authors,
2404          post:      "",
2405        },
2406        FieldSpec {
2407          xpath:     "ltx:bib-name[@role='editor']",
2408          punct:     "",
2409          pre:       "",
2410          class:     "editor",
2411          formatter: Formatter::EditorsA,
2412          post:      "",
2413        },
2414        FieldSpec {
2415          xpath:     "ltx:bib-date[@role='publication']",
2416          punct:     "",
2417          pre:       "",
2418          class:     "year",
2419          formatter: Formatter::Year,
2420          post:      "",
2421        },
2422      ],
2423      vec![FieldSpec {
2424        xpath:     "ltx:bib-title",
2425        punct:     "",
2426        pre:       "",
2427        class:     "title",
2428        formatter: Formatter::Title,
2429        post:      ".",
2430      }],
2431      vec![
2432        FieldSpec {
2433          xpath:     "ltx:bib-type",
2434          punct:     " ",
2435          pre:       "",
2436          class:     "type",
2437          formatter: Formatter::ThesisType,
2438          post:      "",
2439        },
2440        FieldSpec {
2441          xpath:     "ltx:bib-part[@role='part']",
2442          punct:     ", ",
2443          pre:       "Part ",
2444          class:     "part",
2445          formatter: Formatter::Any,
2446          post:      "",
2447        },
2448        FieldSpec {
2449          xpath:     "ltx:bib-publisher",
2450          punct:     ", ",
2451          pre:       "",
2452          class:     "publisher",
2453          formatter: Formatter::Any,
2454          post:      "",
2455        },
2456        FieldSpec {
2457          xpath:     "ltx:bib-organization",
2458          punct:     ", ",
2459          pre:       "",
2460          class:     "publisher",
2461          formatter: Formatter::Any,
2462          post:      "",
2463        },
2464        FieldSpec {
2465          xpath:     "ltx:bib-place",
2466          punct:     ", ",
2467          pre:       "",
2468          class:     "place",
2469          formatter: Formatter::Any,
2470          post:      "",
2471        },
2472        FieldSpec {
2473          xpath:     "ltx:bib-status",
2474          punct:     ", ",
2475          pre:       "(",
2476          class:     "status",
2477          formatter: Formatter::Any,
2478          post:      ")",
2479        },
2480        FieldSpec {
2481          xpath:     "ltx:bib-language",
2482          punct:     ", ",
2483          pre:       "(",
2484          class:     "language",
2485          formatter: Formatter::Any,
2486          post:      ")",
2487        },
2488        FieldSpec {
2489          xpath:     "true",
2490          punct:     ".",
2491          pre:       "",
2492          class:     "",
2493          formatter: Formatter::None,
2494          post:      "",
2495        },
2496      ],
2497    ],
2498    "website" => vec![
2499      vec![
2500        FieldSpec {
2501          xpath:     "ltx:bib-name[@role='author']",
2502          punct:     "",
2503          pre:       "",
2504          class:     "author",
2505          formatter: Formatter::Authors,
2506          post:      "",
2507        },
2508        FieldSpec {
2509          xpath:     "ltx:bib-name[@role='editor']",
2510          punct:     "",
2511          pre:       "",
2512          class:     "editor",
2513          formatter: Formatter::EditorsA,
2514          post:      "",
2515        },
2516        FieldSpec {
2517          xpath:     "ltx:bib-date[@role='publication']",
2518          punct:     "",
2519          pre:       "",
2520          class:     "year",
2521          formatter: Formatter::Year,
2522          post:      "",
2523        },
2524        FieldSpec {
2525          xpath:     "ltx:bib-title",
2526          punct:     "",
2527          pre:       "",
2528          class:     "title",
2529          formatter: Formatter::Any,
2530          post:      "",
2531        },
2532        FieldSpec {
2533          xpath:     "ltx:bib-type",
2534          punct:     "",
2535          pre:       "",
2536          class:     "type",
2537          formatter: Formatter::Any,
2538          post:      "",
2539        },
2540        FieldSpec {
2541          xpath:     "! ltx:bib-type",
2542          punct:     "",
2543          pre:       "",
2544          class:     "type",
2545          formatter: Formatter::None,
2546          post:      "(Website)",
2547        },
2548      ],
2549      vec![
2550        FieldSpec {
2551          xpath:     "ltx:bib-organization",
2552          punct:     ", ",
2553          pre:       " ",
2554          class:     "publisher",
2555          formatter: Formatter::Any,
2556          post:      "",
2557        },
2558        FieldSpec {
2559          xpath:     "ltx:bib-place",
2560          punct:     ", ",
2561          pre:       "",
2562          class:     "place",
2563          formatter: Formatter::Any,
2564          post:      "",
2565        },
2566        FieldSpec {
2567          xpath:     "true",
2568          punct:     ".",
2569          pre:       "",
2570          class:     "",
2571          formatter: Formatter::None,
2572          post:      "",
2573        },
2574      ],
2575    ],
2576    "software" => vec![
2577      vec![
2578        FieldSpec {
2579          xpath:     "ltx:bib-key",
2580          punct:     "",
2581          pre:       "",
2582          class:     "key",
2583          formatter: Formatter::Any,
2584          post:      "",
2585        },
2586        FieldSpec {
2587          xpath:     "ltx:bib-type",
2588          punct:     "",
2589          pre:       "",
2590          class:     "type",
2591          formatter: Formatter::Type,
2592          post:      "",
2593        },
2594      ],
2595      vec![FieldSpec {
2596        xpath:     "ltx:bib-title",
2597        punct:     "",
2598        pre:       "",
2599        class:     "title",
2600        formatter: Formatter::Any,
2601        post:      "",
2602      }],
2603      vec![
2604        FieldSpec {
2605          xpath:     "ltx:bib-organization",
2606          punct:     ", ",
2607          pre:       " ",
2608          class:     "publisher",
2609          formatter: Formatter::Any,
2610          post:      "",
2611        },
2612        FieldSpec {
2613          xpath:     "ltx:bib-place",
2614          punct:     ", ",
2615          pre:       "",
2616          class:     "place",
2617          formatter: Formatter::Any,
2618          post:      "",
2619        },
2620        FieldSpec {
2621          xpath:     "true",
2622          punct:     ".",
2623          pre:       "",
2624          class:     "",
2625          formatter: Formatter::None,
2626          post:      "",
2627        },
2628      ],
2629    ],
2630    _ => vec![
2631      // Default: same as book
2632      vec![
2633        FieldSpec {
2634          xpath:     "ltx:bib-name[@role='author']",
2635          punct:     "",
2636          pre:       "",
2637          class:     "author",
2638          formatter: Formatter::Authors,
2639          post:      "",
2640        },
2641        FieldSpec {
2642          xpath:     "ltx:bib-date[@role='publication']",
2643          punct:     "",
2644          pre:       "",
2645          class:     "year",
2646          formatter: Formatter::Year,
2647          post:      "",
2648        },
2649      ],
2650      vec![FieldSpec {
2651        xpath:     "ltx:bib-title",
2652        punct:     "",
2653        pre:       "",
2654        class:     "title",
2655        formatter: Formatter::Title,
2656        post:      ".",
2657      }],
2658    ],
2659  };
2660  blocks.extend(meta_block);
2661  blocks
2662}
2663
2664// ======================================================================
2665// Formatting helpers
2666
2667/// The renderable content of one bibliography field node.
2668///
2669/// Perl's field formatters are all `do_any`-shaped — they receive
2670/// `$doc->cloneNodes(@nodes)` and return the CLONED NODES (`MakeBibliography.pm`
2671/// L525-531, L550-552), so an `ltx:ref`/`ltx:emph`/`ltx:Math` inside a field
2672/// reaches the bibitem intact. Taking `get_content()` here instead threw all of
2673/// that away and kept only the text, which is how `note = {\url{...}}` rendered
2674/// as dead text even once the field XML carried a proper link.
2675///
2676/// Fields whose content is plain text — the overwhelming majority — keep
2677/// returning a single `Text` node, so their output is byte-identical to before;
2678/// only a field that actually holds markup takes the cloning path. (Perl clones
2679/// the field element itself and lets the XSLT render it transparently; we clone
2680/// its CHILDREN, which yields the same HTML without changing our flatter
2681/// intermediate shape.)
2682fn field_content(node: &Node) -> Vec<NodeData> {
2683  let mut children = Vec::new();
2684  let mut child = node.get_first_child();
2685  let mut has_element = false;
2686  while let Some(n) = child {
2687    has_element |= n.get_type() == Some(libxml::tree::NodeType::ElementNode);
2688    child = n.get_next_sibling();
2689    children.push(n);
2690  }
2691  if has_element {
2692    children.into_iter().map(NodeData::XmlNode).collect()
2693  } else {
2694    vec![NodeData::Text(node.get_content())]
2695  }
2696}
2697
2698/// Apply a formatter function to the given nodes.
2699///
2700/// Port of the various `do_*` functions.
2701fn apply_formatter(doc: &PostDocument, formatter: Formatter, nodes: &[Node]) -> Vec<NodeData> {
2702  match formatter {
2703    Formatter::Any => nodes.iter().flat_map(field_content).collect(),
2704    Formatter::Authors => format_author_nodes(doc, nodes),
2705    Formatter::EditorsA => {
2706      let mut result = format_author_nodes(doc, nodes);
2707      let suffix = if nodes.len() > 1 { " (Eds.)" } else { " (Ed.)" };
2708      result.push(NodeData::Text(suffix.to_string()));
2709      result
2710    },
2711    Formatter::EditorsB => {
2712      let mut result = vec![NodeData::Text("(".to_string())];
2713      result.extend(format_author_nodes(doc, nodes));
2714      let suffix = if nodes.len() > 1 { " Eds.)" } else { " Ed.)" };
2715      result.push(NodeData::Text(suffix.to_string()));
2716      result
2717    },
2718    Formatter::Year => {
2719      // NO disambiguation suffix here, deliberately — see KNOWN_PERL_ERRORS
2720      // #67. Perl `do_year` (L613-615) reads `@…::SUFFIX`, the ARRAY, while
2721      // `formatBibEntry` L417 binds `$…::SUFFIX`, the SCALAR: two different
2722      // Perl variables, so the array is always empty and the letter never
2723      // reaches the entry body. Measured, not assumed — same-host Perl 0.8.8
2724      // on `bib_alpha_style.tex` prints `[SBC99a]` as the label and ` (1999)`
2725      // as the body year. `alpha.bst` agrees, so this is the right output and
2726      // there is no gap to close; the audit item that flagged one was read off
2727      // the sigil.
2728      let suffix = "";
2729      let content: Vec<NodeData> = nodes
2730        .iter()
2731        .map(|n| {
2732          let text = n.get_content();
2733          let year = extract_four_digit_year(&text);
2734          NodeData::Text(year)
2735        })
2736        .collect();
2737      let mut result = vec![NodeData::Text(" (".to_string())];
2738      result.extend(content);
2739      result.push(NodeData::Text(format!("{})", suffix)));
2740      result
2741    },
2742    Formatter::Type => {
2743      let mut result = vec![NodeData::Text("(".to_string())];
2744      result.extend(nodes.iter().flat_map(field_content));
2745      result.push(NodeData::Text(")".to_string()));
2746      result
2747    },
2748    Formatter::Title => nodes.iter().flat_map(field_content).collect(),
2749    Formatter::ThesisType => nodes.iter().flat_map(field_content).collect(),
2750    Formatter::Edition => {
2751      let mut result: Vec<NodeData> = nodes.iter().flat_map(field_content).collect();
2752      result.push(NodeData::Text(" edition".to_string()));
2753      result
2754    },
2755    Formatter::Pages => {
2756      let mut result = vec![NodeData::Text("pp.\u{00A0}".to_string())]; // Non-breaking space
2757      result.extend(nodes.iter().flat_map(field_content));
2758      result
2759    },
2760    Formatter::CrossRef => {
2761      // Port of do_crossref
2762      if let Some(node) = nodes.first() {
2763        if let Some(bibrefs) = node.get_attribute("bibrefs") {
2764          return vec![NodeData::Element {
2765            tag:        "ltx:cite".to_string(),
2766            attributes: None,
2767            children:   vec![NodeData::Element {
2768              tag:        "ltx:bibref".to_string(),
2769              attributes: Some(HashMap::from_iter([
2770                ("bibrefs".to_string(), bibrefs),
2771                ("show".to_string(), "title, author".to_string()),
2772              ])),
2773              children:   vec![],
2774            }],
2775          }];
2776        }
2777      }
2778      Vec::new()
2779    },
2780    Formatter::Links => format_links(doc, nodes),
2781    Formatter::None => Vec::new(),
2782  }
2783}
2784
2785/// Format author name nodes.
2786///
2787/// Port of `do_names` / `do_name`.
2788fn format_author_nodes(_doc: &PostDocument, name_nodes: &[Node]) -> Vec<NodeData> {
2789  let mut result: Vec<NodeData> = Vec::new();
2790  let mut names: Vec<Node> = name_nodes.to_vec();
2791
2792  // Check for "others" sentinel (et al.)
2793  let etal = names
2794    .last()
2795    .map(|n| n.get_content().trim() == "others")
2796    .unwrap_or(false);
2797  if etal {
2798    names.pop();
2799  }
2800
2801  let sep = if names.len() > 2 { ", " } else { " " };
2802
2803  for (i, name) in names.iter().enumerate() {
2804    if i > 0 {
2805      result.push(NodeData::Text(sep.to_string()));
2806      if !etal && i == names.len() - 1 {
2807        result.push(NodeData::Text("and ".to_string()));
2808      }
2809    }
2810    // Format single name: initials + surname
2811    if let Some(givenname) = PostDocument::findnodes_foreign("ltx:givenname", name)
2812      .into_iter()
2813      .next()
2814    {
2815      let given_text = givenname.get_content();
2816      let initials: String = given_text
2817        .split_whitespace()
2818        .map(|word| {
2819          if word.ends_with('.') {
2820            format!("{} ", word)
2821          } else if let Some(first) = word.chars().next() {
2822            format!("{}. ", first)
2823          } else {
2824            String::new()
2825          }
2826        })
2827        .collect();
2828      result.push(NodeData::Text(initials));
2829    }
2830    if let Some(surname) = PostDocument::findnodes_foreign("ltx:surname", name)
2831      .into_iter()
2832      .next()
2833    {
2834      result.push(NodeData::Text(surname.get_content()));
2835    }
2836  }
2837
2838  if etal {
2839    result.push(NodeData::Text(sep.to_string()));
2840    result.push(NodeData::Element {
2841      tag:        "ltx:text".to_string(),
2842      attributes: Some(HashMap::from_iter([(
2843        "class".to_string(),
2844        "ltx_bib_etal".to_string(),
2845      )])),
2846      children:   vec![NodeData::Text("et al.".to_string())],
2847    });
2848  }
2849
2850  result
2851}
2852
2853/// Format external links.
2854///
2855/// Port of `do_links`.
2856fn format_links(doc: &PostDocument, nodes: &[Node]) -> Vec<NodeData> {
2857  let mut links: Vec<NodeData> = Vec::new();
2858
2859  for node in nodes {
2860    let tag = doc.get_qname(node).unwrap_or_default();
2861    let scheme = node.get_attribute("scheme").unwrap_or_default();
2862    let href = node.get_attribute("href");
2863    let content_text = node.get_content();
2864
2865    // General rule (user, 2026-07-04): bibliography links are EXTERNAL.
2866    // DOIs always resolve via https://doi.org/, and scheme-less hrefs are
2867    // an authoring mistake that would resolve relative to the article —
2868    // normalize here so every source path (post .bib conversion,
2869    // .bbl-borne XML, pre-compiled .bib.xml) gets absolute links.
2870    let href = match (&href, scheme.as_str()) {
2871      (None, "doi") if !content_text.trim().is_empty() && content_text.contains('/') => {
2872        Some(doi_href(&content_text))
2873      },
2874      (Some(h), "doi") if !h.contains("://") => Some(doi_href(h.trim_start_matches('/'))),
2875      (Some(h), _) => Some(force_absolute_url(h)),
2876      (None, _) => None,
2877    };
2878    // Perl `MakeBibliography.pm:do_links` L655-667 uses
2879    // `$doc->cloneNodes($node->childNodes)` as the child list in EVERY branch —
2880    // it copies the marked-up children, it does not flatten them. Taking
2881    // `get_content()` (a plain-text collapse) instead silently dropped any
2882    // nested element: an amsrefs `review={\MR{849427}}` digests to
2883    // `<ltx:bib-review>Review <ltx:ref class="ltx_mathreviews" href="…">
2884    // MathReviews</ltx:ref></ltx:bib-review>`, and the flattening rendered a
2885    // dead "Review MathReviews" with the MathSciNet link gone. Witness
2886    // arXiv 2508.17585.
2887    let children: Vec<NodeData> = node
2888      .get_child_nodes()
2889      .into_iter()
2890      .map(NodeData::XmlNode)
2891      .collect();
2892    match tag.as_str() {
2893      "ltx:bib-identifier" | "ltx:bib-review" => {
2894        if let Some(href) = href {
2895          links.push(NodeData::Element {
2896            tag: "ltx:ref".to_string(),
2897            attributes: Some(HashMap::from_iter([
2898              ("href".to_string(), href),
2899              ("class".to_string(), format!("{} ltx_bib_external", scheme)),
2900            ])),
2901            children,
2902          });
2903        } else {
2904          links.push(NodeData::Element {
2905            tag: "ltx:text".to_string(),
2906            attributes: Some(HashMap::from_iter([(
2907              "class".to_string(),
2908              format!("{} ltx_bib_external", scheme),
2909            )])),
2910            children,
2911          });
2912        }
2913      },
2914      "ltx:bib-links" => {
2915        links.push(NodeData::Element {
2916          tag: "ltx:text".to_string(),
2917          attributes: Some(HashMap::from_iter([(
2918            "class".to_string(),
2919            "ltx_bib_external".to_string(),
2920          )])),
2921          children,
2922        });
2923      },
2924      "ltx:bib-url" => {
2925        if let Some(href) = href {
2926          links.push(NodeData::Element {
2927            tag: "ltx:ref".to_string(),
2928            attributes: Some(HashMap::from_iter([
2929              ("href".to_string(), href),
2930              ("class".to_string(), "ltx_bib_external".to_string()),
2931            ])),
2932            children,
2933          });
2934        }
2935      },
2936      _ => {},
2937    }
2938  }
2939
2940  // Join with ",\n"
2941  if links.len() > 1 {
2942    let mut result = Vec::new();
2943    for (i, link) in links.into_iter().enumerate() {
2944      if i > 0 {
2945        result.push(NodeData::Text(",\n".to_string()));
2946      }
2947      result.push(link);
2948    }
2949    result
2950  } else {
2951    links
2952  }
2953}
2954
2955// ======================================================================
2956// Utility functions
2957
2958/// Sort bibliography sort-keys the way Perl's `Post::unisort` does.
2959///
2960/// Perl (`Post.pm` L1399-1403) hands the keys to a `Unicode::Collate::Locale`
2961/// built with `variable => 'non-ignorable'` and `upper_before_lower => 1` — a
2962/// full UCA collation, so `Šmith` lands next to `Smith` rather than after every
2963/// ASCII letter, which is what a plain codepoint `sort()` produces.
2964///
2965/// **Documented approximation** (OXIDIZED_DESIGN #84): we reproduce UCA's
2966/// PRIMARY level for Latin script only — NFD-decompose, drop the combining
2967/// marks, case-fold — and break ties on the raw string. That is exact for
2968/// accented Latin (the input this actually sees: author surnames and titles).
2969/// Perl's `upper_before_lower` needs no counterpart because it is MOOT: the
2970/// sort keys are `to_lowercase()`d when built, so no comparison here can see a
2971/// case difference. It diverges from Perl for script-crossing orders,
2972/// non-decomposable letters (`Ø`, `Æ`, `Ł`) and locale tailorings — none of
2973/// which a DUCET table could be added for without a new dependency carrying
2974/// embedded collation data. `Post.pm` L123-128 shows Perl itself falling back
2975/// to a codepoint `DumbCollator` when `Unicode::Collate` is unavailable, so a
2976/// documented approximation stays inside the range of behaviours Perl ships.
2977///
2978/// The sort is otherwise total and deterministic: equal primary keys fall back
2979/// to the raw key, and the raw keys are the (unique) hash keys of the entry
2980/// map.
2981fn unisort(keys: &mut [String]) {
2982  keys.sort_by_cached_key(|k| (collation_primary_key(k), k.clone()));
2983}
2984
2985/// Whether a `\bibliographystyle` produces an UNSORTED (citation-order)
2986/// bibliography — bibtex's `sort='false'` styles. Kept in sync with the
2987/// engine's `lookup_bibstyle_params` (`latex_constructs.rs`): the two live in
2988/// different crates, but both encode the same small, stable bibtex fact.
2989/// `unsrt`/`unsrtnat` are the base-table unsorted styles; `ieeetr`/`IEEEtran`
2990/// are the surpass-Perl additions matching the real IEEE `.bst` + PDF.
2991fn is_citation_order_style(bibstyle: &str) -> bool {
2992  matches!(bibstyle, "unsrt" | "unsrtnat" | "ieeetr" | "IEEEtran")
2993}
2994
2995/// First-citation order of bib keys (lowercased) → 0-based rank, read from the
2996/// document's inline `<ltx:bibref>`s in reading order.
2997///
2998/// This is bibtex's `\citation`-record order for `\cite`: an UNSORTED `.bst`
2999/// (`unsrt`/`unsrtnat`/`ieeetr`/`IEEEtran`) numbers the References by it. bibrefs
3000/// INSIDE the bibliography (a `\bibitem` crossref, the "Cited by" back-links)
3001/// are excluded via `not(ancestor::ltx:bibliography)` so only real in-text
3002/// citations count. Verified key-for-key against pdflatex+bibtex on witness
3003/// arXiv 2510.05438.
3004///
3005/// `\nocite` emits its bibref too, but BOTH engines defer it to end-of-document
3006/// (`\nocite`→`@at@end@document`), so a mid-document `\nocite` ranks after the
3007/// cited entries rather than at bibtex's `\nocite` position — a documented
3008/// shared-Perl residual (OXIDIZED_DESIGN #116), not exact bibtex parity.
3009fn citation_order(doc: &PostDocument) -> HashMap<String, usize> {
3010  let mut order: HashMap<String, usize> = HashMap::default();
3011  let mut next = 0usize;
3012  for node in doc.findnodes("//ltx:bibref[not(ancestor::ltx:bibliography)]") {
3013    let Some(refs) = node.get_attribute("bibrefs") else {
3014      continue;
3015    };
3016    for key in refs.split(',') {
3017      let k = key.trim().to_lowercase();
3018      if k.is_empty() {
3019        continue;
3020      }
3021      order.entry(k).or_insert_with(|| {
3022        let i = next;
3023        next += 1;
3024        i
3025      });
3026    }
3027  }
3028  order
3029}
3030
3031/// The order to number entries in. With `cite_order = Some(..)` (a `sort='false'`
3032/// style) cited entries come first in first-citation order, and any entry not
3033/// directly cited — pulled in transitively (a crossref) or via `\nocite{*}` —
3034/// falls to the end in the usual `unisort` (alphabetical) order, since it has no
3035/// citation position. Otherwise plain `unisort` (Perl's always-alphabetical).
3036fn order_entry_keys(
3037  entries: &HashMap<String, BibEntryData>,
3038  cite_order: Option<&HashMap<String, usize>>,
3039) -> Vec<String> {
3040  match cite_order {
3041    Some(order) => {
3042      let mut cited: Vec<(usize, String)> = Vec::new();
3043      let mut uncited: Vec<String> = Vec::new();
3044      for (sort_key, entry) in entries {
3045        match order.get(&entry.bib_key.to_lowercase()) {
3046          Some(&idx) => cited.push((idx, sort_key.clone())),
3047          None => uncited.push(sort_key.clone()),
3048        }
3049      }
3050      cited.sort_by_key(|(idx, _)| *idx);
3051      unisort(&mut uncited);
3052      cited.into_iter().map(|(_, k)| k).chain(uncited).collect()
3053    },
3054    None => {
3055      let mut keys: Vec<String> = entries.keys().cloned().collect();
3056      unisort(&mut keys);
3057      keys
3058    },
3059  }
3060}
3061
3062/// The primary-level collation weight of a sort-key: NFD, combining marks
3063/// dropped, case-folded. See [`unisort`].
3064fn collation_primary_key(s: &str) -> String {
3065  use unicode_normalization::{UnicodeNormalization, char::is_combining_mark};
3066  s.nfd()
3067    .filter(|c| !is_combining_mark(*c))
3068    .flat_map(char::to_lowercase)
3069    .collect()
3070}
3071
3072/// Extract author names from a bibentry node.
3073///
3074/// Port of the name extraction logic in `getBibEntries`.
3075/// Returns (sort_names, short_names, full_names).
3076fn extract_names(doc: &PostDocument, bibentry: &Node) -> (String, String, String) {
3077  let mut name_nodes: Vec<Node> =
3078    PostDocument::findnodes_foreign("ltx:bib-name[@role='author']", bibentry);
3079  if name_nodes.is_empty() {
3080    name_nodes = PostDocument::findnodes_foreign("ltx:bib-name[@role='editor']", bibentry);
3081  }
3082
3083  if name_nodes.is_empty() {
3084    // Try bib-key
3085    if let Some(key_node) = PostDocument::findnodes_foreign("ltx:bib-key", bibentry)
3086      .into_iter()
3087      .next()
3088    {
3089      let text = key_node.get_content();
3090      return (text.clone(), text.clone(), text);
3091    }
3092    // Try bib-title
3093    if let Some(title_node) = PostDocument::findnodes_foreign("ltx:bib-title", bibentry)
3094      .into_iter()
3095      .next()
3096    {
3097      let text = title_node.get_content();
3098      return (text.clone(), text.clone(), text);
3099    }
3100    return (String::new(), String::new(), String::new());
3101  }
3102
3103  // Sort names: "Surname Givenname" for each
3104  let sort_names: String = name_nodes
3105    .iter()
3106    .map(|n| get_name_text(doc, n))
3107    .collect::<Vec<_>>()
3108    .join(" ");
3109
3110  // Short names: surnames only, with "et al" for >2
3111  let surnames: Vec<String> = name_nodes
3112    .iter()
3113    .filter_map(|n| {
3114      PostDocument::findnodes_foreign("ltx:surname", n)
3115        .into_iter()
3116        .next()
3117        .map(|s| s.get_content())
3118    })
3119    .collect();
3120
3121  let short_names = if surnames.len() > 2 {
3122    format!("{} et al", surnames[0])
3123  } else if surnames.len() == 2 {
3124    format!("{} and {}", surnames[0], surnames[1])
3125  } else if !surnames.is_empty() {
3126    surnames[0].clone()
3127  } else {
3128    String::new()
3129  };
3130
3131  let full_names = surnames.join(", ");
3132  (sort_names, short_names, full_names)
3133}
3134
3135/// Perl MakeBibliography `do_name` (L555-566): the given-name rendered as
3136/// initials followed by the surname text — e.g. givenname "Aaron D." + surname
3137/// "Ames" → "A. D. Ames". Each whitespace-split given-name word already ending
3138/// in "." is kept verbatim (+ space); otherwise its first char + ". " is used.
3139/// (We flatten the surname to text, consistent with the rest of this file's
3140/// name handling; Perl clones the surname's child nodes to preserve any markup,
3141/// which bibliography surnames essentially never carry.)
3142fn do_name_text(namenode: &Node) -> String {
3143  let mut out = String::new();
3144  if let Some(given) = PostDocument::findnodes_foreign("ltx:givenname", namenode)
3145    .into_iter()
3146    .next()
3147  {
3148    for word in given.get_content().split_whitespace() {
3149      if word.ends_with('.') {
3150        out.push_str(word);
3151        out.push(' ');
3152      } else if let Some(c) = word.chars().next() {
3153        out.push(c);
3154        out.push_str(". ");
3155      }
3156    }
3157  }
3158  if let Some(surname) = PostDocument::findnodes_foreign("ltx:surname", namenode)
3159    .into_iter()
3160    .next()
3161  {
3162    out.push_str(&surname.get_content());
3163  }
3164  out
3165}
3166
3167/// The SHORT author form used for the author-year citation label: surnames
3168/// only, `>2` collapsing to "First et al.".
3169///
3170/// Port of Perl's `do_names_short` (MakeBibliography.pm L586-593) — which is
3171/// **defined there and never called**; Perl's author-year refnum uses the full
3172/// `do_names` instead, producing labels thousands of characters long on
3173/// collaboration papers. See OXIDIZED_DESIGN #71 for why we call it, and
3174/// `cluster_bib_long_author_list_refnum` for the guard.
3175///
3176/// Beyond Perl's version: a trailing BibTeX `others` is dropped and forces the
3177/// "et al." form, so `Smith and others` reads "Smith et al." rather than
3178/// "Smith and others". Perl's unused helper has no `others` handling at all;
3179/// `do_names` (its called sibling) does, so this keeps the two consistent.
3180fn do_names_short(mut names: Vec<Node>) -> Vec<NodeData> {
3181  let surname_text = |n: &Node| -> String {
3182    PostDocument::findnodes_foreign("ltx:surname", n)
3183      .into_iter()
3184      .next()
3185      .map(|s| s.get_content())
3186      .unwrap_or_else(|| n.get_content())
3187      .trim()
3188      .to_string()
3189  };
3190  let mut etal = names
3191    .last()
3192    .map(|n| n.get_content().trim() == "others")
3193    .unwrap_or(false);
3194  if etal {
3195    names.pop();
3196  }
3197  if names.len() > 2 {
3198    etal = true;
3199  }
3200  let etal_span = || NodeData::Element {
3201    tag:        "ltx:text".to_string(),
3202    attributes: Some(HashMap::from_iter([(
3203      "class".to_string(),
3204      "ltx_bib_etal".to_string(),
3205    )])),
3206    children:   vec![NodeData::Text("et al.".to_string())],
3207  };
3208  match (names.len(), etal) {
3209    (0, _) => Vec::new(),
3210    (_, true) => vec![
3211      NodeData::Text(surname_text(&names[0])),
3212      NodeData::Text(" ".to_string()),
3213      etal_span(),
3214    ],
3215    (1, false) => vec![NodeData::Text(surname_text(&names[0]))],
3216    (_, false) => vec![
3217      NodeData::Text(surname_text(&names[0])),
3218      NodeData::Text(" and ".to_string()),
3219      NodeData::Text(surname_text(&names[1])),
3220    ],
3221  }
3222}
3223
3224/// Perl MakeBibliography `do_names` (L568-584) / `do_authors` (L595-597): the
3225/// full author list for the author-year refnum. Each name is rendered via
3226/// `do_name` (initials + surname) and joined with ", " (>2 names) or " " (≤2),
3227/// with "and " before the last; a trailing BibTeX "others" (from "and others")
3228/// collapses to a trailing "et al." span instead.
3229fn do_names(mut names: Vec<Node>) -> Vec<NodeData> {
3230  let sep = if names.len() > 2 { ", " } else { " " };
3231  let mut etal = false;
3232  if names
3233    .last()
3234    .map(|n| n.get_content().trim() == "others")
3235    .unwrap_or(false)
3236  {
3237    names.pop();
3238    etal = true;
3239  }
3240  let last = names.len().saturating_sub(1);
3241  let mut out: Vec<NodeData> = Vec::new();
3242  for (i, name) in names.iter().enumerate() {
3243    if !out.is_empty() {
3244      out.push(NodeData::Text(sep.to_string()));
3245      if !etal && i == last {
3246        out.push(NodeData::Text("and ".to_string()));
3247      }
3248    }
3249    out.push(NodeData::Text(do_name_text(name)));
3250  }
3251  if etal {
3252    out.push(NodeData::Text(sep.to_string()));
3253    out.push(NodeData::Element {
3254      tag:        "ltx:text".to_string(),
3255      attributes: Some(HashMap::from_iter([(
3256        "class".to_string(),
3257        "ltx_bib_etal".to_string(),
3258      )])),
3259      children:   vec![NodeData::Text("et al.".to_string())],
3260    });
3261  }
3262  out
3263}
3264
3265/// Perl MakeBibliography `do_editorsA` (L599-604): `do_names` plus a trailing
3266/// " (Eds.)" (more than one editor) or " (Ed.)" (a single editor).
3267fn do_editors_a(names: Vec<Node>) -> Vec<NodeData> {
3268  let n = names.len();
3269  let mut out = do_names(names);
3270  if n > 1 {
3271    out.push(NodeData::Text(" (Eds.)".to_string()));
3272  } else if n == 1 {
3273    out.push(NodeData::Text(" (Ed.)".to_string()));
3274  }
3275  out
3276}
3277
3278/// Get sort-friendly name text from a bib-name node.
3279///
3280/// Port of `getNameText`.
3281fn get_name_text(_doc: &PostDocument, namenode: &Node) -> String {
3282  let surname = PostDocument::findnodes_foreign("ltx:surname", namenode)
3283    .into_iter()
3284    .next()
3285    .map(|n| n.get_content());
3286  let givenname = PostDocument::findnodes_foreign("ltx:givenname", namenode)
3287    .into_iter()
3288    .next()
3289    .map(|n| n.get_content());
3290  match (surname, givenname) {
3291    (Some(s), Some(g)) => format!("{} {}", s, g),
3292    (Some(s), None) => s,
3293    (None, Some(g)) => g,
3294    (None, None) => String::new(),
3295  }
3296}
3297
3298/// Extract a 4-digit year from a date string.
3299fn extract_four_digit_year(text: &str) -> String {
3300  if let Some(start) = text.find(|c: char| c.is_ascii_digit()) {
3301    let digits: String = text[start..]
3302      .chars()
3303      .take_while(|c| c.is_ascii_digit())
3304      .collect();
3305    if digits.len() >= 4 {
3306      return digits[..4].to_string();
3307    }
3308  }
3309  text.to_string()
3310}
3311
3312/// Convert a suffix string back to a counter value.
3313fn suffix_to_counter(suffix: &str) -> u32 {
3314  let mut n = 0u32;
3315  for c in suffix.chars() {
3316    n = n * 26 + (c as u32 - 'a' as u32 + 1);
3317  }
3318  n
3319}
3320
3321/// Check if metadata field matches an XPath-like selector.
3322fn match_metadata_field(xpath: &str, entry: &BibEntryData) -> bool {
3323  let xpath = xpath.trim_start_matches('!').trim();
3324  match xpath {
3325    "true" => true,
3326    s if s.contains("bib-name[@role='author']") => !entry.authors_short.is_empty(),
3327    s if s.contains("bib-name[@role='editor']") => false, // No editor in metadata
3328    s if s.contains("bib-date[@role='publication']") => !entry.year.is_empty(),
3329    s if s.contains("bib-title") => !entry.title.is_empty(),
3330    _ => false,
3331  }
3332}
3333
3334/// Get content from metadata fields matching an XPath-like selector.
3335fn get_metadata_content(xpath: &str, entry: &BibEntryData) -> Vec<NodeData> {
3336  let xpath = xpath.trim_start_matches('!').trim();
3337  match xpath {
3338    s if s.contains("bib-name[@role='author']") && !entry.authors_full.is_empty() => {
3339      vec![NodeData::Text(format_authors_text(&entry.authors_full))]
3340    },
3341    s if s.contains("bib-date[@role='publication']") && !entry.year.is_empty() => {
3342      vec![NodeData::Text(entry.year.clone())]
3343    },
3344    s if s.contains("bib-title") && !entry.title.is_empty() => {
3345      vec![NodeData::Text(entry.title.clone())]
3346    },
3347    _ => Vec::new(),
3348  }
3349}
3350
3351/// Format author names for display (from metadata string).
3352fn format_authors_text(authors: &str) -> String {
3353  let names: Vec<&str> = authors.split(" and ").collect();
3354  let n = names.len();
3355  if n == 0 {
3356    return authors.to_string();
3357  }
3358
3359  let has_etal = names.last().map(|n| n.trim() == "others").unwrap_or(false);
3360  let real_names: Vec<&str> = if has_etal {
3361    names[..n - 1].to_vec()
3362  } else {
3363    names
3364  };
3365
3366  let formatted: Vec<String> = real_names
3367    .iter()
3368    .map(|name| format_single_name(name.trim()))
3369    .collect();
3370
3371  let mut result = String::new();
3372  let sep = if formatted.len() > 2 { ", " } else { " " };
3373  for (i, name) in formatted.iter().enumerate() {
3374    if i > 0 {
3375      result.push_str(sep);
3376      if !has_etal && i == formatted.len() - 1 {
3377        result.push_str("and ");
3378      }
3379    }
3380    result.push_str(name);
3381  }
3382  if has_etal {
3383    result.push_str(sep);
3384    result.push_str("et al.");
3385  }
3386  result
3387}
3388
3389/// Format a single author name.
3390///
3391/// Port of `do_name`.
3392fn format_single_name(name: &str) -> String {
3393  if let Some((surname, given)) = name.split_once(',') {
3394    let surname = surname.trim();
3395    let initials: String = given
3396      .split_whitespace()
3397      .map(|word| {
3398        if word.ends_with('.') {
3399          format!("{} ", word)
3400        } else if let Some(first) = word.chars().next() {
3401          format!("{}. ", first)
3402        } else {
3403          String::new()
3404        }
3405      })
3406      .collect();
3407    format!("{}{}", initials, surname)
3408  } else {
3409    name.to_string()
3410  }
3411}
3412
3413/// Clone a BibEntryData (for split operation).
3414fn clone_entry(e: &BibEntryData) -> BibEntryData {
3415  BibEntryData {
3416    bib_key:       e.bib_key.clone(),
3417    cited_key:     e.cited_key.clone(),
3418    sort_key:      e.sort_key.clone(),
3419    initial:       e.initial.clone(),
3420    author_year:   e.author_year.clone(),
3421    suffix:        e.suffix.clone(),
3422    authors_short: e.authors_short.clone(),
3423    authors_full:  e.authors_full.clone(),
3424    sort_names:    e.sort_names.clone(),
3425    year:          e.year.clone(),
3426    title:         e.title.clone(),
3427    entry_type:    e.entry_type.clone(),
3428    number:        e.number,
3429    referrers:     e.referrers.clone(),
3430    bibreferrers:  e.bibreferrers.clone(),
3431    citations:     e.citations.clone(),
3432    bibentry:      e.bibentry.clone(),
3433  }
3434}
3435
3436/// Create a bibblock element with xml:space="preserve".
3437fn make_bibblock(class: &str, content: &[NodeData]) -> NodeData {
3438  let mut attrs = HashMap::default();
3439  attrs.insert("xml:space".to_string(), "preserve".to_string());
3440  if !class.is_empty() {
3441    attrs.insert("class".to_string(), class.to_string());
3442  }
3443  NodeData::Element {
3444    tag:        "ltx:bibblock".to_string(),
3445    attributes: Some(attrs),
3446    children:   content.to_vec(),
3447  }
3448}
3449
3450/// Find a file in the given search paths.
3451/// Resolve a bibliography source file (`.bib`/`.bbl`/`.bib.xml`).
3452///
3453/// Delegates to the core `pathname::find`, the faithful translation of Perl's
3454/// `pathname_find` (`LaTeXML/Util/Pathname.pm`): a strict-case search over `.`
3455/// plus the search paths, falling back to a CASE-INSENSITIVE directory scan when
3456/// no strict match exists (Perl's `return @paths ? @paths : @nocase_paths`).
3457/// The bibliography path previously used a bespoke case-SENSITIVE lookup, so an
3458/// author on a case-insensitive filesystem citing `\bibliography{EvoFlock.bib}`
3459/// against an on-disk `Evoflock.bib` (arXiv:2606.25280) lost the ENTIRE
3460/// reference list on Linux — a parity gap, since Perl's `pathname_find` resolves
3461/// it. Delegating restores parity (Perl resolves it silently too; no warning,
3462/// matching how the engine already resolves e.g. `PASJ95.STY`).
3463fn find_file(name: &str, search_paths: &[String]) -> Option<String> {
3464  latexml_core::util::pathname::find(name, latexml_core::util::pathname::PathnameFindOptions {
3465    paths: Some(search_paths.to_vec()),
3466    ..Default::default()
3467  })
3468}
3469
3470/// Percent-encode a DOI into its canonical absolute resolver URL.
3471/// Mirrors the engine's `\bib@field@default@doi` (bibtex.rs; Perl
3472/// BibTeX.pool L750-756): `[^0-9a-zA-Z./\-+]` chars are %-encoded.
3473fn doi_href(doi: &str) -> String {
3474  let mut href = String::from("https://doi.org/");
3475  for c in doi.trim().chars() {
3476    if c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '+') {
3477      href.push(c);
3478    } else {
3479      let mut buf = [0u8; 4];
3480      for &b in c.encode_utf8(&mut buf).as_bytes() {
3481        href.push_str(&format!("%{:02X}", b));
3482      }
3483    }
3484  }
3485  href
3486}
3487
3488/// Bibliography links are external: a scheme-less href would resolve
3489/// relative to the article. Prepend https:// when no scheme is present.
3490fn force_absolute_url(url: &str) -> String {
3491  let u = url.trim();
3492  if u.is_empty() || u.contains("://") || u.starts_with("mailto:") {
3493    u.to_string()
3494  } else {
3495    format!("https://{}", u)
3496  }
3497}
3498
3499#[cfg(test)]
3500mod tests {
3501  use super::*;
3502
3503  #[test]
3504  fn test_extract_four_digit_year() {
3505    assert_eq!(extract_four_digit_year("2024"), "2024");
3506    assert_eq!(
3507      extract_four_digit_year("Published in 2024, January"),
3508      "2024"
3509    );
3510    assert_eq!(extract_four_digit_year("99"), "99");
3511    assert_eq!(extract_four_digit_year(""), "");
3512  }
3513
3514  #[test]
3515  fn test_find_file_case_insensitive_bib() {
3516    // An author on a case-insensitive filesystem cites `\bibliography{EvoFlock.bib}`
3517    // but the file on disk is `Evoflock.bib` (arXiv:2606.25280). `find_file`
3518    // delegates to the core pathname resolver, which mirrors Perl's
3519    // case-insensitive fallback, so the reference list is recovered on
3520    // case-sensitive Linux too — a parity fix (Perl's pathname_find resolves it).
3521    let dir = std::env::temp_dir().join("lxo_bib_case_test");
3522    let _ = std::fs::create_dir_all(&dir);
3523    let on_disk = dir.join("Evoflock.bib");
3524    std::fs::write(&on_disk, "@article{k, title={T}}\n").unwrap();
3525    let dirs = vec![dir.to_str().unwrap().to_string()];
3526    // Exact-case still resolves.
3527    assert!(find_file("Evoflock.bib", &dirs).is_some());
3528    // Case-mismatched request resolves via the pathname fallback.
3529    let hit = find_file("EvoFlock.bib", &dirs);
3530    assert!(
3531      hit.is_some(),
3532      "case-mismatched bib filename should resolve via the pathname fallback"
3533    );
3534    assert!(hit.unwrap().to_lowercase().ends_with("evoflock.bib"));
3535    // A genuinely-absent file still returns None (no phantom match).
3536    assert!(find_file("NoSuchBib.bib", &dirs).is_none());
3537    let _ = std::fs::remove_dir_all(&dir);
3538  }
3539
3540  #[test]
3541  fn test_suffix_to_counter() {
3542    assert_eq!(suffix_to_counter("a"), 1);
3543    assert_eq!(suffix_to_counter("b"), 2);
3544    assert_eq!(suffix_to_counter("z"), 26);
3545    assert_eq!(suffix_to_counter("aa"), 27);
3546  }
3547
3548  #[test]
3549  fn test_format_single_name() {
3550    assert_eq!(format_single_name("Smith, John"), "J. Smith");
3551    assert_eq!(format_single_name("Smith, J."), "J. Smith");
3552    assert_eq!(format_single_name("Smith, John Robert"), "J. R. Smith");
3553    assert_eq!(format_single_name("Smith"), "Smith");
3554  }
3555
3556  #[test]
3557  fn test_format_authors_text() {
3558    assert_eq!(format_authors_text("Smith"), "Smith");
3559    assert_eq!(
3560      format_authors_text("Smith, John and Doe, Jane"),
3561      "J. Smith and J. Doe"
3562    );
3563    assert_eq!(
3564      format_authors_text("Smith, J. and Doe, J. and Roe, R."),
3565      "J. Smith, J. Doe, and R. Roe"
3566    );
3567  }
3568
3569  #[test]
3570  fn test_fmt_spec_coverage() {
3571    // Ensure all format types produce non-empty specs
3572    for fmt in &[
3573      "article",
3574      "book",
3575      "incollection",
3576      "report",
3577      "thesis",
3578      "website",
3579      "software",
3580    ] {
3581      let specs = get_fmt_spec(fmt);
3582      assert!(
3583        !specs.is_empty(),
3584        "FMT_SPEC for '{}' should not be empty",
3585        fmt
3586      );
3587    }
3588  }
3589}