//! An EPUB's navigation document. //! //! Two shapes, because the format changed. EPUB 3 keeps a `").map_or(from.len(), |end| end + 6); &from[..end] } None => nav, }; let mut entries = Vec::new(); let mut depth: i64 = -1; let mut rest = scope; while let Some(at) = rest.find('<') { let after = &rest[at..]; let Some(end) = after.find('>') else { break }; let tag = &after[1..end]; let lower = tag.to_lowercase(); if lower.starts_with("ol") && !lower.starts_with("ol/") { depth += 1; } else if lower.starts_with("/ol") { depth -= 1; } else if lower.starts_with("a ") || lower.starts_with("a>") { let body = &after[end + 1..]; if let Some(close) = body.find("") { let title = tidy(&strip_tags(&body[..close])); if !title.is_empty() { entries.push(Entry { v: corpus_core::V, idx: 0, depth: depth.max(0) as u32, title, target: attribute(tag, "href"), page: None, }); } } } rest = &after[end + 1..]; } entries } /// What sits between two markers. fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> { let start = text.find(open)? + open.len(); let end = text[start..].find(close)? + start; Some(&text[start..end]) } /// An attribute out of one tag's text. fn attribute(tag: &str, name: &str) -> Option { for quote in ['"', '\''] { let needle = format!("{name}={quote}"); if let Some(at) = tag.find(&needle) { let rest = &tag[at + needle.len()..]; if let Some(end) = rest.find(quote) { return Some(rest[..end].to_string()); } } } None } /// Inner text, with the markup a title is wrapped in removed. fn strip_tags(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut inside = false; for c in text.chars() { match c { '<' => inside = true, '>' => inside = false, c if !inside => out.push(c), _ => {} } } out } #[cfg(test)] mod tests { use super::*; const NCX: &str = r#" Part One I. The Setting II. The Second Estate Part Two "#; #[test] fn nesting_becomes_depth() { let entries = from_ncx(NCX); assert_eq!( entries .iter() .map(|e| (e.depth, e.title.as_str())) .collect::>(), [ (0, "Part One"), (1, "I. The Setting"), (1, "II. The Second Estate"), (0, "Part Two"), ] ); } #[test] fn each_entry_keeps_where_it_points() { assert_eq!(from_ncx(NCX)[1].target.as_deref(), Some("ch1.xhtml")); } #[test] fn a_nav_document_nests_by_its_lists() { let nav = r#""#; let entries = from_nav(nav); assert_eq!( entries .iter() .map(|e| (e.depth, e.title.as_str())) .collect::>(), [(0, "Part One"), (1, "I. The Setting"), (0, "Part Two")] ); } #[test] fn only_the_contents_are_read_and_not_the_page_list() { let nav = r#" "#; let entries = from_nav(nav); assert_eq!(entries.len(), 1); assert_eq!(entries[0].title, "Chapter One"); } #[test] fn markup_inside_a_title_is_not_part_of_the_title() { let nav = r#""#; assert_eq!(from_nav(nav)[0].title, "1 The Setting"); } #[test] fn an_entry_with_no_label_is_not_an_entry() { let ncx = " "; assert!(from_ncx(ncx).is_empty()); } #[test] fn what_is_not_a_zip_says_so() { assert!(matches!(read(b"not a zip"), Err(Missing::Unreadable(_)))); } }