use std::io::{self, Seek, Write};
use zip::CompressionMethod;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
use crate::model::{Book, TocEntry};
use crate::util::guess_media_type;
use super::html_synth::escape_xml;
use super::Exporter;
#[derive(Debug, Clone, Default)]
pub struct EpubConfig {
pub compression_level: Option<u32>,
pub normalize: bool,
}
pub struct EpubExporter {
config: EpubConfig,
}
impl EpubExporter {
pub fn new() -> Self {
Self {
config: EpubConfig::default(),
}
}
pub fn with_config(mut self, config: EpubConfig) -> Self {
self.config = config;
self
}
}
impl Default for EpubExporter {
fn default() -> Self {
Self::new()
}
}
impl Exporter for EpubExporter {
fn export<W: Write + Seek>(&self, book: &Book, writer: &mut W) -> crate::Result<()> {
if self.config.normalize || book.requires_normalized_export() {
Ok(self.export_normalized(book, writer)?)
} else {
Ok(self.export_raw(book, writer)?)
}
}
}
impl EpubExporter {
fn export_raw<W: Write + Seek>(&self, book: &Book, writer: &mut W) -> crate::Result<()> {
book.resolve_toc();
let mut zip = ZipWriter::new(writer);
let compression_level = self.config.compression_level.unwrap_or(6);
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
let deflated = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.compression_level(Some(compression_level as i64));
zip.start_file("mimetype", stored).map_err(io_error)?;
zip.write_all(b"application/epub+zip")?;
zip.start_file("META-INF/container.xml", deflated)
.map_err(io_error)?;
zip.write_all(CONTAINER_XML)?;
let spine = book.spine();
let mut manifest_items: Vec<ManifestItem> = Vec::new();
let mut spine_refs: Vec<String> = Vec::new();
let chapter_paths: std::collections::HashSet<String> = spine
.iter()
.map(|entry| {
format!(
"OEBPS/{}",
sanitize_path(book.source_id(entry.id).unwrap_or("unknown.xhtml"))
)
})
.collect();
for (i, entry) in spine.iter().enumerate() {
let source_path = book.source_id(entry.id).unwrap_or("unknown.xhtml");
let id = format!("chapter_{}", i);
manifest_items.push(ManifestItem {
id: id.clone(),
href: format!("OEBPS/{}", sanitize_path(source_path)),
media_type: "application/xhtml+xml",
properties: None,
});
spine_refs.push(id);
}
let assets = book.list_assets();
for (i, asset_path) in assets.iter().enumerate() {
if is_source_packaging(asset_path) {
continue;
}
let href = format!("OEBPS/{}", sanitize_path(asset_path));
if chapter_paths.contains(&href) {
continue;
}
let media_type = asset_media_type(book, asset_path);
let id = format!("asset_{}", i);
manifest_items.push(ManifestItem {
id,
href,
media_type,
properties: None,
});
}
mark_cover_image(&mut manifest_items, book.metadata().cover_image.as_deref());
let nav_zip_path = if manifest_items.iter().any(|m| m.href == "OEBPS/nav.xhtml") {
"OEBPS/boko-nav.xhtml"
} else {
"OEBPS/nav.xhtml"
};
manifest_items.push(ManifestItem {
id: "nav".to_string(),
href: nav_zip_path.to_string(),
media_type: "application/xhtml+xml",
properties: Some("nav"),
});
let first_chapter_href = spine
.first()
.map(|entry| sanitize_path(book.source_id(entry.id).unwrap_or("unknown.xhtml")));
let toc = toc_or_fallback(
book.toc(),
&book.metadata().title,
first_chapter_href.as_deref(),
);
let opf = generate_opf(book.metadata(), &manifest_items, &spine_refs);
zip.start_file("OEBPS/content.opf", deflated)
.map_err(io_error)?;
zip.write_all(opf.as_bytes())?;
let ncx = generate_ncx(book.metadata(), &toc);
zip.start_file("OEBPS/toc.ncx", deflated)
.map_err(io_error)?;
zip.write_all(ncx.as_bytes())?;
let nav = generate_nav(&book.metadata().title, &toc);
zip.start_file(nav_zip_path, deflated).map_err(io_error)?;
zip.write_all(nav.as_bytes())?;
for entry in spine {
let source_path = book
.source_id(entry.id)
.unwrap_or("unknown.xhtml")
.to_string();
let content = book.load_raw(entry.id)?;
let zip_path = format!("OEBPS/{}", sanitize_path(&source_path));
zip.start_file(&zip_path, deflated).map_err(io_error)?;
zip.write_all(&content)?;
}
for asset_path in assets {
if is_source_packaging(asset_path) {
continue;
}
let zip_path = format!("OEBPS/{}", sanitize_path(asset_path));
if chapter_paths.contains(&zip_path) {
continue;
}
let content = book.load_asset(asset_path)?;
let opts = asset_options(&zip_path, &content, stored, deflated);
zip.start_file(&zip_path, opts).map_err(io_error)?;
zip.write_all(&content)?;
}
zip.finish().map_err(io_error)?;
Ok(())
}
fn export_normalized<W: Write + Seek>(&self, book: &Book, writer: &mut W) -> io::Result<()> {
use super::normalize::normalize_book;
book.resolve_toc();
let all_assets = book.list_assets();
let content = normalize_book(book)?;
let mut zip = ZipWriter::new(writer);
let compression_level = self.config.compression_level.unwrap_or(6);
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
let deflated = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.compression_level(Some(compression_level as i64));
zip.start_file("mimetype", stored).map_err(io_error)?;
zip.write_all(b"application/epub+zip")?;
zip.start_file("META-INF/container.xml", deflated)
.map_err(io_error)?;
zip.write_all(CONTAINER_XML)?;
let mut manifest_items: Vec<ManifestItem> = Vec::new();
let mut spine_refs: Vec<String> = Vec::new();
manifest_items.push(ManifestItem {
id: "stylesheet".to_string(),
href: "OEBPS/style.css".to_string(),
media_type: "text/css",
properties: None,
});
for (i, _) in content.chapters.iter().enumerate() {
let id = format!("chapter_{}", i);
let href = format!("OEBPS/chapter_{}.xhtml", i);
manifest_items.push(ManifestItem {
id: id.clone(),
href,
media_type: "application/xhtml+xml",
properties: None,
});
spine_refs.push(id);
}
manifest_items.push(ManifestItem {
id: "nav".to_string(),
href: "OEBPS/nav.xhtml".to_string(),
media_type: "application/xhtml+xml",
properties: Some("nav"),
});
for (asset_idx, asset_path) in content.assets.iter().enumerate() {
let media_type = asset_media_type(book, asset_path);
let id = format!("asset_{}", asset_idx);
let href = format!("OEBPS/{}", sanitize_path(asset_path));
manifest_items.push(ManifestItem {
id,
href,
media_type,
properties: None,
});
}
let mut extra_font_idx = 0;
for asset_path in all_assets {
if !asset_path.starts_with("fonts/") {
continue;
}
if content.assets.contains(asset_path) {
continue;
}
manifest_items.push(ManifestItem {
id: format!("font_{}", extra_font_idx),
href: format!("OEBPS/{}", sanitize_path(asset_path)),
media_type: guess_media_type(asset_path),
properties: None,
});
extra_font_idx += 1;
}
mark_cover_image(&mut manifest_items, book.metadata().cover_image.as_deref());
let opf = generate_opf(book.metadata(), &manifest_items, &spine_refs);
zip.start_file("OEBPS/content.opf", deflated)
.map_err(io_error)?;
zip.write_all(opf.as_bytes())?;
let rewritten_toc = toc_or_fallback(
&content.rewrite_toc(book.toc()),
&book.metadata().title,
(!content.chapters.is_empty()).then_some("chapter_0.xhtml"),
);
let ncx = generate_ncx(book.metadata(), &rewritten_toc);
zip.start_file("OEBPS/toc.ncx", deflated)
.map_err(io_error)?;
zip.write_all(ncx.as_bytes())?;
let nav = generate_nav(&book.metadata().title, &rewritten_toc);
zip.start_file("OEBPS/nav.xhtml", deflated)
.map_err(io_error)?;
zip.write_all(nav.as_bytes())?;
zip.start_file("OEBPS/style.css", deflated)
.map_err(io_error)?;
zip.write_all(content.css.as_bytes())?;
for (i, chapter) in content.chapters.iter().enumerate() {
let zip_path = format!("OEBPS/chapter_{}.xhtml", i);
zip.start_file(&zip_path, deflated).map_err(io_error)?;
zip.write_all(chapter.document.as_bytes())?;
}
for asset_path in &content.assets {
let zip_path = format!("OEBPS/{}", sanitize_path(asset_path));
if let Ok(data) = book.load_asset(asset_path) {
let opts = asset_options(&zip_path, &data, stored, deflated);
zip.start_file(&zip_path, opts).map_err(io_error)?;
zip.write_all(&data)?;
}
}
for asset_path in all_assets {
if !asset_path.starts_with("fonts/") {
continue;
}
if content.assets.contains(asset_path) {
continue;
}
let zip_path = format!("OEBPS/{}", sanitize_path(asset_path));
if let Ok(data) = book.load_asset(asset_path) {
let opts = asset_options(&zip_path, &data, stored, deflated);
zip.start_file(&zip_path, opts).map_err(io_error)?;
zip.write_all(&data)?;
}
}
zip.finish().map_err(io_error)?;
Ok(())
}
}
fn io_error<E: std::error::Error + Send + Sync + 'static>(e: E) -> io::Error {
io::Error::other(e)
}
const CONTAINER_XML: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
<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>
"#;
struct ManifestItem {
id: String,
href: String,
media_type: &'static str,
properties: Option<&'static str>,
}
fn asset_media_type(book: &Book, path: &str) -> &'static str {
let by_ext = guess_media_type(path);
if by_ext != "application/octet-stream" {
return by_ext;
}
book.load_asset(path)
.map(|data| crate::util::detect_media_format(path, &data).mime_type())
.unwrap_or(by_ext)
}
fn is_source_packaging(path: &str) -> bool {
path == "mimetype"
|| path.starts_with("META-INF/")
|| path.ends_with(".opf")
|| path.ends_with(".ncx")
}
fn mark_cover_image(manifest_items: &mut [ManifestItem], cover_image: Option<&str>) {
let Some(cover) = cover_image else { return };
let sanitized = sanitize_path(cover);
if let Some(item) = manifest_items.iter_mut().find(|item| {
let href = item.href.strip_prefix("OEBPS/").unwrap_or(&item.href);
(href == sanitized || href.ends_with(&format!("/{sanitized}")))
&& item.media_type.starts_with("image/")
}) {
item.properties = Some("cover-image");
}
}
fn toc_or_fallback(toc: &[TocEntry], title: &str, first_href: Option<&str>) -> Vec<TocEntry> {
if !toc.is_empty() {
return toc.to_vec();
}
let Some(href) = first_href else {
return Vec::new();
};
let label = if title.is_empty() { "Start" } else { title };
vec![TocEntry::new(label, href)]
}
fn asset_options(
path: &str,
data: &[u8],
stored: SimpleFileOptions,
deflated: SimpleFileOptions,
) -> SimpleFileOptions {
let fmt = crate::util::detect_media_format(path, data);
if fmt.is_image() || fmt.is_font() {
stored
} else {
deflated
}
}
fn generate_opf(
metadata: &crate::model::Metadata,
manifest: &[ManifestItem],
spine_refs: &[String],
) -> String {
let mut opf = String::new();
opf.push_str(
r#"<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="BookId">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
"#,
);
let mut next_id = 1;
let title_id = format!("title{}", next_id);
next_id += 1;
opf.push_str(&format!(
" <dc:title id=\"{}\">{}</dc:title>\n",
title_id,
escape_xml(&metadata.title)
));
if let Some(ref title_sort) = metadata.title_sort {
opf.push_str(&format!(
" <meta refines=\"#{}\" property=\"file-as\">{}</meta>\n",
title_id,
escape_xml(title_sort)
));
}
for (i, author) in metadata.authors.iter().enumerate() {
let creator_id = format!("creator{}", next_id);
next_id += 1;
opf.push_str(&format!(
" <dc:creator id=\"{}\">{}</dc:creator>\n",
creator_id,
escape_xml(author)
));
if i == 0
&& let Some(ref author_sort) = metadata.author_sort
{
opf.push_str(&format!(
" <meta refines=\"#{}\" property=\"file-as\">{}</meta>\n",
creator_id,
escape_xml(author_sort)
));
}
}
if !metadata.language.is_empty() {
opf.push_str(&format!(
" <dc:language>{}</dc:language>\n",
escape_xml(&metadata.language)
));
} else {
opf.push_str(" <dc:language>en</dc:language>\n");
}
if !metadata.identifier.is_empty() {
opf.push_str(&format!(
" <dc:identifier id=\"BookId\">{}</dc:identifier>\n",
escape_xml(&metadata.identifier)
));
} else {
opf.push_str(" <dc:identifier id=\"BookId\">urn:uuid:00000000-0000-0000-0000-000000000000</dc:identifier>\n");
}
if let Some(ref modified) = metadata.modified_date {
opf.push_str(&format!(
" <meta property=\"dcterms:modified\">{}</meta>\n",
escape_xml(modified)
));
} else {
opf.push_str(" <meta property=\"dcterms:modified\">2024-01-01T00:00:00Z</meta>\n");
}
for contrib in &metadata.contributors {
let contrib_id = format!("contrib{}", next_id);
next_id += 1;
opf.push_str(&format!(
" <dc:contributor id=\"{}\">{}</dc:contributor>\n",
contrib_id,
escape_xml(&contrib.name)
));
if let Some(ref role) = contrib.role {
opf.push_str(&format!(
" <meta refines=\"#{}\" property=\"role\" scheme=\"marc:relators\">{}</meta>\n",
contrib_id,
escape_xml(role)
));
}
if let Some(ref file_as) = contrib.file_as {
opf.push_str(&format!(
" <meta refines=\"#{}\" property=\"file-as\">{}</meta>\n",
contrib_id,
escape_xml(file_as)
));
}
}
if let Some(ref coll) = metadata.collection {
let coll_id = format!("collection{}", next_id);
next_id += 1;
opf.push_str(&format!(
" <meta property=\"belongs-to-collection\" id=\"{}\">{}</meta>\n",
coll_id,
escape_xml(&coll.name)
));
if let Some(ref coll_type) = coll.collection_type {
opf.push_str(&format!(
" <meta refines=\"#{}\" property=\"collection-type\">{}</meta>\n",
coll_id,
escape_xml(coll_type)
));
}
if let Some(pos) = coll.position {
let pos_str = if pos.fract() == 0.0 {
format!("{}", pos as i64)
} else {
format!("{}", pos)
};
opf.push_str(&format!(
" <meta refines=\"#{}\" property=\"group-position\">{}</meta>\n",
coll_id, pos_str
));
}
}
let _ = next_id;
if let Some(ref publisher) = metadata.publisher {
opf.push_str(&format!(
" <dc:publisher>{}</dc:publisher>\n",
escape_xml(publisher)
));
}
if let Some(ref description) = metadata.description {
opf.push_str(&format!(
" <dc:description>{}</dc:description>\n",
escape_xml(description)
));
}
for subject in &metadata.subjects {
opf.push_str(&format!(
" <dc:subject>{}</dc:subject>\n",
escape_xml(subject)
));
}
if let Some(ref date) = metadata.date {
opf.push_str(&format!(" <dc:date>{}</dc:date>\n", escape_xml(date)));
}
if let Some(ref rights) = metadata.rights {
opf.push_str(&format!(
" <dc:rights>{}</dc:rights>\n",
escape_xml(rights)
));
}
if let Some(cover_item) = manifest.iter().find(|item| {
item.properties
.is_some_and(|p| p.split_ascii_whitespace().any(|p| p == "cover-image"))
}) {
opf.push_str(&format!(
" <meta name=\"cover\" content=\"{}\"/>\n",
escape_xml(&cover_item.id)
));
}
opf.push_str(" </metadata>\n");
opf.push_str(" <manifest>\n");
opf.push_str(
" <item id=\"ncx\" href=\"toc.ncx\" media-type=\"application/x-dtbncx+xml\"/>\n",
);
for item in manifest {
let href = item.href.strip_prefix("OEBPS/").unwrap_or(&item.href);
let properties = match item.properties {
Some(p) => format!(" properties=\"{p}\""),
None => String::new(),
};
opf.push_str(&format!(
" <item id=\"{}\" href=\"{}\" media-type=\"{}\"{}/>\n",
escape_xml(&item.id),
escape_xml(href),
escape_xml(item.media_type),
properties,
));
}
opf.push_str(" </manifest>\n");
match metadata.page_progression_direction.as_deref() {
Some(dir @ ("rtl" | "ltr")) => {
opf.push_str(&format!(
" <spine toc=\"ncx\" page-progression-direction=\"{dir}\">\n"
));
}
_ => opf.push_str(" <spine toc=\"ncx\">\n"),
}
for id in spine_refs {
opf.push_str(&format!(" <itemref idref=\"{}\"/>\n", escape_xml(id)));
}
opf.push_str(" </spine>\n");
opf.push_str("</package>\n");
opf
}
fn generate_ncx(metadata: &crate::model::Metadata, toc: &[TocEntry]) -> String {
let mut ncx = String::new();
ncx.push_str(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content=""#,
);
ncx.push_str(&escape_xml(&metadata.identifier));
ncx.push_str(&format!(
r#""/>
<meta name="dtb:depth" content="{}"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle>
<text>"#,
toc_depth(toc)
));
ncx.push_str(&escape_xml(&metadata.title));
ncx.push_str(
r#"</text>
</docTitle>
<navMap>
"#,
);
let mut play_order = 1;
write_nav_points(&mut ncx, toc, &mut play_order, 2);
ncx.push_str(" </navMap>\n</ncx>\n");
ncx
}
fn toc_depth(entries: &[TocEntry]) -> usize {
fn depth_of(entries: &[TocEntry], depth: usize) -> usize {
if entries.is_empty() || depth > crate::util::MAX_TREE_DEPTH {
return depth;
}
entries
.iter()
.map(|e| depth_of(&e.children, depth + 1))
.max()
.unwrap_or(depth)
}
depth_of(entries, 0).max(1)
}
fn write_nav_points(ncx: &mut String, entries: &[TocEntry], play_order: &mut usize, indent: usize) {
if indent > crate::util::MAX_TREE_DEPTH {
return;
}
let indent_str = " ".repeat(indent);
for entry in entries {
ncx.push_str(&format!(
"{}<navPoint id=\"navPoint-{}\" playOrder=\"{}\">\n",
indent_str, play_order, play_order
));
ncx.push_str(&format!(
"{} <navLabel><text>{}</text></navLabel>\n",
indent_str,
escape_xml(&entry.title)
));
ncx.push_str(&format!(
"{} <content src=\"{}\"/>\n",
indent_str,
escape_xml(&entry.href)
));
*play_order += 1;
if !entry.children.is_empty() {
write_nav_points(ncx, &entry.children, play_order, indent + 1);
}
ncx.push_str(&format!("{}</navPoint>\n", indent_str));
}
}
fn generate_nav(title: &str, toc: &[TocEntry]) -> String {
let mut doc = String::new();
doc.push_str(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE html>\n\
<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n\
<head>\n <meta charset=\"utf-8\"/>\n <title>",
);
doc.push_str(&escape_xml(title));
doc.push_str("</title>\n</head>\n<body>\n <nav epub:type=\"toc\" id=\"toc\">\n");
if !toc.is_empty() {
write_nav_list(&mut doc, toc, 2);
}
doc.push_str(" </nav>\n</body>\n</html>\n");
doc
}
fn write_nav_list(doc: &mut String, entries: &[TocEntry], indent: usize) {
if indent > crate::util::MAX_TREE_DEPTH {
return;
}
let pad = " ".repeat(indent);
doc.push_str(&pad);
doc.push_str("<ol>\n");
for entry in entries {
doc.push_str(&pad);
doc.push_str(" <li>");
if entry.href.is_empty() {
doc.push_str("<span>");
doc.push_str(&escape_xml(&entry.title));
doc.push_str("</span>");
} else {
doc.push_str("<a href=\"");
doc.push_str(&escape_xml(&entry.href));
doc.push_str("\">");
doc.push_str(&escape_xml(&entry.title));
doc.push_str("</a>");
}
if entry.children.is_empty() {
doc.push_str("</li>\n");
} else {
doc.push('\n');
write_nav_list(doc, &entry.children, indent + 2);
doc.push_str(&pad);
doc.push_str(" </li>\n");
}
}
doc.push_str(&pad);
doc.push_str("</ol>\n");
}
fn sanitize_path(path: &str) -> String {
path.trim_start_matches('/')
.replace('\\', "/")
.replace("//", "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_xml() {
assert_eq!(escape_xml("Hello & World"), "Hello & World");
assert_eq!(escape_xml("<tag>"), "<tag>");
assert_eq!(escape_xml("\"quoted\""), ""quoted"");
}
#[test]
fn test_sanitize_path() {
assert_eq!(sanitize_path("/path/to/file.xhtml"), "path/to/file.xhtml");
assert_eq!(sanitize_path("path\\to\\file.xhtml"), "path/to/file.xhtml");
}
#[test]
fn test_guess_media_type() {
assert_eq!(guess_media_type("file.xhtml"), "application/xhtml+xml");
assert_eq!(guess_media_type("style.css"), "text/css");
assert_eq!(guess_media_type("image.jpg"), "image/jpeg");
assert_eq!(guess_media_type("font.woff2"), "font/woff2");
}
}