use std::collections::BTreeMap;
use std::io::Read;
use std::sync::Arc;
use quick_xml::events::{BytesEnd, BytesStart, Event};
use quick_xml::Reader;
use docparser_core::error::{DocError, Result};
use docparser_core::model::{Block, BlockKind, Document, Page};
use docparser_core::plugin::{FormatPlugin, ParseCapability, RawDoc, RawPage, RawTextItem};
use docparser_core::plugins::html::parse_blocks;
use docparser_core::registry::Registry;
pub const BIG_SECTION_CHARS: usize = 8000;
const SECTION_LEVELS: [u8; 3] = [2, 3, 4];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EpubMeta {
pub title: String,
pub authors: Vec<String>,
pub language: String,
pub publisher: String,
pub year: String,
pub cover_href: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct EpubChapter {
pub title: String,
pub blocks: Vec<Block>,
pub text: String,
}
#[derive(Debug, Clone, Default)]
pub struct EpubBook {
pub meta: EpubMeta,
pub chapters: Vec<EpubChapter>,
}
fn local_name(e: &BytesStart<'_>) -> String {
e.local_name().as_ref().to_string()
}
fn end_name(e: &BytesEnd<'_>) -> String {
e.local_name().as_ref().to_string()
}
fn attrs(e: &BytesStart<'_>) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for a in e.attributes().flatten() {
let key = a.key.local_name().as_ref().to_string();
let val = quick_xml::escape::unescape(&a.value)
.map(|v| v.into_owned())
.unwrap_or_else(|_| a.value.to_string());
out.insert(key, val);
}
out
}
fn push_ref(buf: &mut String, e: &quick_xml::events::BytesRef<'_>) {
match e.resolve_char_ref() {
Ok(Some(ch)) => buf.push(ch),
_ => {
let entity = format!("&{};", e.xml10_content());
if let Ok(t) = quick_xml::escape::unescape(&entity) {
buf.push_str(&t);
}
}
}
}
fn text_of(e: &quick_xml::events::BytesText<'_>) -> Option<String> {
let raw = e.xml10_content();
quick_xml::escape::unescape(&raw)
.ok()
.map(|v| v.into_owned())
}
fn xml_err(what: &str, e: impl std::fmt::Display) -> DocError {
DocError::corrupt(format!("{what} 解析失败: {e}"))
}
#[derive(Debug, Clone, Default)]
struct Item {
id: String,
href: String,
props: String,
}
#[derive(Debug, Clone, Default)]
struct Opf {
title: String,
authors: Vec<String>,
language: String,
publisher: String,
date: String,
items: Vec<Item>,
spine: Vec<String>,
toc: Option<String>,
cover_id: Option<String>,
}
fn find_rootfile(xml: &str) -> Result<Option<String>> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
if local_name(&e) == "rootfile" {
if let Some(p) = attrs(&e).get("full-path") {
return Ok(Some(p.clone()));
}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(xml_err("container.xml", e)),
_ => {}
}
buf.clear();
}
Ok(None)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Field {
Title,
Creator,
Language,
Publisher,
Date,
}
fn on_opf_element(
opf: &mut Opf,
e: &BytesStart<'_>,
stack: &[String],
field: &mut Option<Field>,
acc: &mut String,
is_empty: bool,
) {
let name = local_name(e);
let a = attrs(e);
let in_metadata = stack.iter().any(|s| s == "metadata");
if in_metadata && field.is_none() && !is_empty {
let f = match name.as_str() {
"title" => Some(Field::Title),
"creator" => Some(Field::Creator),
"language" => Some(Field::Language),
"publisher" => Some(Field::Publisher),
"date" => Some(Field::Date),
_ => None,
};
if f.is_some() {
*field = f;
acc.clear();
}
}
match name.as_str() {
"item" => opf.items.push(Item {
id: a.get("id").cloned().unwrap_or_default(),
href: a.get("href").cloned().unwrap_or_default(),
props: a.get("properties").cloned().unwrap_or_default(),
}),
"itemref" => {
if let Some(id) = a.get("idref") {
opf.spine.push(id.clone());
}
}
"spine" => {
if let Some(t) = a.get("toc") {
opf.toc = Some(t.clone());
}
}
"meta" if a.get("name").map(|n| n == "cover").unwrap_or(false) => {
opf.cover_id = a.get("content").cloned();
}
_ => {}
}
}
fn parse_opf(xml: &str) -> Result<Opf> {
let mut opf = Opf::default();
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut stack: Vec<String> = Vec::new();
let mut field: Option<Field> = None;
let mut acc = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
on_opf_element(&mut opf, &e, &stack, &mut field, &mut acc, false);
stack.push(local_name(&e));
}
Ok(Event::Empty(e)) => {
on_opf_element(&mut opf, &e, &stack, &mut field, &mut acc, true);
}
Ok(Event::Text(t)) => {
if field.is_some() {
if let Some(v) = text_of(&t) {
acc.push_str(&v);
}
}
}
Ok(Event::GeneralRef(ref e)) => {
if field.is_some() {
push_ref(&mut acc, e);
}
}
Ok(Event::End(e)) => {
let name = end_name(&e);
if let Some(f) = field {
let matched = matches!(
(f, name.as_str()),
(Field::Title, "title")
| (Field::Creator, "creator")
| (Field::Language, "language")
| (Field::Publisher, "publisher")
| (Field::Date, "date")
);
if matched {
let v = acc.trim().to_string();
match f {
Field::Title => {
if opf.title.is_empty() {
opf.title = v;
}
}
Field::Creator => {
if !v.is_empty() {
opf.authors.push(v);
}
}
Field::Language => {
if opf.language.is_empty() {
opf.language = v;
}
}
Field::Publisher => {
if opf.publisher.is_empty() {
opf.publisher = v;
}
}
Field::Date => {
if opf.date.is_empty() {
opf.date = v;
}
}
}
field = None;
acc.clear();
}
}
if stack.last().map(|s| s == &name).unwrap_or(false) {
stack.pop();
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(xml_err("OPF", e)),
_ => {}
}
buf.clear();
}
Ok(opf)
}
fn nav_titles(xml: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut href: Option<String> = None;
let mut acc = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
if local_name(&e) == "a" {
href = attrs(&e).get("href").cloned();
acc.clear();
}
}
Ok(Event::Text(t)) => {
if href.is_some() {
if let Some(v) = text_of(&t) {
acc.push_str(&v);
}
}
}
Ok(Event::GeneralRef(ref e)) => {
if href.is_some() {
push_ref(&mut acc, e);
}
}
Ok(Event::End(e)) => {
if end_name(&e) == "a" {
if let Some(h) = href.take() {
let h = h.split('#').next().unwrap_or("").to_string();
let title = acc.trim().to_string();
if !h.is_empty() && !title.is_empty() {
out.entry(h).or_insert(title);
}
}
acc.clear();
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
out
}
fn ncx_titles(xml: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut last_label = String::new();
let mut in_text = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
let name = local_name(&e);
match name.as_str() {
"text" => {
in_text = true;
last_label.clear();
}
"content" => {
if let Some(src) = attrs(&e).get("src") {
let h = src.split('#').next().unwrap_or("").to_string();
if !h.is_empty() && !last_label.is_empty() {
out.entry(h).or_insert_with(|| last_label.clone());
}
}
}
_ => {}
}
}
Ok(Event::Text(t)) => {
if in_text {
if let Some(v) = text_of(&t) {
last_label.push_str(&v);
}
}
}
Ok(Event::GeneralRef(ref e)) => {
if in_text {
push_ref(&mut last_label, e);
}
}
Ok(Event::End(e)) => {
if end_name(&e) == "text" {
last_label = last_label.trim().to_string();
in_text = false;
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
out
}
fn resolve(base: &str, href: &str) -> String {
let href = href.split('#').next().unwrap_or("");
let joined = if base.is_empty() {
href.to_string()
} else {
format!("{base}/{href}")
};
let mut parts: Vec<&str> = Vec::new();
for seg in joined.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
fn open_zip(path: &str) -> Result<zip::ZipArchive<std::fs::File>> {
let f =
std::fs::File::open(path).map_err(|e| DocError::corrupt(format!("EPUB 打不开: {e}")))?;
zip::ZipArchive::new(f).map_err(|e| DocError::corrupt(format!("EPUB 打不开: {e}")))
}
fn read_entry(zip: &mut zip::ZipArchive<std::fs::File>, name: &str) -> Result<Option<String>> {
match zip.by_name(name) {
Ok(mut f) => {
let mut buf = Vec::new();
f.read_to_end(&mut buf)
.map_err(|e| DocError::corrupt(format!("读取 {name} 失败: {e}")))?;
Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
}
Err(zip::result::ZipError::FileNotFound) => Ok(None),
Err(e) => Err(DocError::corrupt(format!("ZIP 读取 {name} 失败: {e}"))),
}
}
fn year_of(date: &str) -> String {
let bytes: Vec<char> = date.chars().collect();
for w in bytes.windows(4) {
if w.iter().all(|c| c.is_ascii_digit()) {
return w.iter().collect();
}
}
String::new()
}
fn cut_indices(blocks: &[Block], max_level: u8) -> Vec<(String, Vec<usize>)> {
let mut out: Vec<(String, Vec<usize>)> = Vec::new();
let mut title = String::new();
let mut cur: Vec<usize> = Vec::new();
for (i, b) in blocks.iter().enumerate() {
if b.kind == BlockKind::Heading && b.level.unwrap_or(1) <= max_level {
if !cur.is_empty() {
out.push((std::mem::take(&mut title), std::mem::take(&mut cur)));
}
title = b.content.trim().to_string();
continue;
}
if !b.content.is_empty() {
cur.push(i);
}
}
if !cur.is_empty() {
out.push((title, cur));
}
out.retain(|(_, idx)| !idx.is_empty());
out
}
fn sections(blocks: &[Block]) -> Vec<(String, Vec<usize>)> {
let mut best = Vec::new();
for max_level in SECTION_LEVELS {
best = cut_indices(blocks, max_level);
let max_chars = best
.iter()
.map(|(_, idx)| {
idx.iter()
.map(|&i| blocks[i].content.chars().count())
.sum::<usize>()
})
.max()
.unwrap_or(0);
if best.is_empty() || max_chars <= BIG_SECTION_CHARS {
break;
}
}
best
}
fn page_text(blocks: &[Block]) -> String {
blocks
.iter()
.filter(|b| !b.content.is_empty())
.map(|b| b.content.as_str())
.collect::<Vec<_>>()
.join("\n\n")
}
fn build_page(title: &str, blocks: Vec<Block>) -> Page {
let text = page_text(&blocks);
Page {
name: if title.trim().is_empty() {
None
} else {
Some(title.trim().to_string())
},
blocks,
text,
..Default::default()
}
}
pub struct EpubRawBackend;
impl ParseCapability for EpubRawBackend {
fn id(&self) -> &str {
"epub.read"
}
fn formats(&self) -> &[&str] {
&["epub"]
}
fn parse(&self, path: &str) -> Result<RawDoc> {
let mut zip = open_zip(path)?;
let container = read_entry(&mut zip, "META-INF/container.xml")?
.ok_or_else(|| DocError::corrupt("EPUB 缺 META-INF/container.xml"))?;
let opf_path = find_rootfile(&container)?
.ok_or_else(|| DocError::corrupt("container.xml 缺 rootfile"))?;
let opf_xml = read_entry(&mut zip, &opf_path)?
.ok_or_else(|| DocError::corrupt(format!("EPUB 缺 OPF: {opf_path}")))?;
let opf = parse_opf(&opf_xml)?;
let opf_dir = opf_path
.rsplit_once('/')
.map(|(d, _)| d.to_string())
.unwrap_or_default();
let mut titles = BTreeMap::new();
if let Some(nav) = opf
.items
.iter()
.find(|it| it.props.split_whitespace().any(|p| p == "nav"))
.map(|it| resolve(&opf_dir, &it.href))
{
if let Some(xml) = read_entry(&mut zip, &nav)? {
titles = nav_titles(&xml);
}
}
if titles.is_empty() {
let ncx_path = opf
.toc
.as_ref()
.and_then(|id| {
opf.items
.iter()
.find(|it| &it.id == id)
.map(|it| resolve(&opf_dir, &it.href))
})
.or_else(|| {
opf.items
.iter()
.find(|it| it.href.ends_with(".ncx"))
.map(|it| resolve(&opf_dir, &it.href))
});
if let Some(p) = ncx_path {
if let Some(xml) = read_entry(&mut zip, &p)? {
titles = ncx_titles(&xml);
}
}
}
let cover = opf
.items
.iter()
.find(|it| it.props.split_whitespace().any(|p| p == "cover-image"))
.map(|it| resolve(&opf_dir, &it.href))
.or_else(|| {
opf.cover_id.as_ref().and_then(|id| {
opf.items
.iter()
.find(|it| &it.id == id)
.map(|it| resolve(&opf_dir, &it.href))
})
});
let mut pages = Vec::new();
for idref in &opf.spine {
let Some(item) = opf.items.iter().find(|it| &it.id == idref) else {
continue;
};
let full = resolve(&opf_dir, &item.href);
if full.ends_with(".ncx") {
continue;
}
let Some(xhtml) = read_entry(&mut zip, &full)? else {
continue;
};
let title = titles
.get(&item.href)
.or_else(|| titles.get(&full))
.cloned()
.unwrap_or_default();
pages.push(RawPage {
name: if title.is_empty() { None } else { Some(title) },
items: vec![RawTextItem {
text: xhtml,
bbox: None,
}],
..Default::default()
});
}
let mut meta: std::collections::HashMap<String, String> = std::collections::HashMap::new();
if !opf.title.is_empty() {
meta.insert("epub.title".to_string(), opf.title.clone());
}
if !opf.authors.is_empty() {
meta.insert("epub.authors".to_string(), opf.authors.join("; "));
}
if !opf.language.is_empty() {
meta.insert("epub.language".to_string(), opf.language.clone());
}
if !opf.publisher.is_empty() {
meta.insert("epub.publisher".to_string(), opf.publisher.clone());
}
let year = year_of(&opf.date);
if !year.is_empty() {
meta.insert("epub.year".to_string(), year);
}
if let Some(c) = &cover {
meta.insert("epub.cover_href".to_string(), c.clone());
}
Ok(RawDoc {
pages,
meta,
needs_ocr: false,
})
}
}
pub struct EpubPlugin;
impl FormatPlugin for EpubPlugin {
fn id(&self) -> &str {
"epub"
}
fn formats(&self) -> &[&str] {
&["epub"]
}
fn default_parse_chain(&self) -> Vec<&'static str> {
vec!["epub.read"]
}
fn default_process_chain(&self) -> Vec<&'static str> {
vec![]
}
fn assemble(&self, raw: &RawDoc) -> Document {
let mut pages: Vec<Page> = Vec::new();
for (idx, rp) in raw.pages.iter().enumerate() {
let Some(xhtml) = rp.items.first().map(|i| i.text.as_str()) else {
continue;
};
let blocks = parse_blocks(xhtml);
if blocks.iter().all(|b| b.content.is_empty()) {
continue; }
let secs = sections(&blocks);
if secs.len() > 1 {
for (title, idxs) in secs {
let sec_blocks: Vec<Block> = idxs.iter().map(|&i| blocks[i].clone()).collect();
pages.push(build_page(&title, sec_blocks));
}
continue;
}
let first_heading = blocks
.iter()
.find(|b| b.kind == BlockKind::Heading && !b.content.trim().is_empty())
.map(|b| b.content.trim().to_string());
let title = rp
.name
.clone()
.filter(|s| !s.trim().is_empty())
.or(first_heading)
.unwrap_or_else(|| format!("第{}章", idx + 1));
pages.push(build_page(&title, blocks));
}
let mut doc = Document {
pages,
..Default::default()
};
for key in [
"epub.title",
"epub.authors",
"epub.language",
"epub.publisher",
"epub.year",
"epub.cover_href",
] {
if let Some(v) = raw.meta.get(key) {
doc.meta.extra.insert(key.to_string(), v.clone());
}
}
doc
}
}
pub fn register_defaults(reg: &mut Registry) {
let _ = reg.register_parse(Arc::new(EpubRawBackend), None);
reg.register_plugin(Arc::new(EpubPlugin));
}
pub fn read_epub(path: &str) -> Result<EpubBook> {
let raw = EpubRawBackend.parse(path)?;
let doc = EpubPlugin.assemble(&raw);
let get = |k: &str| raw.meta.get(k).cloned().unwrap_or_default();
let meta = EpubMeta {
title: get("epub.title"),
authors: raw
.meta
.get("epub.authors")
.map(|a| a.split("; ").map(|s| s.to_string()).collect())
.unwrap_or_default(),
language: get("epub.language"),
publisher: get("epub.publisher"),
year: get("epub.year"),
cover_href: raw.meta.get("epub.cover_href").cloned(),
};
let chapters = doc
.pages
.iter()
.map(|p| EpubChapter {
title: p.name.clone().unwrap_or_default(),
blocks: p.blocks.clone(),
text: p.text.clone(),
})
.collect();
Ok(EpubBook { meta, chapters })
}
pub fn epub_cover_href(path: &str) -> (Option<String>, String) {
match EpubRawBackend.parse(path) {
Ok(raw) => (
raw.meta.get("epub.cover_href").cloned(),
raw.meta.get("epub.title").cloned().unwrap_or_default(),
),
Err(_) => (None, String::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const CONTAINER: &str = r#"<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>"#;
const OPF: &str = r#"<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>测试书</dc:title>
<dc:creator>作者甲</dc:creator>
<dc:creator>作者乙</dc:creator>
<dc:language>zh</dc:language>
<dc:publisher>某社</dc:publisher>
<dc:date>2021-05-01</dc:date>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="ch1" href="text/ch1.xhtml" media-type="application/xhtml+xml"/>
<item id="ch2" href="text/ch2.xhtml" media-type="application/xhtml+xml"/>
<item id="cover" href="img/cover.jpg" media-type="image/jpeg" properties="cover-image"/>
</manifest>
<spine>
<itemref idref="ch1"/>
<itemref idref="ch2"/>
</spine>
</package>"#;
const NAV: &str = r#"<?xml version="1.0"?>
<html xmlns="http://www.w3.org/1999/xhtml"><body>
<nav epub:type="toc" xmlns:epub="http://www.idpf.org/2007/ops">
<ol>
<li><a href="text/ch1.xhtml">第一章</a></li>
<li><a href="text/ch2.xhtml#sec1">第二章</a></li>
</ol>
</nav></body></html>"#;
const CH1: &str = r#"<html><body><h1>第一章</h1><p>正文一</p></body></html>"#;
const CH2: &str = r#"<html><body><h2>甲节</h2><p>甲正文</p><h2>乙节</h2><ul><li>要点</li></ul></body></html>"#;
fn write_epub(dir_name: &str, title_opts: Option<&str>) -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join("docparser_epub_test")
.join(dir_name);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("book.epub");
let f = std::fs::File::create(&path).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default();
let mut put = |name: &str, body: &str| {
zw.start_file(name, opts).unwrap();
zw.write_all(body.as_bytes()).unwrap();
};
put("mimetype", "application/epub+zip");
put("META-INF/container.xml", CONTAINER);
put("OEBPS/content.opf", title_opts.unwrap_or(OPF));
put("OEBPS/nav.xhtml", NAV);
put("OEBPS/text/ch1.xhtml", CH1);
put("OEBPS/text/ch2.xhtml", CH2);
zw.finish().unwrap();
path
}
#[test]
fn reads_metadata_and_split_chapters() {
let p = write_epub("ok", None);
let book = read_epub(p.to_str().unwrap()).unwrap();
std::fs::remove_file(&p).ok();
assert_eq!(book.meta.title, "测试书");
assert_eq!(book.meta.authors, vec!["作者甲", "作者乙"]);
assert_eq!(book.meta.language, "zh");
assert_eq!(book.meta.publisher, "某社");
assert_eq!(book.meta.year, "2021");
assert_eq!(book.meta.cover_href.as_deref(), Some("OEBPS/img/cover.jpg"));
let titles: Vec<&str> = book.chapters.iter().map(|c| c.title.as_str()).collect();
assert_eq!(titles, vec!["第一章", "甲节", "乙节"]);
assert_eq!(book.chapters[0].blocks[0].kind, BlockKind::Heading);
assert_eq!(book.chapters[1].text, "甲正文");
assert_eq!(book.chapters[2].blocks[0].kind, BlockKind::List);
}
#[test]
fn engine_route_and_meta_extra() {
let p = write_epub("engine", None);
let mut reg = Registry::new();
docparser_core::plugins::text::register_defaults(&mut reg);
register_defaults(&mut reg);
let doc = docparser_core::DocumentEngine::new(reg)
.parse(p.to_str().unwrap())
.unwrap();
std::fs::remove_file(&p).ok();
assert_eq!(doc.meta.file_type, "epub");
assert_eq!(doc.meta.page_count, 3);
assert_eq!(
doc.meta.extra.get("epub.title").map(|s| s.as_str()),
Some("测试书")
);
assert!(doc.pages.iter().all(|p| p.name.is_some()));
}
#[test]
fn broken_container_is_corrupt() {
let dir = std::env::temp_dir()
.join("docparser_epub_test")
.join("broken");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bad.epub");
let f = std::fs::File::create(&path).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default();
zw.start_file("mimetype", opts).unwrap();
zw.write_all(b"application/epub+zip").unwrap();
zw.finish().unwrap();
let err = read_epub(path.to_str().unwrap()).unwrap_err();
std::fs::remove_file(&path).ok();
assert_eq!(err.code.as_str(), "corrupt");
assert!(err.message.contains("container.xml"));
}
#[test]
fn non_zip_is_corrupt() {
let dir = std::env::temp_dir()
.join("docparser_epub_test")
.join("notzip");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("x.epub");
std::fs::write(&path, b"not a zip").unwrap();
let err = read_epub(path.to_str().unwrap()).unwrap_err();
std::fs::remove_file(&path).ok();
assert_eq!(err.code.as_str(), "corrupt");
}
#[test]
fn resolve_normalizes_dot_segments() {
assert_eq!(resolve("OEBPS", "text/ch1.xhtml"), "OEBPS/text/ch1.xhtml");
assert_eq!(resolve("OEBPS/text", "../img/a.png"), "OEBPS/img/a.png");
assert_eq!(resolve("", "ch1.xhtml#frag"), "ch1.xhtml");
}
#[test]
fn ncx_titles_pair_label_and_src() {
let ncx = r#"<ncx><navMap>
<navPoint id="n1"><navLabel><text>卷首</text></navLabel><content src="ch1.xhtml#top"/></navPoint>
<navPoint id="n2"><navLabel><text>卷尾</text></navLabel><content src="ch2.xhtml"/></navPoint>
</navMap></ncx>"#;
let m = ncx_titles(ncx);
assert_eq!(m.get("ch1.xhtml").map(|s| s.as_str()), Some("卷首"));
assert_eq!(m.get("ch2.xhtml").map(|s| s.as_str()), Some("卷尾"));
}
}