1use 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#[derive(Debug)]
21struct SeeAlso {
22 name: Option<String>,
24 text: String,
26 node: Option<Node>,
28}
29
30#[derive(Debug, Clone)]
34struct PhraseRef {
35 key: String,
36 text: String,
37 node: Option<Node>,
38}
39
40#[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
72struct GlossaryEntry {
74 initial: String,
75 sort_key: String,
76 formatted: NodeData,
77}
78
79pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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()), 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 fn make_index_ref(&self, id: &str, styles: &HashMap<String, bool>) -> NodeData {
477 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 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 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 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".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 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 entries.sort_by_key(|a| a.sort_key.to_lowercase());
612 entries
613 }
614
615 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 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
667fn 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 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 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 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 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
767fn 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
793fn 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
809fn 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
856fn 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
866fn 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
884fn 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#[derive(Debug, Clone)]
904struct SeeChunk {
905 key: String,
906 xml: Vec<NodeData>,
907}
908
909fn seealso_partition(see: &Node) -> Vec<SeeChunk> {
913 let parts = seealso_partition_aux(see);
914 if parts.is_empty() {
915 return parts;
916 }
917 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 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
954fn 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 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 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 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
1036fn 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
1053fn 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; }
1063 rest = trimmed;
1064 }
1065 rest = rest.strip_prefix(word)?;
1066 }
1067 match rest.chars().next() {
1069 Some(c) if c.is_alphanumeric() || c == '_' => None,
1070 _ => Some(rest),
1071 }
1072}
1073
1074fn 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
1085fn 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
1095fn 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
1102fn 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 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 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 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
1140fn 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
1180fn 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
1199fn 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
1224fn 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 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 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}