1use std::{cell::RefCell, rc::Rc};
9
10use libxml::tree::{Node, NodeType};
11use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
12
13use crate::{
14 document::{NodeData, PostDocument, get_xml_id},
15 object_db::{Entry, ObjectDB, Value},
16 processor::{ProcessResult, Processor},
17 scan::title_text_content,
18};
19
20const NORMAL_TOC_TYPES: &[&str] = &[
27 "ltx:document",
28 "ltx:part",
29 "ltx:chapter",
30 "ltx:section",
31 "ltx:subsection",
32 "ltx:subsubsection",
33 "ltx:paragraph",
34 "ltx:subparagraph",
35 "ltx:index",
36 "ltx:bibliography",
37 "ltx:glossary",
38 "ltx:appendix",
39];
40
41struct ChildPages {
46 ids: Vec<String>,
47 index_of: HashMap<String, usize>,
48}
49
50fn ref_fallbacks(key: &str) -> &'static [&'static str] {
52 match key {
53 "typerefnum" => &["refnum"],
54 "toctitle" => &["title", "toccaption"],
55 "title" => &["toccaption"],
56 "rawtoctitle" => &["toctitle", "title", "toccaption"],
57 "rawtitle" => &["title", "toccaption"],
58 _ => &[],
59 }
60}
61
62fn value_text(doc: &PostDocument, val: &Value) -> String {
70 let raw = match val {
71 Value::Xml(node) => title_text_content(doc, node),
72 other => other.to_string(),
73 };
74 raw.split_whitespace().collect::<Vec<_>>().join(" ")
77}
78
79fn ref_content_children(val: &Value) -> Vec<NodeData> {
92 let node = match val {
93 Value::Xml(node) => node,
94 other => return vec![NodeData::Text(other.to_string())],
95 };
96 let mut out: Vec<NodeData> = Vec::new();
97 let mut child = node.get_first_child();
98 while let Some(c) = child {
99 match c.get_type() {
100 Some(NodeType::TextNode) => out.push(NodeData::Text(c.get_content())),
101 Some(NodeType::ElementNode) => out.push(NodeData::XmlNode(c.clone())),
102 _ => {},
103 }
104 child = c.get_next_sibling();
105 }
106 if let Some(NodeData::Text(s)) = out.first_mut() {
109 let t = s.trim_start().to_string();
110 if t.is_empty() {
111 out.remove(0);
112 } else {
113 *s = t;
114 }
115 }
116 if let Some(NodeData::Text(s)) = out.last_mut() {
117 let t = s.trim_end().to_string();
118 if t.is_empty() {
119 out.pop();
120 } else {
121 *s = t;
122 }
123 }
124 out
125}
126
127fn strip_ref_display_fragids(doc: &PostDocument) {
139 for mut n in doc.findnodes("//ltx:ref//*[@fragid]") {
140 let _ = n.remove_attribute("fragid");
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum UrlStyle {
149 File,
152 Server,
155 Negotiated,
158}
159
160impl UrlStyle {
161 pub fn from_cli(s: &str) -> Option<Self> {
164 match s {
165 "file" => Some(UrlStyle::File),
166 "server" => Some(UrlStyle::Server),
167 "negotiated" => Some(UrlStyle::Negotiated),
168 _ => None,
169 }
170 }
171
172 pub fn as_cli(self) -> &'static str {
175 match self {
176 UrlStyle::File => "file",
177 UrlStyle::Server => "server",
178 UrlStyle::Negotiated => "negotiated",
179 }
180 }
181}
182
183fn apply_url_style(url: &str, style: UrlStyle, extension: &str) -> String {
189 match style {
190 UrlStyle::Server => {
192 let index_suffix = format!("index.{extension}");
193 if let Some(prefix) = url.strip_suffix(&index_suffix) {
194 if prefix.is_empty() {
195 return "./".to_string(); } else if prefix.ends_with('/') {
197 return prefix.to_string(); } }
200 url.to_string()
201 },
202 UrlStyle::Negotiated => {
204 let stripped = url.strip_suffix(&format!(".{extension}")).unwrap_or(url);
205 if stripped == "index" {
206 String::new() } else if let Some(prefix) = stripped.strip_suffix("index") {
208 if prefix.ends_with('/') {
209 prefix.to_string() } else {
211 stripped.to_string() }
213 } else {
214 stripped.to_string()
215 }
216 },
217 UrlStyle::File => url.to_string(),
218 }
219}
220
221pub struct CrossRef {
225 name: String,
226 pub db: ObjectDB,
228 url_style: UrlStyle,
230 extension: String,
232 toc_show: String,
234 ref_show: String,
236 min_ref_length: usize,
238 ref_join: String,
240 navigation_toc: Option<String>,
242 missing: HashMap<String, HashMap<String, HashMap<String, u32>>>,
244 child_pages: RefCell<HashMap<String, Rc<ChildPages>>>,
250}
251
252fn render_bibref_show(
264 show: &str,
265 authors: Option<&str>,
266 fullauthors: Option<&str>,
267 year: Option<&str>,
268 number: Option<&str>,
269 refnum: Option<&str>,
270 phrases: &[String],
271) -> (String, bool) {
272 let lower = show.to_ascii_lowercase();
273 let lb = lower.as_bytes();
274 let mut out = String::new();
275 let mut resolved_ay = false;
276 let mut i = 0;
277 while i < show.len() {
278 if lb[i..].starts_with(b"phrase") {
280 let ds = i + "phrase".len();
281 let mut j = ds;
282 while j < lb.len() && lb[j].is_ascii_digit() {
283 j += 1;
284 }
285 if j > ds {
286 if let Ok(n) = show[ds..j].parse::<usize>() {
287 if n >= 1 && n <= phrases.len() {
288 out.push_str(&phrases[n - 1]);
289 }
290 }
291 i = j;
292 continue;
293 }
294 }
295 let mut matched = false;
298 for (kw, val, is_ay) in [
299 ("fullauthors", fullauthors.or(authors), true),
300 ("authors", authors, true),
301 ("year", year, true),
302 ("number", number, false),
303 ("refnum", refnum, false),
304 ] {
305 if lb[i..].starts_with(kw.as_bytes()) {
306 if let Some(v) = val {
307 if !v.is_empty() {
308 out.push_str(v);
309 if is_ay {
310 resolved_ay = true;
311 }
312 }
313 }
314 i += kw.len();
315 matched = true;
316 break;
317 }
318 }
319 if matched {
320 continue;
321 }
322 let ch = show[i..].chars().next().unwrap();
324 out.push(ch);
325 i += ch.len_utf8();
326 }
327 (out, resolved_ay)
328}
329
330impl CrossRef {
331 pub fn new(db: ObjectDB, url_style: UrlStyle, number_sections: bool) -> Self {
332 CrossRef {
333 name: "CrossRef".to_string(),
334 db,
335 url_style,
336 extension: "xml".to_string(),
337 toc_show: "toctitle".to_string(),
338 ref_show: if number_sections {
339 "refnum".to_string()
340 } else {
341 "title".to_string()
342 },
343 min_ref_length: 1,
344 ref_join: " \u{2023} ".to_string(), navigation_toc: None,
346 missing: HashMap::default(),
347 child_pages: RefCell::new(HashMap::default()),
348 }
349 }
350
351 pub fn set_extension(&mut self, ext: &str) { self.extension = ext.to_string(); }
353
354 pub fn set_navigation_toc(&mut self, format: &str) {
356 self.navigation_toc = Some(format.to_string());
357 }
358
359 fn note_missing(&mut self, severity: &str, ref_type: &str, key: &str) {
361 self
362 .missing
363 .entry(severity.to_string())
364 .or_default()
365 .entry(ref_type.to_string())
366 .or_default()
367 .entry(key.to_string())
368 .and_modify(|c| *c += 1)
369 .or_insert(1);
370 }
371
372 fn generate_url(&mut self, doc: &PostDocument, id: &str) -> Option<String> {
376 let entry = self.db.lookup(&format!("ID:{}", id))?;
377 let location = entry.get_string("location")?;
378
379 let doc_location = doc.site_relative_destination().unwrap_or_default();
380 let mut url = relative_url(location, &doc_location);
381
382 url = apply_url_style(&url, self.url_style, &self.extension);
383
384 if url.is_empty() {
385 url = ".".to_string();
386 }
387
388 let fragid = entry.get_string("fragid").map(String::from);
390 let loc = location.to_string();
391 if let Some(fid) = fragid {
392 if url == "." || loc == doc_location {
393 url = String::new();
394 }
395 url = format!("{}#{}", url, fid);
396 } else if loc == doc_location {
397 url = String::new();
398 }
399
400 Some(url)
401 }
402
403 fn generate_title(&self, doc: &PostDocument, id: &str, shown: &str) -> Option<String> {
407 let mut current_id = id.to_string();
408 let mut result = String::new();
409 let mut prefix = String::new();
410 let mut shown_so_far = shown.to_string();
411
412 while let Some(entry) = self.db.lookup(&format!("ID:{}", current_id)) {
413 let mut pieces = Vec::new();
414 let mut is_dup = false;
415
416 if let Some(title_val) = entry.get_value("title") {
418 if title_val.is_truthy() {
419 is_dup = shown_so_far.contains("title");
420 pieces.push(value_text(doc, title_val));
424 }
425 }
426 if pieces.is_empty() {
427 let has_type = entry
428 .get_value("tag:creftypecap")
429 .or_else(|| entry.get_value("tag:creftype"));
430 let has_refnum = entry.get_value("refnum");
431 if has_type.is_some() && has_refnum.is_some() {
432 is_dup = shown_so_far.contains("type") && shown_so_far.contains("refnum");
433 if let Some(t) = has_type {
434 pieces.push(t.to_string());
435 }
436 if let Some(r) = has_refnum {
437 pieces.push(r.to_string());
438 }
439 } else if let Some(tr) = entry.get_value("typerefnum") {
440 is_dup = shown_so_far.contains("type") && shown_so_far.contains("refnum");
441 pieces.push(tr.to_string());
442 } else if let Some(r) = has_refnum {
443 is_dup = shown_so_far.contains("refnum");
444 pieces.push(r.to_string());
445 }
446 }
447
448 if is_dup {
449 prefix = "In ".to_string();
450 shown_so_far.clear();
451 } else {
452 let title = pieces.join(" ");
453 let title = title.trim();
454 if !title.is_empty() {
455 result.push_str(&prefix);
456 prefix = self.ref_join.clone();
457 result.push_str(title);
458 }
459 }
460
461 match entry.get_string("parent").map(String::from) {
463 Some(pid) => current_id = pid,
464 None => break,
465 }
466 }
467
468 if result.is_empty() {
469 None
470 } else {
471 Some(result)
472 }
473 }
474
475 fn generate_document_title(&self, doc: &PostDocument) -> Option<String> {
479 if let Some(docid) = doc.get_document_element().as_ref().and_then(get_xml_id) {
483 let title = self.generate_title(doc, &docid, "");
490 if title.as_ref().map(|t| !t.is_empty()).unwrap_or(false) {
491 return title;
492 }
493 }
494 if let Some(node) =
496 doc.findnode("//ltx:title | //ltx:toctitle | //ltx:caption | //ltx:toccaption")
497 {
498 let text = get_text_content_node(&node);
499 if !text.is_empty() {
500 return Some(text);
501 }
502 }
503 None
504 }
505
506 fn generate_glossary_ref_title(&self, entry_key: &str, show: &str) -> Vec<NodeData> {
510 let entry = match self.db.lookup(entry_key) {
511 Some(e) => e,
512 None => return vec![],
513 };
514
515 let phrase_key = format!("phrase:{}", show);
516 if let Some(val) = entry.get_value(&phrase_key) {
517 return vec![NodeData::Element {
518 tag: "ltx:text".to_string(),
519 attributes: Some(HashMap::from_iter([(
520 "class".to_string(),
521 format!("ltx_glossary_{}", show),
522 )])),
523 children: vec![NodeData::Text(val.to_string())],
524 }];
525 }
526
527 if let Some(base_show) = show.strip_suffix("-plural") {
529 let base_key = format!("phrase:{}", base_show);
530 if let Some(val) = entry.get_value(&base_key) {
531 return vec![NodeData::Element {
532 tag: "ltx:text".to_string(),
533 attributes: Some(HashMap::from_iter([(
534 "class".to_string(),
535 format!("ltx_glossary_{}", show),
536 )])),
537 children: vec![NodeData::Text(format!("{}s", val))],
538 }];
539 }
540 }
541 if let Some(base_show) = show.strip_suffix("-indefinite") {
542 let base_key = format!("phrase:{}", base_show);
543 if let Some(val) = entry.get_value(&base_key) {
544 let text = val.to_string();
545 let article = if text.starts_with(|c: char| "aeiouAEIOU".contains(c)) {
546 "an "
547 } else {
548 "a "
549 };
550 return vec![NodeData::Element {
551 tag: "ltx:text".to_string(),
552 attributes: Some(HashMap::from_iter([(
553 "class".to_string(),
554 format!("ltx_glossary_{}", show),
555 )])),
556 children: vec![NodeData::Text(article.to_string()), NodeData::Text(text)],
557 }];
558 }
559 }
560
561 vec![]
562 }
563
564 fn copy_resources(&self, doc: &PostDocument) {
568 let refs = doc.findnodes("//ltx:ref[@href and not(@idref) and not(@labelref)]");
569 for ref_node in &refs {
570 if let Some(url) = ref_node.get_attribute("href") {
571 if !url.contains("://") && !url.starts_with('/') {
573 log::trace!("CrossRef: would copy resource '{}'", url);
575 }
576 }
577 }
578 }
579
580 fn generate_ref(&mut self, _doc: &PostDocument, req_id: &str, req_show: &str) -> Vec<NodeData> {
584 let show_options = if !req_show.contains("title") {
585 vec![req_show.to_string(), "title".to_string()]
586 } else {
587 vec![req_show.to_string(), "refnum".to_string()]
588 };
589
590 for show in &show_options {
591 let mut stuff = Vec::new();
592 let mut id = req_id.to_string();
593 let mut pending = String::new();
594 loop {
595 let entry_exists = self.db.lookup(&format!("ID:{}", id)).is_some();
596 if !entry_exists {
597 break;
598 }
599 let s = self.generate_ref_aux(&id, show);
600 if !s.is_empty() {
601 if !pending.is_empty() {
602 stuff.push(NodeData::Text(pending.clone()));
603 }
604 stuff.extend(s);
605 if self.check_ref_content(&stuff) {
606 return stuff;
607 }
608 pending = self.ref_join.clone();
609 }
610 let parent = self
611 .db
612 .lookup(&format!("ID:{}", id))
613 .and_then(|e| e.get_string("parent").map(String::from));
614 match parent {
615 Some(pid) => id = pid,
616 None => break,
617 }
618 }
619 if !stuff.is_empty() {
620 return stuff;
621 }
622 }
623
624 self.note_missing("info", "Usable title for ID", req_id);
625 vec![NodeData::Text(req_id.to_string())]
626 }
627
628 fn generate_ref_aux(&self, id: &str, show: &str) -> Vec<NodeData> {
630 let entry = match self.db.lookup(&format!("ID:{}", id)) {
631 Some(e) => e,
632 None => return vec![],
633 };
634
635 let mut stuff = Vec::new();
636 let mut ok = false;
637 let mut remaining = show.to_string();
638
639 while !remaining.is_empty() {
640 if remaining.starts_with(|c: char| c.is_alphanumeric()) {
641 let keyword: String = remaining
642 .chars()
643 .take_while(|c| c.is_alphanumeric())
644 .collect();
645 remaining = remaining[keyword.len()..].to_string();
646 let key = keyword.to_lowercase();
647 let class = if key.contains("title") {
648 "ltx_ref_title"
649 } else {
650 "ltx_ref_tag"
651 };
652
653 let mut keys_to_try = vec![key.clone(), format!("tag:{}", key)];
654 keys_to_try.extend(ref_fallbacks(&key).iter().map(|s| s.to_string()));
655
656 for k in &keys_to_try {
657 if let Some(val) = entry.get_value(k) {
658 if val.is_truthy() {
659 ok = true;
660 stuff.push(NodeData::Element {
668 tag: "ltx:text".to_string(),
669 attributes: Some(HashMap::from_iter([(
670 "class".to_string(),
671 class.to_string(),
672 )])),
673 children: ref_content_children(val),
674 });
675 break;
676 }
677 }
678 }
679 } else if remaining.starts_with('{') {
680 if let Some(end) = remaining[1..].find('}') {
681 let literal = &remaining[1..1 + end];
682 if !literal.is_empty() {
683 stuff.push(NodeData::Text(literal.to_string()));
684 }
685 remaining = remaining[2 + end..].to_string();
686 } else {
687 remaining.clear();
688 }
689 } else if remaining.starts_with('~') {
690 remaining = remaining[1..].to_string();
691 if !stuff.is_empty() {
692 stuff.push(NodeData::Text("\u{00A0}".to_string()));
693 }
694 } else if remaining.starts_with(|c: char| c.is_whitespace()) {
695 let ws: String = remaining
696 .chars()
697 .take_while(|c| c.is_whitespace())
698 .collect();
699 remaining = remaining[ws.len()..].to_string();
700 if !stuff.is_empty() {
701 stuff.push(NodeData::Text(ws));
702 }
703 } else {
704 let sym: String = remaining
705 .chars()
706 .take_while(|c| !c.is_alphanumeric() && *c != '{' && *c != '~')
707 .collect();
708 remaining = remaining[sym.len()..].to_string();
709 stuff.push(NodeData::Text(sym));
710 }
711 }
712
713 if ok { stuff } else { vec![] }
714 }
715
716 fn check_ref_content(&self, stuff: &[NodeData]) -> bool {
718 let text = text_content(stuff);
719 let cleaned = text.replace("in ", "");
720 cleaned.chars().any(|c| c.is_alphanumeric())
721 }
722
723 fn fill_in_relations(&mut self, doc: &mut PostDocument) {
727 let page_id = match doc.get_document_element().as_ref().and_then(get_xml_id) {
731 Some(id) => id,
732 None => return,
733 };
734
735 let mut current_id = page_id.clone();
737 let mut rel = "up".to_string();
738 let mut topmost = current_id.clone();
739 loop {
740 let parent_id = self
741 .db
742 .lookup(&format!("ID:{}", current_id))
743 .and_then(|e| e.get_string("parent").map(String::from));
744 match parent_id {
745 Some(pid) => {
746 let has_title = self
747 .db
748 .lookup(&format!("ID:{}", pid))
749 .and_then(|e| e.get_value("title"))
750 .map(|v| v.is_truthy())
751 .unwrap_or(false);
752 if has_title {
753 doc.add_navigation(&rel, &pid);
754 rel = format!("{} up", rel);
755 }
756 current_id = pid.clone();
757 topmost = pid;
758 },
759 None => break,
760 }
761 }
762
763 if topmost != page_id {
765 if let Some(top_pageid) = self
766 .db
767 .lookup(&format!("ID:{}", topmost))
768 .and_then(|e| e.get_string("pageid").map(String::from))
769 {
770 doc.add_navigation("start", &top_pageid);
771 }
772 }
773
774 if let Some(prev) = self.find_previous_page_id(&page_id) {
776 doc.add_navigation("prev", &prev);
777 }
778 if let Some(next) = self.find_next_page_id(&page_id) {
779 doc.add_navigation("next", &next);
780 }
781
782 let mut xentry = page_id.clone();
789 while let Some(parent) = self.get_parent_page_id(&xentry) {
790 for sib in self.child_pages(&parent).ids.iter() {
791 if *sib == page_id {
792 continue;
793 }
794 self.add_typed_navigation(doc, sib);
795 }
796 xentry = parent;
797 }
798 for child in self.child_pages(&page_id).ids.iter() {
799 self.add_typed_navigation(doc, child);
800 }
801 }
802
803 fn add_typed_navigation(&self, doc: &mut PostDocument, related_id: &str) {
808 if self.is_primary_page(related_id) {
809 let rel = self
810 .db
811 .lookup(&format!("ID:{}", related_id))
812 .and_then(|e| e.get_string("type").map(String::from))
813 .map(|t| t.rsplit(':').next().unwrap_or(&t).to_string());
815 if let Some(rel) = rel.filter(|r| !r.is_empty()) {
816 doc.add_navigation(&rel, related_id);
817 }
818 } else {
819 doc.add_navigation("sidebar", related_id);
820 }
821 }
822
823 fn is_primary_page(&self, page_id: &str) -> bool {
826 self
827 .db
828 .lookup(&format!("ID:{}", page_id))
829 .and_then(|e| e.get_value("primary"))
830 .map(|v| v.is_truthy())
831 .unwrap_or(false)
832 }
833
834 fn get_parent_page_id(&self, entry_id: &str) -> Option<String> {
837 let entry = self.db.lookup(&format!("ID:{}", entry_id))?;
838 let pageid = entry.get_string("pageid")?.to_string();
839 let page_entry = self.db.lookup(&format!("ID:{}", pageid))?;
840 let parent_id = page_entry.get_string("parent")?.to_string();
841 let parent_entry = self.db.lookup(&format!("ID:{}", parent_id))?;
842 Some(parent_entry.get_string("pageid")?.to_string())
843 }
844
845 fn child_pages(&self, entry_id: &str) -> Rc<ChildPages> {
850 if let Some(cached) = self.child_pages.borrow().get(entry_id) {
851 return cached.clone();
852 }
853 let ids = self.compute_child_page_ids(entry_id);
854 let mut index_of = HashMap::default();
855 for (i, id) in ids.iter().enumerate() {
857 index_of.insert(id.clone(), i);
858 }
859 let rc = Rc::new(ChildPages { ids, index_of });
860 self
861 .child_pages
862 .borrow_mut()
863 .insert(entry_id.to_string(), rc.clone());
864 rc
865 }
866
867 fn compute_child_page_ids(&self, entry_id: &str) -> Vec<String> {
871 let entry = match self.db.lookup(&format!("ID:{}", entry_id)) {
872 Some(e) => e,
873 None => return Vec::new(),
874 };
875 let here_pageid = entry.get_string("pageid").map(String::from);
876 let children = entry.get_children();
877 let mut out = Vec::new();
878 for ch in children {
879 let ch_entry = match self.db.lookup(&format!("ID:{}", ch)) {
880 Some(e) => e,
881 None => continue,
882 };
883 let ch_pageid = match ch_entry.get_string("pageid") {
884 Some(p) => p.to_string(),
885 None => continue,
886 };
887 if here_pageid.as_deref() != Some(&ch_pageid) {
888 out.push(ch_pageid);
889 } else {
890 out.extend(self.child_pages(&ch).ids.iter().cloned());
891 }
892 }
893 out
894 }
895
896 fn find_previous_page_id(&self, page_id: &str) -> Option<String> {
900 let parent_id = self.get_parent_page_id(page_id)?;
901 let siblings = self.child_pages(&parent_id);
902 let pos = *siblings.index_of.get(page_id)?;
904 let mut current = match siblings.ids[..pos]
910 .iter()
911 .rev()
912 .find(|s| self.is_primary_page(s))
913 {
914 Some(sib) => sib.clone(),
915 None => return Some(parent_id),
916 };
917 loop {
919 let kids = self.child_pages(¤t);
920 match kids.ids.iter().rev().find(|s| self.is_primary_page(s)) {
921 Some(deepest) => current = deepest.clone(),
922 None => break,
923 }
924 }
925 Some(current)
926 }
927
928 fn find_next_page_id(&self, page_id: &str) -> Option<String> {
932 if let Some(first) = self
934 .child_pages(page_id)
935 .ids
936 .iter()
937 .find(|s| self.is_primary_page(s))
938 {
939 return Some(first.clone());
940 }
941 let mut current = page_id.to_string();
942 loop {
943 let parent = self.get_parent_page_id(¤t)?;
944 let siblings = self.child_pages(&parent);
945 let pos = *siblings.index_of.get(¤t)?;
947 if let Some(first) = siblings.ids[pos + 1..]
949 .iter()
950 .find(|s| self.is_primary_page(s))
951 {
952 return Some(first.clone());
953 }
954 current = parent;
955 }
956 }
957
958 fn fill_in_tocs(&mut self, doc: &mut PostDocument) {
959 let tocs = match doc.get_document_element() {
966 Some(root) => doc.findnodes_at("descendant::ltx:TOC[not(ltx:toclist)]", Some(&root)),
967 None => Vec::new(),
968 };
969 for toc in &tocs {
970 let mut id = doc
975 .get_document_element()
976 .as_ref()
977 .and_then(get_xml_id)
978 .unwrap_or_default();
979 if toc.get_attribute("scope").as_deref() == Some("global") {
985 id = self.get_root_page_id(&id);
986 }
987 let show = toc
988 .get_attribute("show")
989 .unwrap_or_else(|| self.toc_show.clone());
990
991 let select_attr = toc.get_attribute("select");
997 let types: Option<HashSet<&str>> = select_attr.as_deref().map(|s| {
998 s.split('|')
999 .map(str::trim)
1000 .filter(|t| !t.is_empty())
1001 .collect()
1002 });
1003 let lists_attr = toc.get_attribute("lists");
1004 let lists: HashSet<&str> = match lists_attr.as_deref() {
1005 Some(l) => l.split_whitespace().collect(),
1006 None => HashSet::from_iter(["toc"]),
1007 };
1008
1009 let format = toc.get_attribute("format").unwrap_or_default();
1014 let list = if format.is_empty() || format.starts_with("normal") {
1015 self.gen_toc(&id, &show, types.as_ref(), &lists, None, None)
1016 } else if format == "context" {
1017 let toc_lists: HashSet<&str> = HashSet::from_iter(["toc"]);
1018 self.gen_toc_context(&id, &show, types.as_ref(), &toc_lists)
1019 } else {
1020 Vec::new()
1021 };
1022 if !list.is_empty() {
1023 let toclist = NodeData::Element {
1024 tag: "ltx:toclist".to_string(),
1025 attributes: None,
1026 children: list,
1027 };
1028 let mut toc_mut = toc.clone();
1029 doc.add_nodes(&mut toc_mut, &[toclist]);
1030 }
1031 }
1032 }
1033
1034 fn get_root_page_id(&self, start_id: &str) -> String {
1039 let mut root_id = start_id.to_string();
1040 let mut cursor = start_id.to_string();
1041 while let Some(page_id) = self.parent_page_of(&cursor) {
1042 root_id = page_id.clone();
1043 cursor = page_id;
1044 }
1045 self
1047 .db
1048 .lookup(&format!("ID:{}", root_id))
1049 .and_then(|e| e.get_string("pageid"))
1050 .map(String::from)
1051 .unwrap_or(root_id)
1052 }
1053
1054 fn parent_page_of(&self, id: &str) -> Option<String> {
1057 let parent_id = self
1059 .db
1060 .lookup(&format!("ID:{}", id))
1061 .and_then(|e| e.get_string("parent"))
1062 .filter(|s| !s.is_empty())?;
1063 let page_id = self
1065 .db
1066 .lookup(&format!("ID:{}", parent_id))
1067 .and_then(|e| e.get_string("pageid"))
1068 .filter(|s| !s.is_empty())?
1069 .to_string();
1070 self.db.lookup(&format!("ID:{}", page_id)).map(|_| page_id)
1072 }
1073
1074 fn gen_toc(
1080 &self,
1081 id: &str,
1082 show: &str,
1083 types: Option<&HashSet<&str>>,
1084 lists: &HashSet<&str>,
1085 localto: Option<&str>,
1086 selfid: Option<&str>,
1087 ) -> Vec<NodeData> {
1088 let entry = match self.db.lookup(&format!("ID:{}", id)) {
1089 Some(e) => e,
1090 None => return vec![],
1091 };
1092
1093 let recurse = match localto {
1096 None => true,
1097 Some(target) => entry.get_string("location").unwrap_or("") == target,
1098 };
1099 let kids: Vec<NodeData> = if recurse {
1100 entry
1101 .get_children()
1102 .iter()
1103 .flat_map(|child_id| self.gen_toc(child_id, show, types, lists, localto, selfid))
1104 .collect()
1105 } else {
1106 Vec::new()
1107 };
1108
1109 let entry_type = entry.get_string("type").unwrap_or("");
1110 let type_ok = types.map(|t| t.contains(entry_type)).unwrap_or(true);
1115 let in_toc = entry
1116 .get_value("inlist")
1117 .map(|v| match v {
1118 Value::Hash(h) => lists.iter().any(|l| h.contains_key(*l)),
1119 _ => false,
1120 })
1121 .unwrap_or(false);
1122
1123 if type_ok && in_toc {
1124 vec![self.gen_tocentry(entry, selfid, show, kids)]
1125 } else {
1126 kids
1127 }
1128 }
1129
1130 fn gen_tocentry(
1135 &self,
1136 entry: &Entry,
1137 selfid: Option<&str>,
1138 show: &str,
1139 children: Vec<NodeData>,
1140 ) -> NodeData {
1141 let id = entry
1142 .get_string("id")
1143 .or_else(|| entry.get_key().strip_prefix("ID:"))
1144 .unwrap_or("")
1145 .to_string();
1146 let entry_type = entry.get_string("type").unwrap_or("");
1147 let type_name = entry_type.strip_prefix("ltx:").unwrap_or(entry_type);
1148
1149 let (mut before, mut after): (Option<&str>, Option<&str>) = (None, None);
1151 let mut show_mid = show;
1152 if let Some((b, rest)) = show_mid.split_once('<') {
1153 before = Some(b);
1154 show_mid = rest;
1155 }
1156 if let Some((mid, a)) = show_mid.split_once('>') {
1157 show_mid = mid;
1158 after = Some(a);
1159 }
1160
1161 let self_class = if selfid == Some(id.as_str()) {
1162 " ltx_ref_self"
1163 } else {
1164 ""
1165 };
1166
1167 let mut kids: Vec<NodeData> = Vec::new();
1168 if let Some(b) = before.filter(|b| !b.is_empty()) {
1169 kids.extend(self.generate_ref_simple(&id, b));
1170 }
1171 kids.push(NodeData::Element {
1172 tag: "ltx:ref".to_string(),
1173 attributes: Some(HashMap::from_iter([
1174 ("show".to_string(), show_mid.to_string()),
1175 ("idref".to_string(), id.clone()),
1176 ])),
1177 children: vec![],
1178 });
1179 if let Some(a) = after.filter(|a| !a.is_empty()) {
1180 kids.extend(self.generate_ref_simple(&id, a));
1181 }
1182 if !children.is_empty() {
1183 kids.push(NodeData::Element {
1184 tag: "ltx:toclist".to_string(),
1185 attributes: Some(HashMap::from_iter([(
1186 "class".to_string(),
1187 format!("ltx_toclist_{}", type_name),
1188 )])),
1189 children,
1190 });
1191 }
1192
1193 NodeData::Element {
1194 tag: "ltx:tocentry".to_string(),
1195 attributes: Some(HashMap::from_iter([(
1196 "class".to_string(),
1197 format!("ltx_tocentry_{}{}", type_name, self_class),
1198 )])),
1199 children: kids,
1200 }
1201 }
1202
1203 fn generate_ref_simple(&self, req_id: &str, req_show: &str) -> Vec<NodeData> {
1206 if !req_show.is_empty()
1207 && !req_id.is_empty()
1208 && self.db.lookup(&format!("ID:{}", req_id)).is_some()
1209 {
1210 self.generate_ref_aux(req_id, req_show)
1211 } else {
1212 Vec::new()
1213 }
1214 }
1215
1216 fn gen_toc_context(
1220 &self,
1221 id: &str,
1222 show: &str,
1223 types: Option<&HashSet<&str>>,
1224 lists: &HashSet<&str>,
1225 ) -> Vec<NodeData> {
1226 let start = match self.db.lookup(&format!("ID:{}", id)) {
1227 Some(e) => e,
1228 None => return vec![],
1229 };
1230
1231 let location = start.get_string("location").unwrap_or("").to_string();
1234 let mut navtoc = self.gen_toc(id, show, types, lists, Some(&location), Some(id));
1235
1236 let mut came_from = id.to_string();
1240 let mut parent_id = start.get_string("parent").map(String::from);
1241
1242 while let Some(pid) = parent_id {
1243 let parent = match self.db.lookup(&format!("ID:{}", pid)) {
1244 Some(e) => e,
1245 None => break,
1246 };
1247
1248 let mut row: Vec<NodeData> = Vec::new();
1251 for child_id in parent.get_children() {
1252 let child = match self.db.lookup(&format!("ID:{}", child_id)) {
1253 Some(e) => e,
1254 None => continue,
1255 };
1256 if !NORMAL_TOC_TYPES.contains(&child.get_string("type").unwrap_or("")) {
1257 continue;
1258 }
1259 let child_id_val = child.get_string("id").unwrap_or(&child_id);
1260 if child_id_val == came_from {
1261 row.append(&mut navtoc);
1262 } else {
1263 row.push(self.gen_tocentry(child, None, show, Vec::new()));
1264 }
1265 }
1266 navtoc = row;
1267
1268 let parent_type = parent.get_string("type").unwrap_or("");
1272 let parent_ok = types.map(|t| t.contains(parent_type)).unwrap_or(true);
1273 let parent_has_parent = parent
1274 .get_string("parent")
1275 .map(|s| !s.is_empty())
1276 .unwrap_or(false);
1277 if parent_ok && parent_has_parent {
1278 navtoc = vec![self.gen_tocentry(parent, None, show, navtoc)];
1279 }
1280
1281 came_from = pid;
1282 parent_id = parent.get_string("parent").map(String::from);
1283 }
1284
1285 navtoc
1286 }
1287
1288 fn fill_in_frags(&self, doc: &PostDocument) {
1289 if doc.idcache_len() <= self.db.len() {
1299 for (id, node) in doc.idcache_iter() {
1301 if let Some(entry) = self.db.lookup(&format!("ID:{}", id)) {
1302 if let Some(fragid) = entry.get_string("fragid") {
1303 let mut n = node.clone();
1304 n.set_attribute("fragid", fragid).ok();
1305 }
1306 }
1307 }
1308 } else {
1309 for key in self.db.keys_iter() {
1312 let id = match key.strip_prefix("ID:") {
1313 Some(rest) => rest,
1314 None => continue,
1315 };
1316 let entry = match self.db.lookup(key) {
1317 Some(e) => e,
1318 None => continue,
1319 };
1320 let fragid = match entry.get_string("fragid") {
1321 Some(f) => f,
1322 None => continue,
1323 };
1324 if let Some(node) = doc.find_node_by_id(id) {
1325 let mut n = node.clone();
1326 n.set_attribute("fragid", fragid).ok();
1327 }
1328 }
1329 }
1330 }
1331
1332 fn fill_in_refs(&mut self, doc: &mut PostDocument) {
1333 let mut refs = doc.findnodes("//*[@idref]");
1334 refs.extend(doc.findnodes("//*[@labelref]"));
1335 for ref_node in &refs {
1336 let tag = doc.get_qname(ref_node).unwrap_or_default();
1337 if tag == "ltx:XMRef" {
1338 continue;
1339 }
1340
1341 let mut ref_mut = ref_node.clone();
1342 let mut id = ref_node.get_attribute("idref");
1343 let show = ref_node
1344 .get_attribute("show")
1345 .unwrap_or_else(|| self.ref_show.clone());
1346
1347 if id.is_none() {
1348 if let Some(label) = ref_node.get_attribute("labelref") {
1349 if let Some(entry) = self.db.lookup(&label) {
1350 if let Some(resolved_id) = entry.get_string("id") {
1351 ref_mut.set_attribute("idref", resolved_id).ok();
1352 id = Some(resolved_id.to_string());
1353 }
1354 }
1355 if id.is_none() {
1356 self.note_missing("warn", "Target for Label", &label);
1357 PostDocument::add_class(&mut ref_mut, "ltx_missing_label");
1358 }
1359 }
1360 }
1361
1362 if let Some(ref id_str) = id {
1363 if ref_mut.get_attribute("href").is_none() {
1364 if let Some(url) = self.generate_url(doc, id_str) {
1365 ref_mut.set_attribute("href", &url).ok();
1366 }
1367 }
1368 if ref_mut.get_attribute("title").is_none() {
1369 if let Some(titlestring) = self.generate_title(doc, id_str, &show) {
1370 ref_mut.set_attribute("title", &titlestring).ok();
1371 }
1372 if let Some(rel) = ref_mut.get_attribute("rel") {
1380 if !rel.is_empty() {
1381 if let Some(fulltitle) = self.generate_title(doc, id_str, "") {
1382 ref_mut.set_attribute("fulltitle", &fulltitle).ok();
1383 }
1384 }
1385 }
1386 }
1387 if ref_mut.get_first_child().is_none() && tag != "ltx:graphics" && tag != "ltx:picture" {
1388 let content = self.generate_ref(doc, id_str, &show);
1389 doc.add_nodes(&mut ref_mut, &content);
1390 }
1391 }
1392 }
1393 }
1394
1395 fn fill_in_glossaryrefs(&mut self, doc: &mut PostDocument) {
1396 for ref_node in &doc.findnodes("descendant::ltx:glossaryref") {
1404 let mut ref_mut = ref_node.clone();
1405 let key = ref_node.get_attribute("key").unwrap_or_default();
1406 let list = ref_node.get_attribute("inlist").unwrap_or_default();
1407
1408 let gkey = format!("GLOSSARY:{}:{}", list, key);
1409 if let Some(entry) = self.db.lookup(&gkey) {
1410 if let Some(id) = entry.get_string("id") {
1411 ref_mut.set_attribute("idref", id).ok();
1412 }
1413 if ref_mut.get_attribute("title").is_none() {
1416 if let Some(desc) = entry.get_string("phrase:description") {
1417 if !desc.is_empty() {
1418 ref_mut.set_attribute("title", desc).ok();
1419 }
1420 }
1421 }
1422 } else {
1423 self.note_missing("warn", "Glossary Entry for key", &key);
1424 }
1425
1426 if ref_mut.get_first_child().is_none() {
1427 doc.add_nodes(&mut ref_mut, &[NodeData::Text(key.clone())]);
1428 PostDocument::add_class(&mut ref_mut, "ltx_missing");
1429 }
1430 }
1431 }
1432
1433 fn fill_in_rdfa_refs(&mut self, doc: &mut PostDocument) {
1461 for key in ["about", "resource"] {
1462 let refs = doc.findnodes(&format!("//*[@{key}idref or @{key}labelref]"));
1465 for ref_node in &refs {
1466 let mut ref_mut = ref_node.clone();
1467 let idref_attr = format!("{key}idref");
1468 let mut id = ref_node
1471 .get_attribute(&idref_attr)
1472 .filter(|v| !v.is_empty());
1473
1474 if id.is_none()
1478 && let Some(label) = ref_node.get_attribute(&format!("{key}labelref"))
1479 {
1480 if let Some(entry) = self.db.lookup(&label)
1481 && let Some(resolved) = entry.get_string("id")
1482 {
1483 ref_mut.set_attribute(&idref_attr, resolved).ok();
1484 id = Some(resolved.to_string());
1485 }
1486 if id.is_none() {
1487 self.note_missing("warn", &format!("Target for {key} Label"), &label);
1488 }
1489 }
1490
1491 if let Some(ref id_str) = id
1493 && ref_mut.get_attribute(key).is_none()
1494 {
1495 let value = if self.db.lookup(&format!("ID:{id_str}")).is_some() {
1496 self.generate_url(doc, id_str)
1497 } else {
1498 Some(format!("#{id_str}"))
1499 };
1500 if let Some(value) = value {
1501 ref_mut.set_attribute(key, &value).ok();
1502 }
1503 }
1504 }
1505 }
1506 }
1507
1508 fn fill_in_bibrefs(&mut self, doc: &mut PostDocument) {
1509 let bibrefs = doc.findnodes("//ltx:bibref");
1510 for bibref in &bibrefs {
1511 let keys_str = bibref.get_attribute("bibrefs").unwrap_or_default();
1512 let show = bibref
1513 .get_attribute("show")
1514 .unwrap_or_else(|| "refnum".to_string());
1515 let show_wants_ay = show.contains("Author") || show.contains("Year");
1523 let inlist = bibref.get_attribute("inlist").unwrap_or_default();
1538 let mut lists: Vec<&str> = inlist.split_whitespace().collect();
1539 if !lists.contains(&"bibliography") {
1540 lists.push("bibliography");
1541 }
1542 let force_numeric = show_wants_ay && {
1553 let keys: Vec<&str> = keys_str.split(',').filter(|k| !k.is_empty()).collect();
1554 !keys.is_empty()
1555 && keys.iter().all(|key| {
1556 let mut id = None;
1557 for list in &lists {
1558 if let Some(be) = self.db.lookup(&format!("BIBLABEL:{}:{}", list, key)) {
1559 id = be.get_string("id").map(String::from);
1560 if id.is_some() {
1561 break;
1562 }
1563 }
1564 }
1565 let Some(id) = id else { return false };
1566 match self.db.lookup(&format!("ID:{}", id)) {
1567 Some(e) => {
1568 let nonempty = |k: &str| {
1569 e.get_value(k)
1570 .is_some_and(|v| !v.to_string().trim().is_empty())
1571 };
1572 !nonempty("authors")
1573 && !nonempty("fullauthors")
1574 && !nonempty("year")
1575 && (nonempty("number") || nonempty("refnum"))
1576 },
1577 None => false,
1578 }
1579 })
1580 };
1581 let internal_delims = show
1586 .find("Year")
1587 .is_some_and(|yp| show[yp + "Year".len()..].contains("Phrase"));
1588 let want_authoryear = show_wants_ay && !force_numeric;
1591 let sep = if force_numeric {
1592 ",".to_string()
1593 } else {
1594 bibref
1595 .get_attribute("separator")
1596 .unwrap_or_else(|| ",".to_string())
1597 };
1598
1599 let mut refs: Vec<NodeData> = Vec::new();
1600 for key in keys_str.split(',').filter(|k| !k.is_empty()) {
1601 let mut found_id = None;
1602 for list in &lists {
1603 let bkey = format!("BIBLABEL:{}:{}", list, key);
1604 if let Some(bentry) = self.db.lookup(&bkey) {
1605 found_id = bentry.get_string("id").map(String::from);
1606 if found_id.is_some() {
1607 break;
1608 }
1609 }
1610 }
1611 if !refs.is_empty() {
1612 refs.push(NodeData::Text(format!("{} ", sep)));
1613 }
1614 if let Some(id) = found_id {
1615 let mut attrs = HashMap::default();
1616 attrs.insert("idref".to_string(), id.clone());
1617 if let Some(url) = self.generate_url(doc, &id) {
1618 attrs.insert("href".to_string(), url);
1619 }
1620 let entry = self.db.lookup(&format!("ID:{}", id));
1627 let get = |k: &str| {
1630 entry
1631 .and_then(|e| e.get_value(k))
1632 .map(|v| v.to_string())
1633 .map(|s| s.trim().to_string())
1634 .filter(|s| !s.is_empty())
1635 };
1636 let authors = get("authors");
1637 let fullauthors = get("fullauthors");
1638 let keytag = get("keytag");
1639 let year = get("year");
1640 let typetag = get("typetag");
1641 let number = get("number");
1642 let refnum = get("refnum");
1643 let number_or_refnum = || {
1644 number
1645 .clone()
1646 .or_else(|| refnum.clone())
1647 .unwrap_or_else(|| key.to_string())
1648 };
1649 let display = if want_authoryear {
1650 let phrases: Vec<String> = crate::document::element_children(bibref)
1654 .iter()
1655 .filter(|c| doc.get_qname(c).as_deref() == Some("ltx:bibrefphrase"))
1656 .map(|c| c.get_content())
1657 .collect();
1658 let a = authors
1659 .as_deref()
1660 .or(fullauthors.as_deref())
1661 .or(keytag.as_deref());
1662 let y = year.as_deref().or(typetag.as_deref());
1663 let (text, resolved) = render_bibref_show(
1664 &show,
1665 a,
1666 fullauthors.as_deref(),
1667 y,
1668 number.as_deref(),
1669 refnum.as_deref(),
1670 &phrases,
1671 );
1672 if resolved && !text.trim().is_empty() {
1675 text
1676 } else {
1677 number_or_refnum()
1678 }
1679 } else {
1680 number_or_refnum()
1681 };
1682 refs.push(NodeData::Element {
1683 tag: "ltx:ref".to_string(),
1684 attributes: Some(attrs),
1685 children: vec![NodeData::Text(display)],
1686 });
1687 } else {
1688 self.note_missing("warn", "Entry for citation", key);
1689 refs.push(NodeData::Element {
1690 tag: "ltx:ref".to_string(),
1691 attributes: Some(HashMap::from_iter([
1692 ("idref".to_string(), key.to_string()),
1693 ("class".to_string(), "ltx_missing_citation".to_string()),
1694 ])),
1695 children: vec![NodeData::Text(key.to_string())],
1696 });
1697 }
1698 }
1699 if force_numeric && internal_delims && !refs.is_empty() {
1702 refs.insert(0, NodeData::Text("[".to_string()));
1703 refs.push(NodeData::Text("]".to_string()));
1704 }
1705 if !refs.is_empty() {
1706 doc.replace_node(bibref, &refs);
1707 }
1708 }
1709 }
1710
1711 fn fill_in_mathlinks(&mut self, doc: &PostDocument) {
1712 for sym in &doc.findnodes("descendant::*[@decl_id or @meaning]") {
1713 let tag = doc.get_qname(sym).unwrap_or_default();
1714 if tag == "ltx:XMRef" || sym.get_attribute("href").is_some() {
1715 continue;
1716 }
1717 let entry_key = sym
1718 .get_attribute("decl_id")
1719 .map(|did| format!("DECLARATION:local:{}", did))
1720 .or_else(|| {
1721 sym
1722 .get_attribute("meaning")
1723 .map(|m| format!("DECLARATION:global:{}", m))
1724 });
1725 let parent_id = entry_key
1726 .as_ref()
1727 .and_then(|ek| self.db.lookup(ek))
1728 .and_then(|entry| entry.get_string("parent").map(String::from));
1729 if let Some(pid) = parent_id {
1730 if let Some(url) = self.generate_url(doc, &pid) {
1731 let mut sym_mut = sym.clone();
1732 sym_mut.set_attribute("href", &url).ok();
1733 }
1734 }
1735 }
1736 }
1737
1738 fn report_missing(&self) {
1739 for (severity, types) in &self.missing {
1740 for (ref_type, items) in types {
1741 let keys: Vec<&String> = items.keys().collect();
1742 let msg = format!(
1743 "Missing {}: {}",
1744 ref_type,
1745 keys
1746 .iter()
1747 .map(|s| s.as_str())
1748 .collect::<Vec<_>>()
1749 .join(",")
1750 );
1751 match severity.as_str() {
1754 "error" => Error!("expected", "ids", "{}", msg),
1755 "warn" => Warn!("expected", "ids", "{}", msg),
1756 _ => Info!("expected", "ids", "{}", msg),
1757 }
1758 }
1759 }
1760 }
1761}
1762
1763impl Processor for CrossRef {
1764 fn get_name(&self) -> &str { &self.name }
1765
1766 fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
1767 match doc.get_document_element() {
1768 Some(el) => vec![el],
1769 None => vec![],
1770 }
1771 }
1772
1773 fn process(&mut self, mut doc: PostDocument, _nodes: Vec<Node>) -> ProcessResult {
1774 self.missing.clear();
1775
1776 let doc_title = self.generate_document_title(&doc);
1778 let navtoc = self.navigation_toc.clone();
1779
1780 if (navtoc.is_some() || doc_title.is_some()) && doc.findnode("//ltx:navigation").is_none() {
1781 if let Some(mut root) = doc.get_document_element() {
1782 doc.add_nodes(&mut root, &[NodeData::Element {
1783 tag: "ltx:navigation".to_string(),
1784 attributes: None,
1785 children: vec![],
1786 }]);
1787 }
1788 }
1789 if let Some(ref format) = navtoc {
1790 if let Some(mut nav) = doc.findnode("//ltx:navigation") {
1791 doc.add_nodes(&mut nav, &[NodeData::Element {
1798 tag: "ltx:TOC".to_string(),
1799 attributes: Some(HashMap::from_iter([("format".to_string(), format.clone())])),
1800 children: vec![],
1801 }]);
1802 }
1803 }
1804 if let Some(ref title) = doc_title {
1805 if let Some(mut nav) = doc.findnode("//ltx:navigation") {
1806 doc.add_nodes(&mut nav, &[NodeData::Element {
1807 tag: "ltx:title".to_string(),
1808 attributes: None,
1809 children: vec![NodeData::Text(title.clone())],
1810 }]);
1811 }
1812 }
1813
1814 self.fill_in_relations(&mut doc);
1815 self.fill_in_tocs(&mut doc);
1816 self.fill_in_frags(&doc);
1817 self.fill_in_glossaryrefs(&mut doc);
1818 self.fill_in_refs(&mut doc);
1819 self.fill_in_rdfa_refs(&mut doc);
1820 self.fill_in_bibrefs(&mut doc);
1821 self.fill_in_mathlinks(&doc);
1822 self.copy_resources(&doc);
1823 strip_ref_display_fragids(&doc);
1824 self.report_missing();
1825 Ok(vec![doc])
1826 }
1827}
1828
1829fn relative_url(target: &str, base: &str) -> String {
1833 if target == base {
1834 return ".".to_string();
1835 }
1836 let target_parts: Vec<&str> = target.split('/').collect();
1837 let base_parts: Vec<&str> = base.split('/').collect();
1838 let common = target_parts
1839 .iter()
1840 .zip(base_parts.iter())
1841 .take_while(|(a, b)| a == b)
1842 .count();
1843 let mut result = String::new();
1844 for _ in common..base_parts.len().saturating_sub(1) {
1845 result.push_str("../");
1846 }
1847 result.push_str(&target_parts[common..].join("/"));
1848 if result.is_empty() {
1849 ".".to_string()
1850 } else {
1851 result
1852 }
1853}
1854
1855fn get_text_content_node(node: &Node) -> String {
1859 let text = node.get_content();
1860 let trimmed = text.trim();
1861 trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
1863}
1864
1865fn text_content(nodes: &[NodeData]) -> String {
1866 nodes
1867 .iter()
1868 .map(|n| match n {
1869 NodeData::Text(s) => s.clone(),
1870 NodeData::Element { children, .. } => text_content(children),
1871 NodeData::XmlNode(n) => n.get_content(),
1872 })
1873 .collect::<Vec<_>>()
1874 .join("")
1875}
1876
1877#[cfg(test)]
1878mod tests {
1879 use super::*;
1880
1881 #[test]
1882 fn relative_url_identical_paths_is_dot() {
1883 assert_eq!(relative_url("a/b.html", "a/b.html"), ".");
1884 }
1885
1886 #[test]
1887 fn relative_url_same_dir() {
1888 assert_eq!(relative_url("a/other.html", "a/index.html"), "other.html");
1890 }
1891
1892 #[test]
1893 fn relative_url_sibling_dir() {
1894 assert_eq!(relative_url("b/x.html", "a/index.html"), "../b/x.html");
1896 }
1897
1898 #[test]
1899 fn relative_url_deeply_nested_base() {
1900 assert_eq!(
1902 relative_url("top/sibling.html", "top/deep/nested/page.html"),
1903 "../../sibling.html"
1904 );
1905 }
1906
1907 #[test]
1908 fn relative_url_same_prefix_different_file() {
1909 assert_eq!(
1910 relative_url("a/b/c/target.html", "a/b/c/source.html"),
1911 "target.html"
1912 );
1913 }
1914
1915 #[test]
1916 fn ref_fallbacks_typerefnum_goes_to_refnum() {
1917 assert_eq!(ref_fallbacks("typerefnum"), &["refnum"]);
1918 }
1919
1920 #[test]
1921 fn ref_fallbacks_title_chain() {
1922 assert_eq!(ref_fallbacks("title"), &["toccaption"]);
1923 assert_eq!(ref_fallbacks("toctitle"), &["title", "toccaption"]);
1924 assert_eq!(ref_fallbacks("rawtoctitle"), &[
1925 "toctitle",
1926 "title",
1927 "toccaption"
1928 ]);
1929 assert_eq!(ref_fallbacks("rawtitle"), &["title", "toccaption"]);
1930 }
1931
1932 #[test]
1933 fn ref_fallbacks_unknown_key_is_empty() {
1934 let empty: &[&str] = &[];
1935 assert_eq!(ref_fallbacks("nonexistent"), empty);
1936 assert_eq!(ref_fallbacks(""), empty);
1937 }
1938
1939 #[test]
1940 fn text_content_flattens_text() {
1941 let nodes = vec![
1942 NodeData::Text("hello ".to_string()),
1943 NodeData::Text("world".to_string()),
1944 ];
1945 assert_eq!(text_content(&nodes), "hello world");
1946 }
1947
1948 #[test]
1949 fn text_content_recurses_into_elements() {
1950 let nodes = vec![NodeData::Element {
1951 tag: "span".to_string(),
1952 attributes: None,
1953 children: vec![
1954 NodeData::Text("inner ".to_string()),
1955 NodeData::Text("text".to_string()),
1956 ],
1957 }];
1958 assert_eq!(text_content(&nodes), "inner text");
1959 }
1960
1961 #[test]
1962 fn text_content_empty_list_is_empty_string() {
1963 assert_eq!(text_content(&[]), "");
1964 }
1965
1966 #[test]
1967 fn text_content_mixed_text_and_nested_element() {
1968 let nodes = vec![
1969 NodeData::Text("outer ".to_string()),
1970 NodeData::Element {
1971 tag: "em".to_string(),
1972 attributes: None,
1973 children: vec![NodeData::Text("inner".to_string())],
1974 },
1975 NodeData::Text(" tail".to_string()),
1976 ];
1977 assert_eq!(text_content(&nodes), "outer inner tail");
1978 }
1979
1980 #[test]
1983 fn url_style_file_is_identity() {
1984 for url in ["a/b.html", "index.html", "sub/index.html", "index", ""] {
1986 assert_eq!(apply_url_style(url, UrlStyle::File, "html"), url);
1987 }
1988 }
1989
1990 #[test]
1991 fn url_style_server_strips_trailing_index() {
1992 assert_eq!(
1994 apply_url_style("index.html", UrlStyle::Server, "html"),
1995 "./"
1996 );
1997 assert_eq!(
1998 apply_url_style("dir/index.html", UrlStyle::Server, "html"),
1999 "dir/"
2000 );
2001 assert_eq!(
2002 apply_url_style("a/b/index.html", UrlStyle::Server, "html"),
2003 "a/b/"
2004 );
2005 assert_eq!(
2007 apply_url_style("dir/page.html", UrlStyle::Server, "html"),
2008 "dir/page.html"
2009 );
2010 assert_eq!(
2013 apply_url_style("myindex.html", UrlStyle::Server, "html"),
2014 "myindex.html"
2015 );
2016 }
2017
2018 #[test]
2019 fn url_style_negotiated_strips_extension_and_index() {
2020 assert_eq!(
2022 apply_url_style("dir/page.html", UrlStyle::Negotiated, "html"),
2023 "dir/page"
2024 );
2025 assert_eq!(
2027 apply_url_style("index.html", UrlStyle::Negotiated, "html"),
2028 ""
2029 );
2030 assert_eq!(
2031 apply_url_style("dir/index.html", UrlStyle::Negotiated, "html"),
2032 "dir/"
2033 );
2034 assert_eq!(
2036 apply_url_style("myindex.html", UrlStyle::Negotiated, "html"),
2037 "myindex"
2038 );
2039 assert_eq!(
2041 apply_url_style("dir/index.xml", UrlStyle::Negotiated, "xml"),
2042 "dir/"
2043 );
2044 }
2045}