Skip to main content

latexml_post/
make_index.rs

1//! Index generation processor.
2//!
3//! Port of `LaTeXML::Post::MakeIndex` (504 lines of Perl).
4//! Collects INDEX:* entries from the ObjectDB, builds a tree of index entries
5//! grouped by initial letter, and fills in `ltx:index` and `ltx:glossary` elements.
6//! Supports permuted indexes, splitting by initial, see-also references,
7//! range-style page references, and glossary entry formatting.
8
9use libxml::tree::{Node, NodeType};
10use rustc_hash::FxHashMap as HashMap;
11use unicode_normalization::UnicodeNormalization;
12
13use crate::{
14  document::{NodeData, PostDocument},
15  object_db::{ObjectDB, Value},
16  processor::{ProcessResult, Processor},
17};
18
19/// A see/see-also cross reference extracted from an `ltx:indexsee` node.
20#[derive(Debug)]
21struct SeeAlso {
22  /// The cross-reference word from the `name` attribute ("see", "see also").
23  name: Option<String>,
24  /// Normalized text content, used to resolve the target entry.
25  text: String,
26  /// The original node, for re-rendering the phrase with its markup.
27  node: Option<Node>,
28}
29
30/// One level of an index phrase, as collected by Scan: the sort/merge
31/// key plus the original `ltx:indexphrase` node (when available) for
32/// markup-preserving rendering.
33#[derive(Debug, Clone)]
34struct PhraseRef {
35  key:  String,
36  text: String,
37  node: Option<Node>,
38}
39
40/// Index tree node.
41#[derive(Debug)]
42struct IndexTree {
43  id:               String,
44  key:              Option<String>,
45  full_key:         Option<String>,
46  phrase:           Option<String>,
47  phrase_node:      Option<Node>,
48  phrase_text:      Option<String>,
49  full_phrase_text: Option<String>,
50  subtrees:         HashMap<String, IndexTree>,
51  referrers:        HashMap<String, HashMap<String, bool>>,
52  see_also:         Vec<SeeAlso>,
53}
54
55impl IndexTree {
56  fn new(id: &str) -> Self {
57    IndexTree {
58      id:               id.to_string(),
59      key:              None,
60      full_key:         None,
61      phrase:           None,
62      phrase_node:      None,
63      phrase_text:      None,
64      full_phrase_text: None,
65      subtrees:         HashMap::default(),
66      referrers:        HashMap::default(),
67      see_also:         Vec::new(),
68    }
69  }
70}
71
72/// A glossary entry ready for rendering.
73struct GlossaryEntry {
74  initial:   String,
75  sort_key:  String,
76  formatted: NodeData,
77}
78
79/// MakeIndex post-processor.
80///
81/// Port of `LaTeXML::Post::MakeIndex`.
82pub struct MakeIndex {
83  name:     String,
84  pub db:   ObjectDB,
85  permuted: bool,
86  split:    bool,
87}
88
89impl MakeIndex {
90  pub fn new(db: ObjectDB, split: bool, permuted: bool) -> Self {
91    MakeIndex {
92      name: "MakeIndex".to_string(),
93      db,
94      split,
95      permuted,
96    }
97  }
98
99  /// Build the index tree from ObjectDB INDEX:* entries.
100  ///
101  /// Port of `MakeIndex::build_tree`.
102  fn build_tree(&self, index_id: &str) -> Option<(IndexTree, HashMap<String, String>)> {
103    let keys: Vec<String> = self
104      .db
105      .get_keys()
106      .into_iter()
107      .filter(|k| k.starts_with("INDEX:"))
108      .cloned()
109      .collect();
110    if keys.is_empty() {
111      return None;
112    }
113    Info!("make_index", "count", "MakeIndex: {} entries", keys.len());
114
115    let mut all_phrases: HashMap<String, String> = HashMap::default();
116    let mut tree = IndexTree::new(index_id);
117    // Perl's final rescan runs every generated id through uniquifyID;
118    // we dedup inline. Distinct phrases can strip to the same
119    // alphanumeric key id (e.g. "probability density, ε-biased" with
120    // and without the space → idx.probabilitydensityepsilonbiased),
121    // which would otherwise emit a duplicate HTML id. Seeded with the
122    // index root id so children can't collide with it.
123    let mut used_ids: HashMap<String, u32> = HashMap::default();
124    used_ids.insert(index_id.to_string(), 0);
125
126    for key in &keys {
127      if let Some(entry) = self.db.lookup(key) {
128        // Perl drives the tree off the scanned ltx:indexphrase NODES
129        // (`$entry->getValue('phrases')`): the `key` attribute is the
130        // sort/merge key, the node itself re-renders the phrase with
131        // its markup. Fall back to splitting the DB key for entries
132        // scanned without nodes.
133        let mut phrase_refs: Vec<PhraseRef> = Vec::new();
134        if let Some(Value::List(phrase_nodes)) = entry.get_value("phrases") {
135          for item in phrase_nodes {
136            if let Value::Xml(node) = item {
137              let nkey = node
138                .get_attribute("key")
139                .unwrap_or_else(|| get_index_content_key(&node.get_content()));
140              phrase_refs.push(PhraseRef {
141                key:  nkey,
142                text: get_index_content_key(&node.get_content()),
143                node: Some(node.clone()),
144              });
145            }
146          }
147        }
148        if phrase_refs.is_empty() {
149          phrase_refs = key
150            .strip_prefix("INDEX:")
151            .unwrap_or("")
152            .split(':')
153            .filter(|s| !s.is_empty())
154            .map(|s| PhraseRef {
155              key:  s.to_string(),
156              text: s.to_string(),
157              node: None,
158            })
159            .collect();
160        }
161        if phrase_refs.is_empty() {
162          Warn!("expected", key, "Missing phrases in indexmark: '{}'", key);
163          continue;
164        }
165
166        if self.permuted {
167          // Cyclic permutations of phrase keys
168          for perm in cyclic_permute(&phrase_refs) {
169            if self.split {
170              let init = initial_letter(&perm[0].key);
171              let subtree = tree.subtrees.entry(init.clone()).or_insert_with(|| {
172                let mut st = IndexTree::new(&tree.id);
173                st.phrase = Some(init);
174                st
175              });
176              add_tree_rec(subtree, &perm, &mut all_phrases, &mut used_ids, entry);
177            } else {
178              add_tree_rec(&mut tree, &perm, &mut all_phrases, &mut used_ids, entry);
179            }
180          }
181        } else if self.split {
182          let init = initial_letter(&phrase_refs[0].key);
183          let subtree = tree.subtrees.entry(init.clone()).or_insert_with(|| {
184            let mut st = IndexTree::new(&tree.id);
185            st.phrase = Some(init);
186            st
187          });
188          add_tree_rec(
189            subtree,
190            &phrase_refs,
191            &mut all_phrases,
192            &mut used_ids,
193            entry,
194          );
195        } else {
196          add_tree_rec(
197            &mut tree,
198            &phrase_refs,
199            &mut all_phrases,
200            &mut used_ids,
201            entry,
202          );
203        }
204      }
205    }
206    Some((tree, all_phrases))
207  }
208
209  /// Generate XML for an index list from a tree.
210  ///
211  /// `ancestor_phrases` carries the full_phrase_text of each enclosing
212  /// entry (outermost first) — Perl reaches the same context by walking
213  /// the tree's parent pointers in `lookupSeealsoPhrase`.
214  fn make_index_list(
215    &self,
216    all_phrases: &HashMap<String, String>,
217    tree: &IndexTree,
218    ancestor_phrases: &[String],
219  ) -> Option<NodeData> {
220    if tree.subtrees.is_empty() {
221      return None;
222    }
223    let mut sorted_keys: Vec<&String> = tree.subtrees.keys().collect();
224    sorted_keys.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then(a.cmp(b)));
225
226    let entries: Vec<NodeData> = sorted_keys
227      .iter()
228      .filter_map(|key| {
229        tree
230          .subtrees
231          .get(*key)
232          .map(|st| self.make_index_entry(all_phrases, st, ancestor_phrases))
233      })
234      .collect();
235    if entries.is_empty() {
236      return None;
237    }
238    Some(NodeData::Element {
239      tag:        "ltx:indexlist".to_string(),
240      attributes: None,
241      children:   entries,
242    })
243  }
244
245  /// Generate a single index entry with sub-entries, references, and see-also.
246  ///
247  /// Port of `MakeIndex::makeIndexEntry`.
248  fn make_index_entry(
249    &self,
250    all_phrases: &HashMap<String, String>,
251    tree: &IndexTree,
252    ancestor_phrases: &[String],
253  ) -> NodeData {
254    let mut children = Vec::new();
255
256    // Phrase: re-render the scanned ltx:indexphrase node when we have
257    // one (math and styled text inside the phrase survive); plain text
258    // otherwise. Perl: `$doc->trimChildNodes($$tree{phrase})`.
259    if tree.phrase.is_some() || tree.phrase_node.is_some() {
260      let phrase_children: Vec<NodeData> = match tree.phrase_node {
261        Some(ref n) => trimmed_child_nodes(n),
262        None => vec![NodeData::Text(tree.phrase.clone().unwrap_or_default())],
263      };
264      children.push(NodeData::Element {
265        tag:        "ltx:indexphrase".to_string(),
266        attributes: tree
267          .key
268          .as_ref()
269          .map(|k| HashMap::from_iter([("key".to_string(), k.clone())])),
270        children:   phrase_children,
271      });
272    }
273
274    // Referrer links (combined with range handling). Perl prefixes
275    // them with a one-space ltx:text separating phrase from refs.
276    let mut links = Vec::new();
277    if !tree.referrers.is_empty() {
278      links.push(NodeData::Element {
279        tag:        "ltx:text".to_string(),
280        attributes: None,
281        children:   vec![NodeData::Text(" ".to_string())],
282      });
283      links.extend(self.combine_index_entries(&tree.referrers));
284    }
285
286    // See/see-also cross references (Perl makeIndexEntry): each gets
287    // a ", " separator, the italic name word ("see", "see also") when
288    // present, and the phrase — resolved to a linked target entry via
289    // seealsoSearch when possible, with its original markup.
290    //
291    // Lookup context, innermost first (Perl walks parent pointers):
292    // this entry's full phrase, each ancestor's, then bare top-level.
293    let mut see_context: Vec<String> = Vec::new();
294    if let Some(ref fpt) = tree.full_phrase_text {
295      see_context.push(fpt.clone());
296    }
297    see_context.extend(ancestor_phrases.iter().rev().cloned());
298    see_context.push(String::new());
299
300    for see in &tree.see_also {
301      links.push(NodeData::Text(", ".to_string()));
302      if let Some(ref name) = see.name {
303        if !name.is_empty() {
304          links.push(NodeData::Element {
305            tag:        "ltx:text".to_string(),
306            attributes: Some(HashMap::from_iter([(
307              "font".to_string(),
308              "italic".to_string(),
309            )])),
310            children:   vec![NodeData::Text(format!("{} ", name))],
311          });
312        }
313      }
314      let parts: Vec<SeeChunk> = match see.node {
315        Some(ref n) => seealso_partition(n),
316        None => vec![SeeChunk {
317          key: see.text.clone(),
318          xml: vec![NodeData::Text(see.text.clone())],
319        }],
320      };
321      match seealso_search_rec(&parts, all_phrases, &see_context) {
322        Some(see_links) => {
323          links.extend(see_links);
324        },
325        _ => {
326          // Perl warns unless the see phrase already contains refs of
327          // its own, then falls back to the phrase's own markup.
328          let already_linked = see
329            .node
330            .as_ref()
331            .map(node_has_ref_descendant)
332            .unwrap_or(false);
333          if !already_linked {
334            Warn!(
335              "expected",
336              &see.text,
337              "Missing index see-also term '{}' (seen under {})",
338              see.text,
339              tree.full_key.as_deref().unwrap_or("?")
340            );
341          }
342          let content: Vec<NodeData> = match see.node {
343            Some(ref n) => n
344              .get_child_nodes()
345              .into_iter()
346              .map(NodeData::XmlNode)
347              .collect(),
348            None => vec![NodeData::Text(see.text.clone())],
349          };
350          links.push(NodeData::Element {
351            tag:        "ltx:text".to_string(),
352            attributes: None,
353            children:   content,
354          });
355        },
356      }
357    }
358
359    if !links.is_empty() {
360      children.push(NodeData::Element {
361        tag:        "ltx:indexrefs".to_string(),
362        attributes: None,
363        children:   links,
364      });
365    }
366
367    // Sub-entries: this entry's full phrase joins the ancestor context.
368    let mut child_ancestors: Vec<String> = ancestor_phrases.to_vec();
369    if let Some(ref fpt) = tree.full_phrase_text {
370      child_ancestors.push(fpt.clone());
371    }
372    if let Some(sublist) = self.make_index_list(all_phrases, tree, &child_ancestors) {
373      children.push(sublist);
374    }
375
376    NodeData::Element {
377      tag: "ltx:indexentry".to_string(),
378      attributes: if tree.id.is_empty() {
379        None
380      } else {
381        // fragid mirrors xml:id so the XSLT add_id template emits an
382        // HTML id for the <li> — same workaround as glossaryentry:
383        // these nodes are created after Scan ran, so CrossRef's
384        // fill_in_frags has no DB entry to source fragid from.
385        Some(HashMap::from_iter([
386          ("xml:id".to_string(), tree.id.clone()),
387          ("fragid".to_string(), tree.id.clone()),
388        ]))
389      },
390      children,
391    }
392  }
393
394  /// Register an `ID:` entry for every index entry just created, so
395  /// CrossRef can resolve the see/see-also `ltx:ref idref=idx.*` links
396  /// to hrefs. Perl gets this for free from `$self->rescan($doc)` at
397  /// the end of MakeIndex::process; we register the minimum CrossRef
398  /// needs (location + fragid) directly.
399  fn register_entry_ids(&mut self, tree: &IndexTree, location: &str) {
400    for subtree in tree.subtrees.values() {
401      if !subtree.id.is_empty() {
402        let entry = self.db.register(&format!("ID:{}", subtree.id), vec![]);
403        entry.set_value("location", Value::from(location));
404        entry.set_value("fragid", Value::from(subtree.id.as_str()));
405        entry.set_value("id", Value::from(subtree.id.as_str()));
406        // Parent chain feeds CrossRef's generate_title context walk
407        // (a see-ref's tooltip becomes the enclosing index's title,
408        // as with Perl's rescan-built entries).
409        if !tree.id.is_empty() {
410          entry.set_value("parent", Value::from(tree.id.as_str()));
411        }
412      }
413      self.register_entry_ids(subtree, location);
414    }
415  }
416
417  /// Combine index entry referrers into a comma-separated list with range support.
418  ///
419  /// Port of `combineIndexEntries`.
420  fn combine_index_entries(&self, refs: &HashMap<String, HashMap<String, bool>>) -> Vec<NodeData> {
421    let mut ids: Vec<&String> = refs.keys().collect();
422    ids.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then(a.cmp(b)));
423
424    let mut links = Vec::new();
425    let mut i = 0;
426    while i < ids.len() {
427      if !links.is_empty() {
428        links.push(NodeData::Text(", ".to_string()));
429      }
430      let id = ids[i];
431      let styles = refs.get(id).unwrap();
432
433      // Check for range start. Perl pairs the start with the NEXT
434      // range marker of either kind ($lvl-- for rangestart AND
435      // rangeend): since ids are sorted alphabetically rather than
436      // by document order, no real nesting is possible, and a stray
437      // second start terminates the range rather than extending it.
438      if styles.contains_key("rangestart") {
439        let start_id = id;
440        let mut end_id = id;
441        let mut level = 1i32;
442        i += 1;
443        while i < ids.len() && level > 0 {
444          end_id = ids[i];
445          if let Some(s) = refs.get(end_id) {
446            if s.contains_key("rangestart") {
447              level -= 1;
448            }
449            if s.contains_key("rangeend") {
450              level -= 1;
451            }
452          }
453          i += 1;
454        }
455        // Range: start–end
456        links.push(NodeData::Element {
457          tag:        "ltx:text".to_string(),
458          attributes: None,
459          children:   vec![
460            self.make_index_ref(start_id, styles),
461            NodeData::Text("\u{2014}".to_string()), // em-dash
462            self.make_index_ref(end_id, refs.get(end_id).unwrap_or(styles)),
463          ],
464        });
465      } else {
466        links.push(self.make_index_ref(id, styles));
467        i += 1;
468      }
469    }
470    links
471  }
472
473  /// Make a single index reference link.
474  ///
475  /// Port of `makeIndexRefs`.
476  fn make_index_ref(&self, id: &str, styles: &HashMap<String, bool>) -> NodeData {
477    // Perl: `sort keys %$entry` then take the first — "sorted styles
478    // gives bold, italic, normal; let's just do the first". The sort
479    // is what makes the pick deterministic (and prefers bold).
480    let mut style: Vec<&String> = styles
481      .keys()
482      .filter(|s| *s != "rangestart" && *s != "rangeend")
483      .collect();
484    style.sort();
485    let primary_style = style.first().map(|s| s.as_str()).unwrap_or("normal");
486
487    let ref_node = NodeData::Element {
488      tag:        "ltx:ref".to_string(),
489      attributes: Some(HashMap::from_iter([
490        ("idref".to_string(), id.to_string()),
491        ("show".to_string(), "typerefnum".to_string()),
492      ])),
493      children:   vec![],
494    };
495
496    if primary_style != "normal" {
497      NodeData::Element {
498        tag:        "ltx:text".to_string(),
499        attributes: Some(HashMap::from_iter([(
500          "font".to_string(),
501          primary_style.to_string(),
502        )])),
503        children:   vec![ref_node],
504      }
505    } else {
506      ref_node
507    }
508  }
509
510  /// Get glossary entries from the ObjectDB.
511  ///
512  /// Port of `MakeIndex::getGlossaryEntries`.
513  fn get_glossary_entries(&mut self, lists: &str, glossary_id: &str) -> Vec<GlossaryEntry> {
514    let list_set: rustc_hash::FxHashSet<&str> = lists.split(',').collect();
515    let mut entries = Vec::new();
516
517    // Clone the keys up-front so we can do mutable `lookup_mut` writes
518    // inside the loop (registering `id` so CrossRef can resolve refs).
519    let keys: Vec<String> = self.db.get_keys().into_iter().cloned().collect();
520    for db_key in &keys {
521      let db_key = db_key.as_str();
522      if !db_key.starts_with("GLOSSARY:") {
523        continue;
524      }
525      let parts: Vec<&str> = db_key.splitn(3, ':').collect();
526      if parts.len() < 3 {
527        continue;
528      }
529      let list = parts[1];
530      let key = parts[2];
531      if !list_set.contains(list) {
532        continue;
533      }
534
535      if let Some(entry) = self.db.lookup(db_key) {
536        // Check if it has referrers
537        let has_refs = entry
538          .get_value("referrers")
539          .map(|v| v.is_truthy())
540          .unwrap_or(false);
541        if !has_refs {
542          continue;
543        }
544
545        let sort_key = entry.get_string("phrase:sort").unwrap_or(key).to_string();
546        let initial = sort_key
547          .chars()
548          .next()
549          .filter(|c| c.is_ascii_alphabetic())
550          .map(|c| c.to_uppercase().to_string())
551          .unwrap_or_else(|| "*".to_string());
552        let id = format!("{}.{}", glossary_id, key);
553        let term = entry.get_string("phrase:name").unwrap_or(key).to_string();
554        let desc = entry
555          .get_string("phrase:description")
556          .unwrap_or("")
557          .to_string();
558
559        entries.push(GlossaryEntry {
560          initial,
561          sort_key,
562          formatted: NodeData::Element {
563            tag:        "ltx:glossaryentry".to_string(),
564            attributes: Some(HashMap::from_iter([
565              ("lists".to_string(), lists.to_string()),
566              ("xml:id".to_string(), id.clone()),
567              // fragid mirrors xml:id so the XSLT `add_id` template emits
568              // `<dt id="glo.main.cabbage">`. Without fragid, the dt
569              // renders as `<dt class="..."/>` only. Normally CrossRef's
570              // fill_in_frags populates fragid from the ObjectDB ID entry,
571              // but our new glossaryentry nodes are created AFTER Scan
572              // already ran, so the DB has no `ID:<id>` entry to source
573              // from. Setting fragid eagerly here matches the end state.
574              ("fragid".to_string(), id.clone()),
575              ("key".to_string(), key.to_string()),
576            ])),
577            children:   vec![
578              NodeData::Element {
579                tag:        "ltx:glossaryphrase".to_string(),
580                attributes: Some(HashMap::from_iter([
581                  ("role".to_string(), "label".to_string()),
582                  ("key".to_string(), key.to_string()),
583                ])),
584                children:   vec![NodeData::Text(term)],
585              },
586              NodeData::Element {
587                tag:        "ltx:glossaryphrase".to_string(),
588                attributes: Some(HashMap::from_iter([(
589                  "role".to_string(),
590                  "definition".to_string(),
591                )])),
592                children:   vec![NodeData::Text(desc)],
593              },
594            ],
595          },
596        });
597        // Register the entry's id back in the DB so CrossRef's
598        // fill_in_glossaryrefs can resolve `<glossaryref key="X">` to
599        // the corresponding `<glossaryentry xml:id=…>`. Mirrors Perl
600        // MakeIndex.pm where the entry construction sets `id`.
601        if let Some(entry_mut) = self.db.lookup_mut(db_key) {
602          entry_mut.set_value("id", Value::from(id.clone()));
603        }
604      }
605    }
606    // Perl `MakeIndex.pm` L487: `$doc->unisort(keys %hash)` — Unicode-
607    // aware case-insensitive sort. For ASCII-Latin (which covers the
608    // test fixture and the vast majority of glossary entries), lowercase
609    // comparison matches the expected order: "Cabbage" sorts beside
610    // "cabbage" rather than before all lowercase letters.
611    entries.sort_by_key(|a| a.sort_key.to_lowercase());
612    entries
613  }
614
615  /// Generate a glossary list.
616  ///
617  /// Port of `MakeIndex::makeGlossaryList`.
618  fn make_glossary_list(&self, entries: &[GlossaryEntry]) -> NodeData {
619    NodeData::Element {
620      tag:        "ltx:glossarylist".to_string(),
621      attributes: None,
622      children:   entries.iter().map(|e| e.formatted.clone()).collect(),
623    }
624  }
625}
626
627impl Processor for MakeIndex {
628  fn get_name(&self) -> &str { &self.name }
629
630  fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
631    doc.findnodes("//ltx:index[not(ltx:indexlist)] | //ltx:glossary[not(ltx:glossarylist)]")
632  }
633
634  fn process(&mut self, mut doc: PostDocument, nodes: Vec<Node>) -> ProcessResult {
635    for node in &nodes {
636      let tag = doc.get_qname(node).unwrap_or_default();
637      // xml:id is namespace-bound: plain get_attribute misses it on
638      // engine-constructed nodes (it only happens to work on nodes
639      // round-tripped through serialization).
640      let id = crate::document::get_xml_id(node).unwrap_or_default();
641
642      if tag == "ltx:index" {
643        if let Some((tree, all_phrases)) = self.build_tree(&id) {
644          if let Some(index_list) = self.make_index_list(&all_phrases, &tree, &[]) {
645            let mut node_mut = node.clone();
646            doc.add_nodes(&mut node_mut, &[index_list]);
647            let location = doc.site_relative_destination().unwrap_or_default();
648            self.register_entry_ids(&tree, &location);
649          }
650        }
651      } else if tag == "ltx:glossary" {
652        let lists = node
653          .get_attribute("lists")
654          .unwrap_or_else(|| "glossary".to_string());
655        let entries = self.get_glossary_entries(&lists, &id);
656        if !entries.is_empty() {
657          let glist = self.make_glossary_list(&entries);
658          let mut node_mut = node.clone();
659          doc.add_nodes(&mut node_mut, &[glist]);
660        }
661      }
662    }
663    Ok(vec![doc])
664  }
665}
666
667// ======================================================================
668// Helpers
669
670fn add_tree_rec(
671  tree: &mut IndexTree,
672  phrases: &[PhraseRef],
673  all_phrases: &mut HashMap<String, String>,
674  used_ids: &mut HashMap<String, u32>,
675  entry: &crate::object_db::Entry,
676) {
677  if phrases.is_empty() {
678    // Leaf: record referrers and see_also.
679    // Scan's note_association(["referrers", pid, style]) nests THREE
680    // levels: referrers → {pid → {style → true}} (mirroring Perl's
681    // $$entry{referrers}{$id}{$style}). The inner value is a Hash of
682    // styles, not a scalar — reading it with to_string() used to
683    // collapse every style to "" (Value's Display renders hashes as
684    // the empty string), silently dropping bold/italic/etc. and
685    // wrapping every index ref in an empty-font ltx:text.
686    if let Some(Value::Hash(refs)) = entry.get_value("referrers") {
687      for (k, v) in refs {
688        let styles = tree.referrers.entry(k.clone()).or_default();
689        if let Value::Hash(style_map) = v {
690          for style in style_map.keys() {
691            styles.insert(style.clone(), true);
692          }
693        }
694      }
695    }
696    if let Some(Value::List(see_items)) = entry.get_value("see_also") {
697      for item in see_items {
698        // Stored as Value::Xml(ltx:indexsee) by Scan; keep the node
699        // for markup-preserving rendering, plus its name attribute
700        // ("see"/"see also") and normalized text for target lookup.
701        if let Value::Xml(node) = item {
702          tree.see_also.push(SeeAlso {
703            name: node.get_attribute("name"),
704            text: get_index_content_key(&node.get_content()),
705            node: Some(node.clone()),
706          });
707        } else {
708          tree.see_also.push(SeeAlso {
709            name: None,
710            text: get_index_content_key(&item.to_string()),
711            node: None,
712          });
713        }
714      }
715    }
716    return;
717  }
718  let phrase = &phrases[0];
719  let rest = &phrases[1..];
720  let key = &phrase.key;
721  let key_id = get_index_key_id(key);
722  let parent_key = tree.full_key.clone().unwrap_or_default();
723  let full_key = if parent_key.is_empty() {
724    key.to_string()
725  } else {
726    format!("{}.{}", parent_key, key)
727  };
728  // Perl: phrasetext (the normalized CONTENT, not the key) feeds the
729  // see/see-also lookup table; multi-level phrases join with a space.
730  let phrase_text = phrase.text.clone();
731  let parent_phrase = tree.full_phrase_text.clone().unwrap_or_default();
732  let full_phrase = if parent_phrase.is_empty() {
733    phrase_text.clone()
734  } else {
735    format!("{} {}", parent_phrase, phrase_text)
736  };
737
738  let tree_id = tree.id.clone();
739  // Allocate the id BEFORE or_insert_with so we can uniquify against
740  // ids already used by sibling phrases that strip to the same
741  // key_id. Only done when the subtree is new (an existing subtree
742  // keeps its id). Perl: uniquifyID appends radix_alpha(clashes).
743  let needs_id = !tree.subtrees.contains_key(key.as_str());
744  let id = if needs_id {
745    Some(uniquify_id(&format!("{}.{}", tree_id, key_id), used_ids))
746  } else {
747    None
748  };
749  let subtree = tree.subtrees.entry(key.to_string()).or_insert_with(|| {
750    let id = id.unwrap();
751    all_phrases.insert(full_key.clone(), id.clone());
752    all_phrases.insert(full_key.to_lowercase(), id.clone());
753    all_phrases.insert(full_phrase.clone(), id.clone());
754    all_phrases.insert(full_phrase.to_lowercase(), id.clone());
755    let mut st = IndexTree::new(&id);
756    st.key = Some(key.to_string());
757    st.full_key = Some(full_key.clone());
758    st.phrase = Some(phrase_text.clone());
759    st.phrase_node = phrase.node.clone();
760    st.phrase_text = Some(phrase_text);
761    st.full_phrase_text = Some(full_phrase);
762    st
763  });
764  add_tree_rec(subtree, rest, all_phrases, used_ids, entry);
765}
766
767/// Perl Post::uniquifyID: return `base` if unused, else append
768/// radix_alpha(n) (a, b, …) for the next free clash counter.
769fn uniquify_id(base: &str, used: &mut HashMap<String, u32>) -> String {
770  if !used.contains_key(base) {
771    used.insert(base.to_string(), 0);
772    return base.to_string();
773  }
774  loop {
775    let n = used.get_mut(base).unwrap();
776    *n += 1;
777    let candidate = format!("{}{}", base, crate::radix::radix_alpha(*n));
778    if !used.contains_key(&candidate) {
779      used.insert(candidate.clone(), 0);
780      return candidate;
781    }
782  }
783}
784
785fn initial_letter(key: &str) -> String {
786  let decomposed: String = key.nfd().collect();
787  match decomposed.trim().chars().next() {
788    Some(c) if c.is_ascii_alphabetic() => c.to_uppercase().to_string(),
789    _ => "*".to_string(),
790  }
791}
792
793/// Perl getIndexKeyID: NFD-decompose, transliterate Greek letters to
794/// their names (so math-bearing keys don't collapse to nothing), then
795/// strip everything but ASCII alphanumerics.
796fn get_index_key_id(key: &str) -> String {
797  let decomposed: String = key.trim().nfd().collect();
798  let mut out = String::with_capacity(decomposed.len());
799  for c in decomposed.chars() {
800    if let Some(name) = greek_ascii(c) {
801      out.push_str(name);
802    } else if c.is_ascii_alphanumeric() {
803      out.push(c);
804    }
805  }
806  out
807}
808
809/// Perl %GREEK_ASCII_MAP.
810fn greek_ascii(c: char) -> Option<&'static str> {
811  Some(match c {
812    '\u{03B1}' => "alpha",
813    '\u{03B2}' => "beta",
814    '\u{03B3}' => "gamma",
815    '\u{03B4}' => "delta",
816    '\u{03F5}' => "epsilon",
817    '\u{03B5}' => "varepsilon",
818    '\u{03B6}' => "zeta",
819    '\u{03B7}' => "eta",
820    '\u{03B8}' => "theta",
821    '\u{03D1}' => "vartheta",
822    '\u{03B9}' => "iota",
823    '\u{03BA}' => "kappa",
824    '\u{03BB}' => "lambda",
825    '\u{03BC}' => "mu",
826    '\u{03BD}' => "nu",
827    '\u{03BE}' => "xi",
828    '\u{03C0}' => "pi",
829    '\u{03D6}' => "varpi",
830    '\u{03C1}' => "rho",
831    '\u{03F1}' => "varrho",
832    '\u{03C3}' => "sigma",
833    '\u{03C2}' => "varsigma",
834    '\u{03C4}' => "tau",
835    '\u{03C5}' => "upsilon",
836    '\u{03D5}' => "phi",
837    '\u{03C6}' => "varphi",
838    '\u{03C7}' => "chi",
839    '\u{03C8}' => "psi",
840    '\u{03C9}' => "omega",
841    '\u{0393}' => "Gamma",
842    '\u{0394}' => "Delta",
843    '\u{0398}' => "Theta",
844    '\u{039B}' => "Lambda",
845    '\u{039E}' => "Xi",
846    '\u{03A0}' => "Pi",
847    '\u{03A3}' => "Sigma",
848    '\u{03A5}' => "Upsilon",
849    '\u{03A6}' => "Phi",
850    '\u{03A8}' => "Psi",
851    '\u{03A9}' => "Omega",
852    _ => return None,
853  })
854}
855
856/// Perl getIndexContentKey: trim, collapse internal whitespace,
857/// strip trailing punctuation.
858fn get_index_content_key(s: &str) -> String {
859  let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
860  collapsed
861    .trim_end_matches(['.', ',', ';'])
862    .trim_end()
863    .to_string()
864}
865
866/// Perl `$doc->trimChildNodes`: the node's children minus pure-whitespace
867/// text nodes at either end (whitespace INSIDE the sequence is kept).
868fn trimmed_child_nodes(node: &Node) -> Vec<NodeData> {
869  let children = node.get_child_nodes();
870  let is_ws =
871    |n: &Node| n.get_type() == Some(NodeType::TextNode) && n.get_content().trim().is_empty();
872  let start = children.iter().position(|n| !is_ws(n));
873  let end = children.iter().rposition(|n| !is_ws(n));
874  match (start, end) {
875    (Some(s), Some(e)) => children[s..=e]
876      .iter()
877      .cloned()
878      .map(NodeData::XmlNode)
879      .collect(),
880    _ => vec![],
881  }
882}
883
884/// Does the node contain an ltx:ref descendant? (Perl checks
885/// `descendant-or-self::ltx:ref` before warning about an unresolved
886/// see-also phrase.)
887fn node_has_ref_descendant(node: &Node) -> bool {
888  if node.get_type() == Some(NodeType::ElementNode) && node.get_name() == "ref" {
889    return true;
890  }
891  node.get_child_nodes().iter().any(node_has_ref_descendant)
892}
893
894// ======================================================================
895// See & see-also resolution (Perl MakeIndex.pm "A LOTTA work, for such
896// a little thing!"). A see phrase is partitioned into alternating
897// candidate-term and candidate-delimiter chunks; the search then tries
898// interpreting each delimiter as part of a longer phrase or as a
899// separator between independent targets.
900
901/// One partition chunk: the normalized lookup key plus the XML pieces
902/// it came from (used to fill the resulting ltx:ref).
903#[derive(Debug, Clone)]
904struct SeeChunk {
905  key: String,
906  xml: Vec<NodeData>,
907}
908
909/// Perl seealsoPartition: chunk the phrase, then (pass 1) combine
910/// adjacent chunks that are both-or-neither delimiters, and (pass 2)
911/// fold pure-space chunks into their neighbours.
912fn seealso_partition(see: &Node) -> Vec<SeeChunk> {
913  let parts = seealso_partition_aux(see);
914  if parts.is_empty() {
915    return parts;
916  }
917  // Pass 1: combine adjacent conjunction/punctuation chunks.
918  let mut iter = parts.into_iter();
919  let mut result: Vec<SeeChunk> = vec![iter.next().unwrap()];
920  for next in iter {
921    let prev_is = is_delimiter_key(&result.last().unwrap().key);
922    let next_is = starts_with_delimiter(&next.key);
923    if prev_is == next_is {
924      let last = result.last_mut().unwrap();
925      last.key.push_str(&next.key);
926      last.xml.extend(next.xml);
927    } else {
928      result.push(next);
929    }
930  }
931  // Pass 2: a pure-space chunk merges its neighbours into one phrase.
932  let mut iter = result.into_iter();
933  let mut merged: Vec<SeeChunk> = vec![iter.next().unwrap()];
934  let rest: Vec<SeeChunk> = iter.collect();
935  let mut i = 0;
936  while i < rest.len() {
937    let next = rest[i].clone();
938    if !next.key.is_empty() && next.key.trim().is_empty() && i + 1 < rest.len() {
939      let after = rest[i + 1].clone();
940      let last = merged.last_mut().unwrap();
941      last.key.push_str(&next.key);
942      last.key.push_str(&after.key);
943      last.xml.extend(next.xml);
944      last.xml.extend(after.xml);
945      i += 2;
946    } else {
947      merged.push(next);
948      i += 1;
949    }
950  }
951  merged
952}
953
954/// Perl seealsoPartition_aux: split into pure phrase/delimiter chunks.
955fn seealso_partition_aux(node: &Node) -> Vec<SeeChunk> {
956  let mut result = Vec::new();
957  for ch in node.get_child_nodes() {
958    match ch.get_type() {
959      Some(NodeType::TextNode) => {
960        let mut s = ch.get_content();
961        while !s.is_empty() {
962          if let Some((delim, rest)) = take_delimiter(&s) {
963            result.push(SeeChunk {
964              key: delim.clone(),
965              xml: vec![NodeData::Text(delim)],
966            });
967            s = rest;
968          } else {
969            // ^([^,\.\s]+)
970            let end = s
971              .find(|c: char| c == ',' || c == '.' || c.is_whitespace())
972              .unwrap_or(s.len());
973            let (tok, rest) = s.split_at(end);
974            result.push(SeeChunk {
975              key: get_index_content_key(tok),
976              xml: vec![NodeData::Text(tok.to_string())],
977            });
978            s = rest.to_string();
979          }
980        }
981      },
982      Some(NodeType::ElementNode) => {
983        let name = ch.get_name();
984        if name == "text" || name == "emph" {
985          // Recurse, re-wrapping each sub-chunk in the styling element
986          // so delimiters can split styled phrases (Perl does the same).
987          // Drop the source node's id ("id" is how get_properties()
988          // reports xml:id): the styling wrapper is CLONED once per
989          // sub-chunk, and copying the id onto every clone would mint
990          // schema-invalid duplicate xml:ids. The namespace probe keeps a
991          // GENUINE plain `id` attribute (model-granted on a few bib
992          // elements) from being dropped along with it.
993          //
994          // Perl copies the attributes here — or means to: its
995          // `map { ($_ => $ch->getAttribute($_)) } $ch->attributes`
996          // (MakeIndex.pm L445) keys the hash on attribute NODES, which
997          // stringify to ` role="x"`-style junk with undef values, so it
998          // copies nothing usable. We implement the intent, minus the ids.
999          // KNOWN_PERL_ERRORS #72.
1000          let ch_has_xml_id = ch
1001            .get_attribute_ns("id", latexml_core::common::xml::XML_NS)
1002            .is_some();
1003          let attrs: HashMap<String, String> = ch
1004            .get_properties()
1005            .into_iter()
1006            .filter(|(k, _)| k != "xml:id" && k != "fragid" && !(k == "id" && ch_has_xml_id))
1007            .collect();
1008          for sub in seealso_partition_aux(&ch) {
1009            result.push(SeeChunk {
1010              key: sub.key,
1011              xml: vec![NodeData::Element {
1012                tag:        format!("ltx:{}", name),
1013                attributes: if attrs.is_empty() {
1014                  None
1015                } else {
1016                  Some(attrs.clone())
1017                },
1018                children:   sub.xml,
1019              }],
1020            });
1021          }
1022        } else {
1023          // Opaque element (math etc.): one phrase chunk.
1024          result.push(SeeChunk {
1025            key: get_index_content_key(&ch.get_content()),
1026            xml: vec![NodeData::XmlNode(ch.clone())],
1027          });
1028        }
1029      },
1030      _ => {},
1031    }
1032  }
1033  result
1034}
1035
1036/// Leading delimiter per Perl: `^(,|\.|\s+|and\s+also\b|and\b|or\b)`.
1037fn take_delimiter(s: &str) -> Option<(String, String)> {
1038  if s.starts_with(',') || s.starts_with('.') {
1039    return Some((s[..1].to_string(), s[1..].to_string()));
1040  }
1041  let ws_len = s.len() - s.trim_start().len();
1042  if ws_len > 0 {
1043    return Some((s[..ws_len].to_string(), s[ws_len..].to_string()));
1044  }
1045  for kw in ["and also", "and", "or"] {
1046    if let Some(rest) = strip_keyword(s, kw) {
1047      return Some((s[..s.len() - rest.len()].to_string(), rest.to_string()));
1048    }
1049  }
1050  None
1051}
1052
1053/// Match a keyword (with internal `\s+` flexibility for "and also")
1054/// at the start of `s`, requiring a word boundary after it.
1055fn strip_keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
1056  let mut rest = s;
1057  for (i, word) in kw.split(' ').enumerate() {
1058    if i > 0 {
1059      let trimmed = rest.trim_start();
1060      if trimmed.len() == rest.len() {
1061        return None; // needed \s+ between words
1062      }
1063      rest = trimmed;
1064    }
1065    rest = rest.strip_prefix(word)?;
1066  }
1067  // \b: next char must not be a word character
1068  match rest.chars().next() {
1069    Some(c) if c.is_alphanumeric() || c == '_' => None,
1070    _ => Some(rest),
1071  }
1072}
1073
1074/// Is this chunk key delimiter-shaped?
1075/// Perl: `^,?\s*(?:,|\.|\s+|\band\s+also|\band|\bor)\s*$`.
1076fn is_delimiter_key(key: &str) -> bool {
1077  let k = key.strip_prefix(',').unwrap_or(key).trim();
1078  if k.is_empty() || k == "," || k == "." || k == "and" || k == "or" {
1079    return true;
1080  }
1081  let words: Vec<&str> = k.split_whitespace().collect();
1082  words == ["and", "also"]
1083}
1084
1085/// Does this chunk key START with a delimiter?
1086/// Perl: `^(?:,|\.|\s+|and\b|or\b)`.
1087fn starts_with_delimiter(key: &str) -> bool {
1088  key.starts_with(',')
1089    || key.starts_with('.')
1090    || key.starts_with(|c: char| c.is_whitespace())
1091    || strip_keyword(key, "and").is_some()
1092    || strip_keyword(key, "or").is_some()
1093}
1094
1095/// Perl seealsoJoin: reassemble consecutive chunks into one candidate.
1096fn seealso_join(parts: &[SeeChunk]) -> SeeChunk {
1097  let key = get_index_content_key(&parts.iter().map(|p| p.key.as_str()).collect::<String>());
1098  let xml = parts.iter().flat_map(|p| p.xml.clone()).collect();
1099  SeeChunk { key, xml }
1100}
1101
1102/// Perl seealsoSearch_rec: parts alternate (potential) term and
1103/// (potential) delimiter. Try each delimiter first as phrase-internal
1104/// (joining its neighbours), then as a separator between targets.
1105fn seealso_search_rec(
1106  parts: &[SeeChunk],
1107  all_phrases: &HashMap<String, String>,
1108  context: &[String],
1109) -> Option<Vec<NodeData>> {
1110  if parts.is_empty() {
1111    return None;
1112  }
1113  if parts.len() < 3 {
1114    // Single term (with possible trailing punctuation): just look it up.
1115    let link = lookup_seealso_phrase(&parts[0], all_phrases, context)?;
1116    let mut out = vec![link];
1117    if parts.len() > 1 {
1118      out.extend(parts[1].xml.clone());
1119    }
1120    return Some(out);
1121  }
1122  // Try the first delimiter "literally" (term+delim+term as one phrase).
1123  let mut joined: Vec<SeeChunk> = vec![seealso_join(&parts[0..3])];
1124  joined.extend_from_slice(&parts[3..]);
1125  if let Some(links) = seealso_search_rec(&joined, all_phrases, context) {
1126    return Some(links);
1127  }
1128  // Try the delimiter as a separator between individual entries.
1129  if let Some(link) = lookup_seealso_phrase(&parts[0], all_phrases, context) {
1130    if let Some(rest) = seealso_search_rec(&parts[2..], all_phrases, context) {
1131      let mut out = vec![link];
1132      out.extend(parts[1].xml.clone());
1133      out.extend(rest);
1134      return Some(out);
1135    }
1136  }
1137  None
1138}
1139
1140/// Perl lookupSeealsoPhrase: try the phrase as-is, ignoring commas,
1141/// plurals, case, or treating commas as level separators — first within
1142/// the current entry's context (innermost first), then at top level.
1143fn lookup_seealso_phrase(
1144  chunk: &SeeChunk,
1145  all_phrases: &HashMap<String, String>,
1146  context: &[String],
1147) -> Option<NodeData> {
1148  let phrase = chunk.key.trim().to_string();
1149  if phrase.is_empty() {
1150    return None;
1151  }
1152  let pnc = comma_to_space(&phrase);
1153  let ps = strip_plurals(&phrase);
1154  let psnc = comma_to_space(&ps);
1155  let pnlvl = comma_to(&phrase, ".");
1156  let mut trials: Vec<String> = Vec::new();
1157  for t in [&phrase, &pnc, &ps, &psnc, &pnlvl] {
1158    trials.push((*t).clone());
1159    trials.push(t.to_lowercase());
1160  }
1161  for trial in &trials {
1162    for prefix in context {
1163      let lookup_key = if prefix.is_empty() {
1164        trial.clone()
1165      } else {
1166        format!("{} {}", prefix, trial)
1167      };
1168      if let Some(id) = all_phrases.get(&lookup_key) {
1169        return Some(NodeData::Element {
1170          tag:        "ltx:ref".to_string(),
1171          attributes: Some(HashMap::from_iter([("idref".to_string(), id.clone())])),
1172          children:   chunk.xml.clone(),
1173        });
1174      }
1175    }
1176  }
1177  None
1178}
1179
1180/// `s/,\s*/ /g`
1181fn comma_to_space(s: &str) -> String { comma_to(s, " ") }
1182
1183fn comma_to(s: &str, repl: &str) -> String {
1184  let mut out = String::with_capacity(s.len());
1185  let mut chars = s.chars().peekable();
1186  while let Some(c) = chars.next() {
1187    if c == ',' {
1188      out.push_str(repl);
1189      while chars.peek().is_some_and(|c| c.is_whitespace()) {
1190        chars.next();
1191      }
1192    } else {
1193      out.push(c);
1194    }
1195  }
1196  out
1197}
1198
1199/// `s/(\w+)s\b/$1/g` — strip a trailing "s" from every word.
1200fn strip_plurals(s: &str) -> String {
1201  let mut out = String::with_capacity(s.len());
1202  let mut word = String::new();
1203  for c in s.chars() {
1204    if c.is_alphanumeric() || c == '_' {
1205      word.push(c);
1206    } else {
1207      push_depluraled(&mut out, &word);
1208      word.clear();
1209      out.push(c);
1210    }
1211  }
1212  push_depluraled(&mut out, &word);
1213  out
1214}
1215
1216fn push_depluraled(out: &mut String, word: &str) {
1217  if word.len() > 1 && word.ends_with('s') {
1218    out.push_str(&word[..word.len() - 1]);
1219  } else {
1220    out.push_str(word);
1221  }
1222}
1223
1224/// Generate cyclic permutations of a slice.
1225fn cyclic_permute<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
1226  if items.len() <= 1 {
1227    return vec![items.to_vec()];
1228  }
1229  (0..items.len())
1230    .map(|i| {
1231      let mut perm = items[i..].to_vec();
1232      perm.extend_from_slice(&items[..i]);
1233      perm
1234    })
1235    .collect()
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240  use super::*;
1241
1242  #[test]
1243  fn initial_letter_ascii_uppercases() {
1244    assert_eq!(initial_letter("alpha"), "A");
1245    assert_eq!(initial_letter("Zebra"), "Z");
1246  }
1247
1248  #[test]
1249  fn initial_letter_skips_leading_whitespace() {
1250    assert_eq!(initial_letter("  beta"), "B");
1251  }
1252
1253  #[test]
1254  fn initial_letter_nfd_decomposes_accents() {
1255    // NFD decomposes 'É' (U+00C9) into 'E' + combining accent — the first
1256    // char is then ASCII 'E'.
1257    assert_eq!(initial_letter("Éclair"), "E");
1258    assert_eq!(initial_letter("über"), "U");
1259  }
1260
1261  #[test]
1262  fn initial_letter_non_alpha_is_star() {
1263    assert_eq!(initial_letter("123abc"), "*");
1264    assert_eq!(initial_letter("#hash"), "*");
1265    assert_eq!(initial_letter(""), "*");
1266    assert_eq!(initial_letter("   "), "*");
1267  }
1268
1269  #[test]
1270  fn get_index_key_id_strips_non_alphanumeric() {
1271    assert_eq!(get_index_key_id("Foo Bar!"), "FooBar");
1272    assert_eq!(get_index_key_id("abc-123"), "abc123");
1273  }
1274
1275  #[test]
1276  fn get_index_key_id_nfd_drops_combining_marks() {
1277    // 'é' → 'e' + combining accent; combining mark isn't ASCII alphanumeric,
1278    // so it's dropped, leaving just "e".
1279    assert_eq!(get_index_key_id("é"), "e");
1280    assert_eq!(get_index_key_id("Éclair"), "Eclair");
1281  }
1282
1283  #[test]
1284  fn get_index_key_id_empty_input() {
1285    assert_eq!(get_index_key_id(""), "");
1286    assert_eq!(get_index_key_id("!!!"), "");
1287  }
1288
1289  #[test]
1290  fn cyclic_permute_empty_returns_single_empty() {
1291    let empty: Vec<i32> = vec![];
1292    assert_eq!(cyclic_permute::<i32>(&empty), vec![Vec::<i32>::new()]);
1293  }
1294
1295  #[test]
1296  fn cyclic_permute_single_element_returns_itself() {
1297    assert_eq!(cyclic_permute(&[42]), vec![vec![42]]);
1298  }
1299
1300  #[test]
1301  fn cyclic_permute_three_element_rotations() {
1302    let result = cyclic_permute(&["a", "b", "c"]);
1303    assert_eq!(result, vec![
1304      vec!["a", "b", "c"],
1305      vec!["b", "c", "a"],
1306      vec!["c", "a", "b"],
1307    ]);
1308  }
1309
1310  #[test]
1311  fn cyclic_permute_count_equals_input_len() {
1312    let result = cyclic_permute(&[1, 2, 3, 4, 5]);
1313    assert_eq!(result.len(), 5);
1314    for perm in &result {
1315      assert_eq!(perm.len(), 5);
1316    }
1317  }
1318}