lib_epub/
builder.rs

1//! Epub Builder
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` functionality to use this module.
36
37use std::{
38    collections::HashMap,
39    env,
40    fs::{self, File},
41    io::{BufReader, Cursor, Read, Seek, Write},
42    marker::PhantomData,
43    path::{Path, PathBuf},
44};
45
46use chrono::{SecondsFormat, Utc};
47use infer::Infer;
48use log::warn;
49use quick_xml::{
50    Writer,
51    events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
52};
53use walkdir::WalkDir;
54use zip::{CompressionMethod, ZipWriter, write::FileOptions};
55
56use crate::{
57    epub::EpubDoc,
58    error::{EpubBuilderError, EpubError},
59    types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
60    utils::{
61        ELEMENT_IN_DC_NAMESPACE, check_realtive_link_leakage, local_time, remove_leading_slash,
62    },
63};
64
65type XmlWriter = Writer<Cursor<Vec<u8>>>;
66
67// struct EpubVersion2;
68#[cfg_attr(test, derive(Debug))]
69pub struct EpubVersion3;
70
71/// EPUB Builder
72///
73/// The main structure used to create and build EPUB ebook files.
74/// Supports the EPUB 3.0 specification and can build a complete EPUB file structure.
75///
76/// ## Usage
77///
78/// ```rust, no_run
79/// # #[cfg(feature = "builder")]
80/// # fn main() -> Result<(), lib_epub::error::EpubError> {
81/// use lib_epub::{
82///     builder::{EpubBuilder, EpubVersion3},
83///     types::{MetadataItem, ManifestItem, NavPoint, SpineItem},
84/// };
85///
86/// let mut builder = EpubBuilder::<EpubVersion3>::new()?;
87///
88/// builder
89///     .add_rootfile("EPUB/content.opf")?
90///     .add_metadata(MetadataItem::new("title", "Test Book"))
91///     .add_metadata(MetadataItem::new("language", "en"))
92///     .add_metadata(
93///         MetadataItem::new("identifier", "unique-id")
94///             .with_id("pub-id")
95///             .build(),
96///     )
97///     .add_manifest(
98///         "./test_case/Overview.xhtml",
99///         ManifestItem::new("content", "target/path")?,
100///     )?
101///     .add_spine(SpineItem::new("content"))
102///     .add_catalog_item(NavPoint::new("label"));
103///
104/// builder.build("output.epub")?;
105///
106/// # Ok(())
107/// # }
108/// ```
109///
110/// ## Notes
111///
112/// - All resource files **must** exist on the local file system.
113/// - **At least one rootfile** must be added before adding manifest items.
114/// - Requires at least one `title`, `language`, and `identifier` with id `pub-id`.
115#[cfg_attr(test, derive(Debug))]
116pub struct EpubBuilder<Version> {
117    /// EPUB version placeholder
118    epub_version: PhantomData<Version>,
119
120    /// Temporary directory path for storing files during the build process
121    temp_dir: PathBuf,
122
123    /// List of root file paths
124    rootfiles: Vec<String>,
125
126    /// List of metadata items
127    metadata: Vec<MetadataItem>,
128
129    /// Manifest item mapping table, with ID as the key and manifest item as the value
130    manifest: HashMap<String, ManifestItem>,
131
132    /// List of spine items, defining the reading order
133    spine: Vec<SpineItem>,
134
135    catalog_title: String,
136
137    /// List of catalog navigation points
138    catalog: Vec<NavPoint>,
139}
140
141impl EpubBuilder<EpubVersion3> {
142    /// Create a new `EpubBuilder` instance
143    ///
144    /// ## Return
145    /// - `Ok(EpubBuilder)`: Builder instance created successfully
146    /// - `Err(EpubError)`: Error occurred during builder initialization
147    pub fn new() -> Result<Self, EpubError> {
148        let temp_dir = env::temp_dir().join(local_time());
149        fs::create_dir(&temp_dir)?;
150        fs::create_dir(temp_dir.join("META-INF"))?;
151
152        let mime_file = temp_dir.join("mimetype");
153        fs::write(mime_file, "application/epub+zip")?;
154
155        Ok(EpubBuilder {
156            epub_version: PhantomData,
157            temp_dir,
158
159            rootfiles: vec![],
160            metadata: vec![],
161            manifest: HashMap::new(),
162            spine: vec![],
163
164            catalog_title: String::new(),
165            catalog: vec![],
166        })
167    }
168
169    /// Add a rootfile path
170    ///
171    /// The added path points to an OPF file that does not yet exist
172    /// and will be created when building the Epub file.
173    ///
174    /// ## Parameters
175    /// - `rootfile`: Rootfile path
176    ///
177    /// ## Notes
178    /// - The added rootfile path must be a relative path and cannot start with "../".
179    /// - At least one rootfile must be added before adding metadata items.
180    pub fn add_rootfile(&mut self, rootfile: &str) -> Result<&mut Self, EpubError> {
181        let rootfile = if rootfile.starts_with("/") || rootfile.starts_with("../") {
182            return Err(EpubBuilderError::IllegalRootfilePath.into());
183        } else if let Some(rootfile) = rootfile.strip_prefix("./") {
184            rootfile
185        } else {
186            rootfile
187        };
188
189        self.rootfiles.push(rootfile.to_string());
190
191        Ok(self)
192    }
193
194    /// Add metadata item
195    ///
196    /// Required metadata includes title, language, and an identifier with 'pub-id'.
197    /// Missing this data will result in an error when building the epub file.
198    ///
199    /// ## Parameters
200    /// - `item`: Metadata items to add
201    pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
202        self.metadata.push(item);
203        self
204    }
205
206    /// Add manifest item and corresponding resource file
207    ///
208    /// The builder will automatically recognize the file type of
209    /// the added resource and update it in `ManifestItem`.
210    ///
211    /// ## Parameters
212    /// - `manifest_source` - Local resource file path
213    /// - `manifest_item` - Manifest item information
214    ///
215    /// ## Return
216    /// - `Ok(&mut Self)` - Successful addition, returns a reference to itself
217    /// - `Err(EpubError)` - Error occurred during the addition process
218    ///
219    /// ## Notes
220    /// - At least one rootfile must be added before adding manifest items.
221    pub fn add_manifest(
222        &mut self,
223        manifest_source: &str,
224        manifest_item: ManifestItem,
225    ) -> Result<&mut Self, EpubError> {
226        if self.rootfiles.is_empty() {
227            return Err(EpubBuilderError::MissingRootfile.into());
228        }
229
230        // Check if the source path is a file
231        let source = PathBuf::from(manifest_source);
232        if !source.is_file() {
233            return Err(EpubBuilderError::TargetIsNotFile {
234                target_path: manifest_source.to_string(),
235            }
236            .into());
237        }
238
239        // Get the file extension
240        let extension = match source.extension() {
241            Some(ext) => ext.to_string_lossy().to_lowercase(),
242            None => String::new(),
243        };
244
245        // Read the file
246        let buf = fs::read(source)?;
247
248        // Get the mime type
249        let real_mime = match Infer::new().get(&buf) {
250            Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
251            None => {
252                return Err(EpubBuilderError::UnknownFileFormat {
253                    file_path: manifest_source.to_string(),
254                }
255                .into());
256            }
257        };
258
259        let target_path = self.normalize_manifest_path(&manifest_item.path, &manifest_item.id)?;
260        if let Some(parent_dir) = target_path.parent() {
261            if !parent_dir.exists() {
262                fs::create_dir_all(parent_dir)?
263            }
264        }
265
266        match fs::write(target_path, buf) {
267            Ok(_) => {
268                self.manifest
269                    .insert(manifest_item.id.clone(), manifest_item.set_mime(&real_mime));
270                Ok(self)
271            }
272            Err(err) => Err(err.into()),
273        }
274    }
275
276    /// Add spine item
277    ///
278    /// The spine item defines the reading order of the book.
279    ///
280    /// ## Parameters
281    /// - `item`: Spine item to add
282    pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
283        self.spine.push(item);
284        self
285    }
286
287    /// Set catalog title
288    ///
289    /// ## Parameters
290    /// - `title`: Catalog title
291    pub fn set_catalog_title(&mut self, title: &str) -> &mut Self {
292        self.catalog_title = title.to_string();
293        self
294    }
295
296    /// Add catalog item
297    ///
298    /// Added directory items will be added to the end of the existing list.
299    ///
300    /// ## Parameters
301    /// - `item`: Catalog item to add
302    pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
303        self.catalog.push(item);
304        self
305    }
306
307    /// Re-/ Set catalog
308    ///
309    /// The passed list will overwrite existing data.
310    ///
311    /// ## Parameters
312    /// - `catalog`: Catalog to set
313    pub fn set_catalog(&mut self, catalog: Vec<NavPoint>) -> &mut Self {
314        self.catalog = catalog;
315        self
316    }
317
318    /// Builds an EPUB file and saves it to the specified path
319    ///
320    /// ## Parameters
321    /// - `output_path`: Output file path
322    ///
323    /// ## Return
324    /// - `Ok(())`: Build successful
325    /// - `Err(EpubError)`: Error occurred during the build process
326    pub fn make<P: AsRef<Path>>(mut self, output_path: P) -> Result<(), EpubError> {
327        // Create the container.xml, navigation document, and OPF files in sequence.
328        // The associated metadata will initialized when navigation document is created;
329        // therefore, the navigation document must be created before the opf file is created.
330        self.make_container_xml()?;
331        self.make_navigation_document()?;
332        self.make_opf_file()?;
333
334        if let Some(parent) = output_path.as_ref().parent() {
335            if !parent.exists() {
336                fs::create_dir_all(parent)?;
337            }
338        }
339
340        // pack zip file
341        let file = File::create(output_path)?;
342        let mut zip = ZipWriter::new(file);
343        let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
344
345        for entry in WalkDir::new(&self.temp_dir) {
346            let entry = entry?;
347            let path = entry.path();
348
349            // It can be asserted that the path is prefixed with temp_dir,
350            // and there will be no boundary cases of symbolic links and hard links, etc.
351            let relative_path = path.strip_prefix(&self.temp_dir).unwrap();
352            let target_path = relative_path.to_string_lossy().replace("\\", "/");
353
354            if path.is_file() {
355                zip.start_file(target_path, options)?;
356                let mut buf = Vec::new();
357                File::open(path)?.read_to_end(&mut buf)?;
358                zip.write_all(&buf)?;
359            } else if path.is_dir() {
360                zip.add_directory(target_path, options)?;
361            }
362        }
363
364        zip.finish()?;
365        Ok(())
366    }
367
368    /// Builds an EPUB file and returns a `EpubDoc`
369    ///
370    /// Builds an EPUB file at the specified location and parses it into a usable EpubDoc object.
371    ///
372    /// ## Parameters
373    /// - `output_path`: Output file path
374    ///
375    /// ## Return
376    /// - `Ok(EpubDoc)`: Build successful
377    /// - `Err(EpubError)`: Error occurred during the build process
378    pub fn build<P: AsRef<Path>>(
379        self,
380        output_path: P,
381    ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
382        self.make(&output_path)?;
383
384        EpubDoc::new(output_path)
385    }
386
387    /// Creates an `EpubBuilder` instance from an existing `EpubDoc`
388    ///
389    /// This function takes an existing parsed EPUB document and creates a new builder
390    /// instance with all the document's metadata, manifest items, spine, and catalog information.
391    /// It essentially reverses the EPUB building process by extracting all the necessary
392    /// components from the parsed document and preparing them for reconstruction.
393    ///
394    /// The function copies the following information from the provided `EpubDoc`:
395    /// - Rootfile path (based on the document's base path)
396    /// - All metadata items (title, author, identifier, etc.)
397    /// - Spine items (reading order of the publication)
398    /// - Catalog information (navigation points)
399    /// - Catalog title
400    /// - All manifest items (except those with 'nav' property, which are skipped)
401    ///
402    /// ## Parameters
403    /// - `doc`: A mutable reference to an `EpubDoc` instance that contains the parsed EPUB data
404    ///
405    /// ## Return
406    /// - `Ok(EpubBuilder)`: Successfully created builder instance populated with the document's data
407    /// - `Err(EpubError)`: Error occurred during the extraction process
408    ///
409    /// ## Notes
410    /// - This type of conversion will upgrade Epub2.x publications to Epub3.x.
411    ///   This upgrade conversion may encounter unknown errors (it is unclear whether
412    ///   it will cause errors), so please use it with caution.
413    pub fn from<R: Read + Seek>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
414        let mut builder = Self::new()?;
415
416        builder.add_rootfile(&doc.package_path.clone().to_string_lossy())?;
417        builder.metadata = doc.metadata.clone();
418        builder.spine = doc.spine.clone();
419        builder.catalog = doc.catalog.clone();
420        builder.catalog_title = doc.catalog_title.clone();
421
422        // clone manifest hashmap to avoid mut borrow conflict
423        for (_, mut manifest) in doc.manifest.clone().into_iter() {
424            if let Some(properties) = &manifest.properties {
425                if properties.contains("nav") {
426                    continue;
427                }
428            }
429
430            // because manifest paths in EpubDoc are converted to absolute paths rooted in containers,
431            // but in the form of 'path/to/manifest', they need to be converted here to absolute paths
432            // in the form of '/path/to/manifest'.
433            manifest.path = PathBuf::from("/").join(manifest.path);
434
435            let (buf, _) = doc.get_manifest_item(&manifest.id)?; // read raw file
436            let target_path = builder.normalize_manifest_path(&manifest.path, &manifest.id)?;
437            if let Some(parent_dir) = target_path.parent() {
438                if !parent_dir.exists() {
439                    fs::create_dir_all(parent_dir)?
440                }
441            }
442
443            fs::write(target_path, buf)?;
444            builder.manifest.insert(manifest.id.clone(), manifest);
445        }
446
447        Ok(builder)
448    }
449
450    /// Creates the `container.xml` file
451    ///
452    /// An error will occur if the `rootfile` path is not set
453    fn make_container_xml(&self) -> Result<(), EpubError> {
454        if self.rootfiles.is_empty() {
455            return Err(EpubBuilderError::MissingRootfile.into());
456        }
457
458        let mut writer = Writer::new(Cursor::new(Vec::new()));
459
460        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
461
462        writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
463            [
464                ("version", "1.0"),
465                ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
466            ],
467        )))?;
468        writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
469
470        for rootfile in &self.rootfiles {
471            writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
472                ("full-path", rootfile.as_str()),
473                ("media-type", "application/oebps-package+xml"),
474            ])))?;
475        }
476
477        writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
478        writer.write_event(Event::End(BytesEnd::new("container")))?;
479
480        let file_path = self.temp_dir.join("META-INF").join("container.xml");
481        let file_data = writer.into_inner().into_inner();
482        fs::write(file_path, file_data)?;
483
484        Ok(())
485    }
486
487    /// Creates the `navigation document`
488    ///
489    /// An error will occur if navigation information is not initialized.
490    fn make_navigation_document(&mut self) -> Result<(), EpubError> {
491        if self.catalog.is_empty() {
492            return Err(EpubBuilderError::NavigationInfoUninitalized.into());
493        }
494
495        let mut writer = Writer::new(Cursor::new(Vec::new()));
496
497        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
498            ("xmlns", "http://www.w3.org/1999/xhtml"),
499            ("xmlns:epub", "http://www.idpf.org/2007/ops"),
500        ])))?;
501
502        // make head
503        writer.write_event(Event::Start(BytesStart::new("head")))?;
504        writer.write_event(Event::Start(BytesStart::new("title")))?;
505        writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
506        writer.write_event(Event::End(BytesEnd::new("title")))?;
507        writer.write_event(Event::End(BytesEnd::new("head")))?;
508
509        // make body
510        writer.write_event(Event::Start(BytesStart::new("body")))?;
511        writer.write_event(Event::Start(
512            BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
513        ))?;
514
515        if !self.catalog_title.is_empty() {
516            writer.write_event(Event::Start(BytesStart::new("h1")))?;
517            writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
518            writer.write_event(Event::End(BytesEnd::new("h1")))?;
519        }
520
521        Self::make_nav(&mut writer, &self.catalog)?;
522
523        writer.write_event(Event::End(BytesEnd::new("nav")))?;
524        writer.write_event(Event::End(BytesEnd::new("body")))?;
525
526        writer.write_event(Event::End(BytesEnd::new("html")))?;
527
528        let file_path = self.temp_dir.join("nav.xhtml");
529        let file_data = writer.into_inner().into_inner();
530        fs::write(file_path, file_data)?;
531
532        self.manifest.insert(
533            "nav".to_string(),
534            ManifestItem {
535                id: "nav".to_string(),
536                path: PathBuf::from("/nav.xhtml"),
537                mime: "application/xhtml+xml".to_string(),
538                properties: Some("nav".to_string()),
539                fallback: None,
540            },
541        );
542
543        Ok(())
544    }
545
546    /// Creates the `OPF` file
547    ///
548    /// ## Error conditions
549    /// - Missing necessary metadata
550    /// - Circular reference exists in the manifest backlink
551    /// - Navigation information is not initialized
552    fn make_opf_file(&mut self) -> Result<(), EpubError> {
553        if !self.validate_metadata() {
554            return Err(EpubBuilderError::MissingNecessaryMetadata.into());
555        }
556        self.validate_manifest_fallback_chains()?;
557        self.validate_manifest_nav()?;
558
559        let mut writer = Writer::new(Cursor::new(Vec::new()));
560
561        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
562
563        writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
564            ("xmlns", "http://www.idpf.org/2007/opf"),
565            ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
566            ("unique-identifier", "pub-id"),
567            ("version", "3.0"),
568        ])))?;
569
570        self.make_opf_metadata(&mut writer)?;
571        self.make_opf_manifest(&mut writer)?;
572        self.make_opf_spine(&mut writer)?;
573
574        writer.write_event(Event::End(BytesEnd::new("package")))?;
575
576        let file_path = self.temp_dir.join(&self.rootfiles[0]);
577        let file_data = writer.into_inner().into_inner();
578        fs::write(file_path, file_data)?;
579
580        Ok(())
581    }
582
583    fn make_opf_metadata(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
584        self.metadata.push(MetadataItem {
585            id: None,
586            property: "dcterms:modified".to_string(),
587            value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
588            lang: None,
589            refined: vec![],
590        });
591
592        writer.write_event(Event::Start(BytesStart::new("metadata")))?;
593
594        for metadata in &self.metadata {
595            let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
596                format!("dc:{}", metadata.property)
597            } else {
598                "meta".to_string()
599            };
600
601            writer.write_event(Event::Start(
602                BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
603            ))?;
604            writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
605            writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
606
607            for refinement in &metadata.refined {
608                writer.write_event(Event::Start(
609                    BytesStart::new("meta").with_attributes(refinement.attributes()),
610                ))?;
611                writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
612                writer.write_event(Event::End(BytesEnd::new("meta")))?;
613            }
614        }
615
616        writer.write_event(Event::End(BytesEnd::new("metadata")))?;
617
618        Ok(())
619    }
620
621    fn make_opf_manifest(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
622        writer.write_event(Event::Start(BytesStart::new("manifest")))?;
623
624        for manifest in self.manifest.values() {
625            writer.write_event(Event::Empty(
626                BytesStart::new("item").with_attributes(manifest.attributes()),
627            ))?;
628        }
629
630        writer.write_event(Event::End(BytesEnd::new("manifest")))?;
631
632        Ok(())
633    }
634
635    fn make_opf_spine(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
636        writer.write_event(Event::Start(BytesStart::new("spine")))?;
637
638        for spine in &self.spine {
639            writer.write_event(Event::Empty(
640                BytesStart::new("itemref").with_attributes(spine.attributes()),
641            ))?;
642        }
643
644        writer.write_event(Event::End(BytesEnd::new("spine")))?;
645
646        Ok(())
647    }
648
649    fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
650        writer.write_event(Event::Start(BytesStart::new("ol")))?;
651
652        for nav in navgations {
653            writer.write_event(Event::Start(BytesStart::new("li")))?;
654
655            if let Some(path) = &nav.content {
656                writer.write_event(Event::Start(
657                    BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
658                ))?;
659                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
660                writer.write_event(Event::End(BytesEnd::new("a")))?;
661            } else {
662                writer.write_event(Event::Start(BytesStart::new("span")))?;
663                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
664                writer.write_event(Event::End(BytesEnd::new("span")))?;
665            }
666
667            if !nav.children.is_empty() {
668                Self::make_nav(writer, &nav.children)?;
669            }
670
671            writer.write_event(Event::End(BytesEnd::new("li")))?;
672        }
673
674        writer.write_event(Event::End(BytesEnd::new("ol")))?;
675
676        Ok(())
677    }
678
679    /// Verify metadata integrity
680    ///
681    /// Check if the required metadata items are included: title, language, and identifier with pub-id.
682    fn validate_metadata(&self) -> bool {
683        let has_title = self.metadata.iter().any(|item| item.property == "title");
684        let has_language = self.metadata.iter().any(|item| item.property == "language");
685        let has_identifier = self.metadata.iter().any(|item| {
686            item.property == "identifier" && item.id.as_ref().is_some_and(|id| id == "pub-id")
687        });
688
689        has_title && has_identifier && has_language
690    }
691
692    fn validate_manifest_fallback_chains(&self) -> Result<(), EpubError> {
693        for (id, item) in &self.manifest {
694            if item.fallback.is_none() {
695                continue;
696            }
697
698            let mut fallback_chain = Vec::new();
699            self.validate_fallback_chain(id, &mut fallback_chain)?;
700        }
701
702        Ok(())
703    }
704
705    /// Recursively verify the validity of a single fallback chain
706    ///
707    /// This function recursively traces the fallback chain to check for the following issues:
708    /// - Circular reference
709    /// - The referenced fallback resource does not exist
710    fn validate_fallback_chain(
711        &self,
712        manifest_id: &str,
713        fallback_chain: &mut Vec<String>,
714    ) -> Result<(), EpubError> {
715        if fallback_chain.contains(&manifest_id.to_string()) {
716            fallback_chain.push(manifest_id.to_string());
717
718            return Err(EpubBuilderError::ManifestCircularReference {
719                fallback_chain: fallback_chain.join("->"),
720            }
721            .into());
722        }
723
724        // Get the current item; its existence can be ensured based on the calling context.
725        let item = self.manifest.get(manifest_id).unwrap();
726
727        if let Some(fallback_id) = &item.fallback {
728            if !self.manifest.contains_key(fallback_id) {
729                return Err(EpubBuilderError::ManifestNotFound {
730                    manifest_id: fallback_id.to_owned(),
731                }
732                .into());
733            }
734
735            fallback_chain.push(manifest_id.to_string());
736            self.validate_fallback_chain(fallback_id, fallback_chain)
737        } else {
738            // The end of the fallback chain
739            Ok(())
740        }
741    }
742
743    /// Validate navigation list items
744    ///
745    /// Check if there is only one list item with the `nav` property.
746    fn validate_manifest_nav(&self) -> Result<(), EpubError> {
747        if self
748            .manifest
749            .values()
750            .filter(|&item| {
751                if let Some(properties) = &item.properties {
752                    properties
753                        .clone()
754                        .split(" ")
755                        .collect::<Vec<&str>>()
756                        .contains(&"nav")
757                } else {
758                    false
759                }
760            })
761            .count()
762            == 1
763        {
764            Ok(())
765        } else {
766            Err(EpubBuilderError::TooManyNavFlags.into())
767        }
768    }
769
770    /// Normalize manifest path to absolute path within EPUB container
771    ///
772    /// This function takes a path (relative or absolute) and normalizes it to an absolute
773    /// path within the EPUB container structure. It handles various path formats including:
774    /// - Relative paths starting with "../" (with security check to prevent directory traversal)
775    /// - Absolute paths starting with "/" (relative to EPUB root)
776    /// - Relative paths starting with "./" (current directory)
777    /// - Plain relative paths (relative to the OPF file location)
778    ///
779    /// ## Parameters
780    /// - `path`: The input path that may be relative or absolute. Can be any type that
781    ///   implements `AsRef<Path>`, such as `&str`, `String`, `Path`, `PathBuf`, etc.
782    /// - `id`: The id of the manifest item
783    ///
784    /// ## Return
785    /// - `Ok(PathBuf)`: The normalized absolute path within the EPUB container,
786    ///   and the absolute path is not starting with "/"
787    /// - `Err(EpubError)`: Error if path traversal is detected outside the EPUB container,
788    ///   or failed to locate the absolute path.
789    fn normalize_manifest_path<P: AsRef<Path>>(
790        &self,
791        path: P,
792        id: &str,
793    ) -> Result<PathBuf, EpubError> {
794        let opf_path = PathBuf::from(&self.rootfiles[0]);
795        let basic_path = remove_leading_slash(opf_path.parent().unwrap());
796
797        // convert manifest path to absolute path(physical path)
798        let mut target_path = if path.as_ref().starts_with("../") {
799            check_realtive_link_leakage(
800                self.temp_dir.clone(),
801                basic_path.to_path_buf(),
802                &path.as_ref().to_string_lossy(),
803            )
804            .map(PathBuf::from)
805            .ok_or_else(|| EpubError::RealtiveLinkLeakage {
806                path: path.as_ref().to_string_lossy().to_string(),
807            })?
808        } else if let Ok(path) = path.as_ref().strip_prefix("/") {
809            self.temp_dir.join(path)
810        } else if path.as_ref().starts_with("./") {
811            // can not anlyze where the 'current' directory is
812            Err(EpubBuilderError::IllegalManifestPath {
813                manifest_id: id.to_string(),
814            })?
815        } else {
816            self.temp_dir.join(basic_path).join(path)
817        };
818
819        #[cfg(windows)]
820        {
821            target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
822        }
823
824        Ok(target_path)
825    }
826}
827
828impl<Version> Drop for EpubBuilder<Version> {
829    /// Remove temporary directory when dropped
830    fn drop(&mut self) {
831        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
832            warn!("{}", err);
833        };
834    }
835}
836
837/// Refine the mime type
838///
839/// Optimize mime types inferred from file content based on file extensions
840fn refine_mime_type(infer_mime: &str, extension: &str) -> String {
841    match (infer_mime, extension) {
842        ("text/xml", "xhtml")
843        | ("application/xml", "xhtml")
844        | ("text/xml", "xht")
845        | ("application/xml", "xht") => "application/xhtml+xml".to_string(),
846
847        ("text/xml", "opf") | ("application/xml", "opf") => {
848            "application/oebps-package+xml".to_string()
849        }
850
851        ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml".to_string(),
852
853        ("application/zip", "epub") => "application/epub+zip".to_string(),
854
855        ("text/plain", "css") => "text/css".to_string(),
856        ("text/plain", "js") => "application/javascript".to_string(),
857        ("text/plain", "json") => "application/json".to_string(),
858        ("text/plain", "svg") => "image/svg+xml".to_string(),
859
860        _ => infer_mime.to_string(),
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use std::{env, fs, path::PathBuf};
867
868    use crate::{
869        builder::{EpubBuilder, EpubVersion3, refine_mime_type},
870        epub::EpubDoc,
871        error::{EpubBuilderError, EpubError},
872        types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
873        utils::local_time,
874    };
875
876    #[test]
877    fn test_epub_builder_new() {
878        let builder = EpubBuilder::<EpubVersion3>::new();
879        assert!(builder.is_ok());
880
881        let builder = builder.unwrap();
882        assert!(builder.temp_dir.exists());
883        assert!(builder.rootfiles.is_empty());
884        assert!(builder.metadata.is_empty());
885        assert!(builder.manifest.is_empty());
886        assert!(builder.spine.is_empty());
887        assert!(builder.catalog_title.is_empty());
888        assert!(builder.catalog.is_empty());
889    }
890
891    #[test]
892    fn test_add_rootfile() {
893        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
894        assert!(builder.add_rootfile("content.opf").is_ok());
895
896        assert_eq!(builder.rootfiles.len(), 1);
897        assert_eq!(builder.rootfiles[0], "content.opf");
898
899        assert!(builder.add_rootfile("./another.opf").is_ok());
900        assert_eq!(builder.rootfiles.len(), 2);
901        assert_eq!(builder.rootfiles, vec!["content.opf", "another.opf"]);
902    }
903
904    #[test]
905    fn test_add_rootfile_fail() {
906        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
907
908        let result = builder.add_rootfile("/rootfile.opf");
909        assert!(result.is_err());
910        assert_eq!(
911            result.unwrap_err(),
912            EpubBuilderError::IllegalRootfilePath.into()
913        );
914
915        let result = builder.add_rootfile("../rootfile.opf");
916        assert!(result.is_err());
917        assert_eq!(
918            result.unwrap_err(),
919            EpubBuilderError::IllegalRootfilePath.into()
920        );
921    }
922
923    #[test]
924    fn test_add_metadata() {
925        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
926        let metadata_item = MetadataItem::new("title", "Test Book");
927
928        builder.add_metadata(metadata_item);
929
930        assert_eq!(builder.metadata.len(), 1);
931        assert_eq!(builder.metadata[0].property, "title");
932        assert_eq!(builder.metadata[0].value, "Test Book");
933    }
934
935    #[test]
936    fn test_add_manifest_success() {
937        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
938        assert!(builder.add_rootfile("content.opf").is_ok());
939
940        // Create a temporary file for testing
941        let temp_dir = env::temp_dir().join(local_time());
942        fs::create_dir_all(&temp_dir).unwrap();
943        let test_file = temp_dir.join("test.xhtml");
944        fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
945
946        let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
947        let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
948
949        assert!(result.is_ok());
950        assert_eq!(builder.manifest.len(), 1);
951        assert!(builder.manifest.contains_key("test"));
952
953        fs::remove_dir_all(temp_dir).unwrap();
954    }
955
956    #[test]
957    fn test_add_manifest_no_rootfile() {
958        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
959
960        let manifest_item = ManifestItem {
961            id: "main".to_string(),
962            path: PathBuf::from("/Overview.xhtml"),
963            mime: String::new(),
964            properties: None,
965            fallback: None,
966        };
967
968        let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
969        assert!(result.is_err());
970        assert_eq!(
971            result.unwrap_err(),
972            EpubBuilderError::MissingRootfile.into()
973        );
974
975        let result = builder.add_rootfile("package.opf");
976        assert!(result.is_ok());
977
978        let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
979        assert!(result.is_ok());
980    }
981
982    #[test]
983    fn test_add_manifest_nonexistent_file() {
984        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
985        assert!(builder.add_rootfile("content.opf").is_ok());
986
987        let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
988        let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
989
990        assert!(result.is_err());
991        assert_eq!(
992            result.unwrap_err(),
993            EpubBuilderError::TargetIsNotFile {
994                target_path: "nonexistent.xhtml".to_string()
995            }
996            .into()
997        );
998    }
999
1000    #[test]
1001    fn test_add_manifest_unknow_file_format() {
1002        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1003        let result = builder.add_rootfile("package.opf");
1004        assert!(result.is_ok());
1005
1006        let result = builder.add_manifest(
1007            "./test_case/unknown_file_format.xhtml",
1008            ManifestItem {
1009                id: "file".to_string(),
1010                path: PathBuf::from("unknown_file_format.xhtml"),
1011                mime: String::new(),
1012                properties: None,
1013                fallback: None,
1014            },
1015        );
1016
1017        assert!(result.is_err());
1018        assert_eq!(
1019            result.unwrap_err(),
1020            EpubBuilderError::UnknownFileFormat {
1021                file_path: "./test_case/unknown_file_format.xhtml".to_string(),
1022            }
1023            .into()
1024        )
1025    }
1026
1027    #[test]
1028    fn test_add_spine() {
1029        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1030        let spine_item = SpineItem::new("test_item");
1031
1032        builder.add_spine(spine_item.clone());
1033
1034        assert_eq!(builder.spine.len(), 1);
1035        assert_eq!(builder.spine[0].idref, "test_item");
1036    }
1037
1038    #[test]
1039    fn test_set_catalog_title() {
1040        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1041        let title = "Test Catalog Title";
1042
1043        builder.set_catalog_title(title);
1044
1045        assert_eq!(builder.catalog_title, title);
1046    }
1047
1048    #[test]
1049    fn test_add_catalog_item() {
1050        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1051        let nav_point = NavPoint::new("Chapter 1");
1052
1053        builder.add_catalog_item(nav_point.clone());
1054
1055        assert_eq!(builder.catalog.len(), 1);
1056        assert_eq!(builder.catalog[0].label, "Chapter 1");
1057    }
1058
1059    #[test]
1060    fn test_set_catalog() {
1061        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1062        let nav_points = vec![NavPoint::new("Chapter 1"), NavPoint::new("Chapter 2")];
1063
1064        builder.set_catalog(nav_points.clone());
1065
1066        assert_eq!(builder.catalog.len(), 2);
1067        assert_eq!(builder.catalog[0].label, "Chapter 1");
1068        assert_eq!(builder.catalog[1].label, "Chapter 2");
1069    }
1070
1071    #[test]
1072    fn test_make_container_file() {
1073        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1074
1075        let result = builder.make_container_xml();
1076        assert!(result.is_err());
1077        assert_eq!(
1078            result.unwrap_err(),
1079            EpubBuilderError::MissingRootfile.into()
1080        );
1081
1082        assert!(builder.add_rootfile("content.opf").is_ok());
1083        let result = builder.make_container_xml();
1084        assert!(result.is_ok());
1085    }
1086
1087    #[test]
1088    fn test_make_navigation_document() {
1089        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1090
1091        let result = builder.make_navigation_document();
1092        assert!(result.is_err());
1093        assert_eq!(
1094            result.unwrap_err(),
1095            EpubBuilderError::NavigationInfoUninitalized.into()
1096        );
1097
1098        builder.set_catalog(vec![NavPoint::new("test")]);
1099        assert!(builder.make_navigation_document().is_ok());
1100    }
1101
1102    #[test]
1103    fn test_validate_metadata_success() {
1104        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1105
1106        builder.add_metadata(MetadataItem::new("title", "Test Book"));
1107        builder.add_metadata(MetadataItem::new("language", "en"));
1108        builder.add_metadata(
1109            MetadataItem::new("identifier", "urn:isbn:1234567890")
1110                .with_id("pub-id")
1111                .build(),
1112        );
1113
1114        assert!(builder.validate_metadata());
1115    }
1116
1117    #[test]
1118    fn test_validate_metadata_missing_required() {
1119        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1120
1121        builder.add_metadata(MetadataItem::new("title", "Test Book"));
1122        builder.add_metadata(MetadataItem::new("language", "en"));
1123
1124        assert!(!builder.validate_metadata());
1125    }
1126
1127    #[test]
1128    fn test_validate_fallback_chain_valid() {
1129        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1130
1131        let item3 = ManifestItem::new("item3", "path3");
1132        assert!(item3.is_ok());
1133
1134        let item3 = item3.unwrap();
1135        let item2 = ManifestItem::new("item2", "path2")
1136            .unwrap()
1137            .with_fallback("item3")
1138            .build();
1139        let item1 = ManifestItem::new("item1", "path1")
1140            .unwrap()
1141            .with_fallback("item2")
1142            .build();
1143
1144        builder.manifest.insert("item3".to_string(), item3);
1145        builder.manifest.insert("item2".to_string(), item2);
1146        builder.manifest.insert("item1".to_string(), item1);
1147
1148        let result = builder.validate_manifest_fallback_chains();
1149        assert!(result.is_ok());
1150    }
1151
1152    #[test]
1153    fn test_validate_fallback_chain_circular_reference() {
1154        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1155
1156        let item2 = ManifestItem::new("item2", "path2")
1157            .unwrap()
1158            .with_fallback("item1")
1159            .build();
1160        let item1 = ManifestItem::new("item1", "path1")
1161            .unwrap()
1162            .with_fallback("item2")
1163            .build();
1164
1165        builder.manifest.insert("item1".to_string(), item1);
1166        builder.manifest.insert("item2".to_string(), item2);
1167
1168        let result = builder.validate_manifest_fallback_chains();
1169        assert!(result.is_err());
1170        assert!(
1171            result.unwrap_err().to_string().starts_with(
1172                "Epub builder error: Circular reference detected in fallback chain for"
1173            ),
1174        );
1175    }
1176
1177    #[test]
1178    fn test_validate_fallback_chain_not_found() {
1179        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1180
1181        let item1 = ManifestItem::new("item1", "path1")
1182            .unwrap()
1183            .with_fallback("nonexistent")
1184            .build();
1185
1186        builder.manifest.insert("item1".to_string(), item1);
1187
1188        let result = builder.validate_manifest_fallback_chains();
1189        assert!(result.is_err());
1190        assert_eq!(
1191            result.unwrap_err().to_string(),
1192            "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1193        );
1194    }
1195
1196    #[test]
1197    fn test_validate_manifest_nav_single() {
1198        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1199
1200        let nav_item = ManifestItem::new("nav", "nav.xhtml")
1201            .unwrap()
1202            .append_property("nav")
1203            .build();
1204        builder.manifest.insert("nav".to_string(), nav_item);
1205
1206        let result = builder.validate_manifest_nav();
1207        assert!(result.is_ok());
1208    }
1209
1210    #[test]
1211    fn test_validate_manifest_nav_multiple() {
1212        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1213
1214        let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1215            .unwrap()
1216            .append_property("nav")
1217            .build();
1218        let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1219            .unwrap()
1220            .append_property("nav")
1221            .build();
1222
1223        builder.manifest.insert("nav1".to_string(), nav_item1);
1224        builder.manifest.insert("nav2".to_string(), nav_item2);
1225
1226        let result = builder.validate_manifest_nav();
1227        assert!(result.is_err());
1228        assert_eq!(
1229            result.unwrap_err().to_string(),
1230            "Epub builder error: There are too many items with 'nav' property in the manifest."
1231        );
1232    }
1233
1234    #[test]
1235    fn test_make_opf_file_success() {
1236        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1237
1238        assert!(builder.add_rootfile("content.opf").is_ok());
1239        builder.add_metadata(MetadataItem::new("title", "Test Book"));
1240        builder.add_metadata(MetadataItem::new("language", "en"));
1241        builder.add_metadata(
1242            MetadataItem::new("identifier", "urn:isbn:1234567890")
1243                .with_id("pub-id")
1244                .build(),
1245        );
1246
1247        let temp_dir = env::temp_dir().join(local_time());
1248        fs::create_dir_all(&temp_dir).unwrap();
1249
1250        let test_file = temp_dir.join("test.xhtml");
1251        fs::write(&test_file, "<html></html>").unwrap();
1252
1253        let manifest_result = builder.add_manifest(
1254            test_file.to_str().unwrap(),
1255            ManifestItem::new("test", "test.xhtml").unwrap(),
1256        );
1257        assert!(manifest_result.is_ok());
1258
1259        builder.add_catalog_item(NavPoint::new("Chapter"));
1260        builder.add_spine(SpineItem::new("test"));
1261
1262        let result = builder.make_navigation_document();
1263        assert!(result.is_ok());
1264
1265        let result = builder.make_opf_file();
1266        assert!(result.is_ok());
1267
1268        let opf_path = builder.temp_dir.join("content.opf");
1269        assert!(opf_path.exists());
1270
1271        fs::remove_dir_all(temp_dir).unwrap();
1272    }
1273
1274    #[test]
1275    fn test_make_opf_file_missing_metadata() {
1276        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1277        assert!(builder.add_rootfile("content.opf").is_ok());
1278
1279        let result = builder.make_opf_file();
1280        assert!(result.is_err());
1281        assert_eq!(
1282            result.unwrap_err().to_string(),
1283            "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1284        );
1285    }
1286
1287    #[test]
1288    fn test_make() {
1289        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1290
1291        assert!(builder.add_rootfile("content.opf").is_ok());
1292        builder.add_metadata(MetadataItem::new("title", "Test Book"));
1293        builder.add_metadata(MetadataItem::new("language", "en"));
1294        builder.add_metadata(
1295            MetadataItem::new("identifier", "test_identifier")
1296                .with_id("pub-id")
1297                .build(),
1298        );
1299
1300        assert!(
1301            builder
1302                .add_manifest(
1303                    "./test_case/Overview.xhtml",
1304                    ManifestItem {
1305                        id: "test".to_string(),
1306                        path: PathBuf::from("test.xhtml"),
1307                        mime: String::new(),
1308                        properties: None,
1309                        fallback: None,
1310                    },
1311                )
1312                .is_ok()
1313        );
1314
1315        builder.add_catalog_item(NavPoint::new("Chapter"));
1316        builder.add_spine(SpineItem::new("test"));
1317
1318        let file = env::temp_dir()
1319            .join("temp_dir")
1320            .join(format!("{}.epub", local_time()));
1321        assert!(builder.make(&file).is_ok());
1322        assert!(EpubDoc::new(&file).is_ok());
1323    }
1324
1325    #[test]
1326    fn test_build() {
1327        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1328
1329        assert!(builder.add_rootfile("content.opf").is_ok());
1330        builder.add_metadata(MetadataItem::new("title", "Test Book"));
1331        builder.add_metadata(MetadataItem::new("language", "en"));
1332        builder.add_metadata(
1333            MetadataItem::new("identifier", "test_identifier")
1334                .with_id("pub-id")
1335                .build(),
1336        );
1337
1338        assert!(
1339            builder
1340                .add_manifest(
1341                    "./test_case/Overview.xhtml",
1342                    ManifestItem {
1343                        id: "test".to_string(),
1344                        path: PathBuf::from("test.xhtml"),
1345                        mime: String::new(),
1346                        properties: None,
1347                        fallback: None,
1348                    },
1349                )
1350                .is_ok()
1351        );
1352
1353        builder.add_catalog_item(NavPoint::new("Chapter"));
1354        builder.add_spine(SpineItem::new("test"));
1355
1356        let file = env::temp_dir().join(format!("{}.epub", local_time()));
1357        assert!(builder.build(&file).is_ok());
1358    }
1359
1360    #[test]
1361    fn test_from() {
1362        let builder = EpubBuilder::<EpubVersion3>::new();
1363        assert!(builder.is_ok());
1364
1365        let metadata = vec![
1366            MetadataItem {
1367                id: None,
1368                property: "title".to_string(),
1369                value: "Test Book".to_string(),
1370                lang: None,
1371                refined: vec![],
1372            },
1373            MetadataItem {
1374                id: None,
1375                property: "language".to_string(),
1376                value: "en".to_string(),
1377                lang: None,
1378                refined: vec![],
1379            },
1380            MetadataItem {
1381                id: Some("pub-id".to_string()),
1382                property: "identifier".to_string(),
1383                value: "test-book".to_string(),
1384                lang: None,
1385                refined: vec![],
1386            },
1387        ];
1388        let spine = vec![SpineItem {
1389            id: None,
1390            idref: "main".to_string(),
1391            linear: true,
1392            properties: None,
1393        }];
1394        let catalog = vec![
1395            NavPoint {
1396                label: "Nav".to_string(),
1397                content: None,
1398                children: vec![],
1399                play_order: None,
1400            },
1401            NavPoint {
1402                label: "Overview".to_string(),
1403                content: None,
1404                children: vec![],
1405                play_order: None,
1406            },
1407        ];
1408
1409        let mut builder = builder.unwrap();
1410        assert!(builder.add_rootfile("content.opf").is_ok());
1411        builder.metadata = metadata.clone();
1412        builder.spine = spine.clone();
1413        builder.catalog = catalog.clone();
1414        builder.set_catalog_title("catalog title");
1415        let result = builder.add_manifest(
1416            "./test_case/Overview.xhtml",
1417            ManifestItem {
1418                id: "main".to_string(),
1419                path: PathBuf::from("Overview.xhtml"),
1420                mime: String::new(),
1421                properties: None,
1422                fallback: None,
1423            },
1424        );
1425        assert!(result.is_ok());
1426
1427        let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1428        let result = builder.make(&epub_file);
1429        assert!(result.is_ok());
1430
1431        let doc = EpubDoc::new(&epub_file);
1432        assert!(doc.is_ok());
1433
1434        let mut doc = doc.unwrap();
1435        let builder = EpubBuilder::from(&mut doc);
1436        assert!(builder.is_ok());
1437        let builder = builder.unwrap();
1438
1439        assert_eq!(builder.metadata.len(), metadata.len() + 1);
1440        assert_eq!(builder.manifest.len(), 1); // skip nav file
1441        assert_eq!(builder.spine.len(), spine.len());
1442        assert_eq!(builder.catalog, catalog);
1443        assert_eq!(builder.catalog_title, "catalog title");
1444
1445        fs::remove_file(epub_file).unwrap();
1446    }
1447
1448    #[test]
1449    fn test_normalize_manifest_path() {
1450        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1451
1452        assert!(builder.add_rootfile("content.opf").is_ok());
1453
1454        let result = builder.normalize_manifest_path("../../test.xhtml", "id");
1455        assert!(result.is_err());
1456        assert_eq!(
1457            result.unwrap_err(),
1458            EpubError::RealtiveLinkLeakage {
1459                path: "../../test.xhtml".to_string()
1460            }
1461        );
1462
1463        let result = builder.normalize_manifest_path("/test.xhtml", "id");
1464        assert!(result.is_ok());
1465        assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
1466
1467        let result = builder.normalize_manifest_path("./test.xhtml", "manifest_id");
1468        assert!(result.is_err());
1469        assert_eq!(
1470            result.unwrap_err(),
1471            EpubBuilderError::IllegalManifestPath {
1472                manifest_id: "manifest_id".to_string()
1473            }
1474            .into(),
1475        );
1476    }
1477
1478    #[test]
1479    fn test_refine_mime_type() {
1480        assert_eq!(
1481            refine_mime_type("text/xml", "xhtml"),
1482            "application/xhtml+xml"
1483        );
1484        assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
1485        assert_eq!(
1486            refine_mime_type("application/xml", "opf"),
1487            "application/oebps-package+xml"
1488        );
1489        assert_eq!(
1490            refine_mime_type("text/xml", "ncx"),
1491            "application/x-dtbncx+xml"
1492        );
1493        assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1494        assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1495    }
1496}