use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
use zip::ZipArchive;
use crate::dom::Stylesheet;
use crate::epub::{parse_container_xml, parse_nav_landmarks, parse_nav_toc, parse_ncx, parse_opf};
use crate::import::{ChapterId, Importer, SpineEntry, resolve_path_based_href};
use crate::io::{ByteSource, ByteSourceCursor, FileSource};
use crate::model::{AnchorTarget, Chapter, GlobalNodeId, Landmark, Metadata, TocEntry};
impl From<zip::result::ZipError> for crate::Error {
fn from(e: zip::result::ZipError) -> Self {
match e {
zip::result::ZipError::Io(io) => crate::Error::Io(io),
other => crate::Error::Malformed {
format: crate::Format::Epub,
context: other.to_string(),
},
}
}
}
pub struct EpubImporter {
source: Arc<dyn ByteSource>,
zip_index: HashMap<String, ZipEntryLoc>,
metadata: Metadata,
toc: Vec<TocEntry>,
landmarks: Vec<Landmark>,
spine: Vec<SpineEntry>,
spine_paths: Vec<String>,
assets: Vec<String>,
css_cache: RwLock<HashMap<String, Arc<Stylesheet>>>,
path_to_chapter: HashMap<String, ChapterId>,
anchor_map: RwLock<HashMap<String, GlobalNodeId>>,
}
#[derive(Clone, Copy)]
struct ZipEntryLoc {
data_offset: u64,
compressed_size: u64,
uncompressed_size: u64,
compression: u16, }
impl Importer for EpubImporter {
fn open(path: &Path) -> crate::Result<Self> {
let file = std::fs::File::open(path)?;
let source = Arc::new(FileSource::new(file)?);
Self::from_source(source)
}
fn metadata(&self) -> &Metadata {
&self.metadata
}
fn toc(&self) -> &[TocEntry] {
&self.toc
}
fn landmarks(&self) -> &[Landmark] {
&self.landmarks
}
fn spine(&self) -> &[SpineEntry] {
&self.spine
}
fn source_id(&self, id: ChapterId) -> Option<&str> {
self.spine_paths.get(id.0 as usize).map(|s| s.as_str())
}
fn load_raw(&self, id: ChapterId) -> crate::Result<Vec<u8>> {
let path = self
.spine_paths
.get(id.0 as usize)
.ok_or_else(|| crate::Error::NotFound {
what: format!("chapter {}", id.0),
})?;
self.read_entry(path)
}
fn list_assets(&self) -> &[String] {
&self.assets
}
fn load_asset(&self, path: &str) -> crate::Result<Vec<u8>> {
self.read_entry(path)
}
fn load_stylesheet(&self, path: &str) -> Option<Arc<Stylesheet>> {
if let Ok(cache) = self.css_cache.read()
&& let Some(sheet) = cache.get(path)
{
return Some(Arc::clone(sheet));
}
let css_bytes = self.read_entry(path).ok()?;
let css_str = String::from_utf8_lossy(&css_bytes);
let sheet = Arc::new(Stylesheet::parse(&css_str));
match self.css_cache.write() {
Ok(mut cache) => Some(Arc::clone(cache.entry(path.to_string()).or_insert(sheet))),
Err(_) => Some(sheet),
}
}
fn index_anchors(&self, chapters: &[(ChapterId, Arc<Chapter>)]) {
let mut anchor_map = HashMap::new();
for (chapter_id, chapter) in chapters {
let chapter_path = match self.spine_paths.get(chapter_id.0 as usize) {
Some(p) => p.split('#').next().unwrap_or(p),
None => continue,
};
for node_id in chapter.iter_dfs() {
if let Some(id) = chapter.semantics.id(node_id) {
let key = format!("{}#{}", chapter_path, id);
anchor_map.insert(key, GlobalNodeId::new(*chapter_id, node_id));
}
}
}
if let Ok(mut map) = self.anchor_map.write() {
*map = anchor_map;
}
}
fn resolve_href(&self, from_chapter: ChapterId, href: &str) -> Option<AnchorTarget> {
let from_path = self.source_id(from_chapter)?;
resolve_path_based_href(
from_path,
href,
|p| self.path_to_chapter.get(p).copied(),
|k| self.anchor_map.read().ok().and_then(|m| m.get(k).copied()),
)
}
}
impl EpubImporter {
pub fn from_source(source: Arc<dyn ByteSource>) -> crate::Result<Self> {
let cursor = ByteSourceCursor::new(source.clone());
let mut archive = ZipArchive::new(cursor)?;
let mut zip_index = HashMap::new();
let mut assets = Vec::new();
for i in 0..archive.len() {
let file = archive.by_index(i)?;
let name = file.name().to_string();
zip_index.insert(
name.clone(),
ZipEntryLoc {
data_offset: file.data_start().unwrap(),
compressed_size: file.compressed_size(),
uncompressed_size: file.size(),
compression: compression_to_u16(file.compression()),
},
);
if !name.ends_with('/') {
assets.push(name);
}
}
let container_bytes = read_entry(&source, &zip_index, "META-INF/container.xml")?;
let opf_path = parse_container_xml(&container_bytes)?;
let opf_base = match opf_path.rfind('/') {
Some(idx) => opf_path[..=idx].to_string(),
None => String::new(),
};
let opf_bytes = read_entry(&source, &zip_index, &opf_path)?;
let hint_encoding = crate::util::extract_xml_encoding(&opf_bytes);
let opf_str = crate::util::decode_text(&opf_bytes, hint_encoding);
let opf = parse_opf(&opf_str)?;
let mut spine = Vec::new();
let mut spine_paths = Vec::new();
for spine_id in &opf.spine_ids {
if let Some((href, _media_type)) = opf.manifest.get(spine_id) {
let full_path = crate::import::resolve_relative_path(&opf_path, href);
let size_estimate = zip_index
.get(&full_path)
.map(|loc| loc.compressed_size as usize)
.unwrap_or(0);
spine.push(SpineEntry {
id: ChapterId(spine_paths.len() as u32),
size_estimate,
});
spine_paths.push(full_path);
}
}
let nav_str: Option<String> = opf.nav_href.as_ref().and_then(|nav_href| {
let nav_path = crate::import::resolve_relative_path(&opf_path, nav_href);
read_entry(&source, &zip_index, &nav_path)
.ok()
.map(|nav_bytes| {
let hint_encoding = crate::util::extract_xml_encoding(&nav_bytes);
crate::util::decode_text(&nav_bytes, hint_encoding).into_owned()
})
});
let mut toc = if let Some(ncx_href) = &opf.ncx_href {
let ncx_path = crate::import::resolve_relative_path(&opf_path, ncx_href);
if let Ok(ncx_bytes) = read_entry(&source, &zip_index, &ncx_path) {
let hint_encoding = crate::util::extract_xml_encoding(&ncx_bytes);
let ncx_str = crate::util::decode_text(&ncx_bytes, hint_encoding);
let toc_entries = parse_ncx(&ncx_str).unwrap_or_default();
prepend_base_to_toc(&toc_entries, &opf_base)
} else {
Vec::new()
}
} else {
Vec::new()
};
if toc.is_empty()
&& let Some(nav_str) = &nav_str
{
let toc_entries = parse_nav_toc(nav_str).unwrap_or_default();
toc = prepend_base_to_toc(&toc_entries, &opf_base);
}
let landmarks = if let Some(nav_str) = &nav_str {
let mut parsed = parse_nav_landmarks(nav_str).unwrap_or_default();
for landmark in &mut parsed {
if !landmark.href.starts_with('#') && !landmark.href.is_empty() {
landmark.href = crate::import::resolve_relative_path(&opf_path, &landmark.href);
}
}
parsed
} else {
Vec::new()
};
let mut path_to_chapter = HashMap::new();
for (i, path) in spine_paths.iter().enumerate() {
let base_path = path.split('#').next().unwrap_or(path);
path_to_chapter.insert(base_path.to_string(), ChapterId(i as u32));
}
let mut metadata = opf.metadata;
if let Some(ref href) = metadata.cover_image
&& !href.is_empty()
{
metadata.cover_image = Some(crate::import::resolve_relative_path(&opf_path, href));
}
Ok(Self {
source,
zip_index,
metadata,
toc,
landmarks,
spine,
spine_paths,
assets,
path_to_chapter,
anchor_map: RwLock::new(HashMap::new()),
css_cache: RwLock::new(HashMap::new()),
})
}
fn read_entry(&self, path: &str) -> crate::Result<Vec<u8>> {
read_entry(&self.source, &self.zip_index, path)
}
}
fn read_entry(
source: &Arc<dyn ByteSource>,
index: &HashMap<String, ZipEntryLoc>,
path: &str,
) -> crate::Result<Vec<u8>> {
let loc = index.get(path).ok_or_else(|| crate::Error::NotFound {
what: format!("{} (in EPUB archive)", path),
})?;
let compressed = source.read_at(loc.data_offset, loc.compressed_size as usize)?;
match loc.compression {
0 => Ok(compressed), 8 => {
let out = crate::util::bounded_inflate(
&compressed,
loc.uncompressed_size,
crate::util::MAX_DECOMPRESSED_ENTRY,
)?;
Ok(out)
}
method => Err(crate::Error::Malformed {
format: crate::Format::Epub,
context: format!("unsupported compression method: {}", method),
}),
}
}
fn compression_to_u16(method: zip::CompressionMethod) -> u16 {
match method {
zip::CompressionMethod::Stored => 0,
zip::CompressionMethod::Deflated => 8,
_ => 255,
}
}
fn prepend_base_to_toc(entries: &[TocEntry], base: &str) -> Vec<TocEntry> {
entries
.iter()
.map(|entry| {
let href = if entry.href.is_empty() {
entry.href.clone()
} else if entry.href.starts_with('#') {
crate::util::percent_decode_href(&entry.href).into_owned()
} else {
crate::import::resolve_relative_path(base, &entry.href)
};
TocEntry {
title: entry.title.clone(),
href,
children: prepend_base_to_toc(&entry.children, base),
play_order: entry.play_order,
target: None,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prepend_base_to_toc_simple() {
let entries = vec![
TocEntry::new("Chapter 1", "text/ch1.xhtml"),
TocEntry::new("Chapter 2", "text/ch2.xhtml"),
];
let result = prepend_base_to_toc(&entries, "OEBPS/");
assert_eq!(result.len(), 2);
assert_eq!(result[0].href, "OEBPS/text/ch1.xhtml");
assert_eq!(result[1].href, "OEBPS/text/ch2.xhtml");
}
#[test]
fn test_prepend_base_to_toc_with_fragments() {
let entries = vec![
TocEntry::new("Section 1", "text/ch1.xhtml#section1"),
TocEntry::new("Section 2", "text/ch1.xhtml#section2"),
];
let result = prepend_base_to_toc(&entries, "epub/");
assert_eq!(result[0].href, "epub/text/ch1.xhtml#section1");
assert_eq!(result[1].href, "epub/text/ch1.xhtml#section2");
}
#[test]
fn test_prepend_base_to_toc_preserves_anchor_only() {
let entries = vec![
TocEntry::new("Internal Link", "#footnote1"),
TocEntry::new("Empty", ""),
];
let result = prepend_base_to_toc(&entries, "OEBPS/");
assert_eq!(result[0].href, "#footnote1");
assert_eq!(result[1].href, "");
}
#[test]
fn test_prepend_base_to_toc_nested() {
let mut parent = TocEntry::new("Part I", "text/part1.xhtml");
parent.children = vec![
TocEntry::new("Chapter 1", "text/ch1.xhtml"),
TocEntry::new("Chapter 2", "text/ch2.xhtml"),
];
let entries = vec![parent];
let result = prepend_base_to_toc(&entries, "epub/");
assert_eq!(result[0].href, "epub/text/part1.xhtml");
assert_eq!(result[0].children.len(), 2);
assert_eq!(result[0].children[0].href, "epub/text/ch1.xhtml");
assert_eq!(result[0].children[1].href, "epub/text/ch2.xhtml");
}
#[test]
fn test_prepend_base_to_toc_deeply_nested() {
let grandchild = TocEntry::new("Section", "text/ch1.xhtml#sec1");
let mut child = TocEntry::new("Chapter 1", "text/ch1.xhtml");
child.children = vec![grandchild];
let mut parent = TocEntry::new("Part I", "text/part1.xhtml");
parent.children = vec![child];
let entries = vec![parent];
let result = prepend_base_to_toc(&entries, "content/");
assert_eq!(result[0].href, "content/text/part1.xhtml");
assert_eq!(result[0].children[0].href, "content/text/ch1.xhtml");
assert_eq!(
result[0].children[0].children[0].href,
"content/text/ch1.xhtml#sec1"
);
}
}