Skip to main content

lib_epub/builder/
components.rs

1#[cfg(feature = "no-indexmap")]
2use std::collections::HashMap;
3#[cfg(feature = "content-builder")]
4use std::io::Read;
5use std::{
6    fs,
7    path::{Path, PathBuf},
8};
9
10use chrono::{SecondsFormat, Utc};
11#[cfg(not(feature = "no-indexmap"))]
12use indexmap::IndexMap;
13use infer::Infer;
14use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
15
16#[cfg(feature = "content-builder")]
17use crate::builder::content::ContentBuilder;
18use crate::{
19    builder::{XmlWriter, normalize_manifest_path, refine_mime_type},
20    error::{EpubBuilderError, EpubError},
21    types::{ManifestItem, MetadataItem, MetadataSheet, NavPoint, SpineItem},
22    utils::ELEMENT_IN_DC_NAMESPACE,
23};
24
25/// Rootfile builder for EPUB container
26///
27/// The `RootfileBuilder` is responsible for managing the rootfile paths in the EPUB container.
28/// Each rootfile points to an OPF (Open Packaging Format) file that defines the structure
29/// and content of an EPUB publication.
30///
31/// In EPUB 3.0, a single rootfile is typically used, but the structure supports multiple
32/// rootfiles for more complex publications.
33///
34/// ## Notes
35///
36/// - Rootfile paths must be relative and cannot start with "../" or "/"
37/// - At least one rootfile must be added before building the EPUB
38#[derive(Debug)]
39pub struct RootfileBuilder {
40    /// List of rootfile paths
41    pub(crate) rootfiles: Vec<String>,
42}
43
44impl RootfileBuilder {
45    /// Creates a new empty `RootfileBuilder` instance
46    pub(crate) fn new() -> Self {
47        Self { rootfiles: Vec::new() }
48    }
49
50    /// Add a rootfile path
51    ///
52    /// Adds a new rootfile path to the builder. The rootfile points to the OPF file
53    /// that will be created when building the EPUB.
54    ///
55    /// ## Parameters
56    /// - `rootfile`: The relative path to the OPF file
57    ///
58    /// ## Return
59    /// - `Ok(&mut Self)`: Successfully added the rootfile
60    /// - `Err(EpubError)`: Error if the path is invalid (starts with "/" or "../")
61    pub fn add(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
62        let rootfile = rootfile.as_ref();
63
64        if rootfile.starts_with("/") || rootfile.starts_with("../") {
65            return Err(EpubBuilderError::IllegalRootfilePath.into());
66        }
67
68        let rootfile = rootfile.strip_prefix("./").unwrap_or(rootfile);
69
70        self.rootfiles.push(rootfile.into());
71        Ok(self)
72    }
73
74    /// Clear all rootfiles
75    ///
76    /// Removes all rootfile paths from the builder.
77    pub fn clear(&mut self) -> &mut Self {
78        self.rootfiles.clear();
79        self
80    }
81
82    /// Check if the builder is empty
83    pub(crate) fn is_empty(&self) -> bool {
84        self.rootfiles.is_empty()
85    }
86
87    /// Get the first rootfile
88    pub(crate) fn first(&self) -> Option<&String> {
89        self.rootfiles.first()
90    }
91
92    /// Generate the container.xml content
93    ///
94    /// Writes the XML representation of the container and rootfiles to the provided writer.
95    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
96        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
97
98        writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
99            [
100                ("version", "1.0"),
101                ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
102            ],
103        )))?;
104        writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
105
106        for rootfile in &self.rootfiles {
107            writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
108                ("full-path", rootfile.as_str()),
109                ("media-type", "application/oebps-package+xml"),
110            ])))?;
111        }
112
113        writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
114        writer.write_event(Event::End(BytesEnd::new("container")))?;
115
116        Ok(())
117    }
118}
119
120/// Metadata builder for EPUB publications
121///
122/// The `MetadataBuilder` is responsible for managing metadata items in an EPUB publication.
123/// Metadata includes essential information such as title, author, language, identifier,
124/// publisher, and other descriptive information about the publication.
125///
126/// ## Required Metadata
127///
128/// According to the EPUB specification, the following metadata are required:
129/// - `title`: The publication title
130/// - `language`: The language of the publication (e.g., "en", "zh-CN")
131/// - `identifier`: A unique identifier for the publication with id "pub-id"
132#[derive(Debug)]
133pub struct MetadataBuilder {
134    /// List of metadata items
135    pub(crate) metadata: Vec<MetadataItem>,
136}
137
138impl MetadataBuilder {
139    /// Creates a new empty `MetadataBuilder` instance
140    pub(crate) fn new() -> Self {
141        Self { metadata: Vec::new() }
142    }
143
144    /// Add a metadata item
145    ///
146    /// Appends a new metadata item to the builder.
147    ///
148    /// ## Parameters
149    /// - `item`: The metadata item to add
150    ///
151    /// ## Return
152    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
153    pub fn add(&mut self, item: MetadataItem) -> &mut Self {
154        self.metadata.push(item);
155        self
156    }
157
158    /// Clear all metadata items
159    ///
160    /// Removes all metadata items from the builder.
161    pub fn clear(&mut self) -> &mut Self {
162        self.metadata.clear();
163        self
164    }
165
166    /// Add metadata items from a MetadataSheet
167    ///
168    /// Extends the builder with metadata items from the provided `MetadataSheet`.
169    pub fn from(&mut self, sheet: MetadataSheet) -> &mut Self {
170        self.metadata.extend(Vec::<MetadataItem>::from(sheet));
171        self
172    }
173
174    /// Generate the metadata XML content
175    ///
176    /// Writes the XML representation of the metadata to the provided writer.
177    /// This includes all metadata items and their refinements, as well as
178    /// automatically adding a `dcterms:modified` timestamp.
179    pub(crate) fn make(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
180        self.metadata.push(MetadataItem {
181            id: None,
182            property: "dcterms:modified".to_string(),
183            value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
184            lang: None,
185            refined: vec![],
186        });
187
188        writer.write_event(Event::Start(BytesStart::new("metadata")))?;
189
190        for metadata in &self.metadata {
191            let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
192                format!("dc:{}", metadata.property)
193            } else {
194                "meta".to_string()
195            };
196
197            writer.write_event(Event::Start(
198                BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
199            ))?;
200            writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
201            writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
202
203            for refinement in &metadata.refined {
204                writer.write_event(Event::Start(
205                    BytesStart::new("meta").with_attributes(refinement.attributes()),
206                ))?;
207                writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
208                writer.write_event(Event::End(BytesEnd::new("meta")))?;
209            }
210        }
211
212        writer.write_event(Event::End(BytesEnd::new("metadata")))?;
213
214        Ok(())
215    }
216
217    /// Verify metadata integrity
218    ///
219    /// Check if the required metadata items are included: title, language, and identifier with pub-id.
220    pub(crate) fn validate(&self) -> Result<(), EpubError> {
221        let mut has_title = false;
222        let mut has_language = false;
223        let mut has_identifier = false;
224
225        for item in &self.metadata {
226            match item.property.as_str() {
227                "title" => has_title = true,
228                "language" => has_language = true,
229                "identifier" => {
230                    if item.id.as_ref().is_some_and(|id| id == "pub-id") {
231                        has_identifier = true;
232                    }
233                }
234                _ => {}
235            }
236
237            if has_title && has_language && has_identifier {
238                return Ok(());
239            }
240        }
241
242        Err(EpubBuilderError::MissingNecessaryMetadata.into())
243    }
244}
245
246/// Manifest builder for EPUB resources
247///
248/// The `ManifestBuilder` is responsible for managing manifest items in an EPUB publication.
249/// The manifest declares all resources (HTML files, images, stylesheets, fonts, etc.)
250/// that are part of the EPUB publication.
251///
252/// Each manifest item must have a unique identifier and a path to the resource file.
253/// The builder automatically determines the MIME type of each resource based on its content.
254///
255/// ## Resource Fallbacks
256///
257/// The manifest supports fallback chains for resources that may not be supported by all
258/// reading systems. When adding a resource with a fallback, the builder validates that:
259/// - The fallback chain does not contain circular references
260/// - All referenced fallback resources exist in the manifest
261///
262/// ## Navigation Document
263///
264/// The manifest must contain exactly one item with the `nav` property, which serves
265/// as the navigation document (table of contents) of the publication.
266#[derive(Debug)]
267pub struct ManifestBuilder {
268    /// Temporary directory for storing files during build
269    temp_dir: PathBuf,
270
271    /// Rootfile path (OPF file location)
272    rootfile: Option<String>,
273
274    /// Manifest items stored in a map keyed by ID
275    #[cfg(feature = "no-indexmap")]
276    pub(crate) manifest: HashMap<String, ManifestItem>,
277    #[cfg(not(feature = "no-indexmap"))]
278    pub(crate) manifest: IndexMap<String, ManifestItem>,
279}
280
281impl ManifestBuilder {
282    /// Creates a new `ManifestBuilder` instance
283    ///
284    /// ## Parameters
285    /// - `temp_dir`: Temporary directory path for storing files during the build process
286    pub(crate) fn new(temp_dir: impl AsRef<Path>) -> Self {
287        Self {
288            temp_dir: temp_dir.as_ref().to_path_buf(),
289            rootfile: None,
290            #[cfg(feature = "no-indexmap")]
291            manifest: HashMap::new(),
292            #[cfg(not(feature = "no-indexmap"))]
293            manifest: IndexMap::new(),
294        }
295    }
296
297    /// Set the rootfile path
298    ///
299    /// This must be called before adding manifest items.
300    ///
301    /// ## Parameters
302    /// - `rootfile`: The rootfile path
303    pub(crate) fn set_rootfile(&mut self, rootfile: impl Into<String>) {
304        self.rootfile = Some(rootfile.into());
305    }
306
307    /// Add a manifest item and copy the resource file
308    ///
309    /// Adds a new resource to the manifest and copies the source file to the
310    /// temporary directory. The builder automatically determines the MIME type
311    /// based on the file content.
312    ///
313    /// ## Parameters
314    /// - `manifest_source`: Path to the source file on the local filesystem
315    /// - `manifest_item`: Manifest item with ID and target path
316    ///
317    /// ## Return
318    /// - `Ok(&mut Self)`: Successfully added the resource
319    /// - `Err(EpubError)`: Error if the source file doesn't exist or has an unknown format
320    pub fn add(
321        &mut self,
322        manifest_source: impl Into<String>,
323        manifest_item: ManifestItem,
324    ) -> Result<&mut Self, EpubError> {
325        // Check if the source path is a file
326        let manifest_source = manifest_source.into();
327        let source = PathBuf::from(&manifest_source);
328        if !source.is_file() {
329            return Err(EpubBuilderError::TargetIsNotFile { target_path: manifest_source }.into());
330        }
331
332        // Get the file extension
333        let extension = match source.extension() {
334            Some(ext) => ext.to_string_lossy().to_lowercase(),
335            None => String::new(),
336        };
337
338        // Read the file
339        let buf = fs::read(source)?;
340
341        // Get the mime type
342        let real_mime = match Infer::new().get(&buf) {
343            Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
344            None => {
345                return Err(
346                    EpubBuilderError::UnknownFileFormat { file_path: manifest_source }.into(),
347                );
348            }
349        };
350
351        let target_path = normalize_manifest_path(
352            &self.temp_dir,
353            self.rootfile
354                .as_ref()
355                .ok_or(EpubBuilderError::MissingRootfile)?,
356            &manifest_item.path,
357            &manifest_item.id,
358        )?;
359        if let Some(parent_dir) = target_path.parent() {
360            if !parent_dir.exists() {
361                fs::create_dir_all(parent_dir)?
362            }
363        }
364
365        match fs::write(target_path, buf) {
366            Ok(_) => {
367                self.manifest
368                    .insert(manifest_item.id.clone(), manifest_item.set_mime(real_mime));
369                Ok(self)
370            }
371            Err(err) => Err(err.into()),
372        }
373    }
374
375    /// Clear all manifest items
376    ///
377    /// Removes all manifest items from the builder and deletes the associated files
378    /// from the temporary directory.
379    pub fn clear(&mut self) -> &mut Self {
380        let paths = self
381            .manifest
382            .values()
383            .map(|manifest| &manifest.path)
384            .collect::<Vec<&PathBuf>>();
385
386        for path in paths {
387            let _ = fs::remove_file(path);
388        }
389
390        self.manifest.clear();
391
392        self
393    }
394
395    /// Insert a manifest item directly
396    ///
397    /// This method allows direct insertion of a manifest item without copying
398    /// any files. Use this when the file already exists in the temporary directory.
399    pub(crate) fn insert(
400        &mut self,
401        key: impl Into<String>,
402        value: ManifestItem,
403    ) -> Option<ManifestItem> {
404        self.manifest.insert(key.into(), value)
405    }
406
407    /// Generate the manifest XML content
408    ///
409    /// Writes the XML representation of the manifest to the provided writer.
410    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
411        writer.write_event(Event::Start(BytesStart::new("manifest")))?;
412
413        for manifest in self.manifest.values() {
414            writer.write_event(Event::Empty(
415                BytesStart::new("item").with_attributes(manifest.attributes()),
416            ))?;
417        }
418
419        writer.write_event(Event::End(BytesEnd::new("manifest")))?;
420
421        Ok(())
422    }
423
424    /// Validate manifest integrity
425    ///
426    /// Checks fallback chains for circular references and missing items,
427    /// and verifies that exactly one nav item exists.
428    pub(crate) fn validate(&self) -> Result<(), EpubError> {
429        self.validate_fallback_chains()?;
430        self.validate_nav()?;
431
432        Ok(())
433    }
434
435    /// Get manifest item keys
436    ///
437    /// Returns an iterator over the keys (IDs) of all manifest items.
438    ///
439    /// ## Return
440    /// - `impl Iterator<Item = &String>`: Iterator over manifest item keys
441    pub(crate) fn keys(&self) -> impl Iterator<Item = &String> {
442        self.manifest.keys()
443    }
444
445    // TODO: consider using BFS to validate fallback chains, to provide efficient
446    /// Validate all fallback chains in the manifest
447    ///
448    /// Iterates through all manifest items and validates each fallback chain
449    /// to ensure there are no circular references and all referenced items exist.
450    fn validate_fallback_chains(&self) -> Result<(), EpubError> {
451        for (id, item) in &self.manifest {
452            if item.fallback.is_none() {
453                continue;
454            }
455
456            let mut fallback_chain = Vec::new();
457            self.validate_fallback_chain(id, &mut fallback_chain)?;
458        }
459
460        Ok(())
461    }
462
463    /// Recursively verify the validity of a single fallback chain
464    ///
465    /// This function recursively traces the fallback chain to check for the following issues:
466    /// - Circular reference
467    /// - The referenced fallback resource does not exist
468    fn validate_fallback_chain(
469        &self,
470        manifest_id: &str,
471        fallback_chain: &mut Vec<String>,
472    ) -> Result<(), EpubError> {
473        if fallback_chain.contains(&manifest_id.to_string()) {
474            fallback_chain.push(manifest_id.to_string());
475
476            return Err(EpubBuilderError::ManifestCircularReference {
477                fallback_chain: fallback_chain.join("->"),
478            }
479            .into());
480        }
481
482        // Get the current item; its existence can be ensured based on the calling context.
483        let item = self.manifest.get(manifest_id).unwrap();
484
485        if let Some(fallback_id) = &item.fallback {
486            if !self.manifest.contains_key(fallback_id) {
487                return Err(EpubBuilderError::ManifestNotFound {
488                    manifest_id: fallback_id.to_owned(),
489                }
490                .into());
491            }
492
493            fallback_chain.push(manifest_id.to_string());
494            self.validate_fallback_chain(fallback_id, fallback_chain)
495        } else {
496            // The end of the fallback chain
497            Ok(())
498        }
499    }
500
501    /// Validate navigation list items
502    ///
503    /// Check if there is only one list item with the `nav` property.
504    fn validate_nav(&self) -> Result<(), EpubError> {
505        if self
506            .manifest
507            .values()
508            .filter(|&item| {
509                if let Some(properties) = &item.properties {
510                    properties.split(" ").any(|property| property == "nav")
511                } else {
512                    false
513                }
514            })
515            .count()
516            == 1
517        {
518            Ok(())
519        } else {
520            Err(EpubBuilderError::TooManyNavFlags.into())
521        }
522    }
523}
524
525/// Spine builder for EPUB reading order
526///
527/// The `SpineBuilder` is responsible for managing the spine items in an EPUB publication.
528/// The spine defines the default reading order of the publication - the sequence in which
529/// the reading system should present the content documents to the reader.
530///
531/// Each spine item references a manifest item by its ID (idref), indicating which
532/// resource should be displayed at that point in the reading order.
533#[derive(Debug)]
534pub struct SpineBuilder {
535    /// List of spine items defining the reading order
536    pub(crate) spine: Vec<SpineItem>,
537}
538
539impl SpineBuilder {
540    /// Creates a new empty `SpineBuilder` instance
541    pub(crate) fn new() -> Self {
542        Self { spine: Vec::new() }
543    }
544
545    /// Add a spine item
546    ///
547    /// Appends a new spine item to the builder, defining the next position in
548    /// the reading order.
549    ///
550    /// ## Parameters
551    /// - `item`: The spine item to add
552    ///
553    /// ## Return
554    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
555    pub fn add(&mut self, item: SpineItem) -> &mut Self {
556        self.spine.push(item);
557        self
558    }
559
560    /// Clear all spine items
561    ///
562    /// Removes all spine items from the builder.
563    pub fn clear(&mut self) -> &mut Self {
564        self.spine.clear();
565        self
566    }
567
568    /// Generate the spine XML content
569    ///
570    /// Writes the XML representation of the spine to the provided writer.
571    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
572        writer.write_event(Event::Start(BytesStart::new("spine")))?;
573
574        for spine in &self.spine {
575            writer.write_event(Event::Empty(
576                BytesStart::new("itemref").with_attributes(spine.attributes()),
577            ))?;
578        }
579
580        writer.write_event(Event::End(BytesEnd::new("spine")))?;
581
582        Ok(())
583    }
584
585    /// Validate spine references
586    ///
587    /// Checks that all spine item idref values exist in the manifest.
588    ///
589    /// ## Parameters
590    /// - `manifest_keys`: Iterator over manifest item keys
591    pub(crate) fn validate(
592        &self,
593        manifest_keys: impl Iterator<Item = impl AsRef<str>>,
594    ) -> Result<(), EpubError> {
595        let manifest_keys: Vec<String> = manifest_keys.map(|k| k.as_ref().to_string()).collect();
596        for spine in &self.spine {
597            if !manifest_keys.contains(&spine.idref) {
598                return Err(
599                    EpubBuilderError::SpineManifestNotFound { idref: spine.idref.clone() }.into(),
600                );
601            }
602        }
603        Ok(())
604    }
605}
606
607/// Catalog builder for EPUB navigation
608///
609/// The `CatalogBuilder` is responsible for building the navigation document (TOC)
610/// of an EPUB publication. The navigation document provides a hierarchical table
611/// of contents that allows readers to navigate through the publication's content.
612///
613/// The navigation document is a special XHTML document that uses the EPUB Navigation
614/// Document specification.
615#[derive(Debug)]
616pub struct CatalogBuilder {
617    /// Title of the navigation document
618    pub(crate) title: String,
619
620    /// Navigation points (table of contents entries)
621    pub(crate) catalog: Vec<NavPoint>,
622}
623
624impl CatalogBuilder {
625    /// Creates a new empty `CatalogBuilder` instance
626    pub(crate) fn new() -> Self {
627        Self {
628            title: String::new(),
629            catalog: Vec::new(),
630        }
631    }
632
633    /// Set the catalog title
634    ///
635    /// Sets the title that will be displayed at the top of the navigation document.
636    ///
637    /// ## Parameters
638    /// - `title`: The title to set
639    ///
640    /// ## Return
641    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
642    pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
643        self.title = title.into();
644        self
645    }
646
647    /// Add a navigation point
648    ///
649    /// Appends a new navigation point to the catalog. Navigation points can be
650    /// nested by using the `append_child` method on `NavPoint`.
651    ///
652    /// ## Parameters
653    /// - `item`: The navigation point to add
654    ///
655    /// ## Return
656    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
657    pub fn add(&mut self, item: NavPoint) -> &mut Self {
658        self.catalog.push(item);
659        self
660    }
661
662    /// Clear all catalog items
663    ///
664    /// Removes the title and all navigation points from the builder.
665    pub fn clear(&mut self) -> &mut Self {
666        self.title.clear();
667        self.catalog.clear();
668        self
669    }
670
671    /// Check if the catalog is empty
672    ///
673    /// ## Return
674    /// - `true`: No navigation points have been added
675    /// - `false`: At least one navigation point has been added
676    pub(crate) fn is_empty(&self) -> bool {
677        self.catalog.is_empty()
678    }
679
680    /// Generate the navigation document
681    ///
682    /// Creates the EPUB Navigation Document (NAV) as XHTML content with the
683    /// specified title and navigation points.
684    pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
685        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
686            ("xmlns", "http://www.w3.org/1999/xhtml"),
687            ("xmlns:epub", "http://www.idpf.org/2007/ops"),
688        ])))?;
689
690        // make head
691        writer.write_event(Event::Start(BytesStart::new("head")))?;
692        writer.write_event(Event::Start(BytesStart::new("title")))?;
693        writer.write_event(Event::Text(BytesText::new(&self.title)))?;
694        writer.write_event(Event::End(BytesEnd::new("title")))?;
695        writer.write_event(Event::End(BytesEnd::new("head")))?;
696
697        // make body
698        writer.write_event(Event::Start(BytesStart::new("body")))?;
699        writer.write_event(Event::Start(
700            BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
701        ))?;
702
703        if !self.title.is_empty() {
704            writer.write_event(Event::Start(BytesStart::new("h1")))?;
705            writer.write_event(Event::Text(BytesText::new(&self.title)))?;
706            writer.write_event(Event::End(BytesEnd::new("h1")))?;
707        }
708
709        Self::make_nav(writer, &self.catalog)?;
710
711        writer.write_event(Event::End(BytesEnd::new("nav")))?;
712        writer.write_event(Event::End(BytesEnd::new("body")))?;
713
714        writer.write_event(Event::End(BytesEnd::new("html")))?;
715
716        Ok(())
717    }
718
719    /// Generate navigation list items recursively
720    ///
721    /// Recursively writes the navigation list (ol/li elements) for the given
722    /// navigation points.
723    fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
724        writer.write_event(Event::Start(BytesStart::new("ol")))?;
725
726        for nav in navgations {
727            writer.write_event(Event::Start(BytesStart::new("li")))?;
728
729            if let Some(path) = &nav.content {
730                writer.write_event(Event::Start(
731                    BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
732                ))?;
733                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
734                writer.write_event(Event::End(BytesEnd::new("a")))?;
735            } else {
736                writer.write_event(Event::Start(BytesStart::new("span")))?;
737                writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
738                writer.write_event(Event::End(BytesEnd::new("span")))?;
739            }
740
741            if !nav.children.is_empty() {
742                Self::make_nav(writer, &nav.children)?;
743            }
744
745            writer.write_event(Event::End(BytesEnd::new("li")))?;
746        }
747
748        writer.write_event(Event::End(BytesEnd::new("ol")))?;
749
750        Ok(())
751    }
752}
753
754#[cfg(feature = "content-builder")]
755#[derive(Debug)]
756pub struct DocumentBuilder {
757    pub(crate) documents: Vec<(PathBuf, ContentBuilder)>,
758}
759
760#[cfg(feature = "content-builder")]
761impl DocumentBuilder {
762    /// Creates a new empty `DocumentBuilder` instance
763    pub(crate) fn new() -> Self {
764        Self { documents: Vec::new() }
765    }
766
767    /// Add a content document
768    ///
769    /// Appends a new content document to be processed during EPUB building.
770    ///
771    /// ## Parameters
772    /// - `target`: The target path within the EPUB container where the content will be placed
773    /// - `content`: The content builder containing the document content
774    ///
775    /// ## Return
776    /// - `&mut Self`: Returns a mutable reference to itself for method chaining
777    pub fn add(&mut self, target: impl AsRef<str>, content: ContentBuilder) -> &mut Self {
778        self.documents
779            .push((PathBuf::from(target.as_ref()), content));
780        self
781    }
782
783    /// Clear all documents
784    ///
785    /// Removes all content documents from the builder.
786    pub fn clear(&mut self) -> &mut Self {
787        self.documents.clear();
788        self
789    }
790
791    /// Generate manifest items from content documents
792    ///
793    /// Processes all content documents and generates the corresponding manifest items.
794    /// Each content document may generate multiple manifest entries - one for the main
795    /// document and additional entries for any resources (images, fonts, etc.) it contains.
796    ///
797    /// ## Parameters
798    /// - `temp_dir`: The temporary directory path used during the EPUB build process
799    /// - `rootfile`: The path to the OPF file (package document)
800    ///
801    /// ## Return
802    /// - `Ok(Vec<ManifestItem>)`: List of manifest items generated from the content documents
803    /// - `Err(EpubError)`: Error if document generation or file processing fails
804    pub fn make(
805        &mut self,
806        temp_dir: PathBuf,
807        rootfile: impl AsRef<str>,
808    ) -> Result<Vec<ManifestItem>, EpubError> {
809        let mut buf = vec![0; 512];
810        let contents = std::mem::take(&mut self.documents);
811
812        let mut manifest = Vec::new();
813        for (target, mut content) in contents.into_iter() {
814            let manifest_id = content.id.clone();
815
816            // target is relative to the epub file, so we need to normalize it
817            let absolute_target =
818                normalize_manifest_path(&temp_dir, &rootfile, &target, &manifest_id)?;
819            let mut resources = content.make(&absolute_target)?;
820
821            // Helper to compute absolute container path
822            let to_container_path = |p: &PathBuf| -> PathBuf {
823                match p.strip_prefix(&temp_dir) {
824                    Ok(rel) => PathBuf::from("/").join(rel.to_string_lossy().replace("\\", "/")),
825                    Err(_) => unreachable!("path MUST under temp directory"),
826                }
827            };
828
829            // Document (first element, guaranteed to exist)
830            let path = resources.swap_remove(0);
831            let mut file = std::fs::File::open(&path)?;
832            let _ = file.read(&mut buf)?;
833            let extension = path
834                .extension()
835                .map(|e| e.to_string_lossy().to_lowercase())
836                .unwrap_or_default();
837            let mime = match Infer::new().get(&buf) {
838                Some(infer) => refine_mime_type(infer.mime_type(), &extension),
839                None => {
840                    return Err(EpubBuilderError::UnknownFileFormat {
841                        file_path: path.to_string_lossy().to_string(),
842                    }
843                    .into());
844                }
845            }
846            .to_string();
847
848            manifest.push(ManifestItem {
849                id: manifest_id.clone(),
850                path: to_container_path(&path),
851                mime,
852                properties: None,
853                fallback: None,
854            });
855
856            // Other resources (if any): generate stable ids and add to manifest
857            for res in resources {
858                let mut file = fs::File::open(&res)?;
859                let _ = file.read(&mut buf)?;
860                let extension = res
861                    .extension()
862                    .map(|e| e.to_string_lossy().to_lowercase())
863                    .unwrap_or_default();
864                let mime = match Infer::new().get(&buf) {
865                    Some(ft) => refine_mime_type(ft.mime_type(), &extension),
866                    None => {
867                        return Err(EpubBuilderError::UnknownFileFormat {
868                            file_path: path.to_string_lossy().to_string(),
869                        }
870                        .into());
871                    }
872                }
873                .to_string();
874
875                let file_name = res
876                    .file_name()
877                    .map(|s| s.to_string_lossy().to_string())
878                    .unwrap_or_default();
879                let res_id = format!("{}-{}", manifest_id, file_name);
880
881                manifest.push(ManifestItem {
882                    id: res_id,
883                    path: to_container_path(&res),
884                    mime,
885                    properties: None,
886                    fallback: None,
887                });
888            }
889        }
890
891        Ok(manifest)
892    }
893}