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}