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>(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 mut 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(path) = path.as_ref().strip_prefix("/") {
735        temp_dir.as_ref().join(path)
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    {
745        target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
746    }
747
748    Ok(target_path)
749}
750
751#[cfg(test)]
752mod tests {
753    use std::{env, fs, path::PathBuf};
754
755    use crate::{
756        builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
757        epub::EpubDoc,
758        error::{EpubBuilderError, EpubError},
759        types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
760        utils::local_time,
761    };
762
763    mod test_helpers {
764        use super::*;
765
766        pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
767            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
768            builder.add_rootfile("content.opf").unwrap();
769            builder.add_metadata(MetadataItem::new("title", "Test Book"));
770            builder.add_metadata(MetadataItem::new("language", "en"));
771            builder.add_metadata(
772                MetadataItem::new("identifier", "urn:isbn:1234567890")
773                    .with_id("pub-id")
774                    .build(),
775            );
776            builder
777        }
778
779        pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
780            let mut builder = create_basic_builder();
781            builder.add_catalog_item(NavPoint::new("Chapter"));
782            builder.add_spine(SpineItem::new("test"));
783            builder
784        }
785    }
786
787    mod epub_builder_tests {
788        use super::*;
789
790        #[test]
791        fn test_epub_builder_new() {
792            let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
793            assert!(builder.temp_dir.exists());
794            assert!(builder.rootfiles.is_empty());
795            assert!(builder.metadata.metadata.is_empty());
796            assert!(builder.manifest.manifest.is_empty());
797            assert!(builder.spine.spine.is_empty());
798            assert!(builder.catalog.title.is_empty());
799            assert!(builder.catalog.is_empty());
800        }
801
802        #[test]
803        fn test_add_rootfile() {
804            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
805
806            builder
807                .add_rootfile("content.opf")
808                .expect("Failed to add rootfile");
809            assert_eq!(builder.rootfiles.rootfiles.len(), 1);
810            assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
811
812            builder
813                .add_rootfile("./another.opf")
814                .expect("Failed to add another rootfile");
815            assert_eq!(builder.rootfiles.rootfiles.len(), 2);
816            assert_eq!(
817                builder.rootfiles.rootfiles,
818                vec!["content.opf", "another.opf"]
819            );
820        }
821
822        #[test]
823        fn test_add_rootfile_fail() {
824            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
825
826            let result = builder.add_rootfile("/rootfile.opf");
827            assert!(result.is_err());
828            assert_eq!(
829                result.unwrap_err(),
830                EpubBuilderError::IllegalRootfilePath.into()
831            );
832
833            let result = builder.add_rootfile("../rootfile.opf");
834            assert!(result.is_err());
835            assert_eq!(
836                result.unwrap_err(),
837                EpubBuilderError::IllegalRootfilePath.into()
838            );
839        }
840
841        #[test]
842        fn test_add_metadata() {
843            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
844            let metadata_item = MetadataItem::new("title", "Test Book");
845
846            builder.add_metadata(metadata_item);
847
848            assert_eq!(builder.metadata.metadata.len(), 1);
849            assert_eq!(builder.metadata.metadata[0].property, "title");
850            assert_eq!(builder.metadata.metadata[0].value, "Test Book");
851        }
852
853        #[test]
854        fn test_add_spine() {
855            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
856            let spine_item = SpineItem::new("test_item");
857
858            builder.add_spine(spine_item);
859
860            assert_eq!(builder.spine.spine.len(), 1);
861            assert_eq!(builder.spine.spine[0].idref, "test_item");
862        }
863
864        #[test]
865        fn test_set_catalog_title() {
866            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
867            let title = "Test Catalog Title";
868
869            builder.set_catalog_title(title);
870
871            assert_eq!(builder.catalog.title, title);
872        }
873
874        #[test]
875        fn test_add_catalog_item() {
876            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
877            let nav_point = NavPoint::new("Chapter 1");
878
879            builder.add_catalog_item(nav_point);
880
881            assert_eq!(builder.catalog.catalog.len(), 1);
882            assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
883        }
884
885        #[test]
886        fn test_clear_all() {
887            let mut builder = test_helpers::create_full_builder();
888
889            assert_eq!(builder.metadata.metadata.len(), 3);
890            assert_eq!(builder.spine.spine.len(), 1);
891            assert_eq!(builder.catalog.catalog.len(), 1);
892
893            builder.clear_all();
894
895            assert!(builder.metadata.metadata.is_empty());
896            assert!(builder.spine.spine.is_empty());
897            assert!(builder.catalog.catalog.is_empty());
898            assert!(builder.catalog.title.is_empty());
899            assert!(builder.manifest.manifest.is_empty());
900
901            builder.add_metadata(MetadataItem::new("title", "New Book"));
902            builder.add_spine(SpineItem::new("new_chapter"));
903            builder.add_catalog_item(NavPoint::new("New Chapter"));
904
905            assert_eq!(builder.metadata.metadata.len(), 1);
906            assert_eq!(builder.spine.spine.len(), 1);
907            assert_eq!(builder.catalog.catalog.len(), 1);
908        }
909
910        #[test]
911        fn test_make() {
912            let mut builder = test_helpers::create_full_builder();
913
914            builder
915                .add_manifest(
916                    "./test_case/Overview.xhtml",
917                    ManifestItem {
918                        id: "test".to_string(),
919                        path: PathBuf::from("test.xhtml"),
920                        mime: String::new(),
921                        properties: None,
922                        fallback: None,
923                    },
924                )
925                .unwrap();
926
927            let file = env::temp_dir().join(format!("{}.epub", local_time()));
928            assert!(builder.make(&file).is_ok());
929            assert!(EpubDoc::new(&file).is_ok());
930        }
931
932        #[test]
933        fn test_build() {
934            let mut builder = test_helpers::create_full_builder();
935
936            builder
937                .add_manifest(
938                    "./test_case/Overview.xhtml",
939                    ManifestItem {
940                        id: "test".to_string(),
941                        path: PathBuf::from("test.xhtml"),
942                        mime: String::new(),
943                        properties: None,
944                        fallback: None,
945                    },
946                )
947                .unwrap();
948
949            let file = env::temp_dir().join(format!("{}.epub", local_time()));
950            assert!(builder.build(&file).is_ok());
951        }
952
953        #[test]
954        fn test_from() {
955            let metadata = vec![
956                MetadataItem {
957                    id: None,
958                    property: "title".to_string(),
959                    value: "Test Book".to_string(),
960                    lang: None,
961                    refined: vec![],
962                },
963                MetadataItem {
964                    id: None,
965                    property: "language".to_string(),
966                    value: "en".to_string(),
967                    lang: None,
968                    refined: vec![],
969                },
970                MetadataItem {
971                    id: Some("pub-id".to_string()),
972                    property: "identifier".to_string(),
973                    value: "test-book".to_string(),
974                    lang: None,
975                    refined: vec![],
976                },
977            ];
978            let spine = vec![SpineItem {
979                id: None,
980                idref: "main".to_string(),
981                linear: true,
982                properties: None,
983            }];
984            let catalog = vec![
985                NavPoint {
986                    label: "Nav".to_string(),
987                    content: None,
988                    children: vec![],
989                    play_order: None,
990                },
991                NavPoint {
992                    label: "Overview".to_string(),
993                    content: None,
994                    children: vec![],
995                    play_order: None,
996                },
997            ];
998
999            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1000            builder.add_rootfile("content.opf").unwrap();
1001            builder.metadata.metadata = metadata.clone();
1002            builder.spine.spine = spine.clone();
1003            builder.catalog.catalog = catalog.clone();
1004            builder.set_catalog_title("catalog title");
1005            builder
1006                .add_manifest(
1007                    "./test_case/Overview.xhtml",
1008                    ManifestItem {
1009                        id: "main".to_string(),
1010                        path: PathBuf::from("Overview.xhtml"),
1011                        mime: String::new(),
1012                        properties: None,
1013                        fallback: None,
1014                    },
1015                )
1016                .unwrap();
1017
1018            let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1019            builder.make(&epub_file).unwrap();
1020
1021            let mut doc = EpubDoc::new(&epub_file).unwrap();
1022            let builder = EpubBuilder::from(&mut doc).unwrap();
1023
1024            assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
1025            assert_eq!(builder.manifest.manifest.len(), 1);
1026            assert_eq!(builder.spine.spine.len(), spine.len());
1027            assert_eq!(builder.catalog.catalog, catalog);
1028            assert_eq!(builder.catalog.title, "catalog title");
1029        }
1030
1031        #[test]
1032        fn test_make_container_file() {
1033            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1034
1035            let result = builder.make_container_xml();
1036            assert!(result.is_err());
1037            assert_eq!(
1038                result.unwrap_err(),
1039                EpubBuilderError::MissingRootfile.into()
1040            );
1041
1042            builder.add_rootfile("content.opf").unwrap();
1043            assert!(builder.make_container_xml().is_ok());
1044        }
1045
1046        #[test]
1047        fn test_make_navigation_document() {
1048            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1049
1050            let result = builder.make_navigation_document();
1051            assert!(result.is_err());
1052            assert_eq!(
1053                result.unwrap_err(),
1054                EpubBuilderError::NavigationInfoUninitalized.into()
1055            );
1056
1057            builder.add_catalog_item(NavPoint::new("test"));
1058            assert!(builder.make_navigation_document().is_ok());
1059        }
1060
1061        #[test]
1062        fn test_make_opf_file_success() {
1063            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1064
1065            builder.add_rootfile("content.opf").unwrap();
1066            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1067            builder.add_metadata(MetadataItem::new("language", "en"));
1068            builder.add_metadata(
1069                MetadataItem::new("identifier", "urn:isbn:1234567890")
1070                    .with_id("pub-id")
1071                    .build(),
1072            );
1073
1074            let test_file = builder.temp_dir.join("test.xhtml");
1075            fs::write(&test_file, "<html></html>").unwrap();
1076            builder
1077                .add_manifest(
1078                    test_file.to_str().unwrap(),
1079                    ManifestItem::new("test", "test.xhtml").unwrap(),
1080                )
1081                .unwrap();
1082
1083            builder.add_catalog_item(NavPoint::new("Chapter"));
1084            builder.add_spine(SpineItem::new("test"));
1085            builder.make_navigation_document().unwrap();
1086
1087            assert!(builder.make_opf_file().is_ok());
1088            assert!(builder.temp_dir.join("content.opf").exists());
1089        }
1090
1091        #[test]
1092        fn test_make_opf_file_missing_metadata() {
1093            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1094            builder.add_rootfile("content.opf").unwrap();
1095
1096            let result = builder.make_opf_file();
1097            assert!(result.is_err());
1098            assert_eq!(
1099                result.unwrap_err().to_string(),
1100                "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1101            );
1102        }
1103    }
1104
1105    mod manifest_tests {
1106        use super::*;
1107
1108        #[test]
1109        fn test_add_manifest_success() {
1110            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1111            builder.add_rootfile("content.opf").unwrap();
1112
1113            let test_file = builder.temp_dir.join("test.xhtml");
1114            fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
1115
1116            let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
1117            let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
1118
1119            assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
1120            assert_eq!(builder.manifest.manifest.len(), 1);
1121            assert!(builder.manifest.manifest.contains_key("test"));
1122        }
1123
1124        #[test]
1125        fn test_add_manifest_no_rootfile() {
1126            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1127
1128            let manifest_item = ManifestItem {
1129                id: "main".to_string(),
1130                path: PathBuf::from("/Overview.xhtml"),
1131                mime: String::new(),
1132                properties: None,
1133                fallback: None,
1134            };
1135
1136            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
1137            assert!(result.is_err());
1138            assert_eq!(
1139                result.unwrap_err(),
1140                EpubBuilderError::MissingRootfile.into()
1141            );
1142
1143            builder.add_rootfile("package.opf").unwrap();
1144            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
1145            assert!(result.is_ok());
1146        }
1147
1148        #[test]
1149        fn test_add_manifest_nonexistent_file() {
1150            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1151            builder.add_rootfile("content.opf").unwrap();
1152
1153            let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
1154            let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
1155
1156            assert!(result.is_err());
1157            assert_eq!(
1158                result.unwrap_err(),
1159                EpubBuilderError::TargetIsNotFile {
1160                    target_path: "nonexistent.xhtml".to_string()
1161                }
1162                .into()
1163            );
1164        }
1165
1166        #[test]
1167        fn test_add_manifest_unknown_file_format() {
1168            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1169            builder.add_rootfile("package.opf").unwrap();
1170
1171            let result = builder.add_manifest(
1172                "./test_case/unknown_file_format.xhtml",
1173                ManifestItem {
1174                    id: "file".to_string(),
1175                    path: PathBuf::from("unknown_file_format.xhtml"),
1176                    mime: String::new(),
1177                    properties: None,
1178                    fallback: None,
1179                },
1180            );
1181
1182            assert!(result.is_err());
1183            assert_eq!(
1184                result.unwrap_err(),
1185                EpubBuilderError::UnknownFileFormat {
1186                    file_path: "./test_case/unknown_file_format.xhtml".to_string(),
1187                }
1188                .into()
1189            );
1190        }
1191
1192        #[test]
1193        fn test_validate_fallback_chain_valid() {
1194            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1195
1196            let item3 = ManifestItem::new("item3", "path3").unwrap();
1197            let item2 = ManifestItem::new("item2", "path2")
1198                .unwrap()
1199                .with_fallback("item3")
1200                .build();
1201            let item1 = ManifestItem::new("item1", "path1")
1202                .unwrap()
1203                .with_fallback("item2")
1204                .append_property("nav")
1205                .build();
1206
1207            builder.manifest.insert("item3".to_string(), item3);
1208            builder.manifest.insert("item2".to_string(), item2);
1209            builder.manifest.insert("item1".to_string(), item1);
1210
1211            assert!(builder.manifest.validate().is_ok());
1212        }
1213
1214        #[test]
1215        fn test_validate_fallback_chain_circular_reference() {
1216            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1217
1218            let item2 = ManifestItem::new("item2", "path2")
1219                .unwrap()
1220                .with_fallback("item1")
1221                .build();
1222            let item1 = ManifestItem::new("item1", "path1")
1223                .unwrap()
1224                .with_fallback("item2")
1225                .build();
1226
1227            builder.manifest.insert("item1".to_string(), item1);
1228            builder.manifest.insert("item2".to_string(), item2);
1229
1230            let result = builder.manifest.validate();
1231            assert!(result.is_err());
1232            assert!(result.unwrap_err().to_string().starts_with(
1233                "Epub builder error: Circular reference detected in fallback chain for"
1234            ));
1235        }
1236
1237        #[test]
1238        fn test_validate_fallback_chain_not_found() {
1239            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1240
1241            let item1 = ManifestItem::new("item1", "path1")
1242                .unwrap()
1243                .with_fallback("nonexistent")
1244                .build();
1245
1246            builder.manifest.insert("item1".to_string(), item1);
1247
1248            let result = builder.manifest.validate();
1249            assert!(result.is_err());
1250            assert_eq!(
1251                result.unwrap_err().to_string(),
1252                "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1253            );
1254        }
1255
1256        #[test]
1257        fn test_validate_manifest_nav_single() {
1258            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1259
1260            let nav_item = ManifestItem::new("nav", "nav.xhtml")
1261                .unwrap()
1262                .append_property("nav")
1263                .build();
1264            builder
1265                .manifest
1266                .manifest
1267                .insert("nav".to_string(), nav_item);
1268
1269            assert!(builder.manifest.validate().is_ok());
1270        }
1271
1272        #[test]
1273        fn test_validate_manifest_nav_multiple() {
1274            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1275
1276            let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1277                .unwrap()
1278                .append_property("nav")
1279                .build();
1280            let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1281                .unwrap()
1282                .append_property("nav")
1283                .build();
1284
1285            builder
1286                .manifest
1287                .manifest
1288                .insert("nav1".to_string(), nav_item1);
1289            builder
1290                .manifest
1291                .manifest
1292                .insert("nav2".to_string(), nav_item2);
1293
1294            let result = builder.manifest.validate();
1295            assert!(result.is_err());
1296            assert_eq!(
1297                result.unwrap_err().to_string(),
1298                "Epub builder error: There are too many items with 'nav' property in the manifest."
1299            );
1300        }
1301    }
1302
1303    mod metadata_tests {
1304        use super::*;
1305
1306        #[test]
1307        fn test_validate_metadata_success() {
1308            let builder = test_helpers::create_basic_builder();
1309            assert!(builder.metadata.validate().is_ok());
1310        }
1311
1312        #[test]
1313        fn test_validate_metadata_missing_required() {
1314            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1315            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1316            builder.add_metadata(MetadataItem::new("language", "en"));
1317            assert!(builder.metadata.validate().is_err());
1318        }
1319    }
1320
1321    mod utility_tests {
1322        use super::*;
1323
1324        #[test]
1325        fn test_normalize_manifest_path() {
1326            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1327            builder.add_rootfile("content.opf").unwrap();
1328
1329            let result = normalize_manifest_path(
1330                &builder.temp_dir,
1331                builder.rootfiles.first().unwrap(),
1332                "../../test.xhtml",
1333                "id",
1334            );
1335            assert!(result.is_err());
1336            assert_eq!(
1337                result.unwrap_err(),
1338                EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
1339            );
1340
1341            let result = normalize_manifest_path(
1342                &builder.temp_dir,
1343                builder.rootfiles.first().unwrap(),
1344                "/test.xhtml",
1345                "id",
1346            );
1347            assert!(result.is_ok());
1348            assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
1349
1350            let result = normalize_manifest_path(
1351                &builder.temp_dir,
1352                builder.rootfiles.first().unwrap(),
1353                "./test.xhtml",
1354                "manifest_id",
1355            );
1356            assert!(result.is_err());
1357            assert_eq!(
1358                result.unwrap_err(),
1359                EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
1360                    .into(),
1361            );
1362        }
1363
1364        #[test]
1365        fn test_refine_mime_type() {
1366            assert_eq!(
1367                refine_mime_type("text/xml", "xhtml"),
1368                "application/xhtml+xml"
1369            );
1370            assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
1371            assert_eq!(
1372                refine_mime_type("application/xml", "opf"),
1373                "application/oebps-package+xml"
1374            );
1375            assert_eq!(
1376                refine_mime_type("text/xml", "ncx"),
1377                "application/x-dtbncx+xml"
1378            );
1379            assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1380            assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1381        }
1382    }
1383
1384    #[cfg(feature = "content-builder")]
1385    mod content_builder_tests {
1386        use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
1387
1388        #[test]
1389        fn test_make_contents_basic() {
1390            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1391            builder.add_rootfile("content.opf").unwrap();
1392
1393            let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
1394            content_builder
1395                .set_title("Test Chapter")
1396                .add_text_block("This is a test paragraph.", vec![])
1397                .unwrap();
1398
1399            builder.add_content("OEBPS/chapter1.xhtml", content_builder);
1400
1401            assert!(builder.make_contents().is_ok());
1402            assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
1403        }
1404
1405        #[test]
1406        fn test_make_contents_multiple_blocks() {
1407            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1408            builder.add_rootfile("content.opf").unwrap();
1409
1410            let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
1411            content_builder
1412                .set_title("多个区块章节")
1413                .add_text_block("第一段文本。", vec![])
1414                .unwrap()
1415                .add_quote_block("这是一个引用。", vec![])
1416                .unwrap()
1417                .add_title_block("子标题", 2, vec![])
1418                .unwrap()
1419                .add_text_block("最后的文本段落。", vec![])
1420                .unwrap();
1421
1422            builder.add_content("OEBPS/chapter2.xhtml", content_builder);
1423
1424            assert!(builder.make_contents().is_ok());
1425            assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
1426        }
1427
1428        #[test]
1429        fn test_make_contents_with_media() {
1430            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1431            builder.add_rootfile("content.opf").unwrap();
1432
1433            let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
1434            content_builder
1435                .set_title("Chapter with Media")
1436                .add_text_block("Text before image.", vec![])
1437                .unwrap()
1438                .add_image_block(
1439                    std::path::PathBuf::from("./test_case/image.jpg"),
1440                    Some("Test Image".to_string()),
1441                    Some("Figure 1: A test image".to_string()),
1442                    vec![],
1443                )
1444                .unwrap()
1445                .add_text_block("Text after image.", vec![])
1446                .unwrap();
1447
1448            builder.add_content("OEBPS/chapter3.xhtml", content_builder);
1449
1450            assert!(builder.make_contents().is_ok());
1451            assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
1452            assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
1453        }
1454
1455        #[test]
1456        fn test_make_contents_multiple_documents() {
1457            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1458            builder.add_rootfile("content.opf").unwrap();
1459
1460            for (id, title) in [
1461                ("ch1", "Chapter 1"),
1462                ("ch2", "Chapter 2"),
1463                ("ch3", "Chapter 3"),
1464            ] {
1465                let mut content = ContentBuilder::new(id, "en").unwrap();
1466                content
1467                    .set_title(title)
1468                    .add_text_block(&format!("Content of {}", title), vec![])
1469                    .unwrap();
1470                builder.add_content(format!("OEBPS/{}.xhtml", id), content);
1471            }
1472
1473            assert!(builder.make_contents().is_ok());
1474            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1475            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1476            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1477        }
1478
1479        #[test]
1480        fn test_make_contents_different_languages() {
1481            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1482            builder.add_rootfile("content.opf").unwrap();
1483
1484            let langs = [
1485                ("en_ch", "en", "English Chapter"),
1486                ("zh_ch", "zh-CN", "中文章节"),
1487                ("ja_ch", "ja", "日本語の章"),
1488            ];
1489
1490            for (id, lang, title) in langs {
1491                let mut content = ContentBuilder::new(id, lang).unwrap();
1492                content
1493                    .set_title(title)
1494                    .add_text_block(&format!("Text in {}", lang), vec![])
1495                    .unwrap();
1496                builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
1497            }
1498
1499            assert!(builder.make_contents().is_ok());
1500            assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
1501            assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
1502            assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
1503        }
1504
1505        #[test]
1506        fn test_make_contents_unique_identifiers() {
1507            use std::path::PathBuf;
1508
1509            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1510            builder.add_rootfile("content.opf").unwrap();
1511
1512            let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
1513            content1.add_text_block("First content", vec![]).unwrap();
1514            builder.add_content("OEBPS/ch1.xhtml", content1);
1515
1516            let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
1517            content2.add_text_block("Second content", vec![]).unwrap();
1518            builder.add_content("OEBPS/ch2.xhtml", content2);
1519
1520            let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
1521            content3
1522                .add_text_block("Duplicate ID content", vec![])
1523                .unwrap();
1524            builder.add_content("OEBPS/ch3.xhtml", content3);
1525
1526            assert!(builder.make_contents().is_ok());
1527            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1528            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1529            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1530
1531            let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
1532            assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
1533        }
1534
1535        #[test]
1536        fn test_make_contents_complex_structure() {
1537            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1538            builder.add_rootfile("content.opf").unwrap();
1539
1540            let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
1541            content
1542                .set_title("Complex Chapter")
1543                .add_title_block("Section 1", 2, vec![])
1544                .unwrap()
1545                .add_text_block("Introduction text.", vec![])
1546                .unwrap()
1547                .add_quote_block("A wise quote here.", vec![])
1548                .unwrap()
1549                .add_title_block("Section 2", 2, vec![])
1550                .unwrap()
1551                .add_text_block("More content with multiple paragraphs.", vec![])
1552                .unwrap()
1553                .add_text_block("Another paragraph.", vec![])
1554                .unwrap()
1555                .add_title_block("Section 3", 2, vec![])
1556                .unwrap()
1557                .add_quote_block("Another quotation.", vec![])
1558                .unwrap();
1559
1560            builder.add_content("OEBPS/complex_chapter.xhtml", content);
1561
1562            assert!(builder.make_contents().is_ok());
1563            assert!(
1564                builder
1565                    .temp_dir
1566                    .join("OEBPS/complex_chapter.xhtml")
1567                    .exists()
1568            );
1569        }
1570
1571        #[test]
1572        fn test_make_contents_empty_document() {
1573            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1574            builder.add_rootfile("content.opf").unwrap();
1575
1576            let content = ContentBuilder::new("empty_ch", "en").unwrap();
1577            builder.add_content("OEBPS/empty.xhtml", content);
1578
1579            assert!(builder.make_contents().is_ok());
1580            assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
1581        }
1582
1583        #[test]
1584        fn test_make_contents_path_normalization() {
1585            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1586            builder.add_rootfile("OEBPS/content.opf").unwrap();
1587
1588            let mut content = ContentBuilder::new("path_test", "en").unwrap();
1589            content.add_text_block("Path test content", vec![]).unwrap();
1590
1591            builder.add_content("/OEBPS/text/chapter.xhtml", content);
1592
1593            assert!(builder.make_contents().is_ok());
1594            assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
1595        }
1596    }
1597}