#[cfg(feature = "no-indexmap")]
use std::collections::HashMap;
use std::{
cmp::Reverse,
env,
fs::{self, File},
io::{BufReader, Cursor, Read, Seek},
marker::PhantomData,
path::{Path, PathBuf},
};
use chrono::{SecondsFormat, Utc};
#[cfg(not(feature = "no-indexmap"))]
use indexmap::IndexMap;
use infer::Infer;
use log::warn;
use quick_xml::{
Writer,
events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
};
use walkdir::WalkDir;
use zip::{CompressionMethod, ZipWriter, write::FileOptions};
#[cfg(feature = "content-builder")]
use crate::builder::content::ContentBuilder;
use crate::{
epub::EpubDoc,
error::{EpubBuilderError, EpubError},
types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
utils::{
ELEMENT_IN_DC_NAMESPACE, check_realtive_link_leakage, local_time, remove_leading_slash,
},
};
#[cfg(feature = "content-builder")]
pub mod content;
type XmlWriter = Writer<Cursor<Vec<u8>>>;
#[cfg_attr(test, derive(Debug))]
pub struct EpubVersion3;
#[derive(Debug)]
pub struct RootfileBuilder {
pub(crate) rootfiles: Vec<String>,
}
impl RootfileBuilder {
pub(crate) fn new() -> Self {
Self { rootfiles: Vec::new() }
}
pub fn add(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
let rootfile = rootfile.as_ref();
if rootfile.starts_with("/") || rootfile.starts_with("../") {
return Err(EpubBuilderError::IllegalRootfilePath.into());
}
let rootfile = rootfile.strip_prefix("./").unwrap_or(rootfile);
self.rootfiles.push(rootfile.into());
Ok(self)
}
pub fn clear(&mut self) -> &mut Self {
self.rootfiles.clear();
self
}
pub(crate) fn is_empty(&self) -> bool {
self.rootfiles.is_empty()
}
pub(crate) fn first(&self) -> Option<&String> {
self.rootfiles.first()
}
pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
[
("version", "1.0"),
("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
],
)))?;
writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
for rootfile in &self.rootfiles {
writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
("full-path", rootfile.as_str()),
("media-type", "application/oebps-package+xml"),
])))?;
}
writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
writer.write_event(Event::End(BytesEnd::new("container")))?;
Ok(())
}
}
#[derive(Debug)]
pub struct MetadataBuilder {
pub(crate) metadata: Vec<MetadataItem>,
}
impl MetadataBuilder {
pub(crate) fn new() -> Self {
Self { metadata: Vec::new() }
}
pub fn add(&mut self, item: MetadataItem) -> &mut Self {
self.metadata.push(item);
self
}
pub fn clear(&mut self) -> &mut Self {
self.metadata.clear();
self
}
pub(crate) fn make(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
self.metadata.push(MetadataItem {
id: None,
property: "dcterms:modified".to_string(),
value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
lang: None,
refined: vec![],
});
writer.write_event(Event::Start(BytesStart::new("metadata")))?;
for metadata in &self.metadata {
let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
format!("dc:{}", metadata.property)
} else {
"meta".to_string()
};
writer.write_event(Event::Start(
BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
))?;
writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
for refinement in &metadata.refined {
writer.write_event(Event::Start(
BytesStart::new("meta").with_attributes(refinement.attributes()),
))?;
writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
writer.write_event(Event::End(BytesEnd::new("meta")))?;
}
}
writer.write_event(Event::End(BytesEnd::new("metadata")))?;
Ok(())
}
pub(crate) fn validate(&self) -> Result<(), EpubError> {
let mut has_title = false;
let mut has_language = false;
let mut has_identifier = false;
for item in &self.metadata {
match item.property.as_str() {
"title" => has_title = true,
"language" => has_language = true,
"identifier" => {
if item.id.as_ref().is_some_and(|id| id == "pub-id") {
has_identifier = true;
}
}
_ => {}
}
if has_title && has_language && has_identifier {
return Ok(());
}
}
Err(EpubBuilderError::MissingNecessaryMetadata.into())
}
}
#[derive(Debug)]
pub struct ManifestBuilder {
temp_dir: PathBuf,
rootfile: Option<String>,
#[cfg(feature = "no-indexmap")]
pub(crate) manifest: HashMap<String, ManifestItem>,
#[cfg(not(feature = "no-indexmap"))]
pub(crate) manifest: IndexMap<String, ManifestItem>,
}
impl ManifestBuilder {
pub(crate) fn new(temp_dir: impl AsRef<Path>) -> Self {
Self {
temp_dir: temp_dir.as_ref().to_path_buf(),
rootfile: None,
#[cfg(feature = "no-indexmap")]
manifest: HashMap::new(),
#[cfg(not(feature = "no-indexmap"))]
manifest: IndexMap::new(),
}
}
pub(crate) fn set_rootfile(&mut self, rootfile: impl Into<String>) {
self.rootfile = Some(rootfile.into());
}
pub fn add(
&mut self,
manifest_source: impl Into<String>,
manifest_item: ManifestItem,
) -> Result<&mut Self, EpubError> {
let manifest_source = manifest_source.into();
let source = PathBuf::from(&manifest_source);
if !source.is_file() {
return Err(EpubBuilderError::TargetIsNotFile { target_path: manifest_source }.into());
}
let extension = match source.extension() {
Some(ext) => ext.to_string_lossy().to_lowercase(),
None => String::new(),
};
let buf = fs::read(source)?;
let real_mime = match Infer::new().get(&buf) {
Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
None => {
return Err(
EpubBuilderError::UnknownFileFormat { file_path: manifest_source }.into(),
);
}
};
let target_path = normalize_manifest_path(
&self.temp_dir,
self.rootfile
.as_ref()
.ok_or(EpubBuilderError::MissingRootfile)?,
&manifest_item.path,
&manifest_item.id,
)?;
if let Some(parent_dir) = target_path.parent() {
if !parent_dir.exists() {
fs::create_dir_all(parent_dir)?
}
}
match fs::write(target_path, buf) {
Ok(_) => {
self.manifest
.insert(manifest_item.id.clone(), manifest_item.set_mime(real_mime));
Ok(self)
}
Err(err) => Err(err.into()),
}
}
pub fn clear(&mut self) -> &mut Self {
let paths = self
.manifest
.values()
.map(|manifest| &manifest.path)
.collect::<Vec<&PathBuf>>();
for path in paths {
let _ = fs::remove_file(path);
}
self.manifest.clear();
self
}
pub(crate) fn insert(
&mut self,
key: impl Into<String>,
value: ManifestItem,
) -> Option<ManifestItem> {
self.manifest.insert(key.into(), value)
}
pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
writer.write_event(Event::Start(BytesStart::new("manifest")))?;
for manifest in self.manifest.values() {
writer.write_event(Event::Empty(
BytesStart::new("item").with_attributes(manifest.attributes()),
))?;
}
writer.write_event(Event::End(BytesEnd::new("manifest")))?;
Ok(())
}
pub(crate) fn validate(&self) -> Result<(), EpubError> {
self.validate_fallback_chains()?;
self.validate_nav()?;
Ok(())
}
pub(crate) fn keys(&self) -> impl Iterator<Item = &String> {
self.manifest.keys()
}
fn validate_fallback_chains(&self) -> Result<(), EpubError> {
for (id, item) in &self.manifest {
if item.fallback.is_none() {
continue;
}
let mut fallback_chain = Vec::new();
self.validate_fallback_chain(id, &mut fallback_chain)?;
}
Ok(())
}
fn validate_fallback_chain(
&self,
manifest_id: &str,
fallback_chain: &mut Vec<String>,
) -> Result<(), EpubError> {
if fallback_chain.contains(&manifest_id.to_string()) {
fallback_chain.push(manifest_id.to_string());
return Err(EpubBuilderError::ManifestCircularReference {
fallback_chain: fallback_chain.join("->"),
}
.into());
}
let item = self.manifest.get(manifest_id).unwrap();
if let Some(fallback_id) = &item.fallback {
if !self.manifest.contains_key(fallback_id) {
return Err(EpubBuilderError::ManifestNotFound {
manifest_id: fallback_id.to_owned(),
}
.into());
}
fallback_chain.push(manifest_id.to_string());
self.validate_fallback_chain(fallback_id, fallback_chain)
} else {
Ok(())
}
}
fn validate_nav(&self) -> Result<(), EpubError> {
if self
.manifest
.values()
.filter(|&item| {
if let Some(properties) = &item.properties {
properties.split(" ").any(|property| property == "nav")
} else {
false
}
})
.count()
== 1
{
Ok(())
} else {
Err(EpubBuilderError::TooManyNavFlags.into())
}
}
}
#[derive(Debug)]
pub struct SpineBuilder {
pub(crate) spine: Vec<SpineItem>,
}
impl SpineBuilder {
pub(crate) fn new() -> Self {
Self { spine: Vec::new() }
}
pub fn add(&mut self, item: SpineItem) -> &mut Self {
self.spine.push(item);
self
}
pub fn clear(&mut self) -> &mut Self {
self.spine.clear();
self
}
pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
writer.write_event(Event::Start(BytesStart::new("spine")))?;
for spine in &self.spine {
writer.write_event(Event::Empty(
BytesStart::new("itemref").with_attributes(spine.attributes()),
))?;
}
writer.write_event(Event::End(BytesEnd::new("spine")))?;
Ok(())
}
pub(crate) fn validate(
&self,
manifest_keys: impl Iterator<Item = impl AsRef<str>>,
) -> Result<(), EpubError> {
let manifest_keys: Vec<String> = manifest_keys.map(|k| k.as_ref().to_string()).collect();
for spine in &self.spine {
if !manifest_keys.contains(&spine.idref) {
return Err(
EpubBuilderError::SpineManifestNotFound { idref: spine.idref.clone() }.into(),
);
}
}
Ok(())
}
}
#[derive(Debug)]
pub struct CatalogBuilder {
pub(crate) title: String,
pub(crate) catalog: Vec<NavPoint>,
}
impl CatalogBuilder {
pub(crate) fn new() -> Self {
Self {
title: String::new(),
catalog: Vec::new(),
}
}
pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
self.title = title.into();
self
}
pub fn add(&mut self, item: NavPoint) -> &mut Self {
self.catalog.push(item);
self
}
pub fn clear(&mut self) -> &mut Self {
self.title.clear();
self.catalog.clear();
self
}
pub(crate) fn is_empty(&self) -> bool {
self.catalog.is_empty()
}
pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
("xmlns", "http://www.w3.org/1999/xhtml"),
("xmlns:epub", "http://www.idpf.org/2007/ops"),
])))?;
writer.write_event(Event::Start(BytesStart::new("head")))?;
writer.write_event(Event::Start(BytesStart::new("title")))?;
writer.write_event(Event::Text(BytesText::new(&self.title)))?;
writer.write_event(Event::End(BytesEnd::new("title")))?;
writer.write_event(Event::End(BytesEnd::new("head")))?;
writer.write_event(Event::Start(BytesStart::new("body")))?;
writer.write_event(Event::Start(
BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
))?;
if !self.title.is_empty() {
writer.write_event(Event::Start(BytesStart::new("h1")))?;
writer.write_event(Event::Text(BytesText::new(&self.title)))?;
writer.write_event(Event::End(BytesEnd::new("h1")))?;
}
Self::make_nav(writer, &self.catalog)?;
writer.write_event(Event::End(BytesEnd::new("nav")))?;
writer.write_event(Event::End(BytesEnd::new("body")))?;
writer.write_event(Event::End(BytesEnd::new("html")))?;
Ok(())
}
fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
writer.write_event(Event::Start(BytesStart::new("ol")))?;
for nav in navgations {
writer.write_event(Event::Start(BytesStart::new("li")))?;
if let Some(path) = &nav.content {
writer.write_event(Event::Start(
BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
))?;
writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
writer.write_event(Event::End(BytesEnd::new("a")))?;
} else {
writer.write_event(Event::Start(BytesStart::new("span")))?;
writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
writer.write_event(Event::End(BytesEnd::new("span")))?;
}
if !nav.children.is_empty() {
Self::make_nav(writer, &nav.children)?;
}
writer.write_event(Event::End(BytesEnd::new("li")))?;
}
writer.write_event(Event::End(BytesEnd::new("ol")))?;
Ok(())
}
}
#[cfg(feature = "content-builder")]
#[derive(Debug)]
pub struct DocumentBuilder {
pub(crate) documents: Vec<(PathBuf, ContentBuilder)>,
}
#[cfg(feature = "content-builder")]
impl DocumentBuilder {
pub(crate) fn new() -> Self {
Self { documents: Vec::new() }
}
pub fn add(&mut self, target: impl AsRef<str>, content: ContentBuilder) -> &mut Self {
self.documents
.push((PathBuf::from(target.as_ref()), content));
self
}
pub fn clear(&mut self) -> &mut Self {
self.documents.clear();
self
}
pub fn make(
&mut self,
temp_dir: PathBuf,
rootfile: impl AsRef<str>,
) -> Result<Vec<ManifestItem>, EpubError> {
let mut buf = vec![0; 512];
let contents = std::mem::take(&mut self.documents);
let mut manifest = Vec::new();
for (target, mut content) in contents.into_iter() {
let manifest_id = content.id.clone();
let absolute_target =
normalize_manifest_path(&temp_dir, &rootfile, &target, &manifest_id)?;
let mut resources = content.make(&absolute_target)?;
let to_container_path = |p: &PathBuf| -> PathBuf {
match p.strip_prefix(&temp_dir) {
Ok(rel) => PathBuf::from("/").join(rel.to_string_lossy().replace("\\", "/")),
Err(_) => unreachable!("path MUST under temp directory"),
}
};
let path = resources.swap_remove(0);
let mut file = std::fs::File::open(&path)?;
let _ = file.read(&mut buf)?;
let extension = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let mime = match Infer::new().get(&buf) {
Some(infer) => refine_mime_type(infer.mime_type(), &extension),
None => {
return Err(EpubBuilderError::UnknownFileFormat {
file_path: path.to_string_lossy().to_string(),
}
.into());
}
}
.to_string();
manifest.push(ManifestItem {
id: manifest_id.clone(),
path: to_container_path(&path),
mime,
properties: None,
fallback: None,
});
for res in resources {
let mut file = fs::File::open(&res)?;
let _ = file.read(&mut buf)?;
let extension = res
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let mime = match Infer::new().get(&buf) {
Some(ft) => refine_mime_type(ft.mime_type(), &extension),
None => {
return Err(EpubBuilderError::UnknownFileFormat {
file_path: path.to_string_lossy().to_string(),
}
.into());
}
}
.to_string();
let file_name = res
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let res_id = format!("{}-{}", manifest_id, file_name);
manifest.push(ManifestItem {
id: res_id,
path: to_container_path(&res),
mime,
properties: None,
fallback: None,
});
}
}
Ok(manifest)
}
}
#[cfg_attr(test, derive(Debug))]
pub struct EpubBuilder<Version> {
epub_version: PhantomData<Version>,
temp_dir: PathBuf,
rootfiles: RootfileBuilder,
metadata: MetadataBuilder,
manifest: ManifestBuilder,
spine: SpineBuilder,
catalog: CatalogBuilder,
#[cfg(feature = "content-builder")]
content: DocumentBuilder,
}
impl EpubBuilder<EpubVersion3> {
pub fn new() -> Result<Self, EpubError> {
let temp_dir = env::temp_dir().join(local_time());
fs::create_dir(&temp_dir)?;
fs::create_dir(temp_dir.join("META-INF"))?;
let mime_file = temp_dir.join("mimetype");
fs::write(mime_file, "application/epub+zip")?;
Ok(EpubBuilder {
epub_version: PhantomData,
temp_dir: temp_dir.clone(),
rootfiles: RootfileBuilder::new(),
metadata: MetadataBuilder::new(),
manifest: ManifestBuilder::new(temp_dir),
spine: SpineBuilder::new(),
catalog: CatalogBuilder::new(),
#[cfg(feature = "content-builder")]
content: DocumentBuilder::new(),
})
}
pub fn add_rootfile(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
match self.rootfiles.add(rootfile) {
Ok(_) => Ok(self),
Err(err) => Err(err),
}
}
pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
let _ = self.metadata.add(item);
self
}
pub fn add_manifest(
&mut self,
manifest_source: impl Into<String>,
manifest_item: ManifestItem,
) -> Result<&mut Self, EpubError> {
if self.rootfiles.is_empty() {
return Err(EpubBuilderError::MissingRootfile.into());
} else {
self.manifest
.set_rootfile(self.rootfiles.first().expect("Unreachable"));
}
match self.manifest.add(manifest_source, manifest_item) {
Ok(_) => Ok(self),
Err(err) => Err(err),
}
}
pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
self.spine.add(item);
self
}
pub fn set_catalog_title(&mut self, title: impl Into<String>) -> &mut Self {
let _ = self.catalog.set_title(title);
self
}
pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
let _ = self.catalog.add(item);
self
}
#[cfg(feature = "content-builder")]
pub fn add_content(
&mut self,
target_path: impl AsRef<str>,
content: ContentBuilder,
) -> &mut Self {
self.content.add(target_path, content);
self
}
pub fn clear_all(&mut self) -> &mut Self {
self.rootfiles.clear();
self.metadata.clear();
self.manifest.clear();
self.spine.clear();
self.catalog.clear();
#[cfg(feature = "content-builder")]
self.content.clear();
self
}
pub fn rootfile(&mut self) -> &mut RootfileBuilder {
&mut self.rootfiles
}
pub fn metadata(&mut self) -> &mut MetadataBuilder {
&mut self.metadata
}
pub fn manifest(&mut self) -> &mut ManifestBuilder {
&mut self.manifest
}
pub fn spine(&mut self) -> &mut SpineBuilder {
&mut self.spine
}
pub fn catalog(&mut self) -> &mut CatalogBuilder {
&mut self.catalog
}
#[cfg(feature = "content-builder")]
pub fn content(&mut self) -> &mut DocumentBuilder {
&mut self.content
}
pub fn make(mut self, output_path: impl AsRef<Path>) -> Result<(), EpubError> {
self.make_container_xml()?;
self.make_navigation_document()?;
#[cfg(feature = "content-builder")]
self.make_contents()?;
self.make_opf_file()?;
self.remove_empty_dirs()?;
if let Some(parent) = output_path.as_ref().parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
let file = File::create(output_path)?;
let mut zip = ZipWriter::new(file);
let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
for entry in WalkDir::new(&self.temp_dir) {
let entry = entry?;
let path = entry.path();
let relative_path = path.strip_prefix(&self.temp_dir).unwrap();
let target_path = relative_path.to_string_lossy().replace("\\", "/");
if path.is_file() {
zip.start_file(target_path, options)?;
let mut file = File::open(path)?;
std::io::copy(&mut file, &mut zip)?;
} else if path.is_dir() {
zip.add_directory(target_path, options)?;
}
}
zip.finish()?;
Ok(())
}
pub fn build(
self,
output_path: impl AsRef<Path>,
) -> Result<EpubDoc<BufReader<File>>, EpubError> {
self.make(&output_path)?;
EpubDoc::new(output_path)
}
pub fn from<R: Read + Seek>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
let mut builder = Self::new()?;
builder.add_rootfile(doc.package_path.clone().to_string_lossy())?;
builder.metadata.metadata = doc.metadata.clone();
builder.spine.spine = doc.spine.clone();
builder.catalog.catalog = doc.catalog.clone();
builder.catalog.title = doc.catalog_title.clone();
for (_, mut manifest) in doc.manifest.clone().into_iter() {
if let Some(properties) = &manifest.properties {
if properties.contains("nav") {
continue;
}
}
manifest.path = PathBuf::from("/").join(manifest.path);
let (buf, _) = doc.get_manifest_item(&manifest.id)?; let target_path = normalize_manifest_path(
&builder.temp_dir,
builder.rootfiles.first().expect("Unreachable"),
&manifest.path,
&manifest.id,
)?;
if let Some(parent_dir) = target_path.parent() {
if !parent_dir.exists() {
fs::create_dir_all(parent_dir)?
}
}
fs::write(target_path, buf)?;
builder
.manifest
.manifest
.insert(manifest.id.clone(), manifest);
}
Ok(builder)
}
fn make_container_xml(&self) -> Result<(), EpubError> {
if self.rootfiles.is_empty() {
return Err(EpubBuilderError::MissingRootfile.into());
}
let mut writer = Writer::new(Cursor::new(Vec::new()));
self.rootfiles.make(&mut writer)?;
let file_path = self.temp_dir.join("META-INF").join("container.xml");
let file_data = writer.into_inner().into_inner();
fs::write(file_path, file_data)?;
Ok(())
}
#[cfg(feature = "content-builder")]
fn make_contents(&mut self) -> Result<(), EpubError> {
let manifest_list = self.content.make(
self.temp_dir.clone(),
self.rootfiles.first().expect("Unreachable"),
)?;
for item in manifest_list.into_iter() {
self.manifest.insert(item.id.clone(), item);
}
Ok(())
}
fn make_navigation_document(&mut self) -> Result<(), EpubError> {
if self.catalog.is_empty() {
return Err(EpubBuilderError::NavigationInfoUninitalized.into());
}
let mut writer = Writer::new(Cursor::new(Vec::new()));
self.catalog.make(&mut writer)?;
let file_path = self.temp_dir.join("nav.xhtml");
let file_data = writer.into_inner().into_inner();
fs::write(file_path, file_data)?;
self.manifest.insert(
"nav".to_string(),
ManifestItem {
id: "nav".to_string(),
path: PathBuf::from("/nav.xhtml"),
mime: "application/xhtml+xml".to_string(),
properties: Some("nav".to_string()),
fallback: None,
},
);
Ok(())
}
fn make_opf_file(&mut self) -> Result<(), EpubError> {
self.metadata.validate()?;
self.manifest.validate()?;
self.spine.validate(self.manifest.keys())?;
let mut writer = Writer::new(Cursor::new(Vec::new()));
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
("xmlns", "http://www.idpf.org/2007/opf"),
("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
("unique-identifier", "pub-id"),
("version", "3.0"),
])))?;
self.metadata.make(&mut writer)?;
self.manifest.make(&mut writer)?;
self.spine.make(&mut writer)?;
writer.write_event(Event::End(BytesEnd::new("package")))?;
let file_path = self
.temp_dir
.join(self.rootfiles.first().expect("Unreachable"));
let file_data = writer.into_inner().into_inner();
fs::write(file_path, file_data)?;
Ok(())
}
fn remove_empty_dirs(&self) -> Result<(), EpubError> {
let mut dirs = WalkDir::new(self.temp_dir.as_path())
.min_depth(1)
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_dir())
.map(|entry| entry.into_path())
.collect::<Vec<PathBuf>>();
dirs.sort_by_key(|p| Reverse(p.components().count()));
for dir in dirs {
if fs::read_dir(&dir)?.next().is_none() {
fs::remove_dir(dir)?;
}
}
Ok(())
}
}
impl<Version> Drop for EpubBuilder<Version> {
fn drop(&mut self) {
if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
warn!("{}", err);
};
}
}
fn refine_mime_type<'a>(infer_mime: &'a str, extension: &'a str) -> &'a str {
match (infer_mime, extension) {
("text/xml", "xhtml")
| ("application/xml", "xhtml")
| ("text/xml", "xht")
| ("application/xml", "xht") => "application/xhtml+xml",
("text/xml", "opf") | ("application/xml", "opf") => "application/oebps-package+xml",
("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml",
("application/zip", "epub") => "application/epub+zip",
("text/plain", "css") => "text/css",
("text/plain", "js") => "application/javascript",
("text/plain", "json") => "application/json",
("text/plain", "svg") => "image/svg+xml",
_ => infer_mime,
}
}
fn normalize_manifest_path<TempD: AsRef<Path>, S: AsRef<str>, P: AsRef<Path>>(
temp_dir: TempD,
rootfile: S,
path: P,
id: &str,
) -> Result<PathBuf, EpubError> {
let opf_path = PathBuf::from(rootfile.as_ref());
let basic_path = remove_leading_slash(opf_path.parent().unwrap());
let mut target_path = if path.as_ref().starts_with("../") {
check_realtive_link_leakage(
temp_dir.as_ref().to_path_buf(),
basic_path.to_path_buf(),
&path.as_ref().to_string_lossy(),
)
.map(PathBuf::from)
.ok_or_else(|| EpubError::RelativeLinkLeakage {
path: path.as_ref().to_string_lossy().to_string(),
})?
} else if let Ok(path) = path.as_ref().strip_prefix("/") {
temp_dir.as_ref().join(path)
} else if path.as_ref().starts_with("./") {
Err(EpubBuilderError::IllegalManifestPath { manifest_id: id.to_string() })?
} else {
temp_dir.as_ref().join(basic_path).join(path)
};
#[cfg(windows)]
{
target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
}
Ok(target_path)
}
#[cfg(test)]
mod tests {
use std::{env, fs, path::PathBuf};
use crate::{
builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
epub::EpubDoc,
error::{EpubBuilderError, EpubError},
types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
utils::local_time,
};
mod test_helpers {
use super::*;
pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
builder.add_metadata(MetadataItem::new("title", "Test Book"));
builder.add_metadata(MetadataItem::new("language", "en"));
builder.add_metadata(
MetadataItem::new("identifier", "urn:isbn:1234567890")
.with_id("pub-id")
.build(),
);
builder
}
pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
let mut builder = create_basic_builder();
builder.add_catalog_item(NavPoint::new("Chapter"));
builder.add_spine(SpineItem::new("test"));
builder
}
}
mod epub_builder_tests {
use super::*;
#[test]
fn test_epub_builder_new() {
let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
assert!(builder.temp_dir.exists());
assert!(builder.rootfiles.is_empty());
assert!(builder.metadata.metadata.is_empty());
assert!(builder.manifest.manifest.is_empty());
assert!(builder.spine.spine.is_empty());
assert!(builder.catalog.title.is_empty());
assert!(builder.catalog.is_empty());
}
#[test]
fn test_add_rootfile() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder
.add_rootfile("content.opf")
.expect("Failed to add rootfile");
assert_eq!(builder.rootfiles.rootfiles.len(), 1);
assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
builder
.add_rootfile("./another.opf")
.expect("Failed to add another rootfile");
assert_eq!(builder.rootfiles.rootfiles.len(), 2);
assert_eq!(
builder.rootfiles.rootfiles,
vec!["content.opf", "another.opf"]
);
}
#[test]
fn test_add_rootfile_fail() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let result = builder.add_rootfile("/rootfile.opf");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::IllegalRootfilePath.into()
);
let result = builder.add_rootfile("../rootfile.opf");
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::IllegalRootfilePath.into()
);
}
#[test]
fn test_add_metadata() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let metadata_item = MetadataItem::new("title", "Test Book");
builder.add_metadata(metadata_item);
assert_eq!(builder.metadata.metadata.len(), 1);
assert_eq!(builder.metadata.metadata[0].property, "title");
assert_eq!(builder.metadata.metadata[0].value, "Test Book");
}
#[test]
fn test_add_spine() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let spine_item = SpineItem::new("test_item");
builder.add_spine(spine_item);
assert_eq!(builder.spine.spine.len(), 1);
assert_eq!(builder.spine.spine[0].idref, "test_item");
}
#[test]
fn test_set_catalog_title() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let title = "Test Catalog Title";
builder.set_catalog_title(title);
assert_eq!(builder.catalog.title, title);
}
#[test]
fn test_add_catalog_item() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let nav_point = NavPoint::new("Chapter 1");
builder.add_catalog_item(nav_point);
assert_eq!(builder.catalog.catalog.len(), 1);
assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
}
#[test]
fn test_clear_all() {
let mut builder = test_helpers::create_full_builder();
assert_eq!(builder.metadata.metadata.len(), 3);
assert_eq!(builder.spine.spine.len(), 1);
assert_eq!(builder.catalog.catalog.len(), 1);
builder.clear_all();
assert!(builder.metadata.metadata.is_empty());
assert!(builder.spine.spine.is_empty());
assert!(builder.catalog.catalog.is_empty());
assert!(builder.catalog.title.is_empty());
assert!(builder.manifest.manifest.is_empty());
builder.add_metadata(MetadataItem::new("title", "New Book"));
builder.add_spine(SpineItem::new("new_chapter"));
builder.add_catalog_item(NavPoint::new("New Chapter"));
assert_eq!(builder.metadata.metadata.len(), 1);
assert_eq!(builder.spine.spine.len(), 1);
assert_eq!(builder.catalog.catalog.len(), 1);
}
#[test]
fn test_make() {
let mut builder = test_helpers::create_full_builder();
builder
.add_manifest(
"./test_case/Overview.xhtml",
ManifestItem {
id: "test".to_string(),
path: PathBuf::from("test.xhtml"),
mime: String::new(),
properties: None,
fallback: None,
},
)
.unwrap();
let file = env::temp_dir().join(format!("{}.epub", local_time()));
assert!(builder.make(&file).is_ok());
assert!(EpubDoc::new(&file).is_ok());
}
#[test]
fn test_build() {
let mut builder = test_helpers::create_full_builder();
builder
.add_manifest(
"./test_case/Overview.xhtml",
ManifestItem {
id: "test".to_string(),
path: PathBuf::from("test.xhtml"),
mime: String::new(),
properties: None,
fallback: None,
},
)
.unwrap();
let file = env::temp_dir().join(format!("{}.epub", local_time()));
assert!(builder.build(&file).is_ok());
}
#[test]
fn test_from() {
let metadata = vec![
MetadataItem {
id: None,
property: "title".to_string(),
value: "Test Book".to_string(),
lang: None,
refined: vec![],
},
MetadataItem {
id: None,
property: "language".to_string(),
value: "en".to_string(),
lang: None,
refined: vec![],
},
MetadataItem {
id: Some("pub-id".to_string()),
property: "identifier".to_string(),
value: "test-book".to_string(),
lang: None,
refined: vec![],
},
];
let spine = vec![SpineItem {
id: None,
idref: "main".to_string(),
linear: true,
properties: None,
}];
let catalog = vec![
NavPoint {
label: "Nav".to_string(),
content: None,
children: vec![],
play_order: None,
},
NavPoint {
label: "Overview".to_string(),
content: None,
children: vec![],
play_order: None,
},
];
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
builder.metadata.metadata = metadata.clone();
builder.spine.spine = spine.clone();
builder.catalog.catalog = catalog.clone();
builder.set_catalog_title("catalog title");
builder
.add_manifest(
"./test_case/Overview.xhtml",
ManifestItem {
id: "main".to_string(),
path: PathBuf::from("Overview.xhtml"),
mime: String::new(),
properties: None,
fallback: None,
},
)
.unwrap();
let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
builder.make(&epub_file).unwrap();
let mut doc = EpubDoc::new(&epub_file).unwrap();
let builder = EpubBuilder::from(&mut doc).unwrap();
assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
assert_eq!(builder.manifest.manifest.len(), 1);
assert_eq!(builder.spine.spine.len(), spine.len());
assert_eq!(builder.catalog.catalog, catalog);
assert_eq!(builder.catalog.title, "catalog title");
}
#[test]
fn test_make_container_file() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let result = builder.make_container_xml();
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::MissingRootfile.into()
);
builder.add_rootfile("content.opf").unwrap();
assert!(builder.make_container_xml().is_ok());
}
#[test]
fn test_make_navigation_document() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let result = builder.make_navigation_document();
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::NavigationInfoUninitalized.into()
);
builder.add_catalog_item(NavPoint::new("test"));
assert!(builder.make_navigation_document().is_ok());
}
#[test]
fn test_make_opf_file_success() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
builder.add_metadata(MetadataItem::new("title", "Test Book"));
builder.add_metadata(MetadataItem::new("language", "en"));
builder.add_metadata(
MetadataItem::new("identifier", "urn:isbn:1234567890")
.with_id("pub-id")
.build(),
);
let test_file = builder.temp_dir.join("test.xhtml");
fs::write(&test_file, "<html></html>").unwrap();
builder
.add_manifest(
test_file.to_str().unwrap(),
ManifestItem::new("test", "test.xhtml").unwrap(),
)
.unwrap();
builder.add_catalog_item(NavPoint::new("Chapter"));
builder.add_spine(SpineItem::new("test"));
builder.make_navigation_document().unwrap();
assert!(builder.make_opf_file().is_ok());
assert!(builder.temp_dir.join("content.opf").exists());
}
#[test]
fn test_make_opf_file_missing_metadata() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let result = builder.make_opf_file();
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
);
}
}
mod manifest_tests {
use super::*;
#[test]
fn test_add_manifest_success() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let test_file = builder.temp_dir.join("test.xhtml");
fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
assert_eq!(builder.manifest.manifest.len(), 1);
assert!(builder.manifest.manifest.contains_key("test"));
}
#[test]
fn test_add_manifest_no_rootfile() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let manifest_item = ManifestItem {
id: "main".to_string(),
path: PathBuf::from("/Overview.xhtml"),
mime: String::new(),
properties: None,
fallback: None,
};
let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::MissingRootfile.into()
);
builder.add_rootfile("package.opf").unwrap();
let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
assert!(result.is_ok());
}
#[test]
fn test_add_manifest_nonexistent_file() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::TargetIsNotFile {
target_path: "nonexistent.xhtml".to_string()
}
.into()
);
}
#[test]
fn test_add_manifest_unknown_file_format() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("package.opf").unwrap();
let result = builder.add_manifest(
"./test_case/unknown_file_format.xhtml",
ManifestItem {
id: "file".to_string(),
path: PathBuf::from("unknown_file_format.xhtml"),
mime: String::new(),
properties: None,
fallback: None,
},
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::UnknownFileFormat {
file_path: "./test_case/unknown_file_format.xhtml".to_string(),
}
.into()
);
}
#[test]
fn test_validate_fallback_chain_valid() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let item3 = ManifestItem::new("item3", "path3").unwrap();
let item2 = ManifestItem::new("item2", "path2")
.unwrap()
.with_fallback("item3")
.build();
let item1 = ManifestItem::new("item1", "path1")
.unwrap()
.with_fallback("item2")
.append_property("nav")
.build();
builder.manifest.insert("item3".to_string(), item3);
builder.manifest.insert("item2".to_string(), item2);
builder.manifest.insert("item1".to_string(), item1);
assert!(builder.manifest.validate().is_ok());
}
#[test]
fn test_validate_fallback_chain_circular_reference() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let item2 = ManifestItem::new("item2", "path2")
.unwrap()
.with_fallback("item1")
.build();
let item1 = ManifestItem::new("item1", "path1")
.unwrap()
.with_fallback("item2")
.build();
builder.manifest.insert("item1".to_string(), item1);
builder.manifest.insert("item2".to_string(), item2);
let result = builder.manifest.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().starts_with(
"Epub builder error: Circular reference detected in fallback chain for"
));
}
#[test]
fn test_validate_fallback_chain_not_found() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let item1 = ManifestItem::new("item1", "path1")
.unwrap()
.with_fallback("nonexistent")
.build();
builder.manifest.insert("item1".to_string(), item1);
let result = builder.manifest.validate();
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
);
}
#[test]
fn test_validate_manifest_nav_single() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let nav_item = ManifestItem::new("nav", "nav.xhtml")
.unwrap()
.append_property("nav")
.build();
builder
.manifest
.manifest
.insert("nav".to_string(), nav_item);
assert!(builder.manifest.validate().is_ok());
}
#[test]
fn test_validate_manifest_nav_multiple() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
.unwrap()
.append_property("nav")
.build();
let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
.unwrap()
.append_property("nav")
.build();
builder
.manifest
.manifest
.insert("nav1".to_string(), nav_item1);
builder
.manifest
.manifest
.insert("nav2".to_string(), nav_item2);
let result = builder.manifest.validate();
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Epub builder error: There are too many items with 'nav' property in the manifest."
);
}
}
mod metadata_tests {
use super::*;
#[test]
fn test_validate_metadata_success() {
let builder = test_helpers::create_basic_builder();
assert!(builder.metadata.validate().is_ok());
}
#[test]
fn test_validate_metadata_missing_required() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_metadata(MetadataItem::new("title", "Test Book"));
builder.add_metadata(MetadataItem::new("language", "en"));
assert!(builder.metadata.validate().is_err());
}
}
mod utility_tests {
use super::*;
#[test]
fn test_normalize_manifest_path() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let result = normalize_manifest_path(
&builder.temp_dir,
builder.rootfiles.first().unwrap(),
"../../test.xhtml",
"id",
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
);
let result = normalize_manifest_path(
&builder.temp_dir,
builder.rootfiles.first().unwrap(),
"/test.xhtml",
"id",
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
let result = normalize_manifest_path(
&builder.temp_dir,
builder.rootfiles.first().unwrap(),
"./test.xhtml",
"manifest_id",
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
.into(),
);
}
#[test]
fn test_refine_mime_type() {
assert_eq!(
refine_mime_type("text/xml", "xhtml"),
"application/xhtml+xml"
);
assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
assert_eq!(
refine_mime_type("application/xml", "opf"),
"application/oebps-package+xml"
);
assert_eq!(
refine_mime_type("text/xml", "ncx"),
"application/x-dtbncx+xml"
);
assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
}
}
#[cfg(feature = "content-builder")]
mod content_builder_tests {
use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
#[test]
fn test_make_contents_basic() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
content_builder
.set_title("Test Chapter")
.add_text_block("This is a test paragraph.", vec![])
.unwrap();
builder.add_content("OEBPS/chapter1.xhtml", content_builder);
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
}
#[test]
fn test_make_contents_multiple_blocks() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
content_builder
.set_title("多个区块章节")
.add_text_block("第一段文本。", vec![])
.unwrap()
.add_quote_block("这是一个引用。", vec![])
.unwrap()
.add_title_block("子标题", 2, vec![])
.unwrap()
.add_text_block("最后的文本段落。", vec![])
.unwrap();
builder.add_content("OEBPS/chapter2.xhtml", content_builder);
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
}
#[test]
fn test_make_contents_with_media() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
content_builder
.set_title("Chapter with Media")
.add_text_block("Text before image.", vec![])
.unwrap()
.add_image_block(
std::path::PathBuf::from("./test_case/image.jpg"),
Some("Test Image".to_string()),
Some("Figure 1: A test image".to_string()),
vec![],
)
.unwrap()
.add_text_block("Text after image.", vec![])
.unwrap();
builder.add_content("OEBPS/chapter3.xhtml", content_builder);
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
}
#[test]
fn test_make_contents_multiple_documents() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
for (id, title) in [
("ch1", "Chapter 1"),
("ch2", "Chapter 2"),
("ch3", "Chapter 3"),
] {
let mut content = ContentBuilder::new(id, "en").unwrap();
content
.set_title(title)
.add_text_block(&format!("Content of {}", title), vec![])
.unwrap();
builder.add_content(format!("OEBPS/{}.xhtml", id), content);
}
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
}
#[test]
fn test_make_contents_different_languages() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let langs = [
("en_ch", "en", "English Chapter"),
("zh_ch", "zh-CN", "中文章节"),
("ja_ch", "ja", "日本語の章"),
];
for (id, lang, title) in langs {
let mut content = ContentBuilder::new(id, lang).unwrap();
content
.set_title(title)
.add_text_block(&format!("Text in {}", lang), vec![])
.unwrap();
builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
}
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
}
#[test]
fn test_make_contents_unique_identifiers() {
use std::path::PathBuf;
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
content1.add_text_block("First content", vec![]).unwrap();
builder.add_content("OEBPS/ch1.xhtml", content1);
let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
content2.add_text_block("Second content", vec![]).unwrap();
builder.add_content("OEBPS/ch2.xhtml", content2);
let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
content3
.add_text_block("Duplicate ID content", vec![])
.unwrap();
builder.add_content("OEBPS/ch3.xhtml", content3);
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
}
#[test]
fn test_make_contents_complex_structure() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
content
.set_title("Complex Chapter")
.add_title_block("Section 1", 2, vec![])
.unwrap()
.add_text_block("Introduction text.", vec![])
.unwrap()
.add_quote_block("A wise quote here.", vec![])
.unwrap()
.add_title_block("Section 2", 2, vec![])
.unwrap()
.add_text_block("More content with multiple paragraphs.", vec![])
.unwrap()
.add_text_block("Another paragraph.", vec![])
.unwrap()
.add_title_block("Section 3", 2, vec![])
.unwrap()
.add_quote_block("Another quotation.", vec![])
.unwrap();
builder.add_content("OEBPS/complex_chapter.xhtml", content);
assert!(builder.make_contents().is_ok());
assert!(
builder
.temp_dir
.join("OEBPS/complex_chapter.xhtml")
.exists()
);
}
#[test]
fn test_make_contents_empty_document() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("content.opf").unwrap();
let content = ContentBuilder::new("empty_ch", "en").unwrap();
builder.add_content("OEBPS/empty.xhtml", content);
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
}
#[test]
fn test_make_contents_path_normalization() {
let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
builder.add_rootfile("OEBPS/content.opf").unwrap();
let mut content = ContentBuilder::new("path_test", "en").unwrap();
content.add_text_block("Path test content", vec![]).unwrap();
builder.add_content("/OEBPS/text/chapter.xhtml", content);
assert!(builder.make_contents().is_ok());
assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
}
}
}