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(val: &Value) -> String {
66 match val {
67 Value::Xml(node) => title_text_content(node),
68 other => other.to_string(),
69 }
70}
71
72fn ref_content_children(val: &Value) -> Vec<NodeData> {
85 let node = match val {
86 Value::Xml(node) => node,
87 other => return vec![NodeData::Text(other.to_string())],
88 };
89 let mut out: Vec<NodeData> = Vec::new();
90 let mut child = node.get_first_child();
91 while let Some(c) = child {
92 match c.get_type() {
93 Some(NodeType::TextNode) => out.push(NodeData::Text(c.get_content())),
94 Some(NodeType::ElementNode) => out.push(NodeData::XmlNode(c.clone())),
95 _ => {},
96 }
97 child = c.get_next_sibling();
98 }
99 if let Some(NodeData::Text(s)) = out.first_mut() {
102 let t = s.trim_start().to_string();
103 if t.is_empty() {
104 out.remove(0);
105 } else {
106 *s = t;
107 }
108 }
109 if let Some(NodeData::Text(s)) = out.last_mut() {
110 let t = s.trim_end().to_string();
111 if t.is_empty() {
112 out.pop();
113 } else {
114 *s = t;
115 }
116 }
117 out
118}
119
120fn strip_ref_display_fragids(doc: &PostDocument) {
132 for mut n in doc.findnodes("//ltx:ref//*[@fragid]") {
133 let _ = n.remove_attribute("fragid");
134 }
135}
136
137#[derive(Debug, Clone)]
139pub enum UrlStyle {
140 File,
142 Server,
144 Negotiated,
146}
147
148pub struct CrossRef {
152 name: String,
153 pub db: ObjectDB,
155 url_style: UrlStyle,
157 extension: String,
159 toc_show: String,
161 ref_show: String,
163 min_ref_length: usize,
165 ref_join: String,
167 navigation_toc: Option<String>,
169 missing: HashMap<String, HashMap<String, HashMap<String, u32>>>,
171 child_pages: RefCell<HashMap<String, Rc<ChildPages>>>,
177}
178
179fn render_bibref_show(
191 show: &str,
192 authors: Option<&str>,
193 fullauthors: Option<&str>,
194 year: Option<&str>,
195 number: Option<&str>,
196 refnum: Option<&str>,
197 phrases: &[String],
198) -> (String, bool) {
199 let lower = show.to_ascii_lowercase();
200 let lb = lower.as_bytes();
201 let mut out = String::new();
202 let mut resolved_ay = false;
203 let mut i = 0;
204 while i < show.len() {
205 if lb[i..].starts_with(b"phrase") {
207 let ds = i + "phrase".len();
208 let mut j = ds;
209 while j < lb.len() && lb[j].is_ascii_digit() {
210 j += 1;
211 }
212 if j > ds {
213 if let Ok(n) = show[ds..j].parse::<usize>() {
214 if n >= 1 && n <= phrases.len() {
215 out.push_str(&phrases[n - 1]);
216 }
217 }
218 i = j;
219 continue;
220 }
221 }
222 let mut matched = false;
225 for (kw, val, is_ay) in [
226 ("fullauthors", fullauthors.or(authors), true),
227 ("authors", authors, true),
228 ("year", year, true),
229 ("number", number, false),
230 ("refnum", refnum, false),
231 ] {
232 if lb[i..].starts_with(kw.as_bytes()) {
233 if let Some(v) = val {
234 if !v.is_empty() {
235 out.push_str(v);
236 if is_ay {
237 resolved_ay = true;
238 }
239 }
240 }
241 i += kw.len();
242 matched = true;
243 break;
244 }
245 }
246 if matched {
247 continue;
248 }
249 let ch = show[i..].chars().next().unwrap();
251 out.push(ch);
252 i += ch.len_utf8();
253 }
254 (out, resolved_ay)
255}
256
257impl CrossRef {
258 pub fn new(db: ObjectDB, url_style: UrlStyle, number_sections: bool) -> Self {
259 CrossRef {
260 name: "CrossRef".to_string(),
261 db,
262 url_style,
263 extension: "xml".to_string(),
264 toc_show: "toctitle".to_string(),
265 ref_show: if number_sections {
266 "refnum".to_string()
267 } else {
268 "title".to_string()
269 },
270 min_ref_length: 1,
271 ref_join: " \u{2023} ".to_string(), navigation_toc: None,
273 missing: HashMap::default(),
274 child_pages: RefCell::new(HashMap::default()),
275 }
276 }
277
278 pub fn set_extension(&mut self, ext: &str) { self.extension = ext.to_string(); }
280
281 pub fn set_navigation_toc(&mut self, format: &str) {
283 self.navigation_toc = Some(format.to_string());
284 }
285
286 fn note_missing(&mut self, severity: &str, ref_type: &str, key: &str) {
288 self
289 .missing
290 .entry(severity.to_string())
291 .or_default()
292 .entry(ref_type.to_string())
293 .or_default()
294 .entry(key.to_string())
295 .and_modify(|c| *c += 1)
296 .or_insert(1);
297 }
298
299 fn generate_url(&mut self, doc: &PostDocument, id: &str) -> Option<String> {
303 let entry = self.db.lookup(&format!("ID:{}", id))?;
304 let location = entry.get_string("location")?;
305
306 let doc_location = doc.site_relative_destination().unwrap_or_default();
307 let mut url = relative_url(location, &doc_location);
308
309 match self.url_style {
311 UrlStyle::Server => {
312 let index_suffix = format!("index.{}", self.extension);
313 if url.ends_with(&index_suffix) {
314 let prefix = &url[..url.len() - index_suffix.len()];
315 url = if prefix.is_empty() {
316 "./".to_string()
317 } else {
318 prefix.to_string()
319 };
320 }
321 },
322 UrlStyle::Negotiated => {
323 let ext_suffix = format!(".{}", self.extension);
324 if url.ends_with(&ext_suffix) {
325 url = url[..url.len() - ext_suffix.len()].to_string();
326 }
327 if url.ends_with("/index") {
328 url = url[..url.len() - 5].to_string();
329 }
330 },
331 UrlStyle::File => {},
332 }
333
334 if url.is_empty() {
335 url = ".".to_string();
336 }
337
338 let fragid = entry.get_string("fragid").map(String::from);
340 let loc = location.to_string();
341 if let Some(fid) = fragid {
342 if url == "." || loc == doc_location {
343 url = String::new();
344 }
345 url = format!("{}#{}", url, fid);
346 } else if loc == doc_location {
347 url = String::new();
348 }
349
350 Some(url)
351 }
352
353 fn generate_title(&self, _doc: &PostDocument, id: &str, shown: &str) -> Option<String> {
357 let mut current_id = id.to_string();
358 let mut result = String::new();
359 let mut prefix = String::new();
360 let mut shown_so_far = shown.to_string();
361
362 while let Some(entry) = self.db.lookup(&format!("ID:{}", current_id)) {
363 let mut pieces = Vec::new();
364 let mut is_dup = false;
365
366 if let Some(title_val) = entry.get_value("title") {
368 if title_val.is_truthy() {
369 is_dup = shown_so_far.contains("title");
370 pieces.push(value_text(title_val));
374 }
375 }
376 if pieces.is_empty() {
377 let has_type = entry
378 .get_value("tag:creftypecap")
379 .or_else(|| entry.get_value("tag:creftype"));
380 let has_refnum = entry.get_value("refnum");
381 if has_type.is_some() && has_refnum.is_some() {
382 is_dup = shown_so_far.contains("type") && shown_so_far.contains("refnum");
383 if let Some(t) = has_type {
384 pieces.push(t.to_string());
385 }
386 if let Some(r) = has_refnum {
387 pieces.push(r.to_string());
388 }
389 } else if let Some(tr) = entry.get_value("typerefnum") {
390 is_dup = shown_so_far.contains("type") && shown_so_far.contains("refnum");
391 pieces.push(tr.to_string());
392 } else if let Some(r) = has_refnum {
393 is_dup = shown_so_far.contains("refnum");
394 pieces.push(r.to_string());
395 }
396 }
397
398 if is_dup {
399 prefix = "In ".to_string();
400 shown_so_far.clear();
401 } else {
402 let title = pieces.join(" ");
403 let title = title.trim();
404 if !title.is_empty() {
405 result.push_str(&prefix);
406 prefix = self.ref_join.clone();
407 result.push_str(title);
408 }
409 }
410
411 match entry.get_string("parent").map(String::from) {
413 Some(pid) => current_id = pid,
414 None => break,
415 }
416 }
417
418 if result.is_empty() {
419 None
420 } else {
421 Some(result)
422 }
423 }
424
425 fn generate_document_title(&self, doc: &PostDocument) -> Option<String> {
429 if let Some(docid) = doc.get_document_element().as_ref().and_then(get_xml_id) {
433 let title = self.generate_title(doc, &docid, "");
440 if title.as_ref().map(|t| !t.is_empty()).unwrap_or(false) {
441 return title;
442 }
443 }
444 if let Some(node) =
446 doc.findnode("//ltx:title | //ltx:toctitle | //ltx:caption | //ltx:toccaption")
447 {
448 let text = get_text_content_node(&node);
449 if !text.is_empty() {
450 return Some(text);
451 }
452 }
453 None
454 }
455
456 fn generate_glossary_ref_title(&self, entry_key: &str, show: &str) -> Vec<NodeData> {
460 let entry = match self.db.lookup(entry_key) {
461 Some(e) => e,
462 None => return vec![],
463 };
464
465 let phrase_key = format!("phrase:{}", show);
466 if let Some(val) = entry.get_value(&phrase_key) {
467 return vec![NodeData::Element {
468 tag: "ltx:text".to_string(),
469 attributes: Some(HashMap::from_iter([(
470 "class".to_string(),
471 format!("ltx_glossary_{}", show),
472 )])),
473 children: vec![NodeData::Text(val.to_string())],
474 }];
475 }
476
477 if let Some(base_show) = show.strip_suffix("-plural") {
479 let base_key = format!("phrase:{}", base_show);
480 if let Some(val) = entry.get_value(&base_key) {
481 return vec![NodeData::Element {
482 tag: "ltx:text".to_string(),
483 attributes: Some(HashMap::from_iter([(
484 "class".to_string(),
485 format!("ltx_glossary_{}", show),
486 )])),
487 children: vec![NodeData::Text(format!("{}s", val))],
488 }];
489 }
490 }
491 if let Some(base_show) = show.strip_suffix("-indefinite") {
492 let base_key = format!("phrase:{}", base_show);
493 if let Some(val) = entry.get_value(&base_key) {
494 let text = val.to_string();
495 let article = if text.starts_with(|c: char| "aeiouAEIOU".contains(c)) {
496 "an "
497 } else {
498 "a "
499 };
500 return vec![NodeData::Element {
501 tag: "ltx:text".to_string(),
502 attributes: Some(HashMap::from_iter([(
503 "class".to_string(),
504 format!("ltx_glossary_{}", show),
505 )])),
506 children: vec![NodeData::Text(article.to_string()), NodeData::Text(text)],
507 }];
508 }
509 }
510
511 vec![]
512 }
513
514 fn copy_resources(&self, doc: &PostDocument) {
518 let refs = doc.findnodes("//ltx:ref[@href and not(@idref) and not(@labelref)]");
519 for ref_node in &refs {
520 if let Some(url) = ref_node.get_attribute("href") {
521 if !url.contains("://") && !url.starts_with('/') {
523 log::trace!("CrossRef: would copy resource '{}'", url);
525 }
526 }
527 }
528 }
529
530 fn generate_ref(&mut self, _doc: &PostDocument, req_id: &str, req_show: &str) -> Vec<NodeData> {
534 let show_options = if !req_show.contains("title") {
535 vec![req_show.to_string(), "title".to_string()]
536 } else {
537 vec![req_show.to_string(), "refnum".to_string()]
538 };
539
540 for show in &show_options {
541 let mut stuff = Vec::new();
542 let mut id = req_id.to_string();
543 let mut pending = String::new();
544 loop {
545 let entry_exists = self.db.lookup(&format!("ID:{}", id)).is_some();
546 if !entry_exists {
547 break;
548 }
549 let s = self.generate_ref_aux(&id, show);
550 if !s.is_empty() {
551 if !pending.is_empty() {
552 stuff.push(NodeData::Text(pending.clone()));
553 }
554 stuff.extend(s);
555 if self.check_ref_content(&stuff) {
556 return stuff;
557 }
558 pending = self.ref_join.clone();
559 }
560 let parent = self
561 .db
562 .lookup(&format!("ID:{}", id))
563 .and_then(|e| e.get_string("parent").map(String::from));
564 match parent {
565 Some(pid) => id = pid,
566 None => break,
567 }
568 }
569 if !stuff.is_empty() {
570 return stuff;
571 }
572 }
573
574 self.note_missing("info", "Usable title for ID", req_id);
575 vec![NodeData::Text(req_id.to_string())]
576 }
577
578 fn generate_ref_aux(&self, id: &str, show: &str) -> Vec<NodeData> {
580 let entry = match self.db.lookup(&format!("ID:{}", id)) {
581 Some(e) => e,
582 None => return vec![],
583 };
584
585 let mut stuff = Vec::new();
586 let mut ok = false;
587 let mut remaining = show.to_string();
588
589 while !remaining.is_empty() {
590 if remaining.starts_with(|c: char| c.is_alphanumeric()) {
591 let keyword: String = remaining
592 .chars()
593 .take_while(|c| c.is_alphanumeric())
594 .collect();
595 remaining = remaining[keyword.len()..].to_string();
596 let key = keyword.to_lowercase();
597 let class = if key.contains("title") {
598 "ltx_ref_title"
599 } else {
600 "ltx_ref_tag"
601 };
602
603 let mut keys_to_try = vec![key.clone(), format!("tag:{}", key)];
604 keys_to_try.extend(ref_fallbacks(&key).iter().map(|s| s.to_string()));
605
606 for k in &keys_to_try {
607 if let Some(val) = entry.get_value(k) {
608 if val.is_truthy() {
609 ok = true;
610 stuff.push(NodeData::Element {
618 tag: "ltx:text".to_string(),
619 attributes: Some(HashMap::from_iter([(
620 "class".to_string(),
621 class.to_string(),
622 )])),
623 children: ref_content_children(val),
624 });
625 break;
626 }
627 }
628 }
629 } else if remaining.starts_with('{') {
630 if let Some(end) = remaining[1..].find('}') {
631 let literal = &remaining[1..1 + end];
632 if !literal.is_empty() {
633 stuff.push(NodeData::Text(literal.to_string()));
634 }
635 remaining = remaining[2 + end..].to_string();
636 } else {
637 remaining.clear();
638 }
639 } else if remaining.starts_with('~') {
640 remaining = remaining[1..].to_string();
641 if !stuff.is_empty() {
642 stuff.push(NodeData::Text("\u{00A0}".to_string()));
643 }
644 } else if remaining.starts_with(|c: char| c.is_whitespace()) {
645 let ws: String = remaining
646 .chars()
647 .take_while(|c| c.is_whitespace())
648 .collect();
649 remaining = remaining[ws.len()..].to_string();
650 if !stuff.is_empty() {
651 stuff.push(NodeData::Text(ws));
652 }
653 } else {
654 let sym: String = remaining
655 .chars()
656 .take_while(|c| !c.is_alphanumeric() && *c != '{' && *c != '~')
657 .collect();
658 remaining = remaining[sym.len()..].to_string();
659 stuff.push(NodeData::Text(sym));
660 }
661 }
662
663 if ok { stuff } else { vec![] }
664 }
665
666 fn check_ref_content(&self, stuff: &[NodeData]) -> bool {
668 let text = text_content(stuff);
669 let cleaned = text.replace("in ", "");
670 cleaned.chars().any(|c| c.is_alphanumeric())
671 }
672
673 fn fill_in_relations(&mut self, doc: &mut PostDocument) {
677 let page_id = match doc.get_document_element().as_ref().and_then(get_xml_id) {
681 Some(id) => id,
682 None => return,
683 };
684
685 let mut current_id = page_id.clone();
687 let mut rel = "up".to_string();
688 let mut topmost = current_id.clone();
689 loop {
690 let parent_id = self
691 .db
692 .lookup(&format!("ID:{}", current_id))
693 .and_then(|e| e.get_string("parent").map(String::from));
694 match parent_id {
695 Some(pid) => {
696 let has_title = self
697 .db
698 .lookup(&format!("ID:{}", pid))
699 .and_then(|e| e.get_value("title"))
700 .map(|v| v.is_truthy())
701 .unwrap_or(false);
702 if has_title {
703 doc.add_navigation(&rel, &pid);
704 rel = format!("{} up", rel);
705 }
706 current_id = pid.clone();
707 topmost = pid;
708 },
709 None => break,
710 }
711 }
712
713 if topmost != page_id {
715 if let Some(top_pageid) = self
716 .db
717 .lookup(&format!("ID:{}", topmost))
718 .and_then(|e| e.get_string("pageid").map(String::from))
719 {
720 doc.add_navigation("start", &top_pageid);
721 }
722 }
723
724 if let Some(prev) = self.find_previous_page_id(&page_id) {
726 doc.add_navigation("prev", &prev);
727 }
728 if let Some(next) = self.find_next_page_id(&page_id) {
729 doc.add_navigation("next", &next);
730 }
731
732 let mut xentry = page_id.clone();
739 while let Some(parent) = self.get_parent_page_id(&xentry) {
740 for sib in self.child_pages(&parent).ids.iter() {
741 if *sib == page_id {
742 continue;
743 }
744 self.add_typed_navigation(doc, sib);
745 }
746 xentry = parent;
747 }
748 for child in self.child_pages(&page_id).ids.iter() {
749 self.add_typed_navigation(doc, child);
750 }
751 }
752
753 fn add_typed_navigation(&self, doc: &mut PostDocument, related_id: &str) {
758 if self.is_primary_page(related_id) {
759 let rel = self
760 .db
761 .lookup(&format!("ID:{}", related_id))
762 .and_then(|e| e.get_string("type").map(String::from))
763 .map(|t| t.rsplit(':').next().unwrap_or(&t).to_string());
765 if let Some(rel) = rel.filter(|r| !r.is_empty()) {
766 doc.add_navigation(&rel, related_id);
767 }
768 } else {
769 doc.add_navigation("sidebar", related_id);
770 }
771 }
772
773 fn is_primary_page(&self, page_id: &str) -> bool {
776 self
777 .db
778 .lookup(&format!("ID:{}", page_id))
779 .and_then(|e| e.get_value("primary"))
780 .map(|v| v.is_truthy())
781 .unwrap_or(false)
782 }
783
784 fn get_parent_page_id(&self, entry_id: &str) -> Option<String> {
787 let entry = self.db.lookup(&format!("ID:{}", entry_id))?;
788 let pageid = entry.get_string("pageid")?.to_string();
789 let page_entry = self.db.lookup(&format!("ID:{}", pageid))?;
790 let parent_id = page_entry.get_string("parent")?.to_string();
791 let parent_entry = self.db.lookup(&format!("ID:{}", parent_id))?;
792 Some(parent_entry.get_string("pageid")?.to_string())
793 }
794
795 fn child_pages(&self, entry_id: &str) -> Rc<ChildPages> {
800 if let Some(cached) = self.child_pages.borrow().get(entry_id) {
801 return cached.clone();
802 }
803 let ids = self.compute_child_page_ids(entry_id);
804 let mut index_of = HashMap::default();
805 for (i, id) in ids.iter().enumerate() {
807 index_of.insert(id.clone(), i);
808 }
809 let rc = Rc::new(ChildPages { ids, index_of });
810 self
811 .child_pages
812 .borrow_mut()
813 .insert(entry_id.to_string(), rc.clone());
814 rc
815 }
816
817 fn compute_child_page_ids(&self, entry_id: &str) -> Vec<String> {
821 let entry = match self.db.lookup(&format!("ID:{}", entry_id)) {
822 Some(e) => e,
823 None => return Vec::new(),
824 };
825 let here_pageid = entry.get_string("pageid").map(String::from);
826 let children = entry.get_children();
827 let mut out = Vec::new();
828 for ch in children {
829 let ch_entry = match self.db.lookup(&format!("ID:{}", ch)) {
830 Some(e) => e,
831 None => continue,
832 };
833 let ch_pageid = match ch_entry.get_string("pageid") {
834 Some(p) => p.to_string(),
835 None => continue,
836 };
837 if here_pageid.as_deref() != Some(&ch_pageid) {
838 out.push(ch_pageid);
839 } else {
840 out.extend(self.child_pages(&ch).ids.iter().cloned());
841 }
842 }
843 out
844 }
845
846 fn find_previous_page_id(&self, page_id: &str) -> Option<String> {
850 let parent_id = self.get_parent_page_id(page_id)?;
851 let siblings = self.child_pages(&parent_id);
852 let pos = *siblings.index_of.get(page_id)?;
854 let mut current = match siblings.ids[..pos]
860 .iter()
861 .rev()
862 .find(|s| self.is_primary_page(s))
863 {
864 Some(sib) => sib.clone(),
865 None => return Some(parent_id),
866 };
867 loop {
869 let kids = self.child_pages(¤t);
870 match kids.ids.iter().rev().find(|s| self.is_primary_page(s)) {
871 Some(deepest) => current = deepest.clone(),
872 None => break,
873 }
874 }
875 Some(current)
876 }
877
878 fn find_next_page_id(&self, page_id: &str) -> Option<String> {
882 if let Some(first) = self
884 .child_pages(page_id)
885 .ids
886 .iter()
887 .find(|s| self.is_primary_page(s))
888 {
889 return Some(first.clone());
890 }
891 let mut current = page_id.to_string();
892 loop {
893 let parent = self.get_parent_page_id(¤t)?;
894 let siblings = self.child_pages(&parent);
895 let pos = *siblings.index_of.get(¤t)?;
897 if let Some(first) = siblings.ids[pos + 1..]
899 .iter()
900 .find(|s| self.is_primary_page(s))
901 {
902 return Some(first.clone());
903 }
904 current = parent;
905 }
906 }
907
908 fn fill_in_tocs(&mut self, doc: &mut PostDocument) {
909 let tocs = match doc.get_document_element() {
916 Some(root) => doc.findnodes_at("descendant::ltx:TOC[not(ltx:toclist)]", Some(&root)),
917 None => Vec::new(),
918 };
919 for toc in &tocs {
920 let mut id = doc
925 .get_document_element()
926 .as_ref()
927 .and_then(get_xml_id)
928 .unwrap_or_default();
929 if toc.get_attribute("scope").as_deref() == Some("global") {
935 id = self.get_root_page_id(&id);
936 }
937 let show = toc
938 .get_attribute("show")
939 .unwrap_or_else(|| self.toc_show.clone());
940
941 let select_attr = toc.get_attribute("select");
947 let types: Option<HashSet<&str>> = select_attr.as_deref().map(|s| {
948 s.split('|')
949 .map(str::trim)
950 .filter(|t| !t.is_empty())
951 .collect()
952 });
953 let lists_attr = toc.get_attribute("lists");
954 let lists: HashSet<&str> = match lists_attr.as_deref() {
955 Some(l) => l.split_whitespace().collect(),
956 None => HashSet::from_iter(["toc"]),
957 };
958
959 let format = toc.get_attribute("format").unwrap_or_default();
964 let list = if format.is_empty() || format.starts_with("normal") {
965 self.gen_toc(&id, &show, types.as_ref(), &lists, None, None)
966 } else if format == "context" {
967 let toc_lists: HashSet<&str> = HashSet::from_iter(["toc"]);
968 self.gen_toc_context(&id, &show, types.as_ref(), &toc_lists)
969 } else {
970 Vec::new()
971 };
972 if !list.is_empty() {
973 let toclist = NodeData::Element {
974 tag: "ltx:toclist".to_string(),
975 attributes: None,
976 children: list,
977 };
978 let mut toc_mut = toc.clone();
979 doc.add_nodes(&mut toc_mut, &[toclist]);
980 }
981 }
982 }
983
984 fn get_root_page_id(&self, start_id: &str) -> String {
989 let mut root_id = start_id.to_string();
990 let mut cursor = start_id.to_string();
991 while let Some(page_id) = self.parent_page_of(&cursor) {
992 root_id = page_id.clone();
993 cursor = page_id;
994 }
995 self
997 .db
998 .lookup(&format!("ID:{}", root_id))
999 .and_then(|e| e.get_string("pageid"))
1000 .map(String::from)
1001 .unwrap_or(root_id)
1002 }
1003
1004 fn parent_page_of(&self, id: &str) -> Option<String> {
1007 let parent_id = self
1009 .db
1010 .lookup(&format!("ID:{}", id))
1011 .and_then(|e| e.get_string("parent"))
1012 .filter(|s| !s.is_empty())?;
1013 let page_id = self
1015 .db
1016 .lookup(&format!("ID:{}", parent_id))
1017 .and_then(|e| e.get_string("pageid"))
1018 .filter(|s| !s.is_empty())?
1019 .to_string();
1020 self.db.lookup(&format!("ID:{}", page_id)).map(|_| page_id)
1022 }
1023
1024 fn gen_toc(
1030 &self,
1031 id: &str,
1032 show: &str,
1033 types: Option<&HashSet<&str>>,
1034 lists: &HashSet<&str>,
1035 localto: Option<&str>,
1036 selfid: Option<&str>,
1037 ) -> Vec<NodeData> {
1038 let entry = match self.db.lookup(&format!("ID:{}", id)) {
1039 Some(e) => e,
1040 None => return vec![],
1041 };
1042
1043 let recurse = match localto {
1046 None => true,
1047 Some(target) => entry.get_string("location").unwrap_or("") == target,
1048 };
1049 let kids: Vec<NodeData> = if recurse {
1050 entry
1051 .get_children()
1052 .iter()
1053 .flat_map(|child_id| self.gen_toc(child_id, show, types, lists, localto, selfid))
1054 .collect()
1055 } else {
1056 Vec::new()
1057 };
1058
1059 let entry_type = entry.get_string("type").unwrap_or("");
1060 let type_ok = types.map(|t| t.contains(entry_type)).unwrap_or(true);
1065 let in_toc = entry
1066 .get_value("inlist")
1067 .map(|v| match v {
1068 Value::Hash(h) => lists.iter().any(|l| h.contains_key(*l)),
1069 _ => false,
1070 })
1071 .unwrap_or(false);
1072
1073 if type_ok && in_toc {
1074 vec![self.gen_tocentry(entry, selfid, show, kids)]
1075 } else {
1076 kids
1077 }
1078 }
1079
1080 fn gen_tocentry(
1085 &self,
1086 entry: &Entry,
1087 selfid: Option<&str>,
1088 show: &str,
1089 children: Vec<NodeData>,
1090 ) -> NodeData {
1091 let id = entry
1092 .get_string("id")
1093 .or_else(|| entry.get_key().strip_prefix("ID:"))
1094 .unwrap_or("")
1095 .to_string();
1096 let entry_type = entry.get_string("type").unwrap_or("");
1097 let type_name = entry_type.strip_prefix("ltx:").unwrap_or(entry_type);
1098
1099 let (mut before, mut after): (Option<&str>, Option<&str>) = (None, None);
1101 let mut show_mid = show;
1102 if let Some((b, rest)) = show_mid.split_once('<') {
1103 before = Some(b);
1104 show_mid = rest;
1105 }
1106 if let Some((mid, a)) = show_mid.split_once('>') {
1107 show_mid = mid;
1108 after = Some(a);
1109 }
1110
1111 let self_class = if selfid == Some(id.as_str()) {
1112 " ltx_ref_self"
1113 } else {
1114 ""
1115 };
1116
1117 let mut kids: Vec<NodeData> = Vec::new();
1118 if let Some(b) = before.filter(|b| !b.is_empty()) {
1119 kids.extend(self.generate_ref_simple(&id, b));
1120 }
1121 kids.push(NodeData::Element {
1122 tag: "ltx:ref".to_string(),
1123 attributes: Some(HashMap::from_iter([
1124 ("show".to_string(), show_mid.to_string()),
1125 ("idref".to_string(), id.clone()),
1126 ])),
1127 children: vec![],
1128 });
1129 if let Some(a) = after.filter(|a| !a.is_empty()) {
1130 kids.extend(self.generate_ref_simple(&id, a));
1131 }
1132 if !children.is_empty() {
1133 kids.push(NodeData::Element {
1134 tag: "ltx:toclist".to_string(),
1135 attributes: Some(HashMap::from_iter([(
1136 "class".to_string(),
1137 format!("ltx_toclist_{}", type_name),
1138 )])),
1139 children,
1140 });
1141 }
1142
1143 NodeData::Element {
1144 tag: "ltx:tocentry".to_string(),
1145 attributes: Some(HashMap::from_iter([(
1146 "class".to_string(),
1147 format!("ltx_tocentry_{}{}", type_name, self_class),
1148 )])),
1149 children: kids,
1150 }
1151 }
1152
1153 fn generate_ref_simple(&self, req_id: &str, req_show: &str) -> Vec<NodeData> {
1156 if !req_show.is_empty()
1157 && !req_id.is_empty()
1158 && self.db.lookup(&format!("ID:{}", req_id)).is_some()
1159 {
1160 self.generate_ref_aux(req_id, req_show)
1161 } else {
1162 Vec::new()
1163 }
1164 }
1165
1166 fn gen_toc_context(
1170 &self,
1171 id: &str,
1172 show: &str,
1173 types: Option<&HashSet<&str>>,
1174 lists: &HashSet<&str>,
1175 ) -> Vec<NodeData> {
1176 let start = match self.db.lookup(&format!("ID:{}", id)) {
1177 Some(e) => e,
1178 None => return vec![],
1179 };
1180
1181 let location = start.get_string("location").unwrap_or("").to_string();
1184 let mut navtoc = self.gen_toc(id, show, types, lists, Some(&location), Some(id));
1185
1186 let mut came_from = id.to_string();
1190 let mut parent_id = start.get_string("parent").map(String::from);
1191
1192 while let Some(pid) = parent_id {
1193 let parent = match self.db.lookup(&format!("ID:{}", pid)) {
1194 Some(e) => e,
1195 None => break,
1196 };
1197
1198 let mut row: Vec<NodeData> = Vec::new();
1201 for child_id in parent.get_children() {
1202 let child = match self.db.lookup(&format!("ID:{}", child_id)) {
1203 Some(e) => e,
1204 None => continue,
1205 };
1206 if !NORMAL_TOC_TYPES.contains(&child.get_string("type").unwrap_or("")) {
1207 continue;
1208 }
1209 let child_id_val = child.get_string("id").unwrap_or(&child_id);
1210 if child_id_val == came_from {
1211 row.append(&mut navtoc);
1212 } else {
1213 row.push(self.gen_tocentry(child, None, show, Vec::new()));
1214 }
1215 }
1216 navtoc = row;
1217
1218 let parent_type = parent.get_string("type").unwrap_or("");
1222 let parent_ok = types.map(|t| t.contains(parent_type)).unwrap_or(true);
1223 let parent_has_parent = parent
1224 .get_string("parent")
1225 .map(|s| !s.is_empty())
1226 .unwrap_or(false);
1227 if parent_ok && parent_has_parent {
1228 navtoc = vec![self.gen_tocentry(parent, None, show, navtoc)];
1229 }
1230
1231 came_from = pid;
1232 parent_id = parent.get_string("parent").map(String::from);
1233 }
1234
1235 navtoc
1236 }
1237
1238 fn fill_in_frags(&self, doc: &PostDocument) {
1239 if doc.idcache_len() <= self.db.len() {
1249 for (id, node) in doc.idcache_iter() {
1251 if let Some(entry) = self.db.lookup(&format!("ID:{}", id)) {
1252 if let Some(fragid) = entry.get_string("fragid") {
1253 let mut n = node.clone();
1254 n.set_attribute("fragid", fragid).ok();
1255 }
1256 }
1257 }
1258 } else {
1259 for key in self.db.keys_iter() {
1262 let id = match key.strip_prefix("ID:") {
1263 Some(rest) => rest,
1264 None => continue,
1265 };
1266 let entry = match self.db.lookup(key) {
1267 Some(e) => e,
1268 None => continue,
1269 };
1270 let fragid = match entry.get_string("fragid") {
1271 Some(f) => f,
1272 None => continue,
1273 };
1274 if let Some(node) = doc.find_node_by_id(id) {
1275 let mut n = node.clone();
1276 n.set_attribute("fragid", fragid).ok();
1277 }
1278 }
1279 }
1280 }
1281
1282 fn fill_in_refs(&mut self, doc: &mut PostDocument) {
1283 let mut refs = doc.findnodes("//*[@idref]");
1284 refs.extend(doc.findnodes("//*[@labelref]"));
1285 for ref_node in &refs {
1286 let tag = doc.get_qname(ref_node).unwrap_or_default();
1287 if tag == "ltx:XMRef" {
1288 continue;
1289 }
1290
1291 let mut ref_mut = ref_node.clone();
1292 let mut id = ref_node.get_attribute("idref");
1293 let show = ref_node
1294 .get_attribute("show")
1295 .unwrap_or_else(|| self.ref_show.clone());
1296
1297 if id.is_none() {
1298 if let Some(label) = ref_node.get_attribute("labelref") {
1299 if let Some(entry) = self.db.lookup(&label) {
1300 if let Some(resolved_id) = entry.get_string("id") {
1301 ref_mut.set_attribute("idref", resolved_id).ok();
1302 id = Some(resolved_id.to_string());
1303 }
1304 }
1305 if id.is_none() {
1306 self.note_missing("warn", "Target for Label", &label);
1307 PostDocument::add_class(&mut ref_mut, "ltx_missing_label");
1308 }
1309 }
1310 }
1311
1312 if let Some(ref id_str) = id {
1313 if ref_mut.get_attribute("href").is_none() {
1314 if let Some(url) = self.generate_url(doc, id_str) {
1315 ref_mut.set_attribute("href", &url).ok();
1316 }
1317 }
1318 if ref_mut.get_attribute("title").is_none() {
1319 if let Some(titlestring) = self.generate_title(doc, id_str, &show) {
1320 ref_mut.set_attribute("title", &titlestring).ok();
1321 }
1322 if let Some(rel) = ref_mut.get_attribute("rel") {
1330 if !rel.is_empty() {
1331 if let Some(fulltitle) = self.generate_title(doc, id_str, "") {
1332 ref_mut.set_attribute("fulltitle", &fulltitle).ok();
1333 }
1334 }
1335 }
1336 }
1337 if ref_mut.get_first_child().is_none() && tag != "ltx:graphics" && tag != "ltx:picture" {
1338 let content = self.generate_ref(doc, id_str, &show);
1339 doc.add_nodes(&mut ref_mut, &content);
1340 }
1341 }
1342 }
1343 }
1344
1345 fn fill_in_glossaryrefs(&mut self, doc: &mut PostDocument) {
1346 for ref_node in &doc.findnodes("descendant::ltx:glossaryref") {
1354 let mut ref_mut = ref_node.clone();
1355 let key = ref_node.get_attribute("key").unwrap_or_default();
1356 let list = ref_node.get_attribute("inlist").unwrap_or_default();
1357
1358 let gkey = format!("GLOSSARY:{}:{}", list, key);
1359 if let Some(entry) = self.db.lookup(&gkey) {
1360 if let Some(id) = entry.get_string("id") {
1361 ref_mut.set_attribute("idref", id).ok();
1362 }
1363 if ref_mut.get_attribute("title").is_none() {
1366 if let Some(desc) = entry.get_string("phrase:description") {
1367 if !desc.is_empty() {
1368 ref_mut.set_attribute("title", desc).ok();
1369 }
1370 }
1371 }
1372 } else {
1373 self.note_missing("warn", "Glossary Entry for key", &key);
1374 }
1375
1376 if ref_mut.get_first_child().is_none() {
1377 doc.add_nodes(&mut ref_mut, &[NodeData::Text(key.clone())]);
1378 PostDocument::add_class(&mut ref_mut, "ltx_missing");
1379 }
1380 }
1381 }
1382
1383 fn fill_in_rdfa_refs(&mut self, doc: &mut PostDocument) {
1411 for key in ["about", "resource"] {
1412 let refs = doc.findnodes(&format!("//*[@{key}idref or @{key}labelref]"));
1415 for ref_node in &refs {
1416 let mut ref_mut = ref_node.clone();
1417 let idref_attr = format!("{key}idref");
1418 let mut id = ref_node
1421 .get_attribute(&idref_attr)
1422 .filter(|v| !v.is_empty());
1423
1424 if id.is_none()
1428 && let Some(label) = ref_node.get_attribute(&format!("{key}labelref"))
1429 {
1430 if let Some(entry) = self.db.lookup(&label)
1431 && let Some(resolved) = entry.get_string("id")
1432 {
1433 ref_mut.set_attribute(&idref_attr, resolved).ok();
1434 id = Some(resolved.to_string());
1435 }
1436 if id.is_none() {
1437 self.note_missing("warn", &format!("Target for {key} Label"), &label);
1438 }
1439 }
1440
1441 if let Some(ref id_str) = id
1443 && ref_mut.get_attribute(key).is_none()
1444 {
1445 let value = if self.db.lookup(&format!("ID:{id_str}")).is_some() {
1446 self.generate_url(doc, id_str)
1447 } else {
1448 Some(format!("#{id_str}"))
1449 };
1450 if let Some(value) = value {
1451 ref_mut.set_attribute(key, &value).ok();
1452 }
1453 }
1454 }
1455 }
1456 }
1457
1458 fn fill_in_bibrefs(&mut self, doc: &mut PostDocument) {
1459 let bibrefs = doc.findnodes("//ltx:bibref");
1460 for bibref in &bibrefs {
1461 let keys_str = bibref.get_attribute("bibrefs").unwrap_or_default();
1462 let show = bibref
1463 .get_attribute("show")
1464 .unwrap_or_else(|| "refnum".to_string());
1465 let show_wants_ay = show.contains("Author") || show.contains("Year");
1473 let inlist = bibref.get_attribute("inlist").unwrap_or_default();
1488 let mut lists: Vec<&str> = inlist.split_whitespace().collect();
1489 if !lists.contains(&"bibliography") {
1490 lists.push("bibliography");
1491 }
1492 let force_numeric = show_wants_ay && {
1503 let keys: Vec<&str> = keys_str.split(',').filter(|k| !k.is_empty()).collect();
1504 !keys.is_empty()
1505 && keys.iter().all(|key| {
1506 let mut id = None;
1507 for list in &lists {
1508 if let Some(be) = self.db.lookup(&format!("BIBLABEL:{}:{}", list, key)) {
1509 id = be.get_string("id").map(String::from);
1510 if id.is_some() {
1511 break;
1512 }
1513 }
1514 }
1515 let Some(id) = id else { return false };
1516 match self.db.lookup(&format!("ID:{}", id)) {
1517 Some(e) => {
1518 let nonempty = |k: &str| {
1519 e.get_value(k)
1520 .is_some_and(|v| !v.to_string().trim().is_empty())
1521 };
1522 !nonempty("authors")
1523 && !nonempty("fullauthors")
1524 && !nonempty("year")
1525 && (nonempty("number") || nonempty("refnum"))
1526 },
1527 None => false,
1528 }
1529 })
1530 };
1531 let internal_delims = show
1536 .find("Year")
1537 .is_some_and(|yp| show[yp + "Year".len()..].contains("Phrase"));
1538 let want_authoryear = show_wants_ay && !force_numeric;
1541 let sep = if force_numeric {
1542 ",".to_string()
1543 } else {
1544 bibref
1545 .get_attribute("separator")
1546 .unwrap_or_else(|| ",".to_string())
1547 };
1548
1549 let mut refs: Vec<NodeData> = Vec::new();
1550 for key in keys_str.split(',').filter(|k| !k.is_empty()) {
1551 let mut found_id = None;
1552 for list in &lists {
1553 let bkey = format!("BIBLABEL:{}:{}", list, key);
1554 if let Some(bentry) = self.db.lookup(&bkey) {
1555 found_id = bentry.get_string("id").map(String::from);
1556 if found_id.is_some() {
1557 break;
1558 }
1559 }
1560 }
1561 if !refs.is_empty() {
1562 refs.push(NodeData::Text(format!("{} ", sep)));
1563 }
1564 if let Some(id) = found_id {
1565 let mut attrs = HashMap::default();
1566 attrs.insert("idref".to_string(), id.clone());
1567 if let Some(url) = self.generate_url(doc, &id) {
1568 attrs.insert("href".to_string(), url);
1569 }
1570 let entry = self.db.lookup(&format!("ID:{}", id));
1577 let get = |k: &str| {
1580 entry
1581 .and_then(|e| e.get_value(k))
1582 .map(|v| v.to_string())
1583 .map(|s| s.trim().to_string())
1584 .filter(|s| !s.is_empty())
1585 };
1586 let authors = get("authors");
1587 let fullauthors = get("fullauthors");
1588 let keytag = get("keytag");
1589 let year = get("year");
1590 let typetag = get("typetag");
1591 let number = get("number");
1592 let refnum = get("refnum");
1593 let number_or_refnum = || {
1594 number
1595 .clone()
1596 .or_else(|| refnum.clone())
1597 .unwrap_or_else(|| key.to_string())
1598 };
1599 let display = if want_authoryear {
1600 let phrases: Vec<String> = crate::document::element_children(bibref)
1604 .iter()
1605 .filter(|c| doc.get_qname(c).as_deref() == Some("ltx:bibrefphrase"))
1606 .map(|c| c.get_content())
1607 .collect();
1608 let a = authors
1609 .as_deref()
1610 .or(fullauthors.as_deref())
1611 .or(keytag.as_deref());
1612 let y = year.as_deref().or(typetag.as_deref());
1613 let (text, resolved) = render_bibref_show(
1614 &show,
1615 a,
1616 fullauthors.as_deref(),
1617 y,
1618 number.as_deref(),
1619 refnum.as_deref(),
1620 &phrases,
1621 );
1622 if resolved && !text.trim().is_empty() {
1625 text
1626 } else {
1627 number_or_refnum()
1628 }
1629 } else {
1630 number_or_refnum()
1631 };
1632 refs.push(NodeData::Element {
1633 tag: "ltx:ref".to_string(),
1634 attributes: Some(attrs),
1635 children: vec![NodeData::Text(display)],
1636 });
1637 } else {
1638 self.note_missing("warn", "Entry for citation", key);
1639 refs.push(NodeData::Element {
1640 tag: "ltx:ref".to_string(),
1641 attributes: Some(HashMap::from_iter([
1642 ("idref".to_string(), key.to_string()),
1643 ("class".to_string(), "ltx_missing_citation".to_string()),
1644 ])),
1645 children: vec![NodeData::Text(key.to_string())],
1646 });
1647 }
1648 }
1649 if force_numeric && internal_delims && !refs.is_empty() {
1652 refs.insert(0, NodeData::Text("[".to_string()));
1653 refs.push(NodeData::Text("]".to_string()));
1654 }
1655 if !refs.is_empty() {
1656 doc.replace_node(bibref, &refs);
1657 }
1658 }
1659 }
1660
1661 fn fill_in_mathlinks(&mut self, doc: &PostDocument) {
1662 for sym in &doc.findnodes("descendant::*[@decl_id or @meaning]") {
1663 let tag = doc.get_qname(sym).unwrap_or_default();
1664 if tag == "ltx:XMRef" || sym.get_attribute("href").is_some() {
1665 continue;
1666 }
1667 let entry_key = sym
1668 .get_attribute("decl_id")
1669 .map(|did| format!("DECLARATION:local:{}", did))
1670 .or_else(|| {
1671 sym
1672 .get_attribute("meaning")
1673 .map(|m| format!("DECLARATION:global:{}", m))
1674 });
1675 let parent_id = entry_key
1676 .as_ref()
1677 .and_then(|ek| self.db.lookup(ek))
1678 .and_then(|entry| entry.get_string("parent").map(String::from));
1679 if let Some(pid) = parent_id {
1680 if let Some(url) = self.generate_url(doc, &pid) {
1681 let mut sym_mut = sym.clone();
1682 sym_mut.set_attribute("href", &url).ok();
1683 }
1684 }
1685 }
1686 }
1687
1688 fn report_missing(&self) {
1689 for (severity, types) in &self.missing {
1690 for (ref_type, items) in types {
1691 let keys: Vec<&String> = items.keys().collect();
1692 let msg = format!(
1693 "Missing {}: {}",
1694 ref_type,
1695 keys
1696 .iter()
1697 .map(|s| s.as_str())
1698 .collect::<Vec<_>>()
1699 .join(",")
1700 );
1701 match severity.as_str() {
1704 "error" => Error!("expected", "ids", "{}", msg),
1705 "warn" => Warn!("expected", "ids", "{}", msg),
1706 _ => Info!("expected", "ids", "{}", msg),
1707 }
1708 }
1709 }
1710 }
1711}
1712
1713impl Processor for CrossRef {
1714 fn get_name(&self) -> &str { &self.name }
1715
1716 fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
1717 match doc.get_document_element() {
1718 Some(el) => vec![el],
1719 None => vec![],
1720 }
1721 }
1722
1723 fn process(&mut self, mut doc: PostDocument, _nodes: Vec<Node>) -> ProcessResult {
1724 self.missing.clear();
1725
1726 let doc_title = self.generate_document_title(&doc);
1728 let navtoc = self.navigation_toc.clone();
1729
1730 if (navtoc.is_some() || doc_title.is_some()) && doc.findnode("//ltx:navigation").is_none() {
1731 if let Some(mut root) = doc.get_document_element() {
1732 doc.add_nodes(&mut root, &[NodeData::Element {
1733 tag: "ltx:navigation".to_string(),
1734 attributes: None,
1735 children: vec![],
1736 }]);
1737 }
1738 }
1739 if let Some(ref format) = navtoc {
1740 if let Some(mut nav) = doc.findnode("//ltx:navigation") {
1741 doc.add_nodes(&mut nav, &[NodeData::Element {
1748 tag: "ltx:TOC".to_string(),
1749 attributes: Some(HashMap::from_iter([("format".to_string(), format.clone())])),
1750 children: vec![],
1751 }]);
1752 }
1753 }
1754 if let Some(ref title) = doc_title {
1755 if let Some(mut nav) = doc.findnode("//ltx:navigation") {
1756 doc.add_nodes(&mut nav, &[NodeData::Element {
1757 tag: "ltx:title".to_string(),
1758 attributes: None,
1759 children: vec![NodeData::Text(title.clone())],
1760 }]);
1761 }
1762 }
1763
1764 self.fill_in_relations(&mut doc);
1765 self.fill_in_tocs(&mut doc);
1766 self.fill_in_frags(&doc);
1767 self.fill_in_glossaryrefs(&mut doc);
1768 self.fill_in_refs(&mut doc);
1769 self.fill_in_rdfa_refs(&mut doc);
1770 self.fill_in_bibrefs(&mut doc);
1771 self.fill_in_mathlinks(&doc);
1772 self.copy_resources(&doc);
1773 strip_ref_display_fragids(&doc);
1774 self.report_missing();
1775 Ok(vec![doc])
1776 }
1777}
1778
1779fn relative_url(target: &str, base: &str) -> String {
1783 if target == base {
1784 return ".".to_string();
1785 }
1786 let target_parts: Vec<&str> = target.split('/').collect();
1787 let base_parts: Vec<&str> = base.split('/').collect();
1788 let common = target_parts
1789 .iter()
1790 .zip(base_parts.iter())
1791 .take_while(|(a, b)| a == b)
1792 .count();
1793 let mut result = String::new();
1794 for _ in common..base_parts.len().saturating_sub(1) {
1795 result.push_str("../");
1796 }
1797 result.push_str(&target_parts[common..].join("/"));
1798 if result.is_empty() {
1799 ".".to_string()
1800 } else {
1801 result
1802 }
1803}
1804
1805fn get_text_content_node(node: &Node) -> String {
1809 let text = node.get_content();
1810 let trimmed = text.trim();
1811 trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
1813}
1814
1815fn text_content(nodes: &[NodeData]) -> String {
1816 nodes
1817 .iter()
1818 .map(|n| match n {
1819 NodeData::Text(s) => s.clone(),
1820 NodeData::Element { children, .. } => text_content(children),
1821 NodeData::XmlNode(n) => n.get_content(),
1822 })
1823 .collect::<Vec<_>>()
1824 .join("")
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829 use super::*;
1830
1831 #[test]
1832 fn relative_url_identical_paths_is_dot() {
1833 assert_eq!(relative_url("a/b.html", "a/b.html"), ".");
1834 }
1835
1836 #[test]
1837 fn relative_url_same_dir() {
1838 assert_eq!(relative_url("a/other.html", "a/index.html"), "other.html");
1840 }
1841
1842 #[test]
1843 fn relative_url_sibling_dir() {
1844 assert_eq!(relative_url("b/x.html", "a/index.html"), "../b/x.html");
1846 }
1847
1848 #[test]
1849 fn relative_url_deeply_nested_base() {
1850 assert_eq!(
1852 relative_url("top/sibling.html", "top/deep/nested/page.html"),
1853 "../../sibling.html"
1854 );
1855 }
1856
1857 #[test]
1858 fn relative_url_same_prefix_different_file() {
1859 assert_eq!(
1860 relative_url("a/b/c/target.html", "a/b/c/source.html"),
1861 "target.html"
1862 );
1863 }
1864
1865 #[test]
1866 fn ref_fallbacks_typerefnum_goes_to_refnum() {
1867 assert_eq!(ref_fallbacks("typerefnum"), &["refnum"]);
1868 }
1869
1870 #[test]
1871 fn ref_fallbacks_title_chain() {
1872 assert_eq!(ref_fallbacks("title"), &["toccaption"]);
1873 assert_eq!(ref_fallbacks("toctitle"), &["title", "toccaption"]);
1874 assert_eq!(ref_fallbacks("rawtoctitle"), &[
1875 "toctitle",
1876 "title",
1877 "toccaption"
1878 ]);
1879 assert_eq!(ref_fallbacks("rawtitle"), &["title", "toccaption"]);
1880 }
1881
1882 #[test]
1883 fn ref_fallbacks_unknown_key_is_empty() {
1884 let empty: &[&str] = &[];
1885 assert_eq!(ref_fallbacks("nonexistent"), empty);
1886 assert_eq!(ref_fallbacks(""), empty);
1887 }
1888
1889 #[test]
1890 fn text_content_flattens_text() {
1891 let nodes = vec![
1892 NodeData::Text("hello ".to_string()),
1893 NodeData::Text("world".to_string()),
1894 ];
1895 assert_eq!(text_content(&nodes), "hello world");
1896 }
1897
1898 #[test]
1899 fn text_content_recurses_into_elements() {
1900 let nodes = vec![NodeData::Element {
1901 tag: "span".to_string(),
1902 attributes: None,
1903 children: vec![
1904 NodeData::Text("inner ".to_string()),
1905 NodeData::Text("text".to_string()),
1906 ],
1907 }];
1908 assert_eq!(text_content(&nodes), "inner text");
1909 }
1910
1911 #[test]
1912 fn text_content_empty_list_is_empty_string() {
1913 assert_eq!(text_content(&[]), "");
1914 }
1915
1916 #[test]
1917 fn text_content_mixed_text_and_nested_element() {
1918 let nodes = vec![
1919 NodeData::Text("outer ".to_string()),
1920 NodeData::Element {
1921 tag: "em".to_string(),
1922 attributes: None,
1923 children: vec![NodeData::Text("inner".to_string())],
1924 },
1925 NodeData::Text(" tail".to_string()),
1926 ];
1927 assert_eq!(text_content(&nodes), "outer inner tail");
1928 }
1929}