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//! - Files will be manipulated in a temporary directory during the build process;
37//!   automatic cleanup will occur upon completion
38//! - All resource files must exist on the local file system.
39
40use std::{
41    collections::HashMap,
42    env,
43    fs::{self, File},
44    io::{BufReader, Cursor, Read, Write},
45    marker::PhantomData,
46    path::{Path, PathBuf},
47};
48
49use chrono::{SecondsFormat, Utc};
50use infer::Infer;
51use log::warn;
52use quick_xml::{
53    Writer,
54    events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
55};
56use walkdir::WalkDir;
57use zip::{CompressionMethod, ZipWriter, write::FileOptions};
58
59use crate::{
60    epub::EpubDoc,
61    error::{EpubBuilderError, EpubError},
62    types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
63    utils::{ELEMENT_IN_DC_NAMESPACE, local_time},
64};
65
66type XmlWriter = Writer<Cursor<Vec<u8>>>;
67
68// struct EpubVersion2;
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.
75pub struct EpubBuilder<Version> {
76    /// EPUB version placeholder
77    epub_version: PhantomData<Version>,
78
79    /// Temporary directory path for storing files during the build process
80    temp_dir: PathBuf,
81
82    /// List of root file paths
83    rootfiles: Vec<String>,
84
85    /// List of metadata items
86    metadata: Vec<MetadataItem>,
87
88    /// Manifest item mapping table, with ID as the key and manifest item as the value
89    manifest: HashMap<String, ManifestItem>,
90
91    /// List of spine items, defining the reading order
92    spine: Vec<SpineItem>,
93
94    catalog_title: String,
95
96    /// List of catalog navigation points
97    catalog: Vec<NavPoint>,
98}
99
100impl EpubBuilder<EpubVersion3> {
101    /// Create a new `EpubBuilder` instance
102    ///
103    /// # Return
104    /// - `Ok(EpubBuilder)`: Builder instance created successfully
105    /// - `Err(EpubError)`: Error occurred during builder initialization
106    pub fn new() -> Result<Self, EpubError> {
107        let temp_dir = env::temp_dir().join(local_time());
108        fs::create_dir(&temp_dir)?;
109        fs::create_dir(temp_dir.join("META-INF"))?;
110
111        let mime_file = temp_dir.join("mimetype");
112        fs::write(mime_file, "application/epub+zip")?;
113
114        Ok(EpubBuilder {
115            epub_version: PhantomData,
116            temp_dir,
117
118            rootfiles: vec![],
119            metadata: vec![],
120            manifest: HashMap::new(),
121            spine: vec![],
122
123            catalog_title: String::new(),
124            catalog: vec![],
125        })
126    }
127
128    /// Add a rootfile path
129    ///
130    /// The added path points to an OPF file that does not yet exist
131    /// and will be created when building the Epub file.
132    ///
133    /// # Parameters
134    /// - `rootfile`: Rootfile path
135    pub fn add_rootfile(&mut self, rootfile: &str) -> &mut Self {
136        self.rootfiles.push(rootfile.to_string());
137
138        self
139    }
140
141    /// Add metadata item
142    ///
143    /// Required metadata includes title, language, and an identifier with 'pub-id'.
144    /// Missing this data will result in an error when building the epub file.
145    ///
146    /// # Parameters
147    /// - `item`: Metadata items to add
148    pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
149        self.metadata.push(item);
150        self
151    }
152
153    /// Add manifest item and corresponding resource file
154    ///
155    /// The builder will automatically recognize the file type of
156    /// the added resource and update it in `ManifestItem`.
157    ///
158    /// # Parameters
159    /// - `manifest_source` - Local resource file path
160    /// - `manifest_item` - Manifest item information
161    ///
162    /// # Return
163    /// - `Ok(&mut Self)` - Successful addition, returns a reference to itself
164    /// - `Err(EpubError)` - Error occurred during the addition process
165    pub fn add_manifest(
166        &mut self,
167        manifest_source: &str,
168        manifest_item: ManifestItem,
169    ) -> Result<&mut Self, EpubError> {
170        let source = PathBuf::from(manifest_source);
171        if !source.is_file() {
172            return Err(EpubBuilderError::TargetIsNotFile {
173                target_path: manifest_source.to_string(),
174            }
175            .into());
176        }
177
178        let extension = match source.extension() {
179            Some(ext) => ext.to_string_lossy().to_lowercase(),
180            None => String::new(),
181        };
182
183        let buf = match fs::read(source) {
184            Ok(buf) => buf,
185            Err(err) => return Err(err.into()),
186        };
187
188        let real_mime = match Infer::new().get(&buf) {
189            Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
190            None => {
191                return Err(EpubBuilderError::UnknowFileFormat {
192                    file_path: manifest_source.to_string(),
193                }
194                .into());
195            }
196        };
197
198        let target_path = self.temp_dir.join(&manifest_item.path);
199        if let Some(parent_dir) = target_path.parent() {
200            if !parent_dir.exists() {
201                fs::create_dir_all(parent_dir)?
202            }
203        }
204
205        match fs::write(target_path, buf) {
206            Ok(_) => {
207                self.manifest
208                    .insert(manifest_item.id.clone(), manifest_item.set_mime(&real_mime));
209                Ok(self)
210            }
211            Err(err) => Err(err.into()),
212        }
213    }
214
215    /// Add spine item
216    ///
217    /// The spine item defines the reading order of the book.
218    ///
219    /// # Parameters
220    /// - `item`: Spine item to add
221    pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
222        self.spine.push(item);
223        self
224    }
225
226    /// Set catalog title
227    ///
228    /// # Parameters
229    /// - `title`: Catalog title
230    pub fn set_catalog_title(&mut self, title: &str) -> &mut Self {
231        self.catalog_title = title.to_string();
232        self
233    }
234
235    /// Add catalog item
236    ///
237    /// Added directory items will be added to the end of the existing list.
238    ///
239    /// # Parameters
240    /// - `item`: Catalog item to add
241    pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
242        self.catalog.push(item);
243        self
244    }
245
246    /// Re-/ Set catalog
247    ///
248    /// The passed list will overwrite existing data.
249    ///
250    /// # Parameters
251    /// - `catalog`: Catalog to set
252    pub fn set_catalog(&mut self, catalog: Vec<NavPoint>) -> &mut Self {
253        self.catalog = catalog;
254        self
255    }
256
257    /// Builds an EPUB file and saves it to the specified path
258    ///
259    /// # Parameters
260    /// - `output_path`: Output file path
261    ///
262    /// # Return
263    /// - `Ok(())`: Build successful
264    /// - `Err(EpubError)`: Error occurred during the build process
265    pub fn make<P: AsRef<Path>>(mut self, output_path: P) -> Result<(), EpubError> {
266        // Create the container.xml, navigation document, and OPF files in sequence.
267        // The associated metadata will initialized when navigation document is created;
268        // therefore, the navigation document must be created before the opf file is created.
269        self.make_container_xml()?;
270        self.make_navigation_document()?;
271        self.make_opf_file()?;
272
273        if let Some(parent) = output_path.as_ref().parent() {
274            if !parent.exists() {
275                fs::create_dir_all(parent)?;
276            }
277        }
278
279        let file = File::create(output_path)?;
280        let mut zip = ZipWriter::new(file);
281        let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
282
283        for entry in WalkDir::new(&self.temp_dir) {
284            let entry = entry.map_err(|_e| EpubError::FailedParsingXml)?;
285            let path = entry.path();
286
287            let relative_path = path
288                .strip_prefix(&self.temp_dir)
289                .map_err(|_e| EpubError::FailedParsingXml)?;
290            let target_path = relative_path.to_string_lossy().replace("\\", "/");
291
292            if path.is_file() {
293                zip.start_file(target_path, options)?;
294                let mut buf = Vec::new();
295                File::open(path)?.read_to_end(&mut buf)?;
296                zip.write(&buf)?;
297            } else if path.is_dir() {
298                zip.add_directory(target_path, options)?;
299            }
300        }
301
302        zip.finish()?;
303        Ok(())
304    }
305
306    /// Builds an EPUB file and returns a `EpubDoc`
307    ///
308    /// Builds an EPUB file at the specified location and parses it into a usable EpubDoc object.
309    ///
310    /// # Parameters
311    /// - `output_path`: Output file path
312    ///
313    /// # Return
314    /// - `Ok(EpubDoc)`: Build successful
315    /// - `Err(EpubError)`: Error occurred during the build process
316    pub fn build<P: AsRef<Path>>(
317        self,
318        output_path: P,
319    ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
320        self.make(&output_path)?;
321
322        EpubDoc::new(output_path)
323    }
324
325    /// Creates the `container.xml` file
326    ///
327    /// An error will occur if the `rootfile` path is not set
328    fn make_container_xml(&self) -> Result<(), EpubError> {
329        if self.rootfiles.is_empty() {
330            return Err(EpubBuilderError::MissingRootfile.into());
331        }
332
333        let mut writer = Writer::new(Cursor::new(Vec::new()));
334
335        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
336
337        writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
338            [
339                ("version", "1.0"),
340                ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
341            ],
342        )))?;
343        writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
344
345        for rootfile in &self.rootfiles {
346            writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
347                ("full-path", rootfile.as_str()),
348                ("media-type", "application/oebps-package+xml"),
349            ])))?;
350        }
351
352        writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
353        writer.write_event(Event::End(BytesEnd::new("container")))?;
354
355        let file_path = self.temp_dir.join("META-INF").join("container.xml");
356        let file_data = writer.into_inner().into_inner();
357        fs::write(file_path, file_data)?;
358
359        Ok(())
360    }
361
362    /// Creates the `navigation document`
363    ///
364    /// An error will occur if navigation information is not initialized.
365    fn make_navigation_document(&mut self) -> Result<(), EpubError> {
366        if self.catalog.is_empty() {
367            return Err(EpubBuilderError::NavigationInfoUninitalized.into());
368        }
369
370        let mut writer = Writer::new(Cursor::new(Vec::new()));
371
372        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
373            ("xmlns", "http://www.w3.org/1999/xhtml"),
374            ("xmlns:epub", "http://www.idpf.org/2007/ops"),
375        ])))?;
376
377        // make head
378        writer.write_event(Event::Start(BytesStart::new("head")))?;
379        writer.write_event(Event::Start(BytesStart::new("title")))?;
380        writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
381        writer.write_event(Event::End(BytesEnd::new("title")))?;
382        writer.write_event(Event::End(BytesEnd::new("head")))?;
383
384        // make body
385        writer.write_event(Event::Start(BytesStart::new("body")))?;
386        writer.write_event(Event::Start(
387            BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
388        ))?;
389
390        if !self.catalog_title.is_empty() {
391            writer.write_event(Event::Start(BytesStart::new("h1")))?;
392            writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
393            writer.write_event(Event::End(BytesEnd::new("h1")))?;
394        }
395
396        Self::make_nav(&mut writer, &self.catalog)?;
397
398        writer.write_event(Event::End(BytesEnd::new("nav")))?;
399        writer.write_event(Event::End(BytesEnd::new("body")))?;
400
401        writer.write_event(Event::End(BytesEnd::new("html")))?;
402
403        let file_path = self.temp_dir.join("nav.xhtml");
404        let file_data = writer.into_inner().into_inner();
405        fs::write(file_path, file_data)?;
406
407        self.manifest.insert(
408            "nav".to_string(),
409            ManifestItem {
410                id: "nav".to_string(),
411                path: self.temp_dir.join("nav.xhtml"),
412                mime: "application/xhtml+xml".to_string(),
413                properties: Some("nav".to_string()),
414                fallback: None,
415            },
416        );
417
418        Ok(())
419    }
420
421    /// Creates the `OPF` file
422    ///
423    /// # Error conditions
424    /// - Missing necessary metadata
425    /// - Circular reference exists in the manifest backlink
426    /// - Navigation information is not initialized
427    fn make_opf_file(&mut self) -> Result<(), EpubError> {
428        if !self.validate_metadata() {
429            return Err(EpubBuilderError::MissingNecessaryMetadata.into());
430        }
431        self.validate_manifest_fallback_chains()?;
432        self.validate_manifest_nav()?;
433
434        let mut writer = Writer::new(Cursor::new(Vec::new()));
435
436        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
437
438        writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
439            ("xmlns", "http://www.idpf.org/2007/opf"),
440            ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
441            ("unique-identifier", "pub-id"),
442            ("version", "3.0"),
443        ])))?;
444
445        self.make_opf_metadata(&mut writer)?;
446        self.make_opf_manifest(&mut writer)?;
447        self.make_opf_spine(&mut writer)?;
448
449        writer.write_event(Event::End(BytesEnd::new("package")))?;
450
451        let file_path = self.temp_dir.join(&self.rootfiles[0]);
452        let file_data = writer.into_inner().into_inner();
453        fs::write(file_path, file_data)?;
454
455        Ok(())
456    }
457
458    fn make_opf_metadata(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
459        self.metadata.push(MetadataItem {
460            id: None,
461            property: "dcterms:modified".to_string(),
462            value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
463            lang: None,
464            refined: vec![],
465        });
466
467        writer.write_event(Event::Start(BytesStart::new("metadata")))?;
468
469        for metadata in &self.metadata {
470            let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
471                format!("dc:{}", metadata.property)
472            } else {
473                metadata.property.clone()
474            };
475
476            writer.write_event(Event::Start(
477                BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
478            ))?;
479            writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
480            writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
481
482            for refinement in &metadata.refined {
483                writer.write_event(Event::Start(
484                    BytesStart::new("meta").with_attributes(refinement.attributes()),
485                ))?;
486                writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
487                writer.write_event(Event::End(BytesEnd::new("meta")))?;
488            }
489        }
490
491        writer.write_event(Event::End(BytesEnd::new("metadata")))?;
492
493        Ok(())
494    }
495
496    fn make_opf_manifest(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
497        writer.write_event(Event::Start(BytesStart::new("manifest")))?;
498
499        for (_, manifest) in &self.manifest {
500            writer.write_event(Event::Empty(
501                BytesStart::new("item").with_attributes(manifest.attributes()),
502            ))?;
503        }
504
505        writer.write_event(Event::End(BytesEnd::new("manifest")))?;
506
507        Ok(())
508    }
509
510    fn make_opf_spine(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
511        writer.write_event(Event::Start(BytesStart::new("spine")))?;
512
513        for spine in &self.spine {
514            writer.write_event(Event::Empty(
515                BytesStart::new("itemref").with_attributes(spine.attributes()),
516            ))?;
517        }
518
519        writer.write_event(Event::End(BytesEnd::new("spine")))?;
520
521        Ok(())
522    }
523
524    fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
525        writer.write_event(Event::Start(BytesStart::new("ol")))?;
526
527        for nav in navgations {
528            writer.write_event(Event::Start(BytesStart::new("li")))?;
529
530            if let Some(path) = &nav.content {
531                writer.write_event(Event::Start(
532                    BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
533                ))?;
534                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
535                writer.write_event(Event::End(BytesEnd::new("a")))?;
536            } else {
537                writer.write_event(Event::Start(BytesStart::new("span")))?;
538                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
539                writer.write_event(Event::End(BytesEnd::new("span")))?;
540            }
541
542            if !nav.children.is_empty() {
543                Self::make_nav(writer, &nav.children)?;
544            }
545
546            writer.write_event(Event::End(BytesEnd::new("li")))?;
547        }
548
549        writer.write_event(Event::End(BytesEnd::new("ol")))?;
550
551        Ok(())
552    }
553
554    /// Verify metadata integrity
555    ///
556    /// Check if the required metadata items are included: title, language, and identifier with pub-id.
557    fn validate_metadata(&self) -> bool {
558        let has_title = self.metadata.iter().any(|item| item.property == "title");
559        let has_language = self.metadata.iter().any(|item| item.property == "language");
560        let has_identifier = self.metadata.iter().any(|item| {
561            item.property == "identifier" && item.id.as_ref().is_some_and(|id| id == "pub-id")
562        });
563
564        has_title && has_identifier && has_language
565    }
566
567    fn validate_manifest_fallback_chains(&self) -> Result<(), EpubError> {
568        for (id, item) in &self.manifest {
569            if item.fallback.is_none() {
570                continue;
571            }
572
573            let mut fallback_chain = Vec::new();
574            self.validate_fallback_chain(id, &mut fallback_chain)?;
575        }
576
577        Ok(())
578    }
579
580    /// Recursively verify the validity of a single fallback chain
581    ///
582    /// This function recursively traces the fallback chain to check for the following issues:
583    /// - Circular reference
584    /// - The referenced fallback resource does not exist
585    fn validate_fallback_chain(
586        &self,
587        manifest_id: &str,
588        fallback_chain: &mut Vec<String>,
589    ) -> Result<(), EpubError> {
590        if fallback_chain.contains(&manifest_id.to_string()) {
591            fallback_chain.push(manifest_id.to_string());
592
593            return Err(EpubBuilderError::ManifestCircularReference {
594                fallback_chain: fallback_chain.join("->"),
595            }
596            .into());
597        }
598
599        // Get the current item; its existence can be ensured based on the calling context.
600        let item = self.manifest.get(manifest_id).unwrap();
601
602        if let Some(fallback_id) = &item.fallback {
603            if !self.manifest.contains_key(fallback_id) {
604                return Err(EpubBuilderError::ManifestNotFound {
605                    manifest_id: fallback_id.to_owned(),
606                }
607                .into());
608            }
609
610            fallback_chain.push(manifest_id.to_string());
611            self.validate_fallback_chain(fallback_id, fallback_chain)
612        } else {
613            // The end of the fallback chain
614            Ok(())
615        }
616    }
617
618    /// Validate navigation list items
619    ///
620    /// Check if there is only one list item with the `nav` property.
621    fn validate_manifest_nav(&self) -> Result<(), EpubError> {
622        if self
623            .manifest
624            .values()
625            .filter(|&item| {
626                if let Some(properties) = &item.properties {
627                    properties
628                        .clone()
629                        .split(" ")
630                        .collect::<Vec<&str>>()
631                        .contains(&"nav")
632                } else {
633                    return false;
634                }
635            })
636            .count()
637            == 1
638        {
639            Ok(())
640        } else {
641            Err(EpubBuilderError::TooManyNavFlags.into())
642        }
643    }
644}
645
646impl<Version> Drop for EpubBuilder<Version> {
647    /// Remove temporary directory when dropped
648    fn drop(&mut self) {
649        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
650            warn!("{}", err);
651        };
652    }
653}
654
655/// Refine the mime type
656///
657/// Optimize mime types inferred from file content based on file extensions
658fn refine_mime_type(infer_mime: &str, extension: &str) -> String {
659    match (infer_mime, extension) {
660        ("text/xml", "xhtml")
661        | ("application/xml", "xhtml")
662        | ("text/xml", "xht")
663        | ("application/xml", "xht") => "application/xhtml+xml".to_string(),
664
665        ("text/xml", "opf") | ("application/xml", "opf") => {
666            "application/oebps-package+xml".to_string()
667        }
668
669        ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml".to_string(),
670
671        ("application/zip", "epub") => "application/epub+zip".to_string(),
672
673        ("text/plain", "css") => "text/css".to_string(),
674        ("text/plain", "js") => "application/javascript".to_string(),
675        ("text/plain", "json") => "application/json".to_string(),
676        ("text/plain", "svg") => "image/svg+xml".to_string(),
677
678        _ => infer_mime.to_string(),
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use std::{env, fs};
685
686    use crate::{
687        builder::{EpubBuilder, EpubVersion3, refine_mime_type},
688        types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
689        utils::local_time,
690    };
691
692    #[test]
693    fn test_epub_builder_new() {
694        let builder = EpubBuilder::<EpubVersion3>::new();
695        assert!(builder.is_ok());
696
697        let builder = builder.unwrap();
698        assert!(builder.temp_dir.exists());
699        assert!(builder.rootfiles.is_empty());
700        assert!(builder.metadata.is_empty());
701        assert!(builder.manifest.is_empty());
702        assert!(builder.spine.is_empty());
703        assert!(builder.catalog_title.is_empty());
704        assert!(builder.catalog.is_empty());
705    }
706
707    #[test]
708    fn test_add_rootfile() {
709        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
710        builder.add_rootfile("content.opf");
711
712        assert_eq!(builder.rootfiles.len(), 1);
713        assert_eq!(builder.rootfiles[0], "content.opf");
714
715        // Test chaining
716        builder
717            .add_rootfile("another.opf")
718            .add_rootfile("third.opf");
719        assert_eq!(builder.rootfiles.len(), 3);
720        assert_eq!(
721            builder.rootfiles,
722            vec!["content.opf", "another.opf", "third.opf"]
723        );
724    }
725
726    #[test]
727    fn test_add_metadata() {
728        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
729        let metadata_item = MetadataItem::new("title", "Test Book");
730
731        builder.add_metadata(metadata_item);
732
733        assert_eq!(builder.metadata.len(), 1);
734        assert_eq!(builder.metadata[0].property, "title");
735        assert_eq!(builder.metadata[0].value, "Test Book");
736    }
737
738    #[test]
739    fn test_add_manifest_success() {
740        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
741
742        // Create a temporary file for testing
743        let temp_dir = env::temp_dir().join(local_time());
744        fs::create_dir_all(&temp_dir).unwrap();
745        let test_file = temp_dir.join("test.xhtml");
746        fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
747
748        let manifest_item = ManifestItem::new("test", "test.xhtml").unwrap();
749        let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
750
751        assert!(result.is_ok());
752        assert_eq!(builder.manifest.len(), 1);
753        assert!(builder.manifest.contains_key("test"));
754
755        fs::remove_dir_all(temp_dir).unwrap();
756    }
757
758    #[test]
759    fn test_add_manifest_nonexistent_file() {
760        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
761
762        let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
763        let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
764
765        assert!(result.is_err());
766        if let Err(err) = result {
767            assert_eq!(
768                err.to_string(),
769                "Epub builder error: Expect a file, but 'nonexistent.xhtml' is not a file."
770            );
771        }
772    }
773
774    #[test]
775    fn test_add_spine() {
776        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
777        let spine_item = SpineItem::new("test_item");
778
779        builder.add_spine(spine_item.clone());
780
781        assert_eq!(builder.spine.len(), 1);
782        assert_eq!(builder.spine[0].idref, "test_item");
783    }
784
785    #[test]
786    fn test_set_catalog_title() {
787        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
788        let title = "Test Catalog Title";
789
790        builder.set_catalog_title(title);
791
792        assert_eq!(builder.catalog_title, title);
793    }
794
795    #[test]
796    fn test_add_catalog_item() {
797        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
798        let nav_point = NavPoint::new("Chapter 1");
799
800        builder.add_catalog_item(nav_point.clone());
801
802        assert_eq!(builder.catalog.len(), 1);
803        assert_eq!(builder.catalog[0].label, "Chapter 1");
804    }
805
806    #[test]
807    fn test_set_catalog() {
808        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
809        let nav_points = vec![NavPoint::new("Chapter 1"), NavPoint::new("Chapter 2")];
810
811        builder.set_catalog(nav_points.clone());
812
813        assert_eq!(builder.catalog.len(), 2);
814        assert_eq!(builder.catalog[0].label, "Chapter 1");
815        assert_eq!(builder.catalog[1].label, "Chapter 2");
816    }
817
818    #[test]
819    fn test_validate_metadata_success() {
820        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
821
822        builder.add_metadata(MetadataItem::new("title", "Test Book"));
823        builder.add_metadata(MetadataItem::new("language", "en"));
824        builder.add_metadata(
825            MetadataItem::new("identifier", "urn:isbn:1234567890")
826                .with_id("pub-id")
827                .build(),
828        );
829
830        assert!(builder.validate_metadata());
831    }
832
833    #[test]
834    fn test_validate_metadata_missing_required() {
835        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
836
837        builder.add_metadata(MetadataItem::new("title", "Test Book"));
838        builder.add_metadata(MetadataItem::new("language", "en"));
839
840        assert!(!builder.validate_metadata());
841    }
842
843    #[test]
844    fn test_validate_fallback_chain_valid() {
845        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
846
847        let item3 = ManifestItem::new("item3", "path3");
848        assert!(item3.is_ok());
849
850        let item3 = item3.unwrap();
851        let item2 = ManifestItem::new("item2", "path2")
852            .unwrap()
853            .with_fallback("item3")
854            .build();
855        let item1 = ManifestItem::new("item1", "path1")
856            .unwrap()
857            .with_fallback("item2")
858            .build();
859
860        builder.manifest.insert("item3".to_string(), item3);
861        builder.manifest.insert("item2".to_string(), item2);
862        builder.manifest.insert("item1".to_string(), item1);
863
864        let result = builder.validate_manifest_fallback_chains();
865        assert!(result.is_ok());
866    }
867
868    #[test]
869    fn test_validate_fallback_chain_circular_reference() {
870        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
871
872        let item2 = ManifestItem::new("item2", "path2")
873            .unwrap()
874            .with_fallback("item1")
875            .build();
876        let item1 = ManifestItem::new("item1", "path1")
877            .unwrap()
878            .with_fallback("item2")
879            .build();
880
881        builder.manifest.insert("item1".to_string(), item1);
882        builder.manifest.insert("item2".to_string(), item2);
883
884        let result = builder.validate_manifest_fallback_chains();
885        assert!(result.is_err());
886        assert!(
887            result.unwrap_err().to_string().starts_with(
888                "Epub builder error: Circular reference detected in fallback chain for"
889            ),
890        );
891    }
892
893    #[test]
894    fn test_validate_fallback_chain_not_found() {
895        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
896
897        let item1 = ManifestItem::new("item1", "path1")
898            .unwrap()
899            .with_fallback("nonexistent")
900            .build();
901
902        builder.manifest.insert("item1".to_string(), item1);
903
904        let result = builder.validate_manifest_fallback_chains();
905        assert!(result.is_err());
906        assert_eq!(
907            result.unwrap_err().to_string(),
908            "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
909        );
910    }
911
912    #[test]
913    fn test_validate_manifest_nav_single() {
914        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
915
916        let nav_item = ManifestItem::new("nav", "nav.xhtml")
917            .unwrap()
918            .append_property("nav")
919            .build();
920        builder.manifest.insert("nav".to_string(), nav_item);
921
922        let result = builder.validate_manifest_nav();
923        assert!(result.is_ok());
924    }
925
926    #[test]
927    fn test_validate_manifest_nav_multiple() {
928        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
929
930        let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
931            .unwrap()
932            .append_property("nav")
933            .build();
934        let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
935            .unwrap()
936            .append_property("nav")
937            .build();
938
939        builder.manifest.insert("nav1".to_string(), nav_item1);
940        builder.manifest.insert("nav2".to_string(), nav_item2);
941
942        let result = builder.validate_manifest_nav();
943        assert!(result.is_err());
944        assert_eq!(
945            result.unwrap_err().to_string(),
946            "Epub builder error: There are too many items with 'nav' property in the manifest."
947        );
948    }
949
950    #[test]
951    fn test_make_opf_file_success() {
952        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
953
954        builder.add_rootfile("content.opf");
955        builder.add_metadata(MetadataItem::new("title", "Test Book"));
956        builder.add_metadata(MetadataItem::new("language", "en"));
957        builder.add_metadata(
958            MetadataItem::new("identifier", "urn:isbn:1234567890")
959                .with_id("pub-id")
960                .build(),
961        );
962
963        let temp_dir = env::temp_dir().join(local_time());
964        fs::create_dir_all(&temp_dir).unwrap();
965
966        let test_file = temp_dir.join("test.xhtml");
967        fs::write(&test_file, "<html></html>").unwrap();
968
969        let manifest_result = builder.add_manifest(
970            test_file.to_str().unwrap(),
971            ManifestItem::new("test", "test.xhtml").unwrap(),
972        );
973        assert!(manifest_result.is_ok());
974
975        builder.add_catalog_item(NavPoint::new("Chapter"));
976        builder.add_spine(SpineItem::new("test"));
977
978        let result = builder.make_navigation_document();
979        assert!(result.is_ok());
980
981        let result = builder.make_opf_file();
982        assert!(result.is_ok());
983
984        let opf_path = builder.temp_dir.join("content.opf");
985        assert!(opf_path.exists());
986
987        fs::remove_dir_all(temp_dir).unwrap();
988    }
989
990    #[test]
991    fn test_make_opf_file_missing_metadata() {
992        let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
993        builder.add_rootfile("content.opf");
994
995        let result = builder.make_opf_file();
996        assert!(result.is_err());
997        assert_eq!(
998            result.unwrap_err().to_string(),
999            "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1000        );
1001    }
1002
1003    #[test]
1004    fn test_refine_mime_type() {
1005        assert_eq!(
1006            refine_mime_type("text/xml", "xhtml"),
1007            "application/xhtml+xml"
1008        );
1009        assert_eq!(
1010            refine_mime_type("application/xml", "opf"),
1011            "application/oebps-package+xml"
1012        );
1013        assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1014        assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1015    }
1016}