Skip to main content

lib_epub/
builder.rs

1//! EPUB build functionality
2//!
3//! This module provides functionality for creating and building EPUB eBook files.
4//! The `EpubBuilder` structure implements the build logic of the EPUB 3.0 specification,
5//! allowing users to create standard-compliant EPUB files from scratch.
6//!
7//! ## Usage
8//!
9//! ```rust, no_run
10//! # #[cfg(feature = "builder")] {
11//! # fn main() -> Result<(), lib_epub::error::EpubError> {
12//! use lib_epub::{
13//!     builder::{EpubBuilder, EpubVersion3},
14//!     types::{MetadataItem, ManifestItem, SpineItem},
15//! };
16//!
17//! let mut builder = EpubBuilder::<EpubVersion3>::new()?;
18//! builder
19//!     .add_rootfile("OEBPS/content.opf")?
20//!     .add_metadata(MetadataItem::new("title", "Test Book"))
21//!     .add_manifest(
22//!         "path/to/content",
23//!         ManifestItem::new("content_id", "target/path")?,
24//!     )?
25//!     .add_spine(SpineItem::new("content.xhtml"));
26//!
27//! builder.build("output.epub")?;
28//! # Ok(())
29//! # }
30//! # }
31//! ```
32//!
33//! ## Notes
34//!
35//! - Requires `builder` feature to use this module.
36//! - All resource files must exist on the local file system.
37//! - At least one rootfile must be added before adding manifest items.
38//! - Required metadata includes: `title`, `language`, and `identifier` with id `pub-id`.
39
40use std::{
41    cmp::Reverse,
42    env,
43    fs::{self, File},
44    io::{BufReader, Cursor, Read, Seek},
45    marker::PhantomData,
46    path::{Path, PathBuf},
47};
48
49use log::warn;
50use quick_xml::{
51    Writer,
52    events::{BytesDecl, BytesEnd, BytesStart, Event},
53};
54use walkdir::WalkDir;
55use zip::{CompressionMethod, ZipWriter, write::FileOptions};
56
57#[cfg(feature = "content-builder")]
58use crate::builder::content::ContentBuilder;
59use crate::{
60    epub::EpubDoc,
61    error::{EpubBuilderError, EpubError},
62    types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
63    utils::{check_realtive_link_leakage, local_time, remove_leading_slash},
64};
65
66#[cfg(feature = "content-builder")]
67pub mod content;
68
69pub use components::CatalogBuilder;
70#[cfg(feature = "content-builder")]
71pub use components::DocumentBuilder;
72pub use components::ManifestBuilder;
73pub use components::MetadataBuilder;
74pub use components::RootfileBuilder;
75pub use components::SpineBuilder;
76
77pub(crate) mod components;
78
79type XmlWriter = Writer<Cursor<Vec<u8>>>;
80
81// struct EpubVersion2;
82#[cfg_attr(test, derive(Debug))]
83pub struct EpubVersion3;
84
85/// EPUB Builder
86///
87/// The main structure used to create and build EPUB ebook files.
88/// Supports the EPUB 3.0 specification and can build a complete EPUB file structure.
89///
90/// ## Usage
91///
92/// ```rust, no_run
93/// # #[cfg(feature = "builder")]
94/// # fn main() -> Result<(), lib_epub::error::EpubError> {
95/// use lib_epub::{
96///     builder::{EpubBuilder, EpubVersion3},
97///     types::{MetadataItem, ManifestItem, NavPoint, SpineItem},
98/// };
99///
100/// let mut builder = EpubBuilder::<EpubVersion3>::new()?;
101///
102/// builder
103///     .rootfile()
104///     .add("EPUB/content.opf")?;
105///
106/// builder
107///     .metadata()
108///     .add(MetadataItem::new("title", "Test Book"))
109///     .add(MetadataItem::new("language", "en"))
110///     .add(
111///         MetadataItem::new("identifier", "unique-id")
112///             .with_id("pub-id")
113///             .build(),
114///     );
115///
116/// builder
117///     .manifest()
118///     .add(
119///         "./test_case/Overview.xhtml",
120///         ManifestItem::new("content", "target/path")?,
121///     )?;
122///
123/// builder
124///     .spine()
125///     .add(SpineItem::new("content"));
126///
127/// builder
128///     .catalog()
129///     .set_title("Catalog Title")
130///     .add(NavPoint::new("label"));
131///
132/// builder.build("output.epub")?;
133///
134/// # Ok(())
135/// # }
136/// ```
137///
138/// ## Notes
139///
140/// - All resource files **must** exist on the local file system.
141/// - **At least one rootfile** must be added before adding manifest items.
142/// - Requires at least one `title`, `language`, and `identifier` with id `pub-id`.
143#[cfg_attr(test, derive(Debug))]
144pub struct EpubBuilder<Version> {
145    /// EPUB version placeholder
146    epub_version: PhantomData<Version>,
147
148    /// Temporary directory path for storing files during the build process
149    pub(crate) temp_dir: PathBuf,
150
151    pub(crate) rootfiles: RootfileBuilder,
152    pub(crate) metadata: MetadataBuilder,
153    pub(crate) manifest: ManifestBuilder,
154    pub(crate) spine: SpineBuilder,
155    pub(crate) catalog: CatalogBuilder,
156
157    #[cfg(feature = "content-builder")]
158    pub(crate) content: DocumentBuilder,
159}
160
161impl EpubBuilder<EpubVersion3> {
162    /// Create a new `EpubBuilder` instance
163    ///
164    /// ## Return
165    /// - `Ok(EpubBuilder)`: Builder instance created successfully
166    /// - `Err(EpubError)`: Error occurred during builder initialization
167    pub fn new() -> Result<Self, EpubError> {
168        let temp_dir = env::temp_dir().join(local_time());
169        fs::create_dir(&temp_dir)?;
170        fs::create_dir(temp_dir.join("META-INF"))?;
171
172        let mime_file = temp_dir.join("mimetype");
173        fs::write(mime_file, "application/epub+zip")?;
174
175        Ok(EpubBuilder {
176            epub_version: PhantomData,
177            temp_dir: temp_dir.clone(),
178
179            rootfiles: RootfileBuilder::new(),
180            metadata: MetadataBuilder::new(),
181            manifest: ManifestBuilder::new(temp_dir),
182            spine: SpineBuilder::new(),
183            catalog: CatalogBuilder::new(),
184
185            #[cfg(feature = "content-builder")]
186            content: DocumentBuilder::new(),
187        })
188    }
189
190    /// Add a rootfile path
191    ///
192    /// The added path points to an OPF file that does not yet exist
193    /// and will be created when building the Epub file.
194    ///
195    /// ## Parameters
196    /// - `rootfile`: Rootfile path
197    ///
198    /// ## Notes
199    /// - The added rootfile path must be a relative path and cannot start with "../".
200    /// - At least one rootfile must be added before adding metadata items.
201    pub fn add_rootfile(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
202        match self.rootfiles.add(rootfile) {
203            Ok(_) => Ok(self),
204            Err(err) => Err(err),
205        }
206    }
207
208    /// Add metadata item
209    ///
210    /// Required metadata includes title, language, and an identifier with 'pub-id'.
211    /// Missing this data will result in an error when building the epub file.
212    ///
213    /// ## Parameters
214    /// - `item`: Metadata items to add
215    pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
216        let _ = self.metadata.add(item);
217        self
218    }
219
220    /// Add manifest item and corresponding resource file
221    ///
222    /// The builder will automatically recognize the file type of
223    /// the added resource and update it in `ManifestItem`.
224    ///
225    /// ## Parameters
226    /// - `manifest_source` - Local resource file path
227    /// - `manifest_item` - Manifest item information
228    ///
229    /// ## Return
230    /// - `Ok(&mut Self)` - Successful addition, returns a reference to itself
231    /// - `Err(EpubError)` - Error occurred during the addition process
232    ///
233    /// ## Notes
234    /// - At least one rootfile must be added before adding manifest items.
235    /// - If the manifest item ID already exists in the manifest, the manifest item will be overwritten.
236    pub fn add_manifest(
237        &mut self,
238        manifest_source: impl Into<String>,
239        manifest_item: ManifestItem,
240    ) -> Result<&mut Self, EpubError> {
241        if self.rootfiles.is_empty() {
242            return Err(EpubBuilderError::MissingRootfile.into());
243        } else {
244            self.manifest
245                .set_rootfile(self.rootfiles.first().expect("Unreachable"));
246        }
247
248        match self.manifest.add(manifest_source, manifest_item) {
249            Ok(_) => Ok(self),
250            Err(err) => Err(err),
251        }
252    }
253
254    /// Add spine item
255    ///
256    /// The spine item defines the reading order of the book.
257    ///
258    /// ## Parameters
259    /// - `item`: Spine item to add
260    pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
261        self.spine.add(item);
262        self
263    }
264
265    /// Set catalog title
266    ///
267    /// ## Parameters
268    /// - `title`: Catalog title
269    pub fn set_catalog_title(&mut self, title: impl Into<String>) -> &mut Self {
270        let _ = self.catalog.set_title(title);
271        self
272    }
273
274    /// Add catalog item
275    ///
276    /// Added directory items will be added to the end of the existing list.
277    ///
278    /// ## Parameters
279    /// - `item`: Catalog item to add
280    pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
281        let _ = self.catalog.add(item);
282        self
283    }
284
285    /// Add content
286    ///
287    /// The content builder can be used to generate content for the book.
288    /// It is recommended to use the `content-builder` feature to use this function.
289    ///
290    /// ## Parameters
291    /// - `target_path`: The path to the resource file within the EPUB container
292    /// - `content`: The content builder to generate content
293    #[cfg(feature = "content-builder")]
294    pub fn add_content(
295        &mut self,
296        target_path: impl AsRef<str>,
297        content: ContentBuilder,
298    ) -> &mut Self {
299        self.content.add(target_path, content);
300        self
301    }
302
303    /// Clear all data from the builder
304    ///
305    /// This function clears all metadata, manifest items, spine items, catalog items, etc.
306    /// from the builder, effectively resetting it to an empty state.
307    ///
308    /// ## Return
309    /// - `Ok(&mut Self)`: Successfully cleared all data
310    /// - `Err(EpubError)`: Error occurred during the clearing process (specifically during manifest clearing)
311    pub fn clear_all(&mut self) -> &mut Self {
312        self.rootfiles.clear();
313        self.metadata.clear();
314        self.manifest.clear();
315        self.spine.clear();
316        self.catalog.clear();
317        #[cfg(feature = "content-builder")]
318        self.content.clear();
319
320        self
321    }
322
323    /// Get a mutable reference to the rootfile builder
324    ///
325    /// Allows direct manipulation of rootfile entries.
326    ///
327    /// ## Return
328    /// - `&mut RootfileBuilder`: Mutable reference to the rootfile builder
329    pub fn rootfile(&mut self) -> &mut RootfileBuilder {
330        &mut self.rootfiles
331    }
332
333    /// Get a mutable reference to the metadata builder
334    ///
335    /// Allows direct manipulation of metadata items.
336    ///
337    /// ## Return
338    /// - `&mut MetadataBuilder`: Mutable reference to the metadata builder
339    pub fn metadata(&mut self) -> &mut MetadataBuilder {
340        &mut self.metadata
341    }
342
343    /// Get a mutable reference to the manifest builder
344    ///
345    /// Allows direct manipulation of manifest items.
346    ///
347    /// ## Return
348    /// - `&mut ManifestBuilder`: Mutable reference to the manifest builder
349    pub fn manifest(&mut self) -> &mut ManifestBuilder {
350        &mut self.manifest
351    }
352
353    /// Get a mutable reference to the spine builder
354    ///
355    /// Allows direct manipulation of spine items.
356    ///
357    /// ## Return
358    /// - `&mut SpineBuilder`: Mutable reference to the spine builder
359    pub fn spine(&mut self) -> &mut SpineBuilder {
360        &mut self.spine
361    }
362
363    /// Get a mutable reference to the catalog builder
364    ///
365    /// Allows direct manipulation of navigation/catalog items.
366    ///
367    /// ## Return
368    /// - `&mut CatalogBuilder`: Mutable reference to the catalog builder
369    pub fn catalog(&mut self) -> &mut CatalogBuilder {
370        &mut self.catalog
371    }
372
373    /// Get a mutable reference to the content builder
374    ///
375    /// Allows direct manipulation of content documents.
376    ///
377    /// ## Return
378    /// - `&mut DocumentBuilder`: Mutable reference to the document builder
379    #[cfg(feature = "content-builder")]
380    pub fn content(&mut self) -> &mut DocumentBuilder {
381        &mut self.content
382    }
383
384    /// Builds an EPUB file and saves it to the specified path
385    ///
386    /// ## Parameters
387    /// - `output_path`: Output file path
388    ///
389    /// ## Return
390    /// - `Ok(())`: Build successful
391    /// - `Err(EpubError)`: Error occurred during the build process
392    pub fn make(mut self, output_path: impl AsRef<Path>) -> Result<(), EpubError> {
393        // Create the container.xml, navigation document, and OPF files in sequence.
394        // The associated metadata will initialized when navigation document is created;
395        // therefore, the navigation document must be created before the opf file is created.
396        self.make_container_xml()?;
397        self.make_navigation_document()?;
398        #[cfg(feature = "content-builder")]
399        self.make_contents()?;
400        self.make_opf_file()?;
401        self.remove_empty_dirs()?;
402
403        if let Some(parent) = output_path.as_ref().parent() {
404            if !parent.exists() {
405                fs::create_dir_all(parent)?;
406            }
407        }
408
409        // pack zip file
410        let file = File::create(output_path)?;
411        let mut zip = ZipWriter::new(file);
412        let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
413
414        for entry in WalkDir::new(&self.temp_dir) {
415            let entry = entry?;
416            let path = entry.path();
417
418            // It can be asserted that the path is prefixed with temp_dir,
419            // and there will be no boundary cases of symbolic links and hard links, etc.
420            let relative_path = path.strip_prefix(&self.temp_dir).unwrap();
421            let target_path = relative_path.to_string_lossy().replace("\\", "/");
422
423            if path.is_file() {
424                zip.start_file(target_path, options)?;
425
426                let mut file = File::open(path)?;
427                std::io::copy(&mut file, &mut zip)?;
428            } else if path.is_dir() {
429                zip.add_directory(target_path, options)?;
430            }
431        }
432
433        zip.finish()?;
434        Ok(())
435    }
436
437    /// Builds an EPUB file and returns a `EpubDoc`
438    ///
439    /// Builds an EPUB file at the specified location and parses it into a usable EpubDoc object.
440    ///
441    /// ## Parameters
442    /// - `output_path`: Output file path
443    ///
444    /// ## Return
445    /// - `Ok(EpubDoc)`: Build successful
446    /// - `Err(EpubError)`: Error occurred during the build process
447    pub fn build(
448        self,
449        output_path: impl AsRef<Path>,
450    ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
451        self.make(&output_path)?;
452
453        EpubDoc::new(output_path)
454    }
455
456    /// Creates an `EpubBuilder` instance from an existing `EpubDoc`
457    ///
458    /// This function takes an existing parsed EPUB document and creates a new builder
459    /// instance with all the document's metadata, manifest items, spine, and catalog information.
460    /// It essentially reverses the EPUB building process by extracting all the necessary
461    /// components from the parsed document and preparing them for reconstruction.
462    ///
463    /// The function copies the following information from the provided `EpubDoc`:
464    /// - Rootfile path (based on the document's base path)
465    /// - All metadata items (title, author, identifier, etc.)
466    /// - Spine items (reading order of the publication)
467    /// - Catalog information (navigation points)
468    /// - Catalog title
469    /// - All manifest items (except those with 'nav' property, which are skipped)
470    ///
471    /// ## Parameters
472    /// - `doc`: A mutable reference to an `EpubDoc` instance that contains the parsed EPUB data
473    ///
474    /// ## Return
475    /// - `Ok(EpubBuilder)`: Successfully created builder instance populated with the document's data
476    /// - `Err(EpubError)`: Error occurred during the extraction process
477    ///
478    /// ## Notes
479    /// - This type of conversion will upgrade Epub2.x publications to Epub3.x.
480    ///   This upgrade conversion may encounter unknown errors (it is unclear whether
481    ///   it will cause errors), so please use it with caution.
482    pub fn from<R: Read + Seek + Send>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
483        let mut builder = Self::new()?;
484
485        builder.add_rootfile(doc.package_path.clone().to_string_lossy())?;
486        builder.metadata.metadata = doc.metadata.clone();
487        builder.spine.spine = doc.spine.clone();
488        builder.catalog.catalog = doc.catalog.clone();
489        builder.catalog.title = doc.catalog_title.clone();
490
491        // clone manifest hashmap to avoid mut borrow conflict
492        for (_, mut manifest) in doc.manifest.clone().into_iter() {
493            if let Some(properties) = &manifest.properties {
494                if properties.contains("nav") {
495                    continue;
496                }
497            }
498
499            // because manifest paths in EpubDoc are converted to absolute paths rooted in containers,
500            // but in the form of 'path/to/manifest', they need to be converted here to absolute paths
501            // in the form of '/path/to/manifest'.
502            manifest.path = PathBuf::from("/").join(manifest.path);
503
504            let (buf, _) = doc.get_manifest_item(&manifest.id)?; // read raw file
505            let target_path = normalize_manifest_path(
506                &builder.temp_dir,
507                builder.rootfiles.first().expect("Unreachable"),
508                &manifest.path,
509                &manifest.id,
510            )?;
511            if let Some(parent_dir) = target_path.parent() {
512                if !parent_dir.exists() {
513                    fs::create_dir_all(parent_dir)?
514                }
515            }
516
517            fs::write(target_path, buf)?;
518            builder
519                .manifest
520                .manifest
521                .insert(manifest.id.clone(), manifest);
522        }
523
524        Ok(builder)
525    }
526
527    /// Creates the `container.xml` file
528    ///
529    /// An error will occur if the `rootfile` path is not set
530    fn make_container_xml(&self) -> Result<(), EpubError> {
531        if self.rootfiles.is_empty() {
532            return Err(EpubBuilderError::MissingRootfile.into());
533        }
534
535        let mut writer = Writer::new(Cursor::new(Vec::new()));
536        self.rootfiles.make(&mut writer)?;
537
538        let file_path = self.temp_dir.join("META-INF").join("container.xml");
539        let file_data = writer.into_inner().into_inner();
540        fs::write(file_path, file_data)?;
541
542        Ok(())
543    }
544
545    /// Creates the content document
546    #[cfg(feature = "content-builder")]
547    fn make_contents(&mut self) -> Result<(), EpubError> {
548        let manifest_list = self.content.make(
549            self.temp_dir.clone(),
550            self.rootfiles.first().expect("Unreachable"),
551        )?;
552
553        for item in manifest_list.into_iter() {
554            self.manifest.insert(item.id.clone(), item);
555        }
556
557        Ok(())
558    }
559
560    /// Creates the `navigation document`
561    ///
562    /// If the manifest already contains an item with the `nav` property, the
563    /// navigation document is considered user-provided and generation is skipped
564    /// entirely.
565    ///
566    /// An error will occur if navigation information is not initialized.
567    fn make_navigation_document(&mut self) -> Result<(), EpubError> {
568        for (_, manifest_item) in &self.manifest().manifest {
569            if let Some(properties) = &manifest_item.properties
570                && properties.split(' ').any(|property| property == "nav")
571            {
572                return Ok(());
573            }
574        }
575
576        if self.catalog.is_empty() {
577            return Err(EpubBuilderError::NavigationInfoUninitalized.into());
578        }
579
580        let mut writer = Writer::new(Cursor::new(Vec::new()));
581        self.catalog.make(&mut writer)?;
582
583        let file_path = self.temp_dir.join("nav.xhtml");
584        let file_data = writer.into_inner().into_inner();
585        fs::write(file_path, file_data)?;
586
587        self.manifest.insert(
588            "nav".to_string(),
589            ManifestItem {
590                id: "nav".to_string(),
591                path: PathBuf::from("/nav.xhtml"),
592                mime: "application/xhtml+xml".to_string(),
593                properties: Some("nav".to_string()),
594                fallback: None,
595            },
596        );
597
598        Ok(())
599    }
600
601    /// Creates the `OPF` file
602    ///
603    /// ## Error conditions
604    /// - Missing necessary metadata
605    /// - Circular reference exists in the manifest backlink
606    /// - Navigation information is not initialized
607    fn make_opf_file(&mut self) -> Result<(), EpubError> {
608        self.metadata.validate()?;
609        self.manifest.validate()?;
610        self.spine.validate(self.manifest.keys())?;
611
612        let mut writer = Writer::new(Cursor::new(Vec::new()));
613
614        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
615
616        writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
617            ("xmlns", "http://www.idpf.org/2007/opf"),
618            ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
619            ("unique-identifier", "pub-id"),
620            ("version", "3.0"),
621        ])))?;
622
623        self.metadata.make(&mut writer)?;
624        self.manifest.make(&mut writer)?;
625        self.spine.make(&mut writer)?;
626
627        writer.write_event(Event::End(BytesEnd::new("package")))?;
628
629        let file_path = self
630            .temp_dir
631            .join(self.rootfiles.first().expect("Unreachable"));
632        let file_data = writer.into_inner().into_inner();
633        fs::write(file_path, file_data)?;
634
635        Ok(())
636    }
637
638    /// Remove empty directories under the builder temporary directory
639    ///
640    /// By enumerate directories under `self.temp_dir` (excluding the root itself)
641    /// and deletes directories that are empty. Directories are processed from deepest
642    /// to shallowest so that parent directories which become empty after child
643    /// deletion can also be removed.
644    ///
645    /// ## Return
646    /// - `Ok(())`: Successfully removed all empty directories
647    /// - `Err(EpubError)`: IO error
648    fn remove_empty_dirs(&self) -> Result<(), EpubError> {
649        let mut dirs = WalkDir::new(self.temp_dir.as_path())
650            .min_depth(1)
651            .into_iter()
652            .filter_map(|entry| entry.ok())
653            .filter(|entry| entry.file_type().is_dir())
654            .map(|entry| entry.into_path())
655            .collect::<Vec<PathBuf>>();
656
657        dirs.sort_by_key(|p| Reverse(p.components().count()));
658
659        for dir in dirs {
660            if fs::read_dir(&dir)?.next().is_none() {
661                fs::remove_dir(dir)?;
662            }
663        }
664
665        Ok(())
666    }
667}
668
669impl<Version> Drop for EpubBuilder<Version> {
670    /// Remove temporary directory when dropped
671    fn drop(&mut self) {
672        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
673            warn!("{}", err);
674        };
675    }
676}
677
678/// Refine the MIME type based on file extension
679///
680/// This function optimizes MIME types that are inferred from file content by using
681/// the file extension to determine the correct EPUB-specific MIME type. Some file
682/// types have different MIME types depending on how they are used in an EPUB context.
683fn refine_mime_type<'a>(infer_mime: &'a str, extension: &'a str) -> &'a str {
684    match (infer_mime, extension) {
685        ("text/xml", "xhtml")
686        | ("application/xml", "xhtml")
687        | ("text/xml", "xht")
688        | ("application/xml", "xht") => "application/xhtml+xml",
689
690        ("text/xml", "opf") | ("application/xml", "opf") => "application/oebps-package+xml",
691
692        ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml",
693
694        ("application/zip", "epub") => "application/epub+zip",
695
696        ("text/plain", "css") => "text/css",
697        ("text/plain", "js") => "application/javascript",
698        ("text/plain", "json") => "application/json",
699        ("text/plain", "svg") => "image/svg+xml",
700
701        _ => infer_mime,
702    }
703}
704
705/// Normalize manifest path to absolute path within EPUB container
706///
707/// This function takes a path (relative or absolute) and normalizes it to an absolute
708/// path within the EPUB container structure. It handles various path formats including:
709/// - Relative paths starting with "../" (with security check to prevent directory traversal)
710/// - Absolute paths starting with "/" (relative to EPUB root)
711/// - Relative paths starting with "./" (current directory)
712/// - Plain relative paths (relative to the OPF file location)
713///
714/// ## Parameters
715/// - `temp_dir`: The temporary directory path used during the EPUB build process
716/// - `rootfile`: The path to the OPF file (package document), used to determine the base directory
717/// - `path`: The input path that may be relative or absolute. Can be any type that
718///   implements `AsRef<Path>`, such as `&str`, `String`, `Path`, `PathBuf`, etc.
719/// - `id`: The identifier of the manifest item being processed
720///
721/// ## Return
722/// - `Ok(PathBuf)`: The normalized absolute path within the EPUB container,
723///   which does not start with "/"
724/// - `Err(EpubError)`: Error if path traversal is detected outside the EPUB container,
725///   or if the absolute path cannot be determined
726fn normalize_manifest_path<TempD: AsRef<Path>, S: AsRef<str>, P: AsRef<Path>>(
727    temp_dir: TempD,
728    rootfile: S,
729    path: P,
730    id: &str,
731) -> Result<PathBuf, EpubError> {
732    let opf_path = PathBuf::from(rootfile.as_ref());
733    let basic_path = remove_leading_slash(opf_path.parent().unwrap());
734
735    // convert manifest path to absolute path(physical path)
736    let target_path = if path.as_ref().starts_with("../") {
737        check_realtive_link_leakage(
738            temp_dir.as_ref().to_path_buf(),
739            basic_path.to_path_buf(),
740            &path.as_ref().to_string_lossy(),
741        )
742        .map(PathBuf::from)
743        .ok_or_else(|| EpubError::RelativeLinkLeakage {
744            path: path.as_ref().to_string_lossy().to_string(),
745        })?
746    } else if let Ok(stripped) = path.as_ref().strip_prefix("/") {
747        temp_dir.as_ref().join(stripped)
748    } else if path.as_ref().starts_with("./") {
749        // can not anlyze where the 'current' directory is
750        Err(EpubBuilderError::IllegalManifestPath { manifest_id: id.to_string() })?
751    } else {
752        temp_dir.as_ref().join(basic_path).join(path)
753    };
754
755    #[cfg(windows)]
756    let target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
757
758    Ok(target_path)
759}
760
761#[cfg(test)]
762mod tests {
763    use std::{env, fs, path::PathBuf};
764
765    use crate::{
766        builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
767        epub::EpubDoc,
768        error::{EpubBuilderError, EpubError},
769        types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
770        utils::local_time,
771    };
772
773    mod test_helpers {
774        use super::*;
775
776        pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
777            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
778            builder.add_rootfile("content.opf").unwrap();
779            builder.add_metadata(MetadataItem::new("title", "Test Book"));
780            builder.add_metadata(MetadataItem::new("language", "en"));
781            builder.add_metadata(
782                MetadataItem::new("identifier", "urn:isbn:1234567890")
783                    .with_id("pub-id")
784                    .build(),
785            );
786            builder
787        }
788
789        pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
790            let mut builder = create_basic_builder();
791            builder.add_catalog_item(NavPoint::new("Chapter"));
792            builder.add_spine(SpineItem::new("test"));
793            builder
794        }
795    }
796
797    mod epub_builder_tests {
798        use super::*;
799
800        #[test]
801        fn test_epub_builder_new() {
802            let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
803            assert!(builder.temp_dir.exists());
804            assert!(builder.rootfiles.is_empty());
805            assert!(builder.metadata.metadata.is_empty());
806            assert!(builder.manifest.manifest.is_empty());
807            assert!(builder.spine.spine.is_empty());
808            assert!(builder.catalog.title.is_empty());
809            assert!(builder.catalog.is_empty());
810        }
811
812        #[test]
813        fn test_add_rootfile() {
814            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
815
816            builder
817                .add_rootfile("content.opf")
818                .expect("Failed to add rootfile");
819            assert_eq!(builder.rootfiles.rootfiles.len(), 1);
820            assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
821
822            builder
823                .add_rootfile("./another.opf")
824                .expect("Failed to add another rootfile");
825            assert_eq!(builder.rootfiles.rootfiles.len(), 2);
826            assert_eq!(
827                builder.rootfiles.rootfiles,
828                vec!["content.opf", "another.opf"]
829            );
830        }
831
832        #[test]
833        fn test_add_rootfile_fail() {
834            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
835
836            let result = builder.add_rootfile("/rootfile.opf");
837            assert!(result.is_err());
838            assert_eq!(
839                result.unwrap_err(),
840                EpubBuilderError::IllegalRootfilePath.into()
841            );
842
843            let result = builder.add_rootfile("../rootfile.opf");
844            assert!(result.is_err());
845            assert_eq!(
846                result.unwrap_err(),
847                EpubBuilderError::IllegalRootfilePath.into()
848            );
849        }
850
851        #[test]
852        fn test_add_metadata() {
853            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
854            let metadata_item = MetadataItem::new("title", "Test Book");
855
856            builder.add_metadata(metadata_item);
857
858            assert_eq!(builder.metadata.metadata.len(), 1);
859            assert_eq!(builder.metadata.metadata[0].property, "title");
860            assert_eq!(builder.metadata.metadata[0].value, "Test Book");
861        }
862
863        #[test]
864        fn test_add_spine() {
865            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
866            let spine_item = SpineItem::new("test_item");
867
868            builder.add_spine(spine_item);
869
870            assert_eq!(builder.spine.spine.len(), 1);
871            assert_eq!(builder.spine.spine[0].idref, "test_item");
872        }
873
874        #[test]
875        fn test_set_catalog_title() {
876            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
877            let title = "Test Catalog Title";
878
879            builder.set_catalog_title(title);
880
881            assert_eq!(builder.catalog.title, title);
882        }
883
884        #[test]
885        fn test_add_catalog_item() {
886            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
887            let nav_point = NavPoint::new("Chapter 1");
888
889            builder.add_catalog_item(nav_point);
890
891            assert_eq!(builder.catalog.catalog.len(), 1);
892            assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
893        }
894
895        #[test]
896        fn test_clear_all() {
897            let mut builder = test_helpers::create_full_builder();
898
899            assert_eq!(builder.metadata.metadata.len(), 3);
900            assert_eq!(builder.spine.spine.len(), 1);
901            assert_eq!(builder.catalog.catalog.len(), 1);
902
903            builder.clear_all();
904
905            assert!(builder.metadata.metadata.is_empty());
906            assert!(builder.spine.spine.is_empty());
907            assert!(builder.catalog.catalog.is_empty());
908            assert!(builder.catalog.title.is_empty());
909            assert!(builder.manifest.manifest.is_empty());
910
911            builder.add_metadata(MetadataItem::new("title", "New Book"));
912            builder.add_spine(SpineItem::new("new_chapter"));
913            builder.add_catalog_item(NavPoint::new("New Chapter"));
914
915            assert_eq!(builder.metadata.metadata.len(), 1);
916            assert_eq!(builder.spine.spine.len(), 1);
917            assert_eq!(builder.catalog.catalog.len(), 1);
918        }
919
920        #[test]
921        fn test_make() {
922            let mut builder = test_helpers::create_full_builder();
923
924            builder
925                .add_manifest(
926                    "./test_case/Overview.xhtml",
927                    ManifestItem {
928                        id: "test".to_string(),
929                        path: PathBuf::from("test.xhtml"),
930                        mime: String::new(),
931                        properties: None,
932                        fallback: None,
933                    },
934                )
935                .unwrap();
936
937            let file = env::temp_dir().join(format!("{}.epub", local_time()));
938            assert!(builder.make(&file).is_ok());
939            assert!(EpubDoc::new(&file).is_ok());
940        }
941
942        #[test]
943        fn test_build() {
944            let mut builder = test_helpers::create_full_builder();
945
946            builder
947                .add_manifest(
948                    "./test_case/Overview.xhtml",
949                    ManifestItem {
950                        id: "test".to_string(),
951                        path: PathBuf::from("test.xhtml"),
952                        mime: String::new(),
953                        properties: None,
954                        fallback: None,
955                    },
956                )
957                .unwrap();
958
959            let file = env::temp_dir().join(format!("{}.epub", local_time()));
960            assert!(builder.build(&file).is_ok());
961        }
962
963        #[test]
964        fn test_from() {
965            let metadata = vec![
966                MetadataItem {
967                    id: None,
968                    property: "title".to_string(),
969                    value: "Test Book".to_string(),
970                    lang: None,
971                    refined: vec![],
972                },
973                MetadataItem {
974                    id: None,
975                    property: "language".to_string(),
976                    value: "en".to_string(),
977                    lang: None,
978                    refined: vec![],
979                },
980                MetadataItem {
981                    id: Some("pub-id".to_string()),
982                    property: "identifier".to_string(),
983                    value: "test-book".to_string(),
984                    lang: None,
985                    refined: vec![],
986                },
987            ];
988            let spine = vec![SpineItem {
989                id: None,
990                idref: "main".to_string(),
991                linear: true,
992                properties: None,
993            }];
994            let catalog = vec![
995                NavPoint {
996                    label: "Nav".to_string(),
997                    content: None,
998                    children: vec![],
999                    play_order: None,
1000                },
1001                NavPoint {
1002                    label: "Overview".to_string(),
1003                    content: None,
1004                    children: vec![],
1005                    play_order: None,
1006                },
1007            ];
1008
1009            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1010            builder.add_rootfile("content.opf").unwrap();
1011            builder.metadata.metadata = metadata.clone();
1012            builder.spine.spine = spine.clone();
1013            builder.catalog.catalog = catalog.clone();
1014            builder.set_catalog_title("catalog title");
1015            builder
1016                .add_manifest(
1017                    "./test_case/Overview.xhtml",
1018                    ManifestItem {
1019                        id: "main".to_string(),
1020                        path: PathBuf::from("Overview.xhtml"),
1021                        mime: String::new(),
1022                        properties: None,
1023                        fallback: None,
1024                    },
1025                )
1026                .unwrap();
1027
1028            let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1029            builder.make(&epub_file).unwrap();
1030
1031            let mut doc = EpubDoc::new(&epub_file).unwrap();
1032            let builder = EpubBuilder::from(&mut doc).unwrap();
1033
1034            assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
1035            assert_eq!(builder.manifest.manifest.len(), 1);
1036            assert_eq!(builder.spine.spine.len(), spine.len());
1037            assert_eq!(builder.catalog.catalog, catalog);
1038            assert_eq!(builder.catalog.title, "catalog title");
1039        }
1040
1041        #[test]
1042        fn test_make_container_file() {
1043            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1044
1045            let result = builder.make_container_xml();
1046            assert!(result.is_err());
1047            assert_eq!(
1048                result.unwrap_err(),
1049                EpubBuilderError::MissingRootfile.into()
1050            );
1051
1052            builder.add_rootfile("content.opf").unwrap();
1053            assert!(builder.make_container_xml().is_ok());
1054        }
1055
1056        #[test]
1057        fn test_make_navigation_document() {
1058            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1059
1060            let result = builder.make_navigation_document();
1061            assert!(result.is_err());
1062            assert_eq!(
1063                result.unwrap_err(),
1064                EpubBuilderError::NavigationInfoUninitalized.into()
1065            );
1066
1067            builder.add_catalog_item(NavPoint::new("test"));
1068            assert!(builder.make_navigation_document().is_ok());
1069        }
1070
1071        #[test]
1072        fn test_make_navigation_document_skips_with_existing_nav() {
1073            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1074
1075            builder.manifest.manifest.insert(
1076                "custom-nav".to_string(),
1077                ManifestItem::new("custom-nav", "nav.xhtml")
1078                    .unwrap()
1079                    .append_property("nav")
1080                    .build(),
1081            );
1082
1083            assert!(builder.make_navigation_document().is_ok());
1084
1085            assert!(!builder.temp_dir.join("nav.xhtml").exists());
1086            assert!(!builder.manifest.manifest.contains_key("nav"));
1087            assert_eq!(builder.manifest.manifest.len(), 1);
1088            assert_eq!(
1089                builder
1090                    .manifest
1091                    .manifest
1092                    .get("custom-nav")
1093                    .unwrap()
1094                    .properties,
1095                Some("nav".to_string())
1096            );
1097        }
1098
1099        #[test]
1100        fn test_make_navigation_document_skips_with_multiple_properties() {
1101            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1102
1103            builder.manifest.manifest.insert(
1104                "custom-nav".to_string(),
1105                ManifestItem::new("custom-nav", "nav.xhtml")
1106                    .unwrap()
1107                    .append_property("cover-image")
1108                    .append_property("nav")
1109                    .build(),
1110            );
1111
1112            assert!(builder.make_navigation_document().is_ok());
1113            assert!(!builder.temp_dir.join("nav.xhtml").exists());
1114        }
1115
1116        #[test]
1117        fn test_make_navigation_document_does_not_match_substring() {
1118            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1119
1120            builder.manifest.manifest.insert(
1121                "custom-nav".to_string(),
1122                ManifestItem::new("custom-nav", "nav.xhtml")
1123                    .unwrap()
1124                    .append_property("navigation")
1125                    .build(),
1126            );
1127
1128            assert_eq!(
1129                builder.make_navigation_document().unwrap_err(),
1130                EpubBuilderError::NavigationInfoUninitalized.into()
1131            );
1132
1133            builder.add_catalog_item(NavPoint::new("test"));
1134            assert!(builder.make_navigation_document().is_ok());
1135            assert!(builder.temp_dir.join("nav.xhtml").exists());
1136            assert!(builder.manifest.manifest.contains_key("nav"));
1137        }
1138
1139        #[test]
1140        fn test_make_navigation_document_multiple_nav_items_deferred_to_validation() {
1141            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1142
1143            builder.manifest.manifest.insert(
1144                "nav1".to_string(),
1145                ManifestItem::new("nav1", "nav1.xhtml")
1146                    .unwrap()
1147                    .append_property("nav")
1148                    .build(),
1149            );
1150            builder.manifest.manifest.insert(
1151                "nav2".to_string(),
1152                ManifestItem::new("nav2", "nav2.xhtml")
1153                    .unwrap()
1154                    .append_property("nav")
1155                    .build(),
1156            );
1157
1158            assert!(builder.make_navigation_document().is_ok());
1159
1160            let result = builder.manifest.validate();
1161            assert!(result.is_err());
1162            assert_eq!(
1163                result.unwrap_err().to_string(),
1164                "Epub builder error: There are too many items with 'nav' property in the manifest."
1165            );
1166        }
1167
1168        #[test]
1169        fn test_make_with_custom_nav_document() {
1170            let mut builder = test_helpers::create_full_builder();
1171
1172            builder
1173                .add_manifest(
1174                    "./test_case/Overview.xhtml",
1175                    ManifestItem {
1176                        id: "test".to_string(),
1177                        path: PathBuf::from("test.xhtml"),
1178                        mime: String::new(),
1179                        properties: None,
1180                        fallback: None,
1181                    },
1182                )
1183                .unwrap();
1184
1185            builder
1186                .add_manifest(
1187                    "./test_case/nav.xhtml",
1188                    ManifestItem::new("custom-nav", "nav.xhtml")
1189                        .unwrap()
1190                        .append_property("nav")
1191                        .build(),
1192                )
1193                .unwrap();
1194
1195            let file = env::temp_dir().join(format!("{}.epub", local_time()));
1196            assert!(builder.make(&file).is_ok());
1197
1198            let doc = EpubDoc::new(&file).unwrap();
1199            assert!(doc.manifest.contains_key("custom-nav"));
1200            assert_eq!(
1201                doc.manifest
1202                    .get("custom-nav")
1203                    .unwrap()
1204                    .properties
1205                    .as_deref(),
1206                Some("nav")
1207            );
1208            assert!(!doc.manifest.contains_key("nav"));
1209            assert_eq!(doc.manifest.len(), 2);
1210        }
1211
1212        #[test]
1213        fn test_make_opf_file_success() {
1214            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1215
1216            builder.add_rootfile("content.opf").unwrap();
1217            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1218            builder.add_metadata(MetadataItem::new("language", "en"));
1219            builder.add_metadata(
1220                MetadataItem::new("identifier", "urn:isbn:1234567890")
1221                    .with_id("pub-id")
1222                    .build(),
1223            );
1224
1225            let test_file = builder.temp_dir.join("test.xhtml");
1226            fs::write(&test_file, "<html></html>").unwrap();
1227            builder
1228                .add_manifest(
1229                    test_file.to_str().unwrap(),
1230                    ManifestItem::new("test", "test.xhtml").unwrap(),
1231                )
1232                .unwrap();
1233
1234            builder.add_catalog_item(NavPoint::new("Chapter"));
1235            builder.add_spine(SpineItem::new("test"));
1236            builder.make_navigation_document().unwrap();
1237
1238            assert!(builder.make_opf_file().is_ok());
1239            assert!(builder.temp_dir.join("content.opf").exists());
1240        }
1241
1242        #[test]
1243        fn test_make_opf_file_missing_metadata() {
1244            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1245            builder.add_rootfile("content.opf").unwrap();
1246
1247            let result = builder.make_opf_file();
1248            assert!(result.is_err());
1249            assert_eq!(
1250                result.unwrap_err().to_string(),
1251                "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1252            );
1253        }
1254    }
1255
1256    mod manifest_tests {
1257        use super::*;
1258
1259        #[test]
1260        fn test_add_manifest_success() {
1261            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1262            builder.add_rootfile("content.opf").unwrap();
1263
1264            let test_file = builder.temp_dir.join("test.xhtml");
1265            fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
1266
1267            let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
1268            let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
1269
1270            assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
1271            assert_eq!(builder.manifest.manifest.len(), 1);
1272            assert!(builder.manifest.manifest.contains_key("test"));
1273        }
1274
1275        #[test]
1276        fn test_add_manifest_no_rootfile() {
1277            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1278
1279            let manifest_item = ManifestItem {
1280                id: "main".to_string(),
1281                path: PathBuf::from("/Overview.xhtml"),
1282                mime: String::new(),
1283                properties: None,
1284                fallback: None,
1285            };
1286
1287            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
1288            assert!(result.is_err());
1289            assert_eq!(
1290                result.unwrap_err(),
1291                EpubBuilderError::MissingRootfile.into()
1292            );
1293
1294            builder.add_rootfile("package.opf").unwrap();
1295            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
1296            assert!(result.is_ok());
1297        }
1298
1299        #[test]
1300        fn test_add_manifest_nonexistent_file() {
1301            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1302            builder.add_rootfile("content.opf").unwrap();
1303
1304            let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
1305            let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
1306
1307            assert!(result.is_err());
1308            assert_eq!(
1309                result.unwrap_err(),
1310                EpubBuilderError::TargetIsNotFile {
1311                    target_path: "nonexistent.xhtml".to_string()
1312                }
1313                .into()
1314            );
1315        }
1316
1317        #[test]
1318        fn test_add_manifest_unknown_file_format() {
1319            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1320            builder.add_rootfile("package.opf").unwrap();
1321
1322            let result = builder.add_manifest(
1323                "./test_case/unknown_file_format.xhtml",
1324                ManifestItem {
1325                    id: "file".to_string(),
1326                    path: PathBuf::from("unknown_file_format.xhtml"),
1327                    mime: String::new(),
1328                    properties: None,
1329                    fallback: None,
1330                },
1331            );
1332
1333            assert!(result.is_err());
1334            assert_eq!(
1335                result.unwrap_err(),
1336                EpubBuilderError::UnknownFileFormat {
1337                    file_path: "./test_case/unknown_file_format.xhtml".to_string(),
1338                }
1339                .into()
1340            );
1341        }
1342
1343        #[test]
1344        fn test_validate_fallback_chain_valid() {
1345            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1346
1347            let item3 = ManifestItem::new("item3", "path3").unwrap();
1348            let item2 = ManifestItem::new("item2", "path2")
1349                .unwrap()
1350                .with_fallback("item3")
1351                .build();
1352            let item1 = ManifestItem::new("item1", "path1")
1353                .unwrap()
1354                .with_fallback("item2")
1355                .append_property("nav")
1356                .build();
1357
1358            builder.manifest.insert("item3".to_string(), item3);
1359            builder.manifest.insert("item2".to_string(), item2);
1360            builder.manifest.insert("item1".to_string(), item1);
1361
1362            assert!(builder.manifest.validate().is_ok());
1363        }
1364
1365        #[test]
1366        fn test_validate_fallback_chain_circular_reference() {
1367            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1368
1369            let item2 = ManifestItem::new("item2", "path2")
1370                .unwrap()
1371                .with_fallback("item1")
1372                .build();
1373            let item1 = ManifestItem::new("item1", "path1")
1374                .unwrap()
1375                .with_fallback("item2")
1376                .build();
1377
1378            builder.manifest.insert("item1".to_string(), item1);
1379            builder.manifest.insert("item2".to_string(), item2);
1380
1381            let result = builder.manifest.validate();
1382            assert!(result.is_err());
1383            assert!(result.unwrap_err().to_string().starts_with(
1384                "Epub builder error: Circular reference detected in fallback chain for"
1385            ));
1386        }
1387
1388        #[test]
1389        fn test_validate_fallback_chain_not_found() {
1390            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1391
1392            let item1 = ManifestItem::new("item1", "path1")
1393                .unwrap()
1394                .with_fallback("nonexistent")
1395                .build();
1396
1397            builder.manifest.insert("item1".to_string(), item1);
1398
1399            let result = builder.manifest.validate();
1400            assert!(result.is_err());
1401            assert_eq!(
1402                result.unwrap_err().to_string(),
1403                "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1404            );
1405        }
1406
1407        #[test]
1408        fn test_validate_manifest_nav_single() {
1409            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1410
1411            let nav_item = ManifestItem::new("nav", "nav.xhtml")
1412                .unwrap()
1413                .append_property("nav")
1414                .build();
1415            builder
1416                .manifest
1417                .manifest
1418                .insert("nav".to_string(), nav_item);
1419
1420            assert!(builder.manifest.validate().is_ok());
1421        }
1422
1423        #[test]
1424        fn test_validate_manifest_nav_multiple() {
1425            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1426
1427            let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1428                .unwrap()
1429                .append_property("nav")
1430                .build();
1431            let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1432                .unwrap()
1433                .append_property("nav")
1434                .build();
1435
1436            builder
1437                .manifest
1438                .manifest
1439                .insert("nav1".to_string(), nav_item1);
1440            builder
1441                .manifest
1442                .manifest
1443                .insert("nav2".to_string(), nav_item2);
1444
1445            let result = builder.manifest.validate();
1446            assert!(result.is_err());
1447            assert_eq!(
1448                result.unwrap_err().to_string(),
1449                "Epub builder error: There are too many items with 'nav' property in the manifest."
1450            );
1451        }
1452    }
1453
1454    mod metadata_tests {
1455        use super::*;
1456
1457        #[test]
1458        fn test_validate_metadata_success() {
1459            let builder = test_helpers::create_basic_builder();
1460            assert!(builder.metadata.validate().is_ok());
1461        }
1462
1463        #[test]
1464        fn test_validate_metadata_missing_required() {
1465            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1466            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1467            builder.add_metadata(MetadataItem::new("language", "en"));
1468            assert!(builder.metadata.validate().is_err());
1469        }
1470    }
1471
1472    mod utility_tests {
1473        use super::*;
1474
1475        #[test]
1476        fn test_normalize_manifest_path() {
1477            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1478            builder.add_rootfile("content.opf").unwrap();
1479
1480            let result = normalize_manifest_path(
1481                &builder.temp_dir,
1482                builder.rootfiles.first().unwrap(),
1483                "../../test.xhtml",
1484                "id",
1485            );
1486            assert!(result.is_err());
1487            assert_eq!(
1488                result.unwrap_err(),
1489                EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
1490            );
1491
1492            let result = normalize_manifest_path(
1493                &builder.temp_dir,
1494                builder.rootfiles.first().unwrap(),
1495                "/test.xhtml",
1496                "id",
1497            );
1498            assert!(result.is_ok());
1499            assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
1500
1501            let result = normalize_manifest_path(
1502                &builder.temp_dir,
1503                builder.rootfiles.first().unwrap(),
1504                "./test.xhtml",
1505                "manifest_id",
1506            );
1507            assert!(result.is_err());
1508            assert_eq!(
1509                result.unwrap_err(),
1510                EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
1511                    .into(),
1512            );
1513        }
1514
1515        #[test]
1516        fn test_refine_mime_type() {
1517            assert_eq!(
1518                refine_mime_type("text/xml", "xhtml"),
1519                "application/xhtml+xml"
1520            );
1521            assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
1522            assert_eq!(
1523                refine_mime_type("application/xml", "opf"),
1524                "application/oebps-package+xml"
1525            );
1526            assert_eq!(
1527                refine_mime_type("text/xml", "ncx"),
1528                "application/x-dtbncx+xml"
1529            );
1530            assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1531            assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1532        }
1533    }
1534
1535    #[cfg(feature = "content-builder")]
1536    mod content_builder_tests {
1537        use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
1538
1539        #[test]
1540        fn test_make_contents_basic() {
1541            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1542            builder.add_rootfile("content.opf").unwrap();
1543
1544            let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
1545            content_builder
1546                .set_title("Test Chapter")
1547                .add_text_block("This is a test paragraph.", vec![])
1548                .unwrap();
1549
1550            builder.add_content("OEBPS/chapter1.xhtml", content_builder);
1551
1552            assert!(builder.make_contents().is_ok());
1553            assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
1554        }
1555
1556        #[test]
1557        fn test_make_contents_multiple_blocks() {
1558            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1559            builder.add_rootfile("content.opf").unwrap();
1560
1561            let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
1562            content_builder
1563                .set_title("多个区块章节")
1564                .add_text_block("第一段文本。", vec![])
1565                .unwrap()
1566                .add_quote_block("这是一个引用。", vec![])
1567                .unwrap()
1568                .add_title_block("子标题", 2, vec![])
1569                .unwrap()
1570                .add_text_block("最后的文本段落。", vec![])
1571                .unwrap();
1572
1573            builder.add_content("OEBPS/chapter2.xhtml", content_builder);
1574
1575            assert!(builder.make_contents().is_ok());
1576            assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
1577        }
1578
1579        #[test]
1580        fn test_make_contents_with_media() {
1581            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1582            builder.add_rootfile("content.opf").unwrap();
1583
1584            let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
1585            content_builder
1586                .set_title("Chapter with Media")
1587                .add_text_block("Text before image.", vec![])
1588                .unwrap()
1589                .add_image_block(
1590                    std::path::PathBuf::from("./test_case/image.jpg"),
1591                    Some("Test Image".to_string()),
1592                    Some("Figure 1: A test image".to_string()),
1593                    vec![],
1594                )
1595                .unwrap()
1596                .add_text_block("Text after image.", vec![])
1597                .unwrap();
1598
1599            builder.add_content("OEBPS/chapter3.xhtml", content_builder);
1600
1601            assert!(builder.make_contents().is_ok());
1602            assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
1603            assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
1604        }
1605
1606        #[test]
1607        fn test_make_contents_multiple_documents() {
1608            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1609            builder.add_rootfile("content.opf").unwrap();
1610
1611            for (id, title) in [
1612                ("ch1", "Chapter 1"),
1613                ("ch2", "Chapter 2"),
1614                ("ch3", "Chapter 3"),
1615            ] {
1616                let mut content = ContentBuilder::new(id, "en").unwrap();
1617                content
1618                    .set_title(title)
1619                    .add_text_block(&format!("Content of {}", title), vec![])
1620                    .unwrap();
1621                builder.add_content(format!("OEBPS/{}.xhtml", id), content);
1622            }
1623
1624            assert!(builder.make_contents().is_ok());
1625            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1626            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1627            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1628        }
1629
1630        #[test]
1631        fn test_make_contents_different_languages() {
1632            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1633            builder.add_rootfile("content.opf").unwrap();
1634
1635            let langs = [
1636                ("en_ch", "en", "English Chapter"),
1637                ("zh_ch", "zh-CN", "中文章节"),
1638                ("ja_ch", "ja", "日本語の章"),
1639            ];
1640
1641            for (id, lang, title) in langs {
1642                let mut content = ContentBuilder::new(id, lang).unwrap();
1643                content
1644                    .set_title(title)
1645                    .add_text_block(&format!("Text in {}", lang), vec![])
1646                    .unwrap();
1647                builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
1648            }
1649
1650            assert!(builder.make_contents().is_ok());
1651            assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
1652            assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
1653            assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
1654        }
1655
1656        #[test]
1657        fn test_make_contents_unique_identifiers() {
1658            use std::path::PathBuf;
1659
1660            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1661            builder.add_rootfile("content.opf").unwrap();
1662
1663            let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
1664            content1.add_text_block("First content", vec![]).unwrap();
1665            builder.add_content("OEBPS/ch1.xhtml", content1);
1666
1667            let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
1668            content2.add_text_block("Second content", vec![]).unwrap();
1669            builder.add_content("OEBPS/ch2.xhtml", content2);
1670
1671            let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
1672            content3
1673                .add_text_block("Duplicate ID content", vec![])
1674                .unwrap();
1675            builder.add_content("OEBPS/ch3.xhtml", content3);
1676
1677            assert!(builder.make_contents().is_ok());
1678            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1679            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1680            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1681
1682            let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
1683            assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
1684        }
1685
1686        #[test]
1687        fn test_make_contents_complex_structure() {
1688            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1689            builder.add_rootfile("content.opf").unwrap();
1690
1691            let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
1692            content
1693                .set_title("Complex Chapter")
1694                .add_title_block("Section 1", 2, vec![])
1695                .unwrap()
1696                .add_text_block("Introduction text.", vec![])
1697                .unwrap()
1698                .add_quote_block("A wise quote here.", vec![])
1699                .unwrap()
1700                .add_title_block("Section 2", 2, vec![])
1701                .unwrap()
1702                .add_text_block("More content with multiple paragraphs.", vec![])
1703                .unwrap()
1704                .add_text_block("Another paragraph.", vec![])
1705                .unwrap()
1706                .add_title_block("Section 3", 2, vec![])
1707                .unwrap()
1708                .add_quote_block("Another quotation.", vec![])
1709                .unwrap();
1710
1711            builder.add_content("OEBPS/complex_chapter.xhtml", content);
1712
1713            assert!(builder.make_contents().is_ok());
1714            assert!(
1715                builder
1716                    .temp_dir
1717                    .join("OEBPS/complex_chapter.xhtml")
1718                    .exists()
1719            );
1720        }
1721
1722        #[test]
1723        fn test_make_contents_empty_document() {
1724            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1725            builder.add_rootfile("content.opf").unwrap();
1726
1727            let content = ContentBuilder::new("empty_ch", "en").unwrap();
1728            builder.add_content("OEBPS/empty.xhtml", content);
1729
1730            assert!(builder.make_contents().is_ok());
1731            assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
1732        }
1733
1734        #[test]
1735        fn test_make_contents_path_normalization() {
1736            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1737            builder.add_rootfile("OEBPS/content.opf").unwrap();
1738
1739            let mut content = ContentBuilder::new("path_test", "en").unwrap();
1740            content.add_text_block("Path test content", vec![]).unwrap();
1741
1742            builder.add_content("/OEBPS/text/chapter.xhtml", content);
1743
1744            assert!(builder.make_contents().is_ok());
1745            assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
1746        }
1747    }
1748}