Skip to main content

lib_epub/
builder.rs

1//! EPUB build functionality
2//!
3//! This module provides functionality for creating and building EPUB eBook files.
4//! The `EpubBuilder` structure implements the build logic of the EPUB 3.0 specification,
5//! allowing users to create standard-compliant EPUB files from scratch.
6//!
7//! ## Usage
8//!
9//! ```rust, no_run
10//! # #[cfg(feature = "builder")] {
11//! # fn main() -> Result<(), lib_epub::error::EpubError> {
12//! use lib_epub::{
13//!     builder::{EpubBuilder, EpubVersion3},
14//!     types::{MetadataItem, ManifestItem, SpineItem},
15//! };
16//!
17//! let mut builder = EpubBuilder::<EpubVersion3>::new()?;
18//! builder
19//!     .add_rootfile("OEBPS/content.opf")?
20//!     .add_metadata(MetadataItem::new("title", "Test Book"))
21//!     .add_manifest(
22//!         "path/to/content",
23//!         ManifestItem::new("content_id", "target/path")?,
24//!     )?
25//!     .add_spine(SpineItem::new("content.xhtml"));
26//!
27//! builder.build("output.epub")?;
28//! # Ok(())
29//! # }
30//! # }
31//! ```
32//!
33//! ## Notes
34//!
35//! - Requires `builder` feature to use this module.
36//! - All resource files must exist on the local file system.
37//! - At least one rootfile must be added before adding manifest items.
38//! - Required metadata includes: `title`, `language`, and `identifier` with id `pub-id`.
39
40#[cfg(feature = "no-indexmap")]
41use std::collections::HashMap;
42use std::{
43    cmp::Reverse,
44    env,
45    fs::{self, File},
46    io::{BufReader, Cursor, Read, Seek},
47    marker::PhantomData,
48    path::{Path, PathBuf},
49};
50
51use chrono::{SecondsFormat, Utc};
52#[cfg(not(feature = "no-indexmap"))]
53use indexmap::IndexMap;
54use infer::Infer;
55use log::warn;
56use quick_xml::{
57    Writer,
58    events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
59};
60use walkdir::WalkDir;
61use zip::{CompressionMethod, ZipWriter, write::FileOptions};
62
63#[cfg(feature = "content-builder")]
64use crate::builder::content::ContentBuilder;
65use crate::{
66    epub::EpubDoc,
67    error::{EpubBuilderError, EpubError},
68    types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
69    utils::{
70        ELEMENT_IN_DC_NAMESPACE, check_realtive_link_leakage, local_time, remove_leading_slash,
71    },
72};
73
74#[cfg(feature = "content-builder")]
75pub mod content;
76
77type XmlWriter = Writer<Cursor<Vec<u8>>>;
78
79// struct EpubVersion2;
80#[cfg_attr(test, derive(Debug))]
81pub struct EpubVersion3;
82
83/// Rootfile builder for EPUB container
84///
85/// The `RootfileBuilder` is responsible for managing the rootfile paths in the EPUB container.
86/// Each rootfile points to an OPF (Open Packaging Format) file that defines the structure
87/// and content of an EPUB publication.
88///
89/// In EPUB 3.0, a single rootfile is typically used, but the structure supports multiple
90/// rootfiles for more complex publications.
91///
92/// ## Notes
93///
94/// - Rootfile paths must be relative and cannot start with "../" or "/"
95/// - At least one rootfile must be added before building the EPUB
96#[derive(Debug)]
97pub struct RootfileBuilder {
98    /// List of rootfile paths
99    pub(crate) rootfiles: Vec<String>,
100}
101
102impl RootfileBuilder {
103    /// Creates a new empty `RootfileBuilder` instance
104    pub(crate) fn new() -> Self {
105        Self { rootfiles: Vec::new() }
106    }
107
108    /// Add a rootfile path
109    ///
110    /// Adds a new rootfile path to the builder. The rootfile points to the OPF file
111    /// that will be created when building the EPUB.
112    ///
113    /// ## Parameters
114    /// - `rootfile`: The relative path to the OPF file
115    ///
116    /// ## Return
117    /// - `Ok(&mut Self)`: Successfully added the rootfile
118    /// - `Err(EpubError)`: Error if the path is invalid (starts with "/" or "../")
119    pub fn add(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
120        let rootfile = rootfile.as_ref();
121
122        if rootfile.starts_with("/") || rootfile.starts_with("../") {
123            return Err(EpubBuilderError::IllegalRootfilePath.into());
124        }
125
126        let rootfile = rootfile.strip_prefix("./").unwrap_or(rootfile);
127
128        self.rootfiles.push(rootfile.into());
129        Ok(self)
130    }
131
132    /// Clear all rootfiles
133    ///
134    /// Removes all rootfile paths from the builder.
135    pub fn clear(&mut self) -> &mut Self {
136        self.rootfiles.clear();
137        self
138    }
139
140    /// Check if the builder is empty
141    pub(crate) fn is_empty(&self) -> bool {
142        self.rootfiles.is_empty()
143    }
144
145    /// Get the first rootfile
146    pub(crate) fn first(&self) -> Option<&String> {
147        self.rootfiles.first()
148    }
149
150    /// Generate the container.xml content
151    ///
152    /// Writes the XML representation of the container and rootfiles to the provided writer.
153    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
154        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
155
156        writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
157            [
158                ("version", "1.0"),
159                ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
160            ],
161        )))?;
162        writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
163
164        for rootfile in &self.rootfiles {
165            writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
166                ("full-path", rootfile.as_str()),
167                ("media-type", "application/oebps-package+xml"),
168            ])))?;
169        }
170
171        writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
172        writer.write_event(Event::End(BytesEnd::new("container")))?;
173
174        Ok(())
175    }
176}
177
178/// Metadata builder for EPUB publications
179///
180/// The `MetadataBuilder` is responsible for managing metadata items in an EPUB publication.
181/// Metadata includes essential information such as title, author, language, identifier,
182/// publisher, and other descriptive information about the publication.
183///
184/// ## Required Metadata
185///
186/// According to the EPUB specification, the following metadata are required:
187/// - `title`: The publication title
188/// - `language`: The language of the publication (e.g., "en", "zh-CN")
189/// - `identifier`: A unique identifier for the publication with id "pub-id"
190#[derive(Debug)]
191pub struct MetadataBuilder {
192    /// List of metadata items
193    pub(crate) metadata: Vec<MetadataItem>,
194}
195
196impl MetadataBuilder {
197    /// Creates a new empty `MetadataBuilder` instance
198    pub(crate) fn new() -> Self {
199        Self { metadata: Vec::new() }
200    }
201
202    /// Add a metadata item
203    ///
204    /// Appends a new metadata item to the builder.
205    ///
206    /// ## Parameters
207    /// - `item`: The metadata item to add
208    ///
209    /// ## Return
210    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
211    pub fn add(&mut self, item: MetadataItem) -> &mut Self {
212        self.metadata.push(item);
213        self
214    }
215
216    /// Clear all metadata items
217    ///
218    /// Removes all metadata items from the builder.
219    pub fn clear(&mut self) -> &mut Self {
220        self.metadata.clear();
221        self
222    }
223
224    /// Generate the metadata XML content
225    ///
226    /// Writes the XML representation of the metadata to the provided writer.
227    /// This includes all metadata items and their refinements, as well as
228    /// automatically adding a `dcterms:modified` timestamp.
229    pub(crate) fn make(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
230        self.metadata.push(MetadataItem {
231            id: None,
232            property: "dcterms:modified".to_string(),
233            value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
234            lang: None,
235            refined: vec![],
236        });
237
238        writer.write_event(Event::Start(BytesStart::new("metadata")))?;
239
240        for metadata in &self.metadata {
241            let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
242                format!("dc:{}", metadata.property)
243            } else {
244                "meta".to_string()
245            };
246
247            writer.write_event(Event::Start(
248                BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
249            ))?;
250            writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
251            writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
252
253            for refinement in &metadata.refined {
254                writer.write_event(Event::Start(
255                    BytesStart::new("meta").with_attributes(refinement.attributes()),
256                ))?;
257                writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
258                writer.write_event(Event::End(BytesEnd::new("meta")))?;
259            }
260        }
261
262        writer.write_event(Event::End(BytesEnd::new("metadata")))?;
263
264        Ok(())
265    }
266
267    /// Verify metadata integrity
268    ///
269    /// Check if the required metadata items are included: title, language, and identifier with pub-id.
270    pub(crate) fn validate(&self) -> Result<(), EpubError> {
271        let mut has_title = false;
272        let mut has_language = false;
273        let mut has_identifier = false;
274
275        for item in &self.metadata {
276            match item.property.as_str() {
277                "title" => has_title = true,
278                "language" => has_language = true,
279                "identifier" => {
280                    if item.id.as_ref().is_some_and(|id| id == "pub-id") {
281                        has_identifier = true;
282                    }
283                }
284                _ => {}
285            }
286
287            if has_title && has_language && has_identifier {
288                return Ok(());
289            }
290        }
291
292        Err(EpubBuilderError::MissingNecessaryMetadata.into())
293    }
294}
295
296/// Manifest builder for EPUB resources
297///
298/// The `ManifestBuilder` is responsible for managing manifest items in an EPUB publication.
299/// The manifest declares all resources (HTML files, images, stylesheets, fonts, etc.)
300/// that are part of the EPUB publication.
301///
302/// Each manifest item must have a unique identifier and a path to the resource file.
303/// The builder automatically determines the MIME type of each resource based on its content.
304///
305/// ## Resource Fallbacks
306///
307/// The manifest supports fallback chains for resources that may not be supported by all
308/// reading systems. When adding a resource with a fallback, the builder validates that:
309/// - The fallback chain does not contain circular references
310/// - All referenced fallback resources exist in the manifest
311///
312/// ## Navigation Document
313///
314/// The manifest must contain exactly one item with the `nav` property, which serves
315/// as the navigation document (table of contents) of the publication.
316#[derive(Debug)]
317pub struct ManifestBuilder {
318    /// Temporary directory for storing files during build
319    temp_dir: PathBuf,
320
321    /// Rootfile path (OPF file location)
322    rootfile: Option<String>,
323
324    /// Manifest items stored in a map keyed by ID
325    #[cfg(feature = "no-indexmap")]
326    pub(crate) manifest: HashMap<String, ManifestItem>,
327    #[cfg(not(feature = "no-indexmap"))]
328    pub(crate) manifest: IndexMap<String, ManifestItem>,
329}
330
331impl ManifestBuilder {
332    /// Creates a new `ManifestBuilder` instance
333    ///
334    /// ## Parameters
335    /// - `temp_dir`: Temporary directory path for storing files during the build process
336    pub(crate) fn new(temp_dir: impl AsRef<Path>) -> Self {
337        Self {
338            temp_dir: temp_dir.as_ref().to_path_buf(),
339            rootfile: None,
340            #[cfg(feature = "no-indexmap")]
341            manifest: HashMap::new(),
342            #[cfg(not(feature = "no-indexmap"))]
343            manifest: IndexMap::new(),
344        }
345    }
346
347    /// Set the rootfile path
348    ///
349    /// This must be called before adding manifest items.
350    ///
351    /// ## Parameters
352    /// - `rootfile`: The rootfile path
353    pub(crate) fn set_rootfile(&mut self, rootfile: impl Into<String>) {
354        self.rootfile = Some(rootfile.into());
355    }
356
357    /// Add a manifest item and copy the resource file
358    ///
359    /// Adds a new resource to the manifest and copies the source file to the
360    /// temporary directory. The builder automatically determines the MIME type
361    /// based on the file content.
362    ///
363    /// ## Parameters
364    /// - `manifest_source`: Path to the source file on the local filesystem
365    /// - `manifest_item`: Manifest item with ID and target path
366    ///
367    /// ## Return
368    /// - `Ok(&mut Self)`: Successfully added the resource
369    /// - `Err(EpubError)`: Error if the source file doesn't exist or has an unknown format
370    pub fn add(
371        &mut self,
372        manifest_source: impl Into<String>,
373        manifest_item: ManifestItem,
374    ) -> Result<&mut Self, EpubError> {
375        // Check if the source path is a file
376        let manifest_source = manifest_source.into();
377        let source = PathBuf::from(&manifest_source);
378        if !source.is_file() {
379            return Err(EpubBuilderError::TargetIsNotFile { target_path: manifest_source }.into());
380        }
381
382        // Get the file extension
383        let extension = match source.extension() {
384            Some(ext) => ext.to_string_lossy().to_lowercase(),
385            None => String::new(),
386        };
387
388        // Read the file
389        let buf = fs::read(source)?;
390
391        // Get the mime type
392        let real_mime = match Infer::new().get(&buf) {
393            Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
394            None => {
395                return Err(
396                    EpubBuilderError::UnknownFileFormat { file_path: manifest_source }.into(),
397                );
398            }
399        };
400
401        let target_path = normalize_manifest_path(
402            &self.temp_dir,
403            self.rootfile
404                .as_ref()
405                .ok_or(EpubBuilderError::MissingRootfile)?,
406            &manifest_item.path,
407            &manifest_item.id,
408        )?;
409        if let Some(parent_dir) = target_path.parent() {
410            if !parent_dir.exists() {
411                fs::create_dir_all(parent_dir)?
412            }
413        }
414
415        match fs::write(target_path, buf) {
416            Ok(_) => {
417                self.manifest
418                    .insert(manifest_item.id.clone(), manifest_item.set_mime(real_mime));
419                Ok(self)
420            }
421            Err(err) => Err(err.into()),
422        }
423    }
424
425    /// Clear all manifest items
426    ///
427    /// Removes all manifest items from the builder and deletes the associated files
428    /// from the temporary directory.
429    pub fn clear(&mut self) -> &mut Self {
430        let paths = self
431            .manifest
432            .values()
433            .map(|manifest| &manifest.path)
434            .collect::<Vec<&PathBuf>>();
435
436        for path in paths {
437            let _ = fs::remove_file(path);
438        }
439
440        self.manifest.clear();
441
442        self
443    }
444
445    /// Insert a manifest item directly
446    ///
447    /// This method allows direct insertion of a manifest item without copying
448    /// any files. Use this when the file already exists in the temporary directory.
449    pub(crate) fn insert(
450        &mut self,
451        key: impl Into<String>,
452        value: ManifestItem,
453    ) -> Option<ManifestItem> {
454        self.manifest.insert(key.into(), value)
455    }
456
457    /// Generate the manifest XML content
458    ///
459    /// Writes the XML representation of the manifest to the provided writer.
460    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
461        writer.write_event(Event::Start(BytesStart::new("manifest")))?;
462
463        for manifest in self.manifest.values() {
464            writer.write_event(Event::Empty(
465                BytesStart::new("item").with_attributes(manifest.attributes()),
466            ))?;
467        }
468
469        writer.write_event(Event::End(BytesEnd::new("manifest")))?;
470
471        Ok(())
472    }
473
474    /// Validate manifest integrity
475    ///
476    /// Checks fallback chains for circular references and missing items,
477    /// and verifies that exactly one nav item exists.
478    pub(crate) fn validate(&self) -> Result<(), EpubError> {
479        self.validate_fallback_chains()?;
480        self.validate_nav()?;
481
482        Ok(())
483    }
484
485    /// Get manifest item keys
486    ///
487    /// Returns an iterator over the keys (IDs) of all manifest items.
488    ///
489    /// ## Return
490    /// - `impl Iterator<Item = &String>`: Iterator over manifest item keys
491    pub(crate) fn keys(&self) -> impl Iterator<Item = &String> {
492        self.manifest.keys()
493    }
494
495    // TODO: consider using BFS to validate fallback chains, to provide efficient
496    /// Validate all fallback chains in the manifest
497    ///
498    /// Iterates through all manifest items and validates each fallback chain
499    /// to ensure there are no circular references and all referenced items exist.
500    fn validate_fallback_chains(&self) -> Result<(), EpubError> {
501        for (id, item) in &self.manifest {
502            if item.fallback.is_none() {
503                continue;
504            }
505
506            let mut fallback_chain = Vec::new();
507            self.validate_fallback_chain(id, &mut fallback_chain)?;
508        }
509
510        Ok(())
511    }
512
513    /// Recursively verify the validity of a single fallback chain
514    ///
515    /// This function recursively traces the fallback chain to check for the following issues:
516    /// - Circular reference
517    /// - The referenced fallback resource does not exist
518    fn validate_fallback_chain(
519        &self,
520        manifest_id: &str,
521        fallback_chain: &mut Vec<String>,
522    ) -> Result<(), EpubError> {
523        if fallback_chain.contains(&manifest_id.to_string()) {
524            fallback_chain.push(manifest_id.to_string());
525
526            return Err(EpubBuilderError::ManifestCircularReference {
527                fallback_chain: fallback_chain.join("->"),
528            }
529            .into());
530        }
531
532        // Get the current item; its existence can be ensured based on the calling context.
533        let item = self.manifest.get(manifest_id).unwrap();
534
535        if let Some(fallback_id) = &item.fallback {
536            if !self.manifest.contains_key(fallback_id) {
537                return Err(EpubBuilderError::ManifestNotFound {
538                    manifest_id: fallback_id.to_owned(),
539                }
540                .into());
541            }
542
543            fallback_chain.push(manifest_id.to_string());
544            self.validate_fallback_chain(fallback_id, fallback_chain)
545        } else {
546            // The end of the fallback chain
547            Ok(())
548        }
549    }
550
551    /// Validate navigation list items
552    ///
553    /// Check if there is only one list item with the `nav` property.
554    fn validate_nav(&self) -> Result<(), EpubError> {
555        if self
556            .manifest
557            .values()
558            .filter(|&item| {
559                if let Some(properties) = &item.properties {
560                    properties.split(" ").any(|property| property == "nav")
561                } else {
562                    false
563                }
564            })
565            .count()
566            == 1
567        {
568            Ok(())
569        } else {
570            Err(EpubBuilderError::TooManyNavFlags.into())
571        }
572    }
573}
574
575/// Spine builder for EPUB reading order
576///
577/// The `SpineBuilder` is responsible for managing the spine items in an EPUB publication.
578/// The spine defines the default reading order of the publication - the sequence in which
579/// the reading system should present the content documents to the reader.
580///
581/// Each spine item references a manifest item by its ID (idref), indicating which
582/// resource should be displayed at that point in the reading order.
583#[derive(Debug)]
584pub struct SpineBuilder {
585    /// List of spine items defining the reading order
586    pub(crate) spine: Vec<SpineItem>,
587}
588
589impl SpineBuilder {
590    /// Creates a new empty `SpineBuilder` instance
591    pub(crate) fn new() -> Self {
592        Self { spine: Vec::new() }
593    }
594
595    /// Add a spine item
596    ///
597    /// Appends a new spine item to the builder, defining the next position in
598    /// the reading order.
599    ///
600    /// ## Parameters
601    /// - `item`: The spine item to add
602    ///
603    /// ## Return
604    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
605    pub fn add(&mut self, item: SpineItem) -> &mut Self {
606        self.spine.push(item);
607        self
608    }
609
610    /// Clear all spine items
611    ///
612    /// Removes all spine items from the builder.
613    pub fn clear(&mut self) -> &mut Self {
614        self.spine.clear();
615        self
616    }
617
618    /// Generate the spine XML content
619    ///
620    /// Writes the XML representation of the spine to the provided writer.
621    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
622        writer.write_event(Event::Start(BytesStart::new("spine")))?;
623
624        for spine in &self.spine {
625            writer.write_event(Event::Empty(
626                BytesStart::new("itemref").with_attributes(spine.attributes()),
627            ))?;
628        }
629
630        writer.write_event(Event::End(BytesEnd::new("spine")))?;
631
632        Ok(())
633    }
634
635    /// Validate spine references
636    ///
637    /// Checks that all spine item idref values exist in the manifest.
638    ///
639    /// ## Parameters
640    /// - `manifest_keys`: Iterator over manifest item keys
641    pub(crate) fn validate(
642        &self,
643        manifest_keys: impl Iterator<Item = impl AsRef<str>>,
644    ) -> Result<(), EpubError> {
645        let manifest_keys: Vec<String> = manifest_keys.map(|k| k.as_ref().to_string()).collect();
646        for spine in &self.spine {
647            if !manifest_keys.contains(&spine.idref) {
648                return Err(
649                    EpubBuilderError::SpineManifestNotFound { idref: spine.idref.clone() }.into(),
650                );
651            }
652        }
653        Ok(())
654    }
655}
656
657/// Catalog builder for EPUB navigation
658///
659/// The `CatalogBuilder` is responsible for building the navigation document (TOC)
660/// of an EPUB publication. The navigation document provides a hierarchical table
661/// of contents that allows readers to navigate through the publication's content.
662///
663/// The navigation document is a special XHTML document that uses the EPUB Navigation
664/// Document specification.
665#[derive(Debug)]
666pub struct CatalogBuilder {
667    /// Title of the navigation document
668    pub(crate) title: String,
669
670    /// Navigation points (table of contents entries)
671    pub(crate) catalog: Vec<NavPoint>,
672}
673
674impl CatalogBuilder {
675    /// Creates a new empty `CatalogBuilder` instance
676    pub(crate) fn new() -> Self {
677        Self {
678            title: String::new(),
679            catalog: Vec::new(),
680        }
681    }
682
683    /// Set the catalog title
684    ///
685    /// Sets the title that will be displayed at the top of the navigation document.
686    ///
687    /// ## Parameters
688    /// - `title`: The title to set
689    ///
690    /// ## Return
691    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
692    pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
693        self.title = title.into();
694        self
695    }
696
697    /// Add a navigation point
698    ///
699    /// Appends a new navigation point to the catalog. Navigation points can be
700    /// nested by using the `append_child` method on `NavPoint`.
701    ///
702    /// ## Parameters
703    /// - `item`: The navigation point to add
704    ///
705    /// ## Return
706    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
707    pub fn add(&mut self, item: NavPoint) -> &mut Self {
708        self.catalog.push(item);
709        self
710    }
711
712    /// Clear all catalog items
713    ///
714    /// Removes the title and all navigation points from the builder.
715    pub fn clear(&mut self) -> &mut Self {
716        self.title.clear();
717        self.catalog.clear();
718        self
719    }
720
721    /// Check if the catalog is empty
722    ///
723    /// ## Return
724    /// - `true`: No navigation points have been added
725    /// - `false`: At least one navigation point has been added
726    pub(crate) fn is_empty(&self) -> bool {
727        self.catalog.is_empty()
728    }
729
730    /// Generate the navigation document
731    ///
732    /// Creates the EPUB Navigation Document (NAV) as XHTML content with the
733    /// specified title and navigation points.
734    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
735        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
736            ("xmlns", "http://www.w3.org/1999/xhtml"),
737            ("xmlns:epub", "http://www.idpf.org/2007/ops"),
738        ])))?;
739
740        // make head
741        writer.write_event(Event::Start(BytesStart::new("head")))?;
742        writer.write_event(Event::Start(BytesStart::new("title")))?;
743        writer.write_event(Event::Text(BytesText::new(&self.title)))?;
744        writer.write_event(Event::End(BytesEnd::new("title")))?;
745        writer.write_event(Event::End(BytesEnd::new("head")))?;
746
747        // make body
748        writer.write_event(Event::Start(BytesStart::new("body")))?;
749        writer.write_event(Event::Start(
750            BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
751        ))?;
752
753        if !self.title.is_empty() {
754            writer.write_event(Event::Start(BytesStart::new("h1")))?;
755            writer.write_event(Event::Text(BytesText::new(&self.title)))?;
756            writer.write_event(Event::End(BytesEnd::new("h1")))?;
757        }
758
759        Self::make_nav(writer, &self.catalog)?;
760
761        writer.write_event(Event::End(BytesEnd::new("nav")))?;
762        writer.write_event(Event::End(BytesEnd::new("body")))?;
763
764        writer.write_event(Event::End(BytesEnd::new("html")))?;
765
766        Ok(())
767    }
768
769    /// Generate navigation list items recursively
770    ///
771    /// Recursively writes the navigation list (ol/li elements) for the given
772    /// navigation points.
773    fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
774        writer.write_event(Event::Start(BytesStart::new("ol")))?;
775
776        for nav in navgations {
777            writer.write_event(Event::Start(BytesStart::new("li")))?;
778
779            if let Some(path) = &nav.content {
780                writer.write_event(Event::Start(
781                    BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
782                ))?;
783                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
784                writer.write_event(Event::End(BytesEnd::new("a")))?;
785            } else {
786                writer.write_event(Event::Start(BytesStart::new("span")))?;
787                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
788                writer.write_event(Event::End(BytesEnd::new("span")))?;
789            }
790
791            if !nav.children.is_empty() {
792                Self::make_nav(writer, &nav.children)?;
793            }
794
795            writer.write_event(Event::End(BytesEnd::new("li")))?;
796        }
797
798        writer.write_event(Event::End(BytesEnd::new("ol")))?;
799
800        Ok(())
801    }
802}
803
804#[cfg(feature = "content-builder")]
805#[derive(Debug)]
806pub struct DocumentBuilder {
807    pub(crate) documents: Vec<(PathBuf, ContentBuilder)>,
808}
809
810#[cfg(feature = "content-builder")]
811impl DocumentBuilder {
812    /// Creates a new empty `DocumentBuilder` instance
813    pub(crate) fn new() -> Self {
814        Self { documents: Vec::new() }
815    }
816
817    /// Add a content document
818    ///
819    /// Appends a new content document to be processed during EPUB building.
820    ///
821    /// ## Parameters
822    /// - `target`: The target path within the EPUB container where the content will be placed
823    /// - `content`: The content builder containing the document content
824    ///
825    /// ## Return
826    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
827    pub fn add(&mut self, target: impl AsRef<str>, content: ContentBuilder) -> &mut Self {
828        self.documents
829            .push((PathBuf::from(target.as_ref()), content));
830        self
831    }
832
833    /// Clear all documents
834    ///
835    /// Removes all content documents from the builder.
836    pub fn clear(&mut self) -> &mut Self {
837        self.documents.clear();
838        self
839    }
840
841    /// Generate manifest items from content documents
842    ///
843    /// Processes all content documents and generates the corresponding manifest items.
844    /// Each content document may generate multiple manifest entries - one for the main
845    /// document and additional entries for any resources (images, fonts, etc.) it contains.
846    ///
847    /// ## Parameters
848    /// - `temp_dir`: The temporary directory path used during the EPUB build process
849    /// - `rootfile`: The path to the OPF file (package document)
850    ///
851    /// ## Return
852    /// - `Ok(Vec<ManifestItem>)`: List of manifest items generated from the content documents
853    /// - `Err(EpubError)`: Error if document generation or file processing fails
854    pub fn make(
855        &mut self,
856        temp_dir: PathBuf,
857        rootfile: impl AsRef<str>,
858    ) -> Result<Vec<ManifestItem>, EpubError> {
859        let mut buf = vec![0; 512];
860        let contents = std::mem::take(&mut self.documents);
861
862        let mut manifest = Vec::new();
863        for (target, mut content) in contents.into_iter() {
864            let manifest_id = content.id.clone();
865
866            // target is relative to the epub file, so we need to normalize it
867            let absolute_target =
868                normalize_manifest_path(&temp_dir, &rootfile, &target, &manifest_id)?;
869            let mut resources = content.make(&absolute_target)?;
870
871            // Helper to compute absolute container path
872            let to_container_path = |p: &PathBuf| -> PathBuf {
873                match p.strip_prefix(&temp_dir) {
874                    Ok(rel) => PathBuf::from("/").join(rel.to_string_lossy().replace("\\", "/")),
875                    Err(_) => unreachable!("path MUST under temp directory"),
876                }
877            };
878
879            // Document (first element, guaranteed to exist)
880            let path = resources.swap_remove(0);
881            let mut file = std::fs::File::open(&path)?;
882            let _ = file.read(&mut buf)?;
883            let extension = path
884                .extension()
885                .map(|e| e.to_string_lossy().to_lowercase())
886                .unwrap_or_default();
887            let mime = match Infer::new().get(&buf) {
888                Some(infer) => refine_mime_type(infer.mime_type(), &extension),
889                None => {
890                    return Err(EpubBuilderError::UnknownFileFormat {
891                        file_path: path.to_string_lossy().to_string(),
892                    }
893                    .into());
894                }
895            }
896            .to_string();
897
898            manifest.push(ManifestItem {
899                id: manifest_id.clone(),
900                path: to_container_path(&path),
901                mime,
902                properties: None,
903                fallback: None,
904            });
905
906            // Other resources (if any): generate stable ids and add to manifest
907            for res in resources {
908                let mut file = fs::File::open(&res)?;
909                let _ = file.read(&mut buf)?;
910                let extension = res
911                    .extension()
912                    .map(|e| e.to_string_lossy().to_lowercase())
913                    .unwrap_or_default();
914                let mime = match Infer::new().get(&buf) {
915                    Some(ft) => refine_mime_type(ft.mime_type(), &extension),
916                    None => {
917                        return Err(EpubBuilderError::UnknownFileFormat {
918                            file_path: path.to_string_lossy().to_string(),
919                        }
920                        .into());
921                    }
922                }
923                .to_string();
924
925                let file_name = res
926                    .file_name()
927                    .map(|s| s.to_string_lossy().to_string())
928                    .unwrap_or_default();
929                let res_id = format!("{}-{}", manifest_id, file_name);
930
931                manifest.push(ManifestItem {
932                    id: res_id,
933                    path: to_container_path(&res),
934                    mime,
935                    properties: None,
936                    fallback: None,
937                });
938            }
939        }
940
941        Ok(manifest)
942    }
943}
944
945/// EPUB Builder
946///
947/// The main structure used to create and build EPUB ebook files.
948/// Supports the EPUB 3.0 specification and can build a complete EPUB file structure.
949///
950/// ## Usage
951///
952/// ```rust, no_run
953/// # #[cfg(feature = "builder")]
954/// # fn main() -> Result<(), lib_epub::error::EpubError> {
955/// use lib_epub::{
956///     builder::{EpubBuilder, EpubVersion3},
957///     types::{MetadataItem, ManifestItem, NavPoint, SpineItem},
958/// };
959///
960/// let mut builder = EpubBuilder::<EpubVersion3>::new()?;
961///
962/// builder
963///     .rootfile()
964///     .add("EPUB/content.opf")?;
965///
966/// builder
967///     .metadata()
968///     .add(MetadataItem::new("title", "Test Book"))
969///     .add(MetadataItem::new("language", "en"))
970///     .add(
971///         MetadataItem::new("identifier", "unique-id")
972///             .with_id("pub-id")
973///             .build(),
974///     );
975///
976/// builder
977///     .manifest()
978///     .add(
979///         "./test_case/Overview.xhtml",
980///         ManifestItem::new("content", "target/path")?,
981///     )?;
982/// 
983/// builder
984///     .spine()
985///     .add(SpineItem::new("content"));
986/// 
987/// builder
988///     .catalog()
989///     .set_title("Catalog Title")
990///     .add(NavPoint::new("label"));
991///
992/// builder.build("output.epub")?;
993///
994/// # Ok(())
995/// # }
996/// ```
997///
998/// ## Notes
999///
1000/// - All resource files **must** exist on the local file system.
1001/// - **At least one rootfile** must be added before adding manifest items.
1002/// - Requires at least one `title`, `language`, and `identifier` with id `pub-id`.
1003#[cfg_attr(test, derive(Debug))]
1004pub struct EpubBuilder<Version> {
1005    /// EPUB version placeholder
1006    epub_version: PhantomData<Version>,
1007
1008    /// Temporary directory path for storing files during the build process
1009    temp_dir: PathBuf,
1010
1011    rootfiles: RootfileBuilder,
1012    metadata: MetadataBuilder,
1013    manifest: ManifestBuilder,
1014    spine: SpineBuilder,
1015    catalog: CatalogBuilder,
1016
1017    #[cfg(feature = "content-builder")]
1018    content: DocumentBuilder,
1019}
1020
1021impl EpubBuilder<EpubVersion3> {
1022    /// Create a new `EpubBuilder` instance
1023    ///
1024    /// ## Return
1025    /// - `Ok(EpubBuilder)`: Builder instance created successfully
1026    /// - `Err(EpubError)`: Error occurred during builder initialization
1027    pub fn new() -> Result<Self, EpubError> {
1028        let temp_dir = env::temp_dir().join(local_time());
1029        fs::create_dir(&temp_dir)?;
1030        fs::create_dir(temp_dir.join("META-INF"))?;
1031
1032        let mime_file = temp_dir.join("mimetype");
1033        fs::write(mime_file, "application/epub+zip")?;
1034
1035        Ok(EpubBuilder {
1036            epub_version: PhantomData,
1037            temp_dir: temp_dir.clone(),
1038
1039            rootfiles: RootfileBuilder::new(),
1040            metadata: MetadataBuilder::new(),
1041            manifest: ManifestBuilder::new(temp_dir),
1042            spine: SpineBuilder::new(),
1043            catalog: CatalogBuilder::new(),
1044
1045            #[cfg(feature = "content-builder")]
1046            content: DocumentBuilder::new(),
1047        })
1048    }
1049
1050    /// Add a rootfile path
1051    ///
1052    /// The added path points to an OPF file that does not yet exist
1053    /// and will be created when building the Epub file.
1054    ///
1055    /// ## Parameters
1056    /// - `rootfile`: Rootfile path
1057    ///
1058    /// ## Notes
1059    /// - The added rootfile path must be a relative path and cannot start with "../".
1060    /// - At least one rootfile must be added before adding metadata items.
1061    pub fn add_rootfile(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
1062        match self.rootfiles.add(rootfile) {
1063            Ok(_) => Ok(self),
1064            Err(err) => Err(err),
1065        }
1066    }
1067
1068    /// Add metadata item
1069    ///
1070    /// Required metadata includes title, language, and an identifier with 'pub-id'.
1071    /// Missing this data will result in an error when building the epub file.
1072    ///
1073    /// ## Parameters
1074    /// - `item`: Metadata items to add
1075    pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
1076        let _ = self.metadata.add(item);
1077        self
1078    }
1079
1080    /// Add manifest item and corresponding resource file
1081    ///
1082    /// The builder will automatically recognize the file type of
1083    /// the added resource and update it in `ManifestItem`.
1084    ///
1085    /// ## Parameters
1086    /// - `manifest_source` - Local resource file path
1087    /// - `manifest_item` - Manifest item information
1088    ///
1089    /// ## Return
1090    /// - `Ok(&mut Self)` - Successful addition, returns a reference to itself
1091    /// - `Err(EpubError)` - Error occurred during the addition process
1092    ///
1093    /// ## Notes
1094    /// - At least one rootfile must be added before adding manifest items.
1095    /// - If the manifest item ID already exists in the manifest, the manifest item will be overwritten.
1096    pub fn add_manifest(
1097        &mut self,
1098        manifest_source: impl Into<String>,
1099        manifest_item: ManifestItem,
1100    ) -> Result<&mut Self, EpubError> {
1101        if self.rootfiles.is_empty() {
1102            return Err(EpubBuilderError::MissingRootfile.into());
1103        } else {
1104            self.manifest
1105                .set_rootfile(self.rootfiles.first().expect("Unreachable"));
1106        }
1107
1108        match self.manifest.add(manifest_source, manifest_item) {
1109            Ok(_) => Ok(self),
1110            Err(err) => Err(err),
1111        }
1112    }
1113
1114    /// Add spine item
1115    ///
1116    /// The spine item defines the reading order of the book.
1117    ///
1118    /// ## Parameters
1119    /// - `item`: Spine item to add
1120    pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
1121        self.spine.add(item);
1122        self
1123    }
1124
1125    /// Set catalog title
1126    ///
1127    /// ## Parameters
1128    /// - `title`: Catalog title
1129    pub fn set_catalog_title(&mut self, title: impl Into<String>) -> &mut Self {
1130        let _ = self.catalog.set_title(title);
1131        self
1132    }
1133
1134    /// Add catalog item
1135    ///
1136    /// Added directory items will be added to the end of the existing list.
1137    ///
1138    /// ## Parameters
1139    /// - `item`: Catalog item to add
1140    pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
1141        let _ = self.catalog.add(item);
1142        self
1143    }
1144
1145    /// Add content
1146    ///
1147    /// The content builder can be used to generate content for the book.
1148    /// It is recommended to use the `content-builder` feature to use this function.
1149    ///
1150    /// ## Parameters
1151    /// - `target_path`: The path to the resource file within the EPUB container
1152    /// - `content`: The content builder to generate content
1153    #[cfg(feature = "content-builder")]
1154    pub fn add_content(
1155        &mut self,
1156        target_path: impl AsRef<str>,
1157        content: ContentBuilder,
1158    ) -> &mut Self {
1159        self.content.add(target_path, content);
1160        self
1161    }
1162
1163    /// Clear all data from the builder
1164    ///
1165    /// This function clears all metadata, manifest items, spine items, catalog items, etc.
1166    /// from the builder, effectively resetting it to an empty state.
1167    ///
1168    /// ## Return
1169    /// - `Ok(&mut Self)`: Successfully cleared all data
1170    /// - `Err(EpubError)`: Error occurred during the clearing process (specifically during manifest clearing)
1171    pub fn clear_all(&mut self) -> &mut Self {
1172        self.rootfiles.clear();
1173        self.metadata.clear();
1174        self.manifest.clear();
1175        self.spine.clear();
1176        self.catalog.clear();
1177        #[cfg(feature = "content-builder")]
1178        self.content.clear();
1179
1180        self
1181    }
1182
1183    /// Get a mutable reference to the rootfile builder
1184    ///
1185    /// Allows direct manipulation of rootfile entries.
1186    ///
1187    /// ## Return
1188    /// - `&mut RootfileBuilder`: Mutable reference to the rootfile builder
1189    pub fn rootfile(&mut self) -> &mut RootfileBuilder {
1190        &mut self.rootfiles
1191    }
1192
1193    /// Get a mutable reference to the metadata builder
1194    ///
1195    /// Allows direct manipulation of metadata items.
1196    ///
1197    /// ## Return
1198    /// - `&mut MetadataBuilder`: Mutable reference to the metadata builder
1199    pub fn metadata(&mut self) -> &mut MetadataBuilder {
1200        &mut self.metadata
1201    }
1202
1203    /// Get a mutable reference to the manifest builder
1204    ///
1205    /// Allows direct manipulation of manifest items.
1206    ///
1207    /// ## Return
1208    /// - `&mut ManifestBuilder`: Mutable reference to the manifest builder
1209    pub fn manifest(&mut self) -> &mut ManifestBuilder {
1210        &mut self.manifest
1211    }
1212
1213    /// Get a mutable reference to the spine builder
1214    ///
1215    /// Allows direct manipulation of spine items.
1216    ///
1217    /// ## Return
1218    /// - `&mut SpineBuilder`: Mutable reference to the spine builder
1219    pub fn spine(&mut self) -> &mut SpineBuilder {
1220        &mut self.spine
1221    }
1222
1223    /// Get a mutable reference to the catalog builder
1224    ///
1225    /// Allows direct manipulation of navigation/catalog items.
1226    ///
1227    /// ## Return
1228    /// - `&mut CatalogBuilder`: Mutable reference to the catalog builder
1229    pub fn catalog(&mut self) -> &mut CatalogBuilder {
1230        &mut self.catalog
1231    }
1232
1233    /// Get a mutable reference to the content builder
1234    ///
1235    /// Allows direct manipulation of content documents.
1236    ///
1237    /// ## Return
1238    /// - `&mut DocumentBuilder`: Mutable reference to the document builder
1239    #[cfg(feature = "content-builder")]
1240    pub fn content(&mut self) -> &mut DocumentBuilder {
1241        &mut self.content
1242    }
1243
1244    /// Builds an EPUB file and saves it to the specified path
1245    ///
1246    /// ## Parameters
1247    /// - `output_path`: Output file path
1248    ///
1249    /// ## Return
1250    /// - `Ok(())`: Build successful
1251    /// - `Err(EpubError)`: Error occurred during the build process
1252    pub fn make(mut self, output_path: impl AsRef<Path>) -> Result<(), EpubError> {
1253        // Create the container.xml, navigation document, and OPF files in sequence.
1254        // The associated metadata will initialized when navigation document is created;
1255        // therefore, the navigation document must be created before the opf file is created.
1256        self.make_container_xml()?;
1257        self.make_navigation_document()?;
1258        #[cfg(feature = "content-builder")]
1259        self.make_contents()?;
1260        self.make_opf_file()?;
1261        self.remove_empty_dirs()?;
1262
1263        if let Some(parent) = output_path.as_ref().parent() {
1264            if !parent.exists() {
1265                fs::create_dir_all(parent)?;
1266            }
1267        }
1268
1269        // pack zip file
1270        let file = File::create(output_path)?;
1271        let mut zip = ZipWriter::new(file);
1272        let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
1273
1274        for entry in WalkDir::new(&self.temp_dir) {
1275            let entry = entry?;
1276            let path = entry.path();
1277
1278            // It can be asserted that the path is prefixed with temp_dir,
1279            // and there will be no boundary cases of symbolic links and hard links, etc.
1280            let relative_path = path.strip_prefix(&self.temp_dir).unwrap();
1281            let target_path = relative_path.to_string_lossy().replace("\\", "/");
1282
1283            if path.is_file() {
1284                zip.start_file(target_path, options)?;
1285
1286                let mut file = File::open(path)?;
1287                std::io::copy(&mut file, &mut zip)?;
1288            } else if path.is_dir() {
1289                zip.add_directory(target_path, options)?;
1290            }
1291        }
1292
1293        zip.finish()?;
1294        Ok(())
1295    }
1296
1297    /// Builds an EPUB file and returns a `EpubDoc`
1298    ///
1299    /// Builds an EPUB file at the specified location and parses it into a usable EpubDoc object.
1300    ///
1301    /// ## Parameters
1302    /// - `output_path`: Output file path
1303    ///
1304    /// ## Return
1305    /// - `Ok(EpubDoc)`: Build successful
1306    /// - `Err(EpubError)`: Error occurred during the build process
1307    pub fn build(
1308        self,
1309        output_path: impl AsRef<Path>,
1310    ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
1311        self.make(&output_path)?;
1312
1313        EpubDoc::new(output_path)
1314    }
1315
1316    /// Creates an `EpubBuilder` instance from an existing `EpubDoc`
1317    ///
1318    /// This function takes an existing parsed EPUB document and creates a new builder
1319    /// instance with all the document's metadata, manifest items, spine, and catalog information.
1320    /// It essentially reverses the EPUB building process by extracting all the necessary
1321    /// components from the parsed document and preparing them for reconstruction.
1322    ///
1323    /// The function copies the following information from the provided `EpubDoc`:
1324    /// - Rootfile path (based on the document's base path)
1325    /// - All metadata items (title, author, identifier, etc.)
1326    /// - Spine items (reading order of the publication)
1327    /// - Catalog information (navigation points)
1328    /// - Catalog title
1329    /// - All manifest items (except those with 'nav' property, which are skipped)
1330    ///
1331    /// ## Parameters
1332    /// - `doc`: A mutable reference to an `EpubDoc` instance that contains the parsed EPUB data
1333    ///
1334    /// ## Return
1335    /// - `Ok(EpubBuilder)`: Successfully created builder instance populated with the document's data
1336    /// - `Err(EpubError)`: Error occurred during the extraction process
1337    ///
1338    /// ## Notes
1339    /// - This type of conversion will upgrade Epub2.x publications to Epub3.x.
1340    ///   This upgrade conversion may encounter unknown errors (it is unclear whether
1341    ///   it will cause errors), so please use it with caution.
1342    pub fn from<R: Read + Seek>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
1343        let mut builder = Self::new()?;
1344
1345        builder.add_rootfile(doc.package_path.clone().to_string_lossy())?;
1346        builder.metadata.metadata = doc.metadata.clone();
1347        builder.spine.spine = doc.spine.clone();
1348        builder.catalog.catalog = doc.catalog.clone();
1349        builder.catalog.title = doc.catalog_title.clone();
1350
1351        // clone manifest hashmap to avoid mut borrow conflict
1352        for (_, mut manifest) in doc.manifest.clone().into_iter() {
1353            if let Some(properties) = &manifest.properties {
1354                if properties.contains("nav") {
1355                    continue;
1356                }
1357            }
1358
1359            // because manifest paths in EpubDoc are converted to absolute paths rooted in containers,
1360            // but in the form of 'path/to/manifest', they need to be converted here to absolute paths
1361            // in the form of '/path/to/manifest'.
1362            manifest.path = PathBuf::from("/").join(manifest.path);
1363
1364            let (buf, _) = doc.get_manifest_item(&manifest.id)?; // read raw file
1365            let target_path = normalize_manifest_path(
1366                &builder.temp_dir,
1367                builder.rootfiles.first().expect("Unreachable"),
1368                &manifest.path,
1369                &manifest.id,
1370            )?;
1371            if let Some(parent_dir) = target_path.parent() {
1372                if !parent_dir.exists() {
1373                    fs::create_dir_all(parent_dir)?
1374                }
1375            }
1376
1377            fs::write(target_path, buf)?;
1378            builder
1379                .manifest
1380                .manifest
1381                .insert(manifest.id.clone(), manifest);
1382        }
1383
1384        Ok(builder)
1385    }
1386
1387    /// Creates the `container.xml` file
1388    ///
1389    /// An error will occur if the `rootfile` path is not set
1390    fn make_container_xml(&self) -> Result<(), EpubError> {
1391        if self.rootfiles.is_empty() {
1392            return Err(EpubBuilderError::MissingRootfile.into());
1393        }
1394
1395        let mut writer = Writer::new(Cursor::new(Vec::new()));
1396        self.rootfiles.make(&mut writer)?;
1397
1398        let file_path = self.temp_dir.join("META-INF").join("container.xml");
1399        let file_data = writer.into_inner().into_inner();
1400        fs::write(file_path, file_data)?;
1401
1402        Ok(())
1403    }
1404
1405    /// Creates the content document
1406    #[cfg(feature = "content-builder")]
1407    fn make_contents(&mut self) -> Result<(), EpubError> {
1408        let manifest_list = self.content.make(
1409            self.temp_dir.clone(),
1410            self.rootfiles.first().expect("Unreachable"),
1411        )?;
1412
1413        for item in manifest_list.into_iter() {
1414            self.manifest.insert(item.id.clone(), item);
1415        }
1416
1417        Ok(())
1418    }
1419
1420    /// Creates the `navigation document`
1421    ///
1422    /// An error will occur if navigation information is not initialized.
1423    fn make_navigation_document(&mut self) -> Result<(), EpubError> {
1424        if self.catalog.is_empty() {
1425            return Err(EpubBuilderError::NavigationInfoUninitalized.into());
1426        }
1427
1428        let mut writer = Writer::new(Cursor::new(Vec::new()));
1429        self.catalog.make(&mut writer)?;
1430
1431        let file_path = self.temp_dir.join("nav.xhtml");
1432        let file_data = writer.into_inner().into_inner();
1433        fs::write(file_path, file_data)?;
1434
1435        self.manifest.insert(
1436            "nav".to_string(),
1437            ManifestItem {
1438                id: "nav".to_string(),
1439                path: PathBuf::from("/nav.xhtml"),
1440                mime: "application/xhtml+xml".to_string(),
1441                properties: Some("nav".to_string()),
1442                fallback: None,
1443            },
1444        );
1445
1446        Ok(())
1447    }
1448
1449    /// Creates the `OPF` file
1450    ///
1451    /// ## Error conditions
1452    /// - Missing necessary metadata
1453    /// - Circular reference exists in the manifest backlink
1454    /// - Navigation information is not initialized
1455    fn make_opf_file(&mut self) -> Result<(), EpubError> {
1456        self.metadata.validate()?;
1457        self.manifest.validate()?;
1458        self.spine.validate(self.manifest.keys())?;
1459
1460        let mut writer = Writer::new(Cursor::new(Vec::new()));
1461
1462        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1463
1464        writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
1465            ("xmlns", "http://www.idpf.org/2007/opf"),
1466            ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
1467            ("unique-identifier", "pub-id"),
1468            ("version", "3.0"),
1469        ])))?;
1470
1471        self.metadata.make(&mut writer)?;
1472        self.manifest.make(&mut writer)?;
1473        self.spine.make(&mut writer)?;
1474
1475        writer.write_event(Event::End(BytesEnd::new("package")))?;
1476
1477        let file_path = self
1478            .temp_dir
1479            .join(self.rootfiles.first().expect("Unreachable"));
1480        let file_data = writer.into_inner().into_inner();
1481        fs::write(file_path, file_data)?;
1482
1483        Ok(())
1484    }
1485
1486    /// Remove empty directories under the builder temporary directory
1487    ///
1488    /// By enumerate directories under `self.temp_dir` (excluding the root itself)
1489    /// and deletes directories that are empty. Directories are processed from deepest
1490    /// to shallowest so that parent directories which become empty after child
1491    /// deletion can also be removed.
1492    ///
1493    /// ## Return
1494    /// - `Ok(())`: Successfully removed all empty directories
1495    /// - `Err(EpubError)`: IO error
1496    fn remove_empty_dirs(&self) -> Result<(), EpubError> {
1497        let mut dirs = WalkDir::new(self.temp_dir.as_path())
1498            .min_depth(1)
1499            .into_iter()
1500            .filter_map(|entry| entry.ok())
1501            .filter(|entry| entry.file_type().is_dir())
1502            .map(|entry| entry.into_path())
1503            .collect::<Vec<PathBuf>>();
1504
1505        dirs.sort_by_key(|p| Reverse(p.components().count()));
1506
1507        for dir in dirs {
1508            if fs::read_dir(&dir)?.next().is_none() {
1509                fs::remove_dir(dir)?;
1510            }
1511        }
1512
1513        Ok(())
1514    }
1515}
1516
1517impl<Version> Drop for EpubBuilder<Version> {
1518    /// Remove temporary directory when dropped
1519    fn drop(&mut self) {
1520        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1521            warn!("{}", err);
1522        };
1523    }
1524}
1525
1526/// Refine the MIME type based on file extension
1527///
1528/// This function optimizes MIME types that are inferred from file content by using
1529/// the file extension to determine the correct EPUB-specific MIME type. Some file
1530/// types have different MIME types depending on how they are used in an EPUB context.
1531fn refine_mime_type<'a>(infer_mime: &'a str, extension: &'a str) -> &'a str {
1532    match (infer_mime, extension) {
1533        ("text/xml", "xhtml")
1534        | ("application/xml", "xhtml")
1535        | ("text/xml", "xht")
1536        | ("application/xml", "xht") => "application/xhtml+xml",
1537
1538        ("text/xml", "opf") | ("application/xml", "opf") => "application/oebps-package+xml",
1539
1540        ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml",
1541
1542        ("application/zip", "epub") => "application/epub+zip",
1543
1544        ("text/plain", "css") => "text/css",
1545        ("text/plain", "js") => "application/javascript",
1546        ("text/plain", "json") => "application/json",
1547        ("text/plain", "svg") => "image/svg+xml",
1548
1549        _ => infer_mime,
1550    }
1551}
1552
1553/// Normalize manifest path to absolute path within EPUB container
1554///
1555/// This function takes a path (relative or absolute) and normalizes it to an absolute
1556/// path within the EPUB container structure. It handles various path formats including:
1557/// - Relative paths starting with "../" (with security check to prevent directory traversal)
1558/// - Absolute paths starting with "/" (relative to EPUB root)
1559/// - Relative paths starting with "./" (current directory)
1560/// - Plain relative paths (relative to the OPF file location)
1561///
1562/// ## Parameters
1563/// - `temp_dir`: The temporary directory path used during the EPUB build process
1564/// - `rootfile`: The path to the OPF file (package document), used to determine the base directory
1565/// - `path`: The input path that may be relative or absolute. Can be any type that
1566///   implements `AsRef<Path>`, such as `&str`, `String`, `Path`, `PathBuf`, etc.
1567/// - `id`: The identifier of the manifest item being processed
1568///
1569/// ## Return
1570/// - `Ok(PathBuf)`: The normalized absolute path within the EPUB container,
1571///   which does not start with "/"
1572/// - `Err(EpubError)`: Error if path traversal is detected outside the EPUB container,
1573///   or if the absolute path cannot be determined
1574fn normalize_manifest_path<TempD: AsRef<Path>, S: AsRef<str>, P: AsRef<Path>>(
1575    temp_dir: TempD,
1576    rootfile: S,
1577    path: P,
1578    id: &str,
1579) -> Result<PathBuf, EpubError> {
1580    let opf_path = PathBuf::from(rootfile.as_ref());
1581    let basic_path = remove_leading_slash(opf_path.parent().unwrap());
1582
1583    // convert manifest path to absolute path(physical path)
1584    let mut target_path = if path.as_ref().starts_with("../") {
1585        check_realtive_link_leakage(
1586            temp_dir.as_ref().to_path_buf(),
1587            basic_path.to_path_buf(),
1588            &path.as_ref().to_string_lossy(),
1589        )
1590        .map(PathBuf::from)
1591        .ok_or_else(|| EpubError::RelativeLinkLeakage {
1592            path: path.as_ref().to_string_lossy().to_string(),
1593        })?
1594    } else if let Ok(path) = path.as_ref().strip_prefix("/") {
1595        temp_dir.as_ref().join(path)
1596    } else if path.as_ref().starts_with("./") {
1597        // can not anlyze where the 'current' directory is
1598        Err(EpubBuilderError::IllegalManifestPath { manifest_id: id.to_string() })?
1599    } else {
1600        temp_dir.as_ref().join(basic_path).join(path)
1601    };
1602
1603    #[cfg(windows)]
1604    {
1605        target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
1606    }
1607
1608    Ok(target_path)
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613    use std::{env, fs, path::PathBuf};
1614
1615    use crate::{
1616        builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
1617        epub::EpubDoc,
1618        error::{EpubBuilderError, EpubError},
1619        types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
1620        utils::local_time,
1621    };
1622
1623    mod test_helpers {
1624        use super::*;
1625
1626        pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
1627            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1628            builder.add_rootfile("content.opf").unwrap();
1629            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1630            builder.add_metadata(MetadataItem::new("language", "en"));
1631            builder.add_metadata(
1632                MetadataItem::new("identifier", "urn:isbn:1234567890")
1633                    .with_id("pub-id")
1634                    .build(),
1635            );
1636            builder
1637        }
1638
1639        pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
1640            let mut builder = create_basic_builder();
1641            builder.add_catalog_item(NavPoint::new("Chapter"));
1642            builder.add_spine(SpineItem::new("test"));
1643            builder
1644        }
1645    }
1646
1647    mod epub_builder_tests {
1648        use super::*;
1649
1650        #[test]
1651        fn test_epub_builder_new() {
1652            let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
1653            assert!(builder.temp_dir.exists());
1654            assert!(builder.rootfiles.is_empty());
1655            assert!(builder.metadata.metadata.is_empty());
1656            assert!(builder.manifest.manifest.is_empty());
1657            assert!(builder.spine.spine.is_empty());
1658            assert!(builder.catalog.title.is_empty());
1659            assert!(builder.catalog.is_empty());
1660        }
1661
1662        #[test]
1663        fn test_add_rootfile() {
1664            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1665
1666            builder
1667                .add_rootfile("content.opf")
1668                .expect("Failed to add rootfile");
1669            assert_eq!(builder.rootfiles.rootfiles.len(), 1);
1670            assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
1671
1672            builder
1673                .add_rootfile("./another.opf")
1674                .expect("Failed to add another rootfile");
1675            assert_eq!(builder.rootfiles.rootfiles.len(), 2);
1676            assert_eq!(
1677                builder.rootfiles.rootfiles,
1678                vec!["content.opf", "another.opf"]
1679            );
1680        }
1681
1682        #[test]
1683        fn test_add_rootfile_fail() {
1684            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1685
1686            let result = builder.add_rootfile("/rootfile.opf");
1687            assert!(result.is_err());
1688            assert_eq!(
1689                result.unwrap_err(),
1690                EpubBuilderError::IllegalRootfilePath.into()
1691            );
1692
1693            let result = builder.add_rootfile("../rootfile.opf");
1694            assert!(result.is_err());
1695            assert_eq!(
1696                result.unwrap_err(),
1697                EpubBuilderError::IllegalRootfilePath.into()
1698            );
1699        }
1700
1701        #[test]
1702        fn test_add_metadata() {
1703            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1704            let metadata_item = MetadataItem::new("title", "Test Book");
1705
1706            builder.add_metadata(metadata_item);
1707
1708            assert_eq!(builder.metadata.metadata.len(), 1);
1709            assert_eq!(builder.metadata.metadata[0].property, "title");
1710            assert_eq!(builder.metadata.metadata[0].value, "Test Book");
1711        }
1712
1713        #[test]
1714        fn test_add_spine() {
1715            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1716            let spine_item = SpineItem::new("test_item");
1717
1718            builder.add_spine(spine_item);
1719
1720            assert_eq!(builder.spine.spine.len(), 1);
1721            assert_eq!(builder.spine.spine[0].idref, "test_item");
1722        }
1723
1724        #[test]
1725        fn test_set_catalog_title() {
1726            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1727            let title = "Test Catalog Title";
1728
1729            builder.set_catalog_title(title);
1730
1731            assert_eq!(builder.catalog.title, title);
1732        }
1733
1734        #[test]
1735        fn test_add_catalog_item() {
1736            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1737            let nav_point = NavPoint::new("Chapter 1");
1738
1739            builder.add_catalog_item(nav_point);
1740
1741            assert_eq!(builder.catalog.catalog.len(), 1);
1742            assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
1743        }
1744
1745        #[test]
1746        fn test_clear_all() {
1747            let mut builder = test_helpers::create_full_builder();
1748
1749            assert_eq!(builder.metadata.metadata.len(), 3);
1750            assert_eq!(builder.spine.spine.len(), 1);
1751            assert_eq!(builder.catalog.catalog.len(), 1);
1752
1753            builder.clear_all();
1754
1755            assert!(builder.metadata.metadata.is_empty());
1756            assert!(builder.spine.spine.is_empty());
1757            assert!(builder.catalog.catalog.is_empty());
1758            assert!(builder.catalog.title.is_empty());
1759            assert!(builder.manifest.manifest.is_empty());
1760
1761            builder.add_metadata(MetadataItem::new("title", "New Book"));
1762            builder.add_spine(SpineItem::new("new_chapter"));
1763            builder.add_catalog_item(NavPoint::new("New Chapter"));
1764
1765            assert_eq!(builder.metadata.metadata.len(), 1);
1766            assert_eq!(builder.spine.spine.len(), 1);
1767            assert_eq!(builder.catalog.catalog.len(), 1);
1768        }
1769
1770        #[test]
1771        fn test_make() {
1772            let mut builder = test_helpers::create_full_builder();
1773
1774            builder
1775                .add_manifest(
1776                    "./test_case/Overview.xhtml",
1777                    ManifestItem {
1778                        id: "test".to_string(),
1779                        path: PathBuf::from("test.xhtml"),
1780                        mime: String::new(),
1781                        properties: None,
1782                        fallback: None,
1783                    },
1784                )
1785                .unwrap();
1786
1787            let file = env::temp_dir().join(format!("{}.epub", local_time()));
1788            assert!(builder.make(&file).is_ok());
1789            assert!(EpubDoc::new(&file).is_ok());
1790        }
1791
1792        #[test]
1793        fn test_build() {
1794            let mut builder = test_helpers::create_full_builder();
1795
1796            builder
1797                .add_manifest(
1798                    "./test_case/Overview.xhtml",
1799                    ManifestItem {
1800                        id: "test".to_string(),
1801                        path: PathBuf::from("test.xhtml"),
1802                        mime: String::new(),
1803                        properties: None,
1804                        fallback: None,
1805                    },
1806                )
1807                .unwrap();
1808
1809            let file = env::temp_dir().join(format!("{}.epub", local_time()));
1810            assert!(builder.build(&file).is_ok());
1811        }
1812
1813        #[test]
1814        fn test_from() {
1815            let metadata = vec![
1816                MetadataItem {
1817                    id: None,
1818                    property: "title".to_string(),
1819                    value: "Test Book".to_string(),
1820                    lang: None,
1821                    refined: vec![],
1822                },
1823                MetadataItem {
1824                    id: None,
1825                    property: "language".to_string(),
1826                    value: "en".to_string(),
1827                    lang: None,
1828                    refined: vec![],
1829                },
1830                MetadataItem {
1831                    id: Some("pub-id".to_string()),
1832                    property: "identifier".to_string(),
1833                    value: "test-book".to_string(),
1834                    lang: None,
1835                    refined: vec![],
1836                },
1837            ];
1838            let spine = vec![SpineItem {
1839                id: None,
1840                idref: "main".to_string(),
1841                linear: true,
1842                properties: None,
1843            }];
1844            let catalog = vec![
1845                NavPoint {
1846                    label: "Nav".to_string(),
1847                    content: None,
1848                    children: vec![],
1849                    play_order: None,
1850                },
1851                NavPoint {
1852                    label: "Overview".to_string(),
1853                    content: None,
1854                    children: vec![],
1855                    play_order: None,
1856                },
1857            ];
1858
1859            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1860            builder.add_rootfile("content.opf").unwrap();
1861            builder.metadata.metadata = metadata.clone();
1862            builder.spine.spine = spine.clone();
1863            builder.catalog.catalog = catalog.clone();
1864            builder.set_catalog_title("catalog title");
1865            builder
1866                .add_manifest(
1867                    "./test_case/Overview.xhtml",
1868                    ManifestItem {
1869                        id: "main".to_string(),
1870                        path: PathBuf::from("Overview.xhtml"),
1871                        mime: String::new(),
1872                        properties: None,
1873                        fallback: None,
1874                    },
1875                )
1876                .unwrap();
1877
1878            let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1879            builder.make(&epub_file).unwrap();
1880
1881            let mut doc = EpubDoc::new(&epub_file).unwrap();
1882            let builder = EpubBuilder::from(&mut doc).unwrap();
1883
1884            assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
1885            assert_eq!(builder.manifest.manifest.len(), 1);
1886            assert_eq!(builder.spine.spine.len(), spine.len());
1887            assert_eq!(builder.catalog.catalog, catalog);
1888            assert_eq!(builder.catalog.title, "catalog title");
1889        }
1890
1891        #[test]
1892        fn test_make_container_file() {
1893            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1894
1895            let result = builder.make_container_xml();
1896            assert!(result.is_err());
1897            assert_eq!(
1898                result.unwrap_err(),
1899                EpubBuilderError::MissingRootfile.into()
1900            );
1901
1902            builder.add_rootfile("content.opf").unwrap();
1903            assert!(builder.make_container_xml().is_ok());
1904        }
1905
1906        #[test]
1907        fn test_make_navigation_document() {
1908            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1909
1910            let result = builder.make_navigation_document();
1911            assert!(result.is_err());
1912            assert_eq!(
1913                result.unwrap_err(),
1914                EpubBuilderError::NavigationInfoUninitalized.into()
1915            );
1916
1917            builder.add_catalog_item(NavPoint::new("test"));
1918            assert!(builder.make_navigation_document().is_ok());
1919        }
1920
1921        #[test]
1922        fn test_make_opf_file_success() {
1923            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1924
1925            builder.add_rootfile("content.opf").unwrap();
1926            builder.add_metadata(MetadataItem::new("title", "Test Book"));
1927            builder.add_metadata(MetadataItem::new("language", "en"));
1928            builder.add_metadata(
1929                MetadataItem::new("identifier", "urn:isbn:1234567890")
1930                    .with_id("pub-id")
1931                    .build(),
1932            );
1933
1934            let test_file = builder.temp_dir.join("test.xhtml");
1935            fs::write(&test_file, "<html></html>").unwrap();
1936            builder
1937                .add_manifest(
1938                    test_file.to_str().unwrap(),
1939                    ManifestItem::new("test", "test.xhtml").unwrap(),
1940                )
1941                .unwrap();
1942
1943            builder.add_catalog_item(NavPoint::new("Chapter"));
1944            builder.add_spine(SpineItem::new("test"));
1945            builder.make_navigation_document().unwrap();
1946
1947            assert!(builder.make_opf_file().is_ok());
1948            assert!(builder.temp_dir.join("content.opf").exists());
1949        }
1950
1951        #[test]
1952        fn test_make_opf_file_missing_metadata() {
1953            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1954            builder.add_rootfile("content.opf").unwrap();
1955
1956            let result = builder.make_opf_file();
1957            assert!(result.is_err());
1958            assert_eq!(
1959                result.unwrap_err().to_string(),
1960                "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1961            );
1962        }
1963    }
1964
1965    mod manifest_tests {
1966        use super::*;
1967
1968        #[test]
1969        fn test_add_manifest_success() {
1970            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1971            builder.add_rootfile("content.opf").unwrap();
1972
1973            let test_file = builder.temp_dir.join("test.xhtml");
1974            fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
1975
1976            let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
1977            let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
1978
1979            assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
1980            assert_eq!(builder.manifest.manifest.len(), 1);
1981            assert!(builder.manifest.manifest.contains_key("test"));
1982        }
1983
1984        #[test]
1985        fn test_add_manifest_no_rootfile() {
1986            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1987
1988            let manifest_item = ManifestItem {
1989                id: "main".to_string(),
1990                path: PathBuf::from("/Overview.xhtml"),
1991                mime: String::new(),
1992                properties: None,
1993                fallback: None,
1994            };
1995
1996            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
1997            assert!(result.is_err());
1998            assert_eq!(
1999                result.unwrap_err(),
2000                EpubBuilderError::MissingRootfile.into()
2001            );
2002
2003            builder.add_rootfile("package.opf").unwrap();
2004            let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
2005            assert!(result.is_ok());
2006        }
2007
2008        #[test]
2009        fn test_add_manifest_nonexistent_file() {
2010            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2011            builder.add_rootfile("content.opf").unwrap();
2012
2013            let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
2014            let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
2015
2016            assert!(result.is_err());
2017            assert_eq!(
2018                result.unwrap_err(),
2019                EpubBuilderError::TargetIsNotFile {
2020                    target_path: "nonexistent.xhtml".to_string()
2021                }
2022                .into()
2023            );
2024        }
2025
2026        #[test]
2027        fn test_add_manifest_unknown_file_format() {
2028            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2029            builder.add_rootfile("package.opf").unwrap();
2030
2031            let result = builder.add_manifest(
2032                "./test_case/unknown_file_format.xhtml",
2033                ManifestItem {
2034                    id: "file".to_string(),
2035                    path: PathBuf::from("unknown_file_format.xhtml"),
2036                    mime: String::new(),
2037                    properties: None,
2038                    fallback: None,
2039                },
2040            );
2041
2042            assert!(result.is_err());
2043            assert_eq!(
2044                result.unwrap_err(),
2045                EpubBuilderError::UnknownFileFormat {
2046                    file_path: "./test_case/unknown_file_format.xhtml".to_string(),
2047                }
2048                .into()
2049            );
2050        }
2051
2052        #[test]
2053        fn test_validate_fallback_chain_valid() {
2054            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2055
2056            let item3 = ManifestItem::new("item3", "path3").unwrap();
2057            let item2 = ManifestItem::new("item2", "path2")
2058                .unwrap()
2059                .with_fallback("item3")
2060                .build();
2061            let item1 = ManifestItem::new("item1", "path1")
2062                .unwrap()
2063                .with_fallback("item2")
2064                .append_property("nav")
2065                .build();
2066
2067            builder.manifest.insert("item3".to_string(), item3);
2068            builder.manifest.insert("item2".to_string(), item2);
2069            builder.manifest.insert("item1".to_string(), item1);
2070
2071            assert!(builder.manifest.validate().is_ok());
2072        }
2073
2074        #[test]
2075        fn test_validate_fallback_chain_circular_reference() {
2076            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2077
2078            let item2 = ManifestItem::new("item2", "path2")
2079                .unwrap()
2080                .with_fallback("item1")
2081                .build();
2082            let item1 = ManifestItem::new("item1", "path1")
2083                .unwrap()
2084                .with_fallback("item2")
2085                .build();
2086
2087            builder.manifest.insert("item1".to_string(), item1);
2088            builder.manifest.insert("item2".to_string(), item2);
2089
2090            let result = builder.manifest.validate();
2091            assert!(result.is_err());
2092            assert!(result.unwrap_err().to_string().starts_with(
2093                "Epub builder error: Circular reference detected in fallback chain for"
2094            ));
2095        }
2096
2097        #[test]
2098        fn test_validate_fallback_chain_not_found() {
2099            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2100
2101            let item1 = ManifestItem::new("item1", "path1")
2102                .unwrap()
2103                .with_fallback("nonexistent")
2104                .build();
2105
2106            builder.manifest.insert("item1".to_string(), item1);
2107
2108            let result = builder.manifest.validate();
2109            assert!(result.is_err());
2110            assert_eq!(
2111                result.unwrap_err().to_string(),
2112                "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
2113            );
2114        }
2115
2116        #[test]
2117        fn test_validate_manifest_nav_single() {
2118            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2119
2120            let nav_item = ManifestItem::new("nav", "nav.xhtml")
2121                .unwrap()
2122                .append_property("nav")
2123                .build();
2124            builder
2125                .manifest
2126                .manifest
2127                .insert("nav".to_string(), nav_item);
2128
2129            assert!(builder.manifest.validate().is_ok());
2130        }
2131
2132        #[test]
2133        fn test_validate_manifest_nav_multiple() {
2134            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2135
2136            let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
2137                .unwrap()
2138                .append_property("nav")
2139                .build();
2140            let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
2141                .unwrap()
2142                .append_property("nav")
2143                .build();
2144
2145            builder
2146                .manifest
2147                .manifest
2148                .insert("nav1".to_string(), nav_item1);
2149            builder
2150                .manifest
2151                .manifest
2152                .insert("nav2".to_string(), nav_item2);
2153
2154            let result = builder.manifest.validate();
2155            assert!(result.is_err());
2156            assert_eq!(
2157                result.unwrap_err().to_string(),
2158                "Epub builder error: There are too many items with 'nav' property in the manifest."
2159            );
2160        }
2161    }
2162
2163    mod metadata_tests {
2164        use super::*;
2165
2166        #[test]
2167        fn test_validate_metadata_success() {
2168            let builder = test_helpers::create_basic_builder();
2169            assert!(builder.metadata.validate().is_ok());
2170        }
2171
2172        #[test]
2173        fn test_validate_metadata_missing_required() {
2174            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2175            builder.add_metadata(MetadataItem::new("title", "Test Book"));
2176            builder.add_metadata(MetadataItem::new("language", "en"));
2177            assert!(builder.metadata.validate().is_err());
2178        }
2179    }
2180
2181    mod utility_tests {
2182        use super::*;
2183
2184        #[test]
2185        fn test_normalize_manifest_path() {
2186            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2187            builder.add_rootfile("content.opf").unwrap();
2188
2189            let result = normalize_manifest_path(
2190                &builder.temp_dir,
2191                builder.rootfiles.first().unwrap(),
2192                "../../test.xhtml",
2193                "id",
2194            );
2195            assert!(result.is_err());
2196            assert_eq!(
2197                result.unwrap_err(),
2198                EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
2199            );
2200
2201            let result = normalize_manifest_path(
2202                &builder.temp_dir,
2203                builder.rootfiles.first().unwrap(),
2204                "/test.xhtml",
2205                "id",
2206            );
2207            assert!(result.is_ok());
2208            assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
2209
2210            let result = normalize_manifest_path(
2211                &builder.temp_dir,
2212                builder.rootfiles.first().unwrap(),
2213                "./test.xhtml",
2214                "manifest_id",
2215            );
2216            assert!(result.is_err());
2217            assert_eq!(
2218                result.unwrap_err(),
2219                EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
2220                    .into(),
2221            );
2222        }
2223
2224        #[test]
2225        fn test_refine_mime_type() {
2226            assert_eq!(
2227                refine_mime_type("text/xml", "xhtml"),
2228                "application/xhtml+xml"
2229            );
2230            assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
2231            assert_eq!(
2232                refine_mime_type("application/xml", "opf"),
2233                "application/oebps-package+xml"
2234            );
2235            assert_eq!(
2236                refine_mime_type("text/xml", "ncx"),
2237                "application/x-dtbncx+xml"
2238            );
2239            assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
2240            assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
2241        }
2242    }
2243
2244    #[cfg(feature = "content-builder")]
2245    mod content_builder_tests {
2246        use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
2247
2248        #[test]
2249        fn test_make_contents_basic() {
2250            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2251            builder.add_rootfile("content.opf").unwrap();
2252
2253            let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
2254            content_builder
2255                .set_title("Test Chapter")
2256                .add_text_block("This is a test paragraph.", vec![])
2257                .unwrap();
2258
2259            builder.add_content("OEBPS/chapter1.xhtml", content_builder);
2260
2261            assert!(builder.make_contents().is_ok());
2262            assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
2263        }
2264
2265        #[test]
2266        fn test_make_contents_multiple_blocks() {
2267            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2268            builder.add_rootfile("content.opf").unwrap();
2269
2270            let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
2271            content_builder
2272                .set_title("多个区块章节")
2273                .add_text_block("第一段文本。", vec![])
2274                .unwrap()
2275                .add_quote_block("这是一个引用。", vec![])
2276                .unwrap()
2277                .add_title_block("子标题", 2, vec![])
2278                .unwrap()
2279                .add_text_block("最后的文本段落。", vec![])
2280                .unwrap();
2281
2282            builder.add_content("OEBPS/chapter2.xhtml", content_builder);
2283
2284            assert!(builder.make_contents().is_ok());
2285            assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
2286        }
2287
2288        #[test]
2289        fn test_make_contents_with_media() {
2290            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2291            builder.add_rootfile("content.opf").unwrap();
2292
2293            let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
2294            content_builder
2295                .set_title("Chapter with Media")
2296                .add_text_block("Text before image.", vec![])
2297                .unwrap()
2298                .add_image_block(
2299                    std::path::PathBuf::from("./test_case/image.jpg"),
2300                    Some("Test Image".to_string()),
2301                    Some("Figure 1: A test image".to_string()),
2302                    vec![],
2303                )
2304                .unwrap()
2305                .add_text_block("Text after image.", vec![])
2306                .unwrap();
2307
2308            builder.add_content("OEBPS/chapter3.xhtml", content_builder);
2309
2310            assert!(builder.make_contents().is_ok());
2311            assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
2312            assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
2313        }
2314
2315        #[test]
2316        fn test_make_contents_multiple_documents() {
2317            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2318            builder.add_rootfile("content.opf").unwrap();
2319
2320            for (id, title) in [
2321                ("ch1", "Chapter 1"),
2322                ("ch2", "Chapter 2"),
2323                ("ch3", "Chapter 3"),
2324            ] {
2325                let mut content = ContentBuilder::new(id, "en").unwrap();
2326                content
2327                    .set_title(title)
2328                    .add_text_block(&format!("Content of {}", title), vec![])
2329                    .unwrap();
2330                builder.add_content(format!("OEBPS/{}.xhtml", id), content);
2331            }
2332
2333            assert!(builder.make_contents().is_ok());
2334            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
2335            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
2336            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
2337        }
2338
2339        #[test]
2340        fn test_make_contents_different_languages() {
2341            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2342            builder.add_rootfile("content.opf").unwrap();
2343
2344            let langs = [
2345                ("en_ch", "en", "English Chapter"),
2346                ("zh_ch", "zh-CN", "中文章节"),
2347                ("ja_ch", "ja", "日本語の章"),
2348            ];
2349
2350            for (id, lang, title) in langs {
2351                let mut content = ContentBuilder::new(id, lang).unwrap();
2352                content
2353                    .set_title(title)
2354                    .add_text_block(&format!("Text in {}", lang), vec![])
2355                    .unwrap();
2356                builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
2357            }
2358
2359            assert!(builder.make_contents().is_ok());
2360            assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
2361            assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
2362            assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
2363        }
2364
2365        #[test]
2366        fn test_make_contents_unique_identifiers() {
2367            use std::path::PathBuf;
2368
2369            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2370            builder.add_rootfile("content.opf").unwrap();
2371
2372            let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
2373            content1.add_text_block("First content", vec![]).unwrap();
2374            builder.add_content("OEBPS/ch1.xhtml", content1);
2375
2376            let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
2377            content2.add_text_block("Second content", vec![]).unwrap();
2378            builder.add_content("OEBPS/ch2.xhtml", content2);
2379
2380            let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
2381            content3
2382                .add_text_block("Duplicate ID content", vec![])
2383                .unwrap();
2384            builder.add_content("OEBPS/ch3.xhtml", content3);
2385
2386            assert!(builder.make_contents().is_ok());
2387            assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
2388            assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
2389            assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
2390
2391            let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
2392            assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
2393        }
2394
2395        #[test]
2396        fn test_make_contents_complex_structure() {
2397            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2398            builder.add_rootfile("content.opf").unwrap();
2399
2400            let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
2401            content
2402                .set_title("Complex Chapter")
2403                .add_title_block("Section 1", 2, vec![])
2404                .unwrap()
2405                .add_text_block("Introduction text.", vec![])
2406                .unwrap()
2407                .add_quote_block("A wise quote here.", vec![])
2408                .unwrap()
2409                .add_title_block("Section 2", 2, vec![])
2410                .unwrap()
2411                .add_text_block("More content with multiple paragraphs.", vec![])
2412                .unwrap()
2413                .add_text_block("Another paragraph.", vec![])
2414                .unwrap()
2415                .add_title_block("Section 3", 2, vec![])
2416                .unwrap()
2417                .add_quote_block("Another quotation.", vec![])
2418                .unwrap();
2419
2420            builder.add_content("OEBPS/complex_chapter.xhtml", content);
2421
2422            assert!(builder.make_contents().is_ok());
2423            assert!(
2424                builder
2425                    .temp_dir
2426                    .join("OEBPS/complex_chapter.xhtml")
2427                    .exists()
2428            );
2429        }
2430
2431        #[test]
2432        fn test_make_contents_empty_document() {
2433            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2434            builder.add_rootfile("content.opf").unwrap();
2435
2436            let content = ContentBuilder::new("empty_ch", "en").unwrap();
2437            builder.add_content("OEBPS/empty.xhtml", content);
2438
2439            assert!(builder.make_contents().is_ok());
2440            assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
2441        }
2442
2443        #[test]
2444        fn test_make_contents_path_normalization() {
2445            let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2446            builder.add_rootfile("OEBPS/content.opf").unwrap();
2447
2448            let mut content = ContentBuilder::new("path_test", "en").unwrap();
2449            content.add_text_block("Path test content", vec![]).unwrap();
2450
2451            builder.add_content("/OEBPS/text/chapter.xhtml", content);
2452
2453            assert!(builder.make_contents().is_ok());
2454            assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
2455        }
2456    }
2457}