fix: TOC navigation and pagination improvements

- Fix recalculate_pages missing heading_top_spacing in page height calculation
- Improve build_toc path matching: extract filename first, fall back to substring
- Filter out EPUB3 nav.xhtml from content sections
- Skip Windows resource compilation when windres is not available
- Add unit tests for TOC filename matching and nav filtering
This commit is contained in:
Developer
2026-05-21 22:32:18 +08:00
parent 8e8ba01336
commit f12297d580
4 changed files with 281 additions and 13 deletions

View File

@@ -248,7 +248,19 @@ pub fn load_epub(path: impl AsRef<Path>) -> Result<Book, String> {
let title = doc.mdata("title").unwrap_or_else(|| "未知标题".to_string());
let author = doc.mdata("creator").unwrap_or_else(|| "未知作者".to_string());
let cover = doc.get_cover().ok();
let spine = doc.spine.clone();
let spine: Vec<String> = doc.spine.iter()
.filter(|id| {
if id.as_str() == "nav" { return false; }
if let Some((path, _)) = doc.resources.get(*id) {
let path_str = path.to_string_lossy().to_lowercase();
if path_str.ends_with("nav.xhtml") || path_str.ends_with("nav.html") {
return false;
}
}
true
})
.cloned()
.collect();
let mut sections = Vec::new();
for (i, href) in spine.iter().enumerate() {
@@ -290,6 +302,11 @@ fn extract_title(html: &str) -> Option<String> {
None
}
fn extract_filename(path: &str) -> &str {
let path = path.trim_end_matches('/');
path.rsplit('/').next().unwrap_or(path)
}
fn build_toc(
entries: &[epub::doc::NavPoint],
spine: &[String],
@@ -298,10 +315,15 @@ fn build_toc(
.iter()
.map(|e| {
let content_str = e.content.to_string_lossy();
let content_file = extract_filename(&content_str);
let section = spine
.iter()
.position(|s| content_str.contains(s.trim_end_matches('/')))
// unwrap_or(0) is safe: a real TOC entry should always match a spine item
.position(|s| {
let spine_file = extract_filename(s);
spine_file == content_file
|| content_str.contains(s.as_str())
|| s.contains(content_str.as_ref())
})
.unwrap_or(0);
TocEntry {
label: e.label.clone(),
@@ -439,4 +461,66 @@ mod tests {
let toc = build_toc(&[], &[]);
assert!(toc.is_empty());
}
#[test]
fn test_load_sample_epub_nav_filtered() {
let book = load_epub("sample-short.epub").expect("Failed to load sample epub");
// Nav document is filtered out, leaving only chapter_0 as the single section
assert_eq!(book.sections.len(), 1);
assert_eq!(book.sections[0].title, "Understanding Digital Formats");
}
#[test]
fn test_toc_section_bounds() {
let book = load_epub("sample-short.epub").expect("Failed to load sample epub");
// All TOC section indices should be within sections range
fn check_bounds(entries: &[TocEntry], max: usize) {
for e in entries {
assert!(e.section < max, "TOC entry '{}' maps to section {} but only {} sections exist", e.label, e.section, max);
check_bounds(&e.children, max);
}
}
check_bounds(&book.toc, book.sections.len());
}
#[test]
fn test_build_toc_filename_matching() {
use epub::doc::NavPoint;
let spine = vec![
"OEBPS/Text/chapter1.xhtml".to_string(),
"OEBPS/Text/chapter2.xhtml".to_string(),
];
let nav_points = vec![
NavPoint {
label: "Chapter 2".to_string(),
content: std::path::PathBuf::from("Text/chapter2.xhtml"),
play_order: 1,
children: vec![],
},
];
let toc = build_toc(&nav_points, &spine);
assert_eq!(toc.len(), 1);
assert_eq!(toc[0].section, 1); // maps to spine index 1
assert_eq!(toc[0].label, "Chapter 2");
}
#[test]
fn test_build_toc_exact_path_match() {
use epub::doc::NavPoint;
let spine = vec![
"chapter1.xhtml".to_string(),
"chapter2.xhtml".to_string(),
];
let nav_points = vec![
NavPoint {
label: "Chapter 1".to_string(),
content: std::path::PathBuf::from("chapter1.xhtml"),
play_order: 1,
children: vec![],
},
];
let toc = build_toc(&nav_points, &spine);
assert_eq!(toc.len(), 1);
assert_eq!(toc[0].section, 0);
}
}