//! 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 OneI. The SettingII. The Second EstatePart 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(_))));
}
}