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    /// An error will occur if navigation information is not initialized.
563    fn make_navigation_document(&mut self) -> Result<(), EpubError> {
564        if self.catalog.is_empty() {
565            return Err(EpubBuilderError::NavigationInfoUninitalized.into());
566        }
567
568        let mut writer = Writer::new(Cursor::new(Vec::new()));
569        self.catalog.make(&mut writer)?;
570
571        let file_path = self.temp_dir.join("nav.xhtml");
572        let file_data = writer.into_inner().into_inner();
573        fs::write(file_path, file_data)?;
574
575        self.manifest.insert(
576            "nav".to_string(),
577            ManifestItem {
578                id: "nav".to_string(),
579                path: PathBuf::from("/nav.xhtml"),
580                mime: "application/xhtml+xml".to_string(),
581                properties: Some("nav".to_string()),
582                fallback: None,
583            },
584        );
585
586        Ok(())
587    }
588
589    /// Creates the `OPF` file
590    ///
591    /// ## Error conditions
592    /// - Missing necessary metadata
593    /// - Circular reference exists in the manifest backlink
594    /// - Navigation information is not initialized
595    fn make_opf_file(&mut self) -> Result<(), EpubError> {
596        self.metadata.validate()?;
597        self.manifest.validate()?;
598        self.spine.validate(self.manifest.keys())?;
599
600        let mut writer = Writer::new(Cursor::new(Vec::new()));
601
602        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
603
604        writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
605            ("xmlns", "http://www.idpf.org/2007/opf"),
606            ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
607            ("unique-identifier", "pub-id"),
608            ("version", "3.0"),
609        ])))?;
610
611        self.metadata.make(&mut writer)?;
612        self.manifest.make(&mut writer)?;
613        self.spine.make(&mut writer)?;
614
615        writer.write_event(Event::End(BytesEnd::new("package")))?;
616
617        let file_path = self
618            .temp_dir
619            .join(self.rootfiles.first().expect("Unreachable"));
620        let file_data = writer.into_inner().into_inner();
621        fs::write(file_path, file_data)?;
622
623        Ok(())
624    }
625
626    /// Remove empty directories under the builder temporary directory
627    ///
628    /// By enumerate directories under `self.temp_dir` (excluding the root itself)
629    /// and deletes directories that are empty. Directories are processed from deepest
630    /// to shallowest so that parent directories which become empty after child
631    /// deletion can also be removed.
632    ///
633    /// ## Return
634    /// - `Ok(())`: Successfully removed all empty directories
635    /// - `Err(EpubError)`: IO error
636    fn remove_empty_dirs(&self) -> Result<(), EpubError> {
637        let mut dirs = WalkDir::new(self.temp_dir.as_path())
638            .min_depth(1)
639            .into_iter()
640            .filter_map(|entry| entry.ok())
641            .filter(|entry| entry.file_type().is_dir())
642            .map(|entry| entry.into_path())
643            .collect::<Vec<PathBuf>>();
644
645        dirs.sort_by_key(|p| Reverse(p.components().count()));
646
647        for dir in dirs {
648            if fs::read_dir(&dir)?.next().is_none() {
649                fs::remove_dir(dir)?;
650            }
651        }
652
653        Ok(())
654    }
655}
656
657impl<Version> Drop for EpubBuilder<Version> {
658    /// Remove temporary directory when dropped
659    fn drop(&mut self) {
660        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
661            warn!("{}", err);
662        };
663    }
664}
665
666/// Refine the MIME type based on file extension
667///
668/// This function optimizes MIME types that are inferred from file content by using
669/// the file extension to determine the correct EPUB-specific MIME type. Some file
670/// types have different MIME types depending on how they are used in an EPUB context.
671fn refine_mime_type<'a>(infer_mime: &'a str, extension: &'a str) -> &'a str {
672    match (infer_mime, extension) {
673        ("text/xml", "xhtml")
674        | ("application/xml", "xhtml")
675        | ("text/xml", "xht")
676        | ("application/xml", "xht") => "application/xhtml+xml",
677
678        ("text/xml", "opf") | ("application/xml", "opf") => "application/oebps-package+xml",
679
680        ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml",
681
682        ("application/zip", "epub") => "application/epub+zip",
683
684        ("text/plain", "css") => "text/css",
685        ("text/plain", "js") => "application/javascript",
686        ("text/plain", "json") => "application/json",
687        ("text/plain", "svg") => "image/svg+xml",
688
689        _ => infer_mime,
690    }
691}
692
693/// Normalize manifest path to absolute path within EPUB container
694///
695/// This function takes a path (relative or absolute) and normalizes it to an absolute
696/// path within the EPUB container structure. It handles various path formats including:
697/// - Relative paths starting with "../" (with security check to prevent directory traversal)
698/// - Absolute paths starting with "/" (relative to EPUB root)
699/// - Relative paths starting with "./" (current directory)
700/// - Plain relative paths (relative to the OPF file location)
701///
702/// ## Parameters
703/// - `temp_dir`: The temporary directory path used during the EPUB build process
704/// - `rootfile`: The path to the OPF file (package document), used to determine the base directory
705/// - `path`: The input path that may be relative or absolute. Can be any type that
706///   implements `AsRef<Path>`, such as `&str`, `String`, `Path`, `PathBuf`, etc.
707/// - `id`: The identifier of the manifest item being processed
708///
709/// ## Return
710/// - `Ok(PathBuf)`: The normalized absolute path within the EPUB container,
711///   which does not start with "/"
712/// - `Err(EpubError)`: Error if path traversal is detected outside the EPUB container,
713///   or if the absolute path cannot be determined
714fn normalize_manifest_path<TempD: AsRef<Path>, S: AsRef<str>, P: AsRef<Path>>(
715    temp_dir: TempD,
716    rootfile: S,
717    path: P,
718    id: &str,
719) -> Result<PathBuf, EpubError> {
720    let opf_path = PathBuf::from(rootfile.as_ref());
721    let basic_path = remove_leading_slash(opf_path.parent().unwrap());
722
723    // convert manifest path to absolute path(physical path)
724    let target_path = if path.as_ref().starts_with("../") {
725        check_realtive_link_leakage(
726            temp_dir.as_ref().to_path_buf(),
727            basic_path.to_path_buf(),
728            &path.as_ref().to_string_lossy(),
729        )
730        .map(PathBuf::from)
731        .ok_or_else(|| EpubError::RelativeLinkLeakage {
732            path: path.as_ref().to_string_lossy().to_string(),
733        })?
734    } else if let Ok(stripped) = path.as_ref().strip_prefix("/") {
735        temp_dir.as_ref().join(stripped)
736    } else if path.as_ref().starts_with("./") {
737        // can not anlyze where the 'current' directory is
738        Err(EpubBuilderError::IllegalManifestPath { manifest_id: id.to_string() })?
739    } else {
740        temp_dir.as_ref().join(basic_path).join(path)
741    };
742
743    #[cfg(windows)]
744    let target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
745
746    Ok(target_path)
747}
748
749#[cfg(test)]
750mod tests {
751    use std::{env, fs, path::PathBuf};
752
753    use crate::{
754        builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
755        epub::EpubDoc,
756        error::{EpubBuilderError, EpubError},
757        types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
758        utils::local_time,
759    };
760
761    mod test_helpers {
762        use super::*;
763
764        pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
765            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
766            builder.add_rootfile("content.opf").unwrap();
767            builder.add_metadata(MetadataItem::new("title", "Test Book"));
768            builder.add_metadata(MetadataItem::new("language", "en"));
769            builder.add_metadata(
770                MetadataItem::new("identifier", "urn:isbn:1234567890")
771                    .with_id("pub-id")
772                    .build(),
773            );
774            builder
775        }
776
777        pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
778            let mut builder = create_basic_builder();
779            builder.add_catalog_item(NavPoint::new("Chapter"));
780            builder.add_spine(SpineItem::new("test"));
781            builder
782        }
783    }
784
785    mod epub_builder_tests {
786        use super::*;
787
788        #[test]
789        fn test_epub_builder_new() {
790            let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
791            assert!(builder.temp_dir.exists());
792            assert!(builder.rootfiles.is_empty());
793            assert!(builder.metadata.metadata.is_empty());
794            assert!(builder.manifest.manifest.is_empty());
795            assert!(builder.spine.spine.is_empty());
796            assert!(builder.catalog.title.is_empty());
797            assert!(builder.catalog.is_empty());
798        }
799
800        #[test]
801        fn test_add_rootfile() {
802            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
803
804            builder
805                .add_rootfile("content.opf")
806                .expect("Failed to add rootfile");
807            assert_eq!(builder.rootfiles.rootfiles.len(), 1);
808            assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
809
810            builder
811                .add_rootfile("./another.opf")
812                .expect("Failed to add another rootfile");
813            assert_eq!(builder.rootfiles.rootfiles.len(), 2);
814            assert_eq!(
815                builder.rootfiles.rootfiles,
816                vec!["content.opf", "another.opf"]
817            );
818        }
819
820        #[test]
821        fn test_add_rootfile_fail() {
822            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
823
824            let result = builder.add_rootfile("/rootfile.opf");
825            assert!(result.is_err());
826            assert_eq!(
827                result.unwrap_err(),
828                EpubBuilderError::IllegalRootfilePath.into()
829            );
830
831            let result = builder.add_rootfile("../rootfile.opf");
832            assert!(result.is_err());
833            assert_eq!(
834                result.unwrap_err(),
835                EpubBuilderError::IllegalRootfilePath.into()
836            );
837        }
838
839        #[test]
840        fn test_add_metadata() {
841            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
842            let metadata_item = MetadataItem::new("title", "Test Book");
843
844            builder.add_metadata(metadata_item);
845
846            assert_eq!(builder.metadata.metadata.len(), 1);
847            assert_eq!(builder.metadata.metadata[0].property, "title");
848            assert_eq!(builder.metadata.metadata[0].value, "Test Book");
849        }
850
851        #[test]
852        fn test_add_spine() {
853            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
854            let spine_item = SpineItem::new("test_item");
855
856            builder.add_spine(spine_item);
857
858            assert_eq!(builder.spine.spine.len(), 1);
859            assert_eq!(builder.spine.spine[0].idref, "test_item");
860        }
861
862        #[test]
863        fn test_set_catalog_title() {
864            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
865            let title = "Test Catalog Title";
866
867            builder.set_catalog_title(title);
868
869            assert_eq!(builder.catalog.title, title);
870        }
871
872        #[test]
873        fn test_add_catalog_item() {
874            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
875            let nav_point = NavPoint::new("Chapter 1");
876
877            builder.add_catalog_item(nav_point);
878
879            assert_eq!(builder.catalog.catalog.len(), 1);
880            assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
881        }
882
883        #[test]
884        fn test_clear_all() {
885            let mut builder = test_helpers::create_full_builder();
886
887            assert_eq!(builder.metadata.metadata.len(), 3);
888            assert_eq!(builder.spine.spine.len(), 1);
889            assert_eq!(builder.catalog.catalog.len(), 1);
890
891            builder.clear_all();
892
893            assert!(builder.metadata.metadata.is_empty());
894            assert!(builder.spine.spine.is_empty());
895            assert!(builder.catalog.catalog.is_empty());
896            assert!(builder.catalog.title.is_empty());
897            assert!(builder.manifest.manifest.is_empty());
898
899            builder.add_metadata(MetadataItem::new("title", "New Book"));
900            builder.add_spine(SpineItem::new("new_chapter"));
901            builder.add_catalog_item(NavPoint::new("New Chapter"));
902
903            assert_eq!(builder.metadata.metadata.len(), 1);
904            assert_eq!(builder.spine.spine.len(), 1);
905            assert_eq!(builder.catalog.catalog.len(), 1);
906        }
907
908        #[test]
909        fn test_make() {
910            let mut builder = test_helpers::create_full_builder();
911
912            builder
913                .add_manifest(
914                    "./test_case/Overview.xhtml",
915                    ManifestItem {
916                        id: "test".to_string(),
917                        path: PathBuf::from("test.xhtml"),
918                        mime: String::new(),
919                        properties: None,
920                        fallback: None,
921                    },
922                )
923                .unwrap();
924
925            let file = env::temp_dir().join(format!("{}.epub", local_time()));
926            assert!(builder.make(&file).is_ok());
927            assert!(EpubDoc::new(&file).is_ok());
928        }
929
930        #[test]
931        fn test_build() {
932            let mut builder = test_helpers::create_full_builder();
933
934            builder
935                .add_manifest(
936                    "./test_case/Overview.xhtml",
937                    ManifestItem {
938                        id: "test".to_string(),
939                        path: PathBuf::from("test.xhtml"),
940                        mime: String::new(),
941                        properties: None,
942                        fallback: None,
943                    },
944                )
945                .unwrap();
946
947            let file = env::temp_dir().join(format!("{}.epub", local_time()));
948            assert!(builder.build(&file).is_ok());
949        }
950
951        #[test]
952        fn test_from() {
953            let metadata = vec![
954                MetadataItem {
955                    id: None,
956                    property: "title".to_string(),
957                    value: "Test Book".to_string(),
958                    lang: None,
959                    refined: vec![],
960                },
961                MetadataItem {
962                    id: None,
963                    property: "language".to_string(),
964                    value: "en".to_string(),
965                    lang: None,
966                    refined: vec![],
967                },
968                MetadataItem {
969                    id: Some("pub-id".to_string()),
970                    property: "identifier".to_string(),
971                    value: "test-book".to_string(),
972                    lang: None,
973                    refined: vec![],
974                },
975            ];
976            let spine = vec![SpineItem {
977                id: None,
978                idref: "main".to_string(),
979                linear: true,
980                properties: None,
981            }];
982            let catalog = vec![
983                NavPoint {
984                    label: "Nav".to_string(),
985                    content: None,
986                    children: vec![],
987                    play_order: None,
988                },
989                NavPoint {
990                    label: "Overview".to_string(),
991                    content: None,
992                    children: vec![],
993                    play_order: None,
994                },
995            ];
996
997            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
998            builder.add_rootfile("content.opf").unwrap();
999            builder.metadata.metadata = metadata.clone();
1000            builder.spine.spine = spine.clone();
1001            builder.catalog.catalog = catalog.clone();
1002            builder.set_catalog_title("catalog title");
1003            builder
1004                .add_manifest(
1005                    "./test_case/Overview.xhtml",
1006                    ManifestItem {
1007                        id: "main".to_string(),
1008                        path: PathBuf::from("Overview.xhtml"),
1009                        mime: String::new(),
1010                        properties: None,
1011                        fallback: None,
1012                    },
1013                )
1014                .unwrap();
1015
1016            let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1017            builder.make(&epub_file).unwrap();
1018
1019            let mut doc = EpubDoc::new(&epub_file).unwrap();
1020            let builder = EpubBuilder::from(&mut doc).unwrap();
1021
1022            assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
1023            assert_eq!(builder.manifest.manifest.len(), 1);
1024            assert_eq!(builder.spine.spine.len(), spine.len());
1025            assert_eq!(builder.catalog.catalog, catalog);
1026            assert_eq!(builder.catalog.title, "catalog title");
1027        }
1028
1029        #[test]
1030        fn test_make_container_file() {
1031            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1032
1033            let result = builder.make_container_xml();
1034            assert!(result.is_err());
1035            assert_eq!(
1036                result.unwrap_err(),
1037                EpubBuilderError::MissingRootfile.into()
1038            );
1039
1040            builder.add_rootfile("content.opf").unwrap();
1041            assert!(builder.make_container_xml().is_ok());
1042        }
1043
1044        #[test]
1045        fn test_make_navigation_document() {
1046            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1047
1048            let result = builder.make_navigation_document();
1049            assert!(result.is_err());
1050            assert_eq!(
1051                result.unwrap_err(),
1052                EpubBuilderError::NavigationInfoUninitalized.into()
1053            );
1054
1055            builder.add_catalog_item(NavPoint::new("test"));
1056            assert!(builder.make_navigation_document().is_ok());
1057        }
1058
1059        #[test]
1060        fn test_make_opf_file_success() {
1061            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1062
1063            builder.add_rootfile("content.opf").unwrap();
1064            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1065            builder.add_metadata(MetadataItem::new("language", "en"));
1066            builder.add_metadata(
1067                MetadataItem::new("identifier", "urn:isbn:1234567890")
1068                    .with_id("pub-id")
1069                    .build(),
1070            );
1071
1072            let test_file = builder.temp_dir.join("test.xhtml");
1073            fs::write(&test_file, "<html></html>").unwrap();
1074            builder
1075                .add_manifest(
1076                    test_file.to_str().unwrap(),
1077                    ManifestItem::new("test", "test.xhtml").unwrap(),
1078                )
1079                .unwrap();
1080
1081            builder.add_catalog_item(NavPoint::new("Chapter"));
1082            builder.add_spine(SpineItem::new("test"));
1083            builder.make_navigation_document().unwrap();
1084
1085            assert!(builder.make_opf_file().is_ok());
1086            assert!(builder.temp_dir.join("content.opf").exists());
1087        }
1088
1089        #[test]
1090        fn test_make_opf_file_missing_metadata() {
1091            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1092            builder.add_rootfile("content.opf").unwrap();
1093
1094            let result = builder.make_opf_file();
1095            assert!(result.is_err());
1096            assert_eq!(
1097                result.unwrap_err().to_string(),
1098                "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1099            );
1100        }
1101    }
1102
1103    mod manifest_tests {
1104        use super::*;
1105
1106        #[test]
1107        fn test_add_manifest_success() {
1108            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1109            builder.add_rootfile("content.opf").unwrap();
1110
1111            let test_file = builder.temp_dir.join("test.xhtml");
1112            fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
1113
1114            let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
1115            let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
1116
1117            assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
1118            assert_eq!(builder.manifest.manifest.len(), 1);
1119            assert!(builder.manifest.manifest.contains_key("test"));
1120        }
1121
1122        #[test]
1123        fn test_add_manifest_no_rootfile() {
1124            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1125
1126            let manifest_item = ManifestItem {
1127                id: "main".to_string(),
1128                path: PathBuf::from("/Overview.xhtml"),
1129                mime: String::new(),
1130                properties: None,
1131                fallback: None,
1132            };
1133
1134            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
1135            assert!(result.is_err());
1136            assert_eq!(
1137                result.unwrap_err(),
1138                EpubBuilderError::MissingRootfile.into()
1139            );
1140
1141            builder.add_rootfile("package.opf").unwrap();
1142            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
1143            assert!(result.is_ok());
1144        }
1145
1146        #[test]
1147        fn test_add_manifest_nonexistent_file() {
1148            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1149            builder.add_rootfile("content.opf").unwrap();
1150
1151            let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
1152            let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
1153
1154            assert!(result.is_err());
1155            assert_eq!(
1156                result.unwrap_err(),
1157                EpubBuilderError::TargetIsNotFile {
1158                    target_path: "nonexistent.xhtml".to_string()
1159                }
1160                .into()
1161            );
1162        }
1163
1164        #[test]
1165        fn test_add_manifest_unknown_file_format() {
1166            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1167            builder.add_rootfile("package.opf").unwrap();
1168
1169            let result = builder.add_manifest(
1170                "./test_case/unknown_file_format.xhtml",
1171                ManifestItem {
1172                    id: "file".to_string(),
1173                    path: PathBuf::from("unknown_file_format.xhtml"),
1174                    mime: String::new(),
1175                    properties: None,
1176                    fallback: None,
1177                },
1178            );
1179
1180            assert!(result.is_err());
1181            assert_eq!(
1182                result.unwrap_err(),
1183                EpubBuilderError::UnknownFileFormat {
1184                    file_path: "./test_case/unknown_file_format.xhtml".to_string(),
1185                }
1186                .into()
1187            );
1188        }
1189
1190        #[test]
1191        fn test_validate_fallback_chain_valid() {
1192            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1193
1194            let item3 = ManifestItem::new("item3", "path3").unwrap();
1195            let item2 = ManifestItem::new("item2", "path2")
1196                .unwrap()
1197                .with_fallback("item3")
1198                .build();
1199            let item1 = ManifestItem::new("item1", "path1")
1200                .unwrap()
1201                .with_fallback("item2")
1202                .append_property("nav")
1203                .build();
1204
1205            builder.manifest.insert("item3".to_string(), item3);
1206            builder.manifest.insert("item2".to_string(), item2);
1207            builder.manifest.insert("item1".to_string(), item1);
1208
1209            assert!(builder.manifest.validate().is_ok());
1210        }
1211
1212        #[test]
1213        fn test_validate_fallback_chain_circular_reference() {
1214            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1215
1216            let item2 = ManifestItem::new("item2", "path2")
1217                .unwrap()
1218                .with_fallback("item1")
1219                .build();
1220            let item1 = ManifestItem::new("item1", "path1")
1221                .unwrap()
1222                .with_fallback("item2")
1223                .build();
1224
1225            builder.manifest.insert("item1".to_string(), item1);
1226            builder.manifest.insert("item2".to_string(), item2);
1227
1228            let result = builder.manifest.validate();
1229            assert!(result.is_err());
1230            assert!(result.unwrap_err().to_string().starts_with(
1231                "Epub builder error: Circular reference detected in fallback chain for"
1232            ));
1233        }
1234
1235        #[test]
1236        fn test_validate_fallback_chain_not_found() {
1237            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1238
1239            let item1 = ManifestItem::new("item1", "path1")
1240                .unwrap()
1241                .with_fallback("nonexistent")
1242                .build();
1243
1244            builder.manifest.insert("item1".to_string(), item1);
1245
1246            let result = builder.manifest.validate();
1247            assert!(result.is_err());
1248            assert_eq!(
1249                result.unwrap_err().to_string(),
1250                "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1251            );
1252        }
1253
1254        #[test]
1255        fn test_validate_manifest_nav_single() {
1256            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1257
1258            let nav_item = ManifestItem::new("nav", "nav.xhtml")
1259                .unwrap()
1260                .append_property("nav")
1261                .build();
1262            builder
1263                .manifest
1264                .manifest
1265                .insert("nav".to_string(), nav_item);
1266
1267            assert!(builder.manifest.validate().is_ok());
1268        }
1269
1270        #[test]
1271        fn test_validate_manifest_nav_multiple() {
1272            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1273
1274            let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1275                .unwrap()
1276                .append_property("nav")
1277                .build();
1278            let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1279                .unwrap()
1280                .append_property("nav")
1281                .build();
1282
1283            builder
1284                .manifest
1285                .manifest
1286                .insert("nav1".to_string(), nav_item1);
1287            builder
1288                .manifest
1289                .manifest
1290                .insert("nav2".to_string(), nav_item2);
1291
1292            let result = builder.manifest.validate();
1293            assert!(result.is_err());
1294            assert_eq!(
1295                result.unwrap_err().to_string(),
1296                "Epub builder error: There are too many items with 'nav' property in the manifest."
1297            );
1298        }
1299    }
1300
1301    mod metadata_tests {
1302        use super::*;
1303
1304        #[test]
1305        fn test_validate_metadata_success() {
1306            let builder = test_helpers::create_basic_builder();
1307            assert!(builder.metadata.validate().is_ok());
1308        }
1309
1310        #[test]
1311        fn test_validate_metadata_missing_required() {
1312            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1313            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1314            builder.add_metadata(MetadataItem::new("language", "en"));
1315            assert!(builder.metadata.validate().is_err());
1316        }
1317    }
1318
1319    mod utility_tests {
1320        use super::*;
1321
1322        #[test]
1323        fn test_normalize_manifest_path() {
1324            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1325            builder.add_rootfile("content.opf").unwrap();
1326
1327            let result = normalize_manifest_path(
1328                &builder.temp_dir,
1329                builder.rootfiles.first().unwrap(),
1330                "../../test.xhtml",
1331                "id",
1332            );
1333            assert!(result.is_err());
1334            assert_eq!(
1335                result.unwrap_err(),
1336                EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
1337            );
1338
1339            let result = normalize_manifest_path(
1340                &builder.temp_dir,
1341                builder.rootfiles.first().unwrap(),
1342                "/test.xhtml",
1343                "id",
1344            );
1345            assert!(result.is_ok());
1346            assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
1347
1348            let result = normalize_manifest_path(
1349                &builder.temp_dir,
1350                builder.rootfiles.first().unwrap(),
1351                "./test.xhtml",
1352                "manifest_id",
1353            );
1354            assert!(result.is_err());
1355            assert_eq!(
1356                result.unwrap_err(),
1357                EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
1358                    .into(),
1359            );
1360        }
1361
1362        #[test]
1363        fn test_refine_mime_type() {
1364            assert_eq!(
1365                refine_mime_type("text/xml", "xhtml"),
1366                "application/xhtml+xml"
1367            );
1368            assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
1369            assert_eq!(
1370                refine_mime_type("application/xml", "opf"),
1371                "application/oebps-package+xml"
1372            );
1373            assert_eq!(
1374                refine_mime_type("text/xml", "ncx"),
1375                "application/x-dtbncx+xml"
1376            );
1377            assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1378            assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1379        }
1380    }
1381
1382    #[cfg(feature = "content-builder")]
1383    mod content_builder_tests {
1384        use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
1385
1386        #[test]
1387        fn test_make_contents_basic() {
1388            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1389            builder.add_rootfile("content.opf").unwrap();
1390
1391            let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
1392            content_builder
1393                .set_title("Test Chapter")
1394                .add_text_block("This is a test paragraph.", vec![])
1395                .unwrap();
1396
1397            builder.add_content("OEBPS/chapter1.xhtml", content_builder);
1398
1399            assert!(builder.make_contents().is_ok());
1400            assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
1401        }
1402
1403        #[test]
1404        fn test_make_contents_multiple_blocks() {
1405            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1406            builder.add_rootfile("content.opf").unwrap();
1407
1408            let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
1409            content_builder
1410                .set_title("多个区块章节")
1411                .add_text_block("第一段文本。", vec![])
1412                .unwrap()
1413                .add_quote_block("这是一个引用。", vec![])
1414                .unwrap()
1415                .add_title_block("子标题", 2, vec![])
1416                .unwrap()
1417                .add_text_block("最后的文本段落。", vec![])
1418                .unwrap();
1419
1420            builder.add_content("OEBPS/chapter2.xhtml", content_builder);
1421
1422            assert!(builder.make_contents().is_ok());
1423            assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
1424        }
1425
1426        #[test]
1427        fn test_make_contents_with_media() {
1428            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1429            builder.add_rootfile("content.opf").unwrap();
1430
1431            let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
1432            content_builder
1433                .set_title("Chapter with Media")
1434                .add_text_block("Text before image.", vec![])
1435                .unwrap()
1436                .add_image_block(
1437                    std::path::PathBuf::from("./test_case/image.jpg"),
1438                    Some("Test Image".to_string()),
1439                    Some("Figure 1: A test image".to_string()),
1440                    vec![],
1441                )
1442                .unwrap()
1443                .add_text_block("Text after image.", vec![])
1444                .unwrap();
1445
1446            builder.add_content("OEBPS/chapter3.xhtml", content_builder);
1447
1448            assert!(builder.make_contents().is_ok());
1449            assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
1450            assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
1451        }
1452
1453        #[test]
1454        fn test_make_contents_multiple_documents() {
1455            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1456            builder.add_rootfile("content.opf").unwrap();
1457
1458            for (id, title) in [
1459                ("ch1", "Chapter 1"),
1460                ("ch2", "Chapter 2"),
1461                ("ch3", "Chapter 3"),
1462            ] {
1463                let mut content = ContentBuilder::new(id, "en").unwrap();
1464                content
1465                    .set_title(title)
1466                    .add_text_block(&format!("Content of {}", title), vec![])
1467                    .unwrap();
1468                builder.add_content(format!("OEBPS/{}.xhtml", id), content);
1469            }
1470
1471            assert!(builder.make_contents().is_ok());
1472            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1473            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1474            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1475        }
1476
1477        #[test]
1478        fn test_make_contents_different_languages() {
1479            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1480            builder.add_rootfile("content.opf").unwrap();
1481
1482            let langs = [
1483                ("en_ch", "en", "English Chapter"),
1484                ("zh_ch", "zh-CN", "中文章节"),
1485                ("ja_ch", "ja", "日本語の章"),
1486            ];
1487
1488            for (id, lang, title) in langs {
1489                let mut content = ContentBuilder::new(id, lang).unwrap();
1490                content
1491                    .set_title(title)
1492                    .add_text_block(&format!("Text in {}", lang), vec![])
1493                    .unwrap();
1494                builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
1495            }
1496
1497            assert!(builder.make_contents().is_ok());
1498            assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
1499            assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
1500            assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
1501        }
1502
1503        #[test]
1504        fn test_make_contents_unique_identifiers() {
1505            use std::path::PathBuf;
1506
1507            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1508            builder.add_rootfile("content.opf").unwrap();
1509
1510            let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
1511            content1.add_text_block("First content", vec![]).unwrap();
1512            builder.add_content("OEBPS/ch1.xhtml", content1);
1513
1514            let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
1515            content2.add_text_block("Second content", vec![]).unwrap();
1516            builder.add_content("OEBPS/ch2.xhtml", content2);
1517
1518            let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
1519            content3
1520                .add_text_block("Duplicate ID content", vec![])
1521                .unwrap();
1522            builder.add_content("OEBPS/ch3.xhtml", content3);
1523
1524            assert!(builder.make_contents().is_ok());
1525            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1526            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1527            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1528
1529            let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
1530            assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
1531        }
1532
1533        #[test]
1534        fn test_make_contents_complex_structure() {
1535            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1536            builder.add_rootfile("content.opf").unwrap();
1537
1538            let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
1539            content
1540                .set_title("Complex Chapter")
1541                .add_title_block("Section 1", 2, vec![])
1542                .unwrap()
1543                .add_text_block("Introduction text.", vec![])
1544                .unwrap()
1545                .add_quote_block("A wise quote here.", vec![])
1546                .unwrap()
1547                .add_title_block("Section 2", 2, vec![])
1548                .unwrap()
1549                .add_text_block("More content with multiple paragraphs.", vec![])
1550                .unwrap()
1551                .add_text_block("Another paragraph.", vec![])
1552                .unwrap()
1553                .add_title_block("Section 3", 2, vec![])
1554                .unwrap()
1555                .add_quote_block("Another quotation.", vec![])
1556                .unwrap();
1557
1558            builder.add_content("OEBPS/complex_chapter.xhtml", content);
1559
1560            assert!(builder.make_contents().is_ok());
1561            assert!(
1562                builder
1563                    .temp_dir
1564                    .join("OEBPS/complex_chapter.xhtml")
1565                    .exists()
1566            );
1567        }
1568
1569        #[test]
1570        fn test_make_contents_empty_document() {
1571            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1572            builder.add_rootfile("content.opf").unwrap();
1573
1574            let content = ContentBuilder::new("empty_ch", "en").unwrap();
1575            builder.add_content("OEBPS/empty.xhtml", content);
1576
1577            assert!(builder.make_contents().is_ok());
1578            assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
1579        }
1580
1581        #[test]
1582        fn test_make_contents_path_normalization() {
1583            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1584            builder.add_rootfile("OEBPS/content.opf").unwrap();
1585
1586            let mut content = ContentBuilder::new("path_test", "en").unwrap();
1587            content.add_text_block("Path test content", vec![]).unwrap();
1588
1589            builder.add_content("/OEBPS/text/chapter.xhtml", content);
1590
1591            assert!(builder.make_contents().is_ok());
1592            assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
1593        }
1594    }
1595}