use std::collections::BTreeSet; use std::fs::File; use std::io::Write; use std::path::Path; use chrono::Utc; use quick_xml::escape::escape; use zip::CompressionMethod; use zip::write::{SimpleFileOptions, ZipWriter}; use crate::error::{EbookmError, Result}; use crate::pipeline::BuiltEntry; pub fn write_epub( manifest: &crate::manifest::Manifest, built: &[BuiltEntry], output_path: &Path, cover_bytes: Option<(String, Vec)>, ) -> Result<()> { if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent).map_err(|source| EbookmError::Io { path: parent.display().to_string(), source, })?; } let file = File::create(output_path).map_err(|source| EbookmError::Io { path: output_path.display().to_string(), source, })?; let mut zip = ZipWriter::new(file); let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); zip.start_file("mimetype", stored) .map_err(|error| EbookmError::Epub { message: error.to_string(), })?; zip.write_all(b"application/epub+zip") .map_err(|error| EbookmError::Epub { message: error.to_string(), })?; let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); write_file(&mut zip, "META-INF/container.xml", deflated, CONTAINER_XML)?; write_file(&mut zip, "OEBPS/styles/book.css", deflated, DEFAULT_STYLES)?; let nav = build_nav(manifest, built); let ncx = build_ncx(manifest, built); let opf = build_opf( manifest, built, cover_bytes.as_ref().map(|(href, _)| href.as_str()), ); write_file(&mut zip, "OEBPS/nav.xhtml", deflated, &nav)?; write_file(&mut zip, "OEBPS/toc.ncx", deflated, &ncx)?; write_file(&mut zip, "OEBPS/content.opf", deflated, &opf)?; if let Some((href, bytes)) = cover_bytes { write_bytes(&mut zip, &format!("OEBPS/{href}"), deflated, &bytes)?; } let mut seen_assets = BTreeSet::new(); for entry in built { write_file( &mut zip, &format!("OEBPS/text/{}.xhtml", entry.id), deflated, &entry.chapter.xhtml, )?; for asset in &entry.assets { if seen_assets.insert(asset.href.clone()) { write_bytes( &mut zip, &format!("OEBPS/{}", asset.href), deflated, &asset.bytes, )?; } } } zip.finish().map_err(|error| EbookmError::Epub { message: error.to_string(), })?; Ok(()) } fn write_file( zip: &mut ZipWriter, path: &str, options: SimpleFileOptions, contents: &str, ) -> Result<()> { write_bytes(zip, path, options, contents.as_bytes()) } fn write_bytes( zip: &mut ZipWriter, path: &str, options: SimpleFileOptions, contents: &[u8], ) -> Result<()> { zip.start_file(path, options) .map_err(|error| EbookmError::Epub { message: error.to_string(), })?; zip.write_all(contents).map_err(|error| EbookmError::Epub { message: error.to_string(), })?; Ok(()) } fn build_nav(manifest: &crate::manifest::Manifest, built: &[BuiltEntry]) -> String { let mut nav_points = String::new(); for section in &manifest.sections { let section_target = section .entries .iter() .find_map(|entry_id| built.iter().find(|candidate| &candidate.id == entry_id)) .map(|entry| format!("text/{}.xhtml", entry.id)); nav_points.push_str("
  • "); if let Some(target) = section_target { nav_points.push_str(&format!( "{}
      ", escape(&target), escape(§ion.title) )); } else { nav_points.push_str(&format!("{}
        ", escape(§ion.title))); } for entry_id in §ion.entries { if let Some(entry) = built.iter().find(|candidate| &candidate.id == entry_id) { if entry.hidden_from_toc { continue; } nav_points.push_str(&format!( "
      1. {}
      2. ", escape(&entry.id), escape(&entry.chapter.nav_title) )); } } nav_points.push_str("
      "); } format!( r#" {} "#, escape(&manifest.book.title), escape(&manifest.book.title), nav_points ) } fn build_ncx(manifest: &crate::manifest::Manifest, built: &[BuiltEntry]) -> String { let mut play_order = 1usize; let mut nav_points = String::new(); for section in &manifest.sections { let section_entries: Vec<_> = section .entries .iter() .filter_map(|entry_id| built.iter().find(|candidate| &candidate.id == entry_id)) .filter(|entry| !entry.hidden_from_toc) .collect(); if section_entries.is_empty() { continue; } let section_play_order = play_order; play_order += 1; let section_target = section_entries[0] .section_anchor .as_ref() .map(|anchor| format!("text/{}.xhtml#{}", section_entries[0].id, anchor)) .unwrap_or_else(|| format!("text/{}.xhtml", section_entries[0].id)); let mut child_points = String::new(); for entry in §ion_entries { child_points.push_str(&format!( "{}", escape(&xml_id("nav", &entry.id)), play_order, escape(&entry.chapter.nav_title), escape(&entry.id) )); play_order += 1; } nav_points.push_str(&format!( "{}{}", escape(&xml_id("section", §ion.id)), section_play_order, escape(§ion.title), escape(§ion_target), child_points )); } format!( r#" {} {} "#, escape(&manifest.book.identifier), escape(&manifest.book.title), nav_points ) } fn build_opf( manifest: &crate::manifest::Manifest, built: &[BuiltEntry], cover_href: Option<&str>, ) -> String { let mut manifest_items = String::from( r#" "#, ); let mut spine_items = String::new(); for entry in built { manifest_items.push_str(&format!( "", escape(&xml_id("entry", &entry.id)), escape(&entry.id) )); spine_items.push_str(&format!( "", escape(&xml_id("entry", &entry.id)) )); for asset in &entry.assets { manifest_items.push_str(&format!( "", escape(&xml_id("asset", &asset.id)), escape(&asset.href), escape(&asset.media_type) )); } } if let Some(cover_href) = cover_href { manifest_items.push_str(&format!( "", escape(cover_href), escape(&media_type_from_href(cover_href)) )); } let author = manifest .book .author .clone() .unwrap_or_else(|| "Unknown".to_string()); let description = manifest.book.description.clone().unwrap_or_default(); let modified = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); format!( r#" {} {} {} {} {} {} {} {} "#, escape(&manifest.book.identifier), escape(&manifest.book.title), escape(&author), escape(&manifest.book.language), escape(&description), escape(&modified), manifest_items, spine_items ) } fn xml_id(prefix: &str, value: &str) -> String { let mut id = String::with_capacity(prefix.len() + value.len() + 1); id.push_str(prefix); id.push('-'); for ch in value.chars() { if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') { id.push(ch); } else { id.push('-'); } } id } fn media_type_from_href(href: &str) -> String { match href.rsplit('.').next().map(|ext| ext.to_ascii_lowercase()) { Some(extension) => match extension.as_str() { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "gif" => "image/gif", "svg" => "image/svg+xml", "webp" => "image/webp", "avif" => "image/avif", _ => "application/octet-stream", } .to_string(), None => "application/octet-stream".to_string(), } } const CONTAINER_XML: &str = r#" "#; const DEFAULT_STYLES: &str = r#"body { font-family: serif; line-height: 1.5; margin: 5%; } h1 { margin-bottom: 0.2em; } .chapter-meta { color: #555; font-size: 0.9em; margin-bottom: 1.5em; } img { max-width: 100%; height: auto; } a { color: #0b4f7a; text-decoration: none; } "#;