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