Skip to main content

lib_epub/builder/
content.rs

1//! Content Builder
2//!
3//! This module provides functionality for creating EPUB content documents.
4//!
5//! ## Usage
6//! ``` rust, no_run
7//! # #[cfg(feature = "content_builder")] {
8//! # fn main() -> Result<(), lib_epub::error::EpubError> {
9//! use lib_epub::{
10//!     builder::content::{Block, BlockBuilder, ContentBuilder},
11//!     types::{BlockType, Footnote},
12//! };
13//!
14//! let mut block_builder = BlockBuilder::new(BlockType::Title);
15//! block_builder
16//!     .set_content("This is a title")
17//!     .add_footnote(Footnote {
18//!         locate: 15,
19//!         content: "This is a footnote.".to_string(),
20//!     });
21//! let block = block_builder.build()?;
22//!
23//! let mut builder = ContentBuilder::new("chapter1", "zh-CN")?;
24//! builder.set_title("My Chapter")
25//!     .add_block(block)?
26//!     .add_text_block("This is my first chapter.", vec![])?;
27//! let _ = builder.make("output.xhtml")?;
28//! # Ok(())
29//! # }
30//! # }
31//! ```
32//!
33//! ## Future Work
34//! - Support more types of content `Block`
35
36use std::{
37    collections::HashMap,
38    env,
39    fs::{self, File},
40    io::{Cursor, Read},
41    path::{Path, PathBuf},
42};
43
44use infer::{Infer, MatcherType};
45use log::warn;
46use quick_xml::{
47    Reader, Writer,
48    events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
49};
50
51use crate::{
52    builder::XmlWriter,
53    error::{EpubBuilderError, EpubError},
54    types::{BlockType, Footnote},
55    utils::local_time,
56};
57
58/// Content Block
59///
60/// The content block is the basic unit of content in a content document.
61/// It can be one of the following types: Text, Quote, Title, Image, Audio, Video, MathML.
62#[non_exhaustive]
63#[derive(Debug)]
64pub enum Block {
65    /// Text paragraph
66    ///
67    /// This block represents a paragraph of text.
68    #[non_exhaustive]
69    Text {
70        content: String,
71        footnotes: Vec<Footnote>,
72    },
73
74    /// Quote paragraph
75    ///
76    /// This block represents a paragraph of quoted text.
77    #[non_exhaustive]
78    Quote {
79        content: String,
80        footnotes: Vec<Footnote>,
81    },
82
83    /// Heading
84    #[non_exhaustive]
85    Title {
86        content: String,
87        footnotes: Vec<Footnote>,
88
89        /// Heading level
90        ///
91        /// The valid range is 1 to 6.
92        level: usize,
93    },
94
95    /// Image block
96    #[non_exhaustive]
97    Image {
98        /// Image file path
99        url: PathBuf,
100
101        /// Alternative text for the image
102        alt: Option<String>,
103
104        /// Caption for the image
105        caption: Option<String>,
106
107        footnotes: Vec<Footnote>,
108    },
109
110    /// Audio block
111    #[non_exhaustive]
112    Audio {
113        /// Audio file path
114        url: PathBuf,
115
116        /// Fallback text for the audio
117        ///
118        /// This is used when the audio file cannot be played.
119        fallback: String,
120
121        /// Caption for the audio
122        caption: Option<String>,
123
124        footnotes: Vec<Footnote>,
125    },
126
127    /// Video block
128    #[non_exhaustive]
129    Video {
130        /// Video file path
131        url: PathBuf,
132
133        /// Fallback text for the video
134        ///
135        /// This is used when the video file cannot be played.
136        fallback: String,
137
138        /// Caption for the video
139        caption: Option<String>,
140
141        footnotes: Vec<Footnote>,
142    },
143
144    /// MathML block
145    #[non_exhaustive]
146    MathML {
147        /// MathML element raw data
148        ///
149        /// This field stores the raw data of the MathML markup, which we do not verify,
150        /// and the user needs to make sure it is correct.
151        element_str: String,
152
153        /// Fallback image for the MathML block
154        ///
155        /// This field stores the path to the fallback image, which will be displayed
156        /// when the MathML markup cannot be rendered.
157        fallback_image: Option<PathBuf>,
158
159        /// Caption for the MathML block
160        caption: Option<String>,
161
162        footnotes: Vec<Footnote>,
163    },
164}
165
166impl Block {
167    /// Make the block
168    ///
169    /// Convert block data to xhtml markup.
170    pub(crate) fn make(
171        &mut self,
172        writer: &mut XmlWriter,
173        start_index: usize,
174    ) -> Result<(), EpubError> {
175        match self {
176            Block::Text { content, footnotes } => {
177                writer.write_event(Event::Start(
178                    BytesStart::new("p").with_attributes([("class", "content-block")]),
179                ))?;
180
181                Self::make_text(writer, content, footnotes, start_index)?;
182
183                writer.write_event(Event::End(BytesEnd::new("p")))?;
184            }
185
186            Block::Quote { content, footnotes } => {
187                writer.write_event(Event::Start(BytesStart::new("blockquote").with_attributes(
188                    [
189                        ("class", "content-block"),
190                        ("cite", "SOME ATTR NEED TO BE SET"),
191                    ],
192                )))?;
193                writer.write_event(Event::Start(BytesStart::new("p")))?;
194
195                Self::make_text(writer, content, footnotes, start_index)?;
196
197                writer.write_event(Event::End(BytesEnd::new("p")))?;
198                writer.write_event(Event::End(BytesEnd::new("blockquote")))?;
199            }
200
201            Block::Title { content, footnotes, level } => {
202                let tag_name = format!("h{}", level);
203                writer.write_event(Event::Start(
204                    BytesStart::new(tag_name.as_str())
205                        .with_attributes([("class", "content-block")]),
206                ))?;
207
208                Self::make_text(writer, content, footnotes, start_index)?;
209
210                writer.write_event(Event::End(BytesEnd::new(tag_name)))?;
211            }
212
213            Block::Image { url, alt, caption, footnotes } => {
214                let url = format!("./img/{}", url.file_name().unwrap().to_string_lossy());
215
216                let mut attr = Vec::new();
217                attr.push(("src", url.as_str()));
218                attr.push(("class", "image-block"));
219                if let Some(alt) = alt {
220                    attr.push(("alt", alt.as_str()));
221                }
222
223                writer.write_event(Event::Start(
224                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
225                ))?;
226                writer.write_event(Event::Empty(BytesStart::new("img").with_attributes(attr)))?;
227
228                if let Some(caption) = caption {
229                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
230
231                    Self::make_text(writer, caption, footnotes, start_index)?;
232
233                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
234                }
235
236                writer.write_event(Event::End(BytesEnd::new("figure")))?;
237            }
238
239            Block::Audio { url, fallback, caption, footnotes } => {
240                let url = format!("./audio/{}", url.file_name().unwrap().to_string_lossy());
241
242                let attr = vec![
243                    ("src", url.as_str()),
244                    ("class", "audio-block"),
245                    ("controls", "controls"), // attribute special spelling for xhtml
246                ];
247
248                writer.write_event(Event::Start(
249                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
250                ))?;
251                writer.write_event(Event::Start(BytesStart::new("audio").with_attributes(attr)))?;
252
253                writer.write_event(Event::Start(BytesStart::new("p")))?;
254                writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
255                writer.write_event(Event::End(BytesEnd::new("p")))?;
256
257                writer.write_event(Event::End(BytesEnd::new("audio")))?;
258
259                if let Some(caption) = caption {
260                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
261
262                    Self::make_text(writer, caption, footnotes, start_index)?;
263
264                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
265                }
266
267                writer.write_event(Event::End(BytesEnd::new("figure")))?;
268            }
269
270            Block::Video { url, fallback, caption, footnotes } => {
271                let url = format!("./video/{}", url.file_name().unwrap().to_string_lossy());
272
273                let attr = vec![
274                    ("src", url.as_str()),
275                    ("class", "video-block"),
276                    ("controls", "controls"), // attribute special spelling for xhtml
277                ];
278
279                writer.write_event(Event::Start(
280                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
281                ))?;
282                writer.write_event(Event::Start(BytesStart::new("video").with_attributes(attr)))?;
283
284                writer.write_event(Event::Start(BytesStart::new("p")))?;
285                writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
286                writer.write_event(Event::End(BytesEnd::new("p")))?;
287
288                writer.write_event(Event::End(BytesEnd::new("video")))?;
289
290                if let Some(caption) = caption {
291                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
292
293                    Self::make_text(writer, caption, footnotes, start_index)?;
294
295                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
296                }
297
298                writer.write_event(Event::End(BytesEnd::new("figure")))?;
299            }
300
301            Block::MathML {
302                element_str,
303                fallback_image,
304                caption,
305                footnotes,
306            } => {
307                writer.write_event(Event::Start(
308                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
309                ))?;
310
311                Self::write_mathml_element(writer, element_str)?;
312
313                if let Some(fallback_path) = fallback_image {
314                    let img_url = format!(
315                        "./img/{}",
316                        fallback_path.file_name().unwrap().to_string_lossy()
317                    );
318
319                    writer.write_event(Event::Empty(BytesStart::new("img").with_attributes([
320                        ("src", img_url.as_str()),
321                        ("class", "mathml-fallback"),
322                        ("alt", "Mathematical formula"),
323                    ])))?;
324                }
325
326                if let Some(caption) = caption {
327                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
328
329                    Self::make_text(writer, caption, footnotes, start_index)?;
330
331                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
332                }
333
334                writer.write_event(Event::End(BytesEnd::new("figure")))?;
335            }
336        }
337
338        Ok(())
339    }
340
341    pub fn take_footnotes(&self) -> Vec<Footnote> {
342        match self {
343            Block::Text { footnotes, .. } => footnotes.to_vec(),
344            Block::Quote { footnotes, .. } => footnotes.to_vec(),
345            Block::Title { footnotes, .. } => footnotes.to_vec(),
346            Block::Image { footnotes, .. } => footnotes.to_vec(),
347            Block::Audio { footnotes, .. } => footnotes.to_vec(),
348            Block::Video { footnotes, .. } => footnotes.to_vec(),
349            Block::MathML { footnotes, .. } => footnotes.to_vec(),
350        }
351    }
352
353    /// Split content by footnote locate
354    ///
355    /// ## Parameters
356    /// - `content`: The content to split
357    /// - `index_list`: The locations of footnotes
358    fn split_content_by_index(content: &str, index_list: &[usize]) -> Vec<String> {
359        if index_list.is_empty() {
360            return vec![content.to_string()];
361        }
362
363        // index_list.len() footnote splits content into (index_list.len() + 1) parts.
364        let mut result = Vec::with_capacity(index_list.len() + 1);
365        let mut char_iter = content.chars().enumerate();
366
367        let mut current_char_idx = 0;
368        for &target_idx in index_list {
369            let mut segment = String::new();
370
371            // The starting range is the last location or 0,
372            // and the ending range is the current location.
373            while current_char_idx < target_idx {
374                if let Some((_, ch)) = char_iter.next() {
375                    segment.push(ch);
376                    current_char_idx += 1;
377                } else {
378                    break;
379                }
380            }
381
382            if !segment.is_empty() {
383                result.push(segment);
384            }
385        }
386
387        let remainder = char_iter.map(|(_, ch)| ch).collect::<String>();
388        if !remainder.is_empty() {
389            result.push(remainder);
390        }
391
392        result
393    }
394
395    /// Make text
396    ///
397    /// This function is used to format text content and footnote markup.
398    ///
399    /// ## Parameters
400    /// - `writer`: The writer to write XML events
401    /// - `content`: The text content to format
402    /// - `footnotes`: The footnotes to format
403    /// - `start_index`: The starting value of footnote number
404    fn make_text(
405        writer: &mut XmlWriter,
406        content: &str,
407        footnotes: &mut [Footnote],
408        start_index: usize,
409    ) -> Result<(), EpubError> {
410        if footnotes.is_empty() {
411            writer.write_event(Event::Text(BytesText::new(content)))?;
412            return Ok(());
413        }
414
415        footnotes.sort_unstable();
416
417        // statistical footnote locate and quantity
418        let mut position_to_count = HashMap::new();
419        for footnote in footnotes.iter() {
420            *position_to_count.entry(footnote.locate).or_insert(0usize) += 1;
421        }
422
423        let mut positions = position_to_count.keys().copied().collect::<Vec<usize>>();
424        positions.sort_unstable();
425
426        let mut current_index = start_index;
427        let content_list = Self::split_content_by_index(content, &positions);
428        for (index, segment) in content_list.iter().enumerate() {
429            writer.write_event(Event::Text(BytesText::new(segment)))?;
430
431            // get the locate of the index-th footnote
432            if let Some(&position) = positions.get(index) {
433                // get the quantity of the index-th footnote
434                if let Some(&count) = position_to_count.get(&position) {
435                    for _ in 0..count {
436                        Self::make_footnotes(writer, current_index)?;
437                        current_index += 1;
438                    }
439                }
440            }
441        }
442
443        Ok(())
444    }
445
446    /// Make footnote markup
447    #[inline]
448    fn make_footnotes(writer: &mut XmlWriter, index: usize) -> Result<(), EpubError> {
449        writer.write_event(Event::Start(BytesStart::new("a").with_attributes([
450            ("href", format!("#footnote-{}", index).as_str()),
451            ("id", format!("ref-{}", index).as_str()),
452            ("class", "footnote-ref"),
453        ])))?;
454        writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index))))?;
455        writer.write_event(Event::End(BytesEnd::new("a")))?;
456
457        Ok(())
458    }
459
460    /// Write MathML element
461    ///
462    /// This function will parse the MathML element string and write it to the writer.
463    fn write_mathml_element(writer: &mut XmlWriter, element_str: &str) -> Result<(), EpubError> {
464        let mut reader = Reader::from_str(element_str);
465
466        loop {
467            match reader.read_event() {
468                Ok(Event::Eof) => break,
469
470                Ok(event) => writer.write_event(event)?,
471
472                Err(err) => {
473                    return Err(
474                        EpubBuilderError::InvalidMathMLFormat { error: err.to_string() }.into(),
475                    );
476                }
477            }
478        }
479
480        Ok(())
481    }
482}
483
484/// Block Builder
485///
486/// A builder for constructing content blocks of various types.
487///
488/// ## Example
489/// ```rust
490/// # #[cfg(feature = "builder")]
491/// # fn main() -> Result<(), lib_epub::error::EpubError> {
492/// use lib_epub::{builder::content::BlockBuilder, types::{BlockType, Footnote}};
493///
494/// let mut builder = BlockBuilder::new(BlockType::Text);
495/// builder.set_content("Hello, world!").add_footnote(Footnote {
496///     content: "This is a footnote.".to_string(),
497///     locate: 13,               
498/// });
499///
500/// builder.build()?;
501/// # Ok(())
502/// # }
503/// ```
504///
505/// ## Notes
506/// - Not all fields are required for all block types. Required fields vary by block type.
507/// - The `build()` method will validate that required fields are set for the specified block type.
508pub struct BlockBuilder {
509    /// The type of block to construct
510    block_type: BlockType,
511
512    /// Content text for Text, Quote, and Title blocks
513    content: Option<String>,
514
515    /// Heading level (1-6) for Title blocks
516    level: Option<usize>,
517
518    /// File path to media for Image, Audio, and Video blocks
519    url: Option<PathBuf>,
520
521    /// Alternative text for Image blocks
522    alt: Option<String>,
523
524    /// Caption text for Image, Audio, Video, and MathML blocks
525    caption: Option<String>,
526
527    /// Fallback text for Audio and Video blocks (displayed when media cannot be played)
528    fallback: Option<String>,
529
530    /// Raw MathML markup string for MathML blocks
531    element_str: Option<String>,
532
533    /// Fallback image path for MathML blocks (displayed when MathML cannot be rendered)
534    fallback_image: Option<PathBuf>,
535
536    /// Footnotes associated with the block content
537    footnotes: Vec<Footnote>,
538}
539
540impl BlockBuilder {
541    /// Creates a new BlockBuilder instance
542    ///
543    /// Initializes a BlockBuilder with the specified block type.
544    ///
545    /// ## Parameters
546    /// - `block_type`: The type of block to construct
547    pub fn new(block_type: BlockType) -> Self {
548        Self {
549            block_type,
550            content: None,
551            level: None,
552            url: None,
553            alt: None,
554            caption: None,
555            fallback: None,
556            element_str: None,
557            fallback_image: None,
558            footnotes: vec![],
559        }
560    }
561
562    /// Sets the text content of the block
563    ///
564    /// Used for Text, Quote, and Title block types.
565    ///
566    /// ## Parameters
567    /// - `content`: The text content to set
568    pub fn set_content(&mut self, content: &str) -> &mut Self {
569        self.content = Some(content.to_string());
570        self
571    }
572
573    /// Sets the heading level for a Title block
574    ///
575    /// Only applicable to Title block types. Valid range is 1 to 6.
576    /// If the level is outside the valid range, this method silently ignores the setting
577    /// and returns self unchanged.
578    ///
579    /// ## Parameters
580    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
581    pub fn set_title_level(&mut self, level: usize) -> &mut Self {
582        if !(1..=6).contains(&level) {
583            return self;
584        }
585
586        self.level = Some(level);
587        self
588    }
589
590    /// Sets the media file path
591    ///
592    /// Used for Image, Audio, and Video block types. This method validates that
593    /// the file is a recognized image, audio, or video type.
594    ///
595    /// ## Parameters
596    /// - `url`: The path to the media file
597    ///
598    /// ## Return
599    /// - `Ok(&mut self)`: If the file type is valid
600    /// - `Err(EpubError)`: The file does not exist or the file format is not image, audio, or video
601    pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
602        match Self::is_target_type(
603            url,
604            vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
605        ) {
606            Ok(_) => {
607                self.url = Some(url.to_path_buf());
608                Ok(self)
609            }
610            Err(err) => Err(err),
611        }
612    }
613
614    /// Sets the alternative text for an image
615    ///
616    /// Only applicable to Image block types.
617    /// Alternative text is displayed when the image cannot be loaded.
618    ///
619    /// ## Parameters
620    /// - `alt`: The alternative text for the image
621    pub fn set_alt(&mut self, alt: &str) -> &mut Self {
622        self.alt = Some(alt.to_string());
623        self
624    }
625
626    /// Sets the caption for the block
627    ///
628    /// Used for Image, Audio, Video, and MathML block types.
629    /// The caption is displayed below the media or element.
630    ///
631    /// ## Parameters
632    /// - `caption`: The caption text to display
633    pub fn set_caption(&mut self, caption: &str) -> &mut Self {
634        self.caption = Some(caption.to_string());
635        self
636    }
637
638    /// Sets the fallback text for audio or video content
639    ///
640    /// Used for Audio and Video block types.
641    /// The fallback text is displayed when the media file cannot be played.
642    ///
643    /// ## Parameters
644    /// - `fallback`: The fallback text content
645    pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
646        self.fallback = Some(fallback.to_string());
647        self
648    }
649
650    /// Sets the raw MathML element string
651    ///
652    /// Only applicable to MathML block types.
653    /// This method accepts the raw MathML markup data without validation.
654    /// The user is responsible for ensuring the MathML is well-formed.
655    ///
656    /// ## Parameters
657    /// - `element_str`: The raw MathML markup string
658    pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
659        self.element_str = Some(element_str.to_string());
660        self
661    }
662
663    /// Sets the fallback image for MathML content
664    ///
665    /// Only applicable to MathML block types.
666    /// The fallback image is displayed when the MathML markup cannot be rendered.
667    /// This method validates that the file is a recognized image type.
668    ///
669    /// ## Parameters
670    /// - `fallback_image`: The path to the fallback image file
671    ///
672    /// ## Return
673    /// - `Ok(self)`: If the file type is valid
674    /// - `Err(EpubError)`: If validation fails
675    pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
676        match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
677            Ok(_) => {
678                self.fallback_image = Some(fallback_image);
679                Ok(self)
680            }
681            Err(err) => Err(err),
682        }
683    }
684
685    /// Adds a footnote to the block
686    ///
687    /// Adds a single footnote to the block's footnotes collection.
688    /// The footnote must reference a valid position within the content.
689    ///
690    /// ## Parameters
691    /// - `footnote`: The footnote to add
692    pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
693        self.footnotes.push(footnote);
694        self
695    }
696
697    /// Sets all footnotes for the block
698    ///
699    /// Replaces the current footnotes collection with the provided one.
700    /// All footnotes must reference valid positions within the content.
701    ///
702    /// ## Parameters
703    /// - `footnotes`: The vector of footnotes to set
704    pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
705        self.footnotes = footnotes;
706        self
707    }
708
709    /// Removes the last footnote
710    ///
711    /// Removes and discards the last footnote from the footnotes collection.
712    /// If the collection is empty, this method has no effect.
713    pub fn remove_last_footnote(&mut self) -> &mut Self {
714        self.footnotes.pop();
715        self
716    }
717
718    /// Clears all footnotes
719    ///
720    /// Removes all footnotes from the block's footnotes collection.
721    pub fn clear_footnotes(&mut self) -> &mut Self {
722        self.footnotes.clear();
723        self
724    }
725
726    /// Builds the block
727    ///
728    /// Constructs a Block instance based on the configured parameters and block type.
729    /// This method validates that all required fields are set for the specified block type
730    /// and validates the footnotes to ensure they reference valid content positions.
731    ///
732    /// ## Return
733    /// - `Ok(Block)`: Build successful
734    /// - `Err(EpubError)`: Error occurred during the build process
735    pub fn build(self) -> Result<Block, EpubError> {
736        let block = match self.block_type {
737            BlockType::Text => {
738                if let Some(content) = self.content {
739                    Block::Text { content, footnotes: self.footnotes }
740                } else {
741                    return Err(EpubBuilderError::MissingNecessaryBlockData {
742                        block_type: "Text".to_string(),
743                        missing_data: "'content'".to_string(),
744                    }
745                    .into());
746                }
747            }
748
749            BlockType::Quote => {
750                if let Some(content) = self.content {
751                    Block::Quote { content, footnotes: self.footnotes }
752                } else {
753                    return Err(EpubBuilderError::MissingNecessaryBlockData {
754                        block_type: "Quote".to_string(),
755                        missing_data: "'content'".to_string(),
756                    }
757                    .into());
758                }
759            }
760
761            BlockType::Title => match (self.content, self.level) {
762                (Some(content), Some(level)) => Block::Title {
763                    content,
764                    level,
765                    footnotes: self.footnotes,
766                },
767                _ => {
768                    return Err(EpubBuilderError::MissingNecessaryBlockData {
769                        block_type: "Title".to_string(),
770                        missing_data: "'content' or 'level'".to_string(),
771                    }
772                    .into());
773                }
774            },
775
776            BlockType::Image => {
777                if let Some(url) = self.url {
778                    Block::Image {
779                        url,
780                        alt: self.alt,
781                        caption: self.caption,
782                        footnotes: self.footnotes,
783                    }
784                } else {
785                    return Err(EpubBuilderError::MissingNecessaryBlockData {
786                        block_type: "Image".to_string(),
787                        missing_data: "'url'".to_string(),
788                    }
789                    .into());
790                }
791            }
792
793            BlockType::Audio => match (self.url, self.fallback) {
794                (Some(url), Some(fallback)) => Block::Audio {
795                    url,
796                    fallback,
797                    caption: self.caption,
798                    footnotes: self.footnotes,
799                },
800                _ => {
801                    return Err(EpubBuilderError::MissingNecessaryBlockData {
802                        block_type: "Audio".to_string(),
803                        missing_data: "'url' or 'fallback'".to_string(),
804                    }
805                    .into());
806                }
807            },
808
809            BlockType::Video => match (self.url, self.fallback) {
810                (Some(url), Some(fallback)) => Block::Video {
811                    url,
812                    fallback,
813                    caption: self.caption,
814                    footnotes: self.footnotes,
815                },
816                _ => {
817                    return Err(EpubBuilderError::MissingNecessaryBlockData {
818                        block_type: "Video".to_string(),
819                        missing_data: "'url' or 'fallback'".to_string(),
820                    }
821                    .into());
822                }
823            },
824
825            BlockType::MathML => {
826                if let Some(element_str) = self.element_str {
827                    Block::MathML {
828                        element_str,
829                        fallback_image: self.fallback_image,
830                        caption: self.caption,
831                        footnotes: self.footnotes,
832                    }
833                } else {
834                    return Err(EpubBuilderError::MissingNecessaryBlockData {
835                        block_type: "MathML".to_string(),
836                        missing_data: "'element_str'".to_string(),
837                    }
838                    .into());
839                }
840            }
841        };
842
843        Self::validate_footnotes(&block)?;
844        Ok(block)
845    }
846
847    /// Validates that the file type matches expected types
848    ///
849    /// Identifies the file type by reading the file header and validates whether
850    /// it belongs to one of the expected types. Uses file magic numbers for
851    /// reliable type detection.
852    ///
853    /// ## Parameters
854    /// - `path`: The path to the file to check
855    /// - `types`: The vector of expected file types
856    fn is_target_type(path: &PathBuf, types: Vec<MatcherType>) -> Result<(), EpubError> {
857        if !path.is_file() {
858            return Err(EpubBuilderError::TargetIsNotFile {
859                target_path: path.to_string_lossy().to_string(),
860            }
861            .into());
862        }
863
864        let mut file = File::open(path)?;
865        let mut buf = [0; 512];
866        let read_size = file.read(&mut buf)?;
867        let header_bytes = &buf[..read_size];
868
869        match Infer::new().get(header_bytes) {
870            Some(file_type) if !types.contains(&file_type.matcher_type()) => {
871                Err(EpubBuilderError::NotExpectedFileFormat.into())
872            }
873
874            None => Err(EpubBuilderError::UnknownFileFormat {
875                file_path: path.to_string_lossy().to_string(),
876            }
877            .into()),
878
879            _ => Ok(()),
880        }
881    }
882
883    /// Validates the footnotes in a block
884    ///
885    /// Ensures all footnotes reference valid positions within the content.
886    /// For Text, Quote, and Title blocks, footnotes must be within the character count of the content.
887    /// For Image, Audio, Video, and MathML blocks, footnotes must be within the character count
888    /// of the caption (if a caption is set). Blocks with media but no caption cannot have footnotes.
889    fn validate_footnotes(block: &Block) -> Result<(), EpubError> {
890        match block {
891            Block::Text { content, footnotes }
892            | Block::Quote { content, footnotes }
893            | Block::Title { content, footnotes, .. } => {
894                let max_locate = content.chars().count();
895                for footnote in footnotes.iter() {
896                    if footnote.locate == 0 || footnote.locate > content.chars().count() {
897                        return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
898                    }
899                }
900
901                Ok(())
902            }
903
904            Block::Image { caption, footnotes, .. }
905            | Block::MathML { caption, footnotes, .. }
906            | Block::Video { caption, footnotes, .. }
907            | Block::Audio { caption, footnotes, .. } => {
908                if let Some(caption) = caption {
909                    let max_locate = caption.chars().count();
910                    for footnote in footnotes.iter() {
911                        if footnote.locate == 0 || footnote.locate > caption.chars().count() {
912                            return Err(
913                                EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
914                            );
915                        }
916                    }
917                } else if !footnotes.is_empty() {
918                    return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
919                }
920
921                Ok(())
922            }
923        }
924    }
925}
926
927/// Content Builder
928///
929/// A builder for constructing EPUB content documents with various block types.
930/// This builder manages the creation and organization of content blocks including
931/// text, quotes, headings, images, audio, video, and MathML content.
932#[derive(Debug)]
933pub struct ContentBuilder {
934    /// The unique identifier for the content document
935    ///
936    /// This identifier is used to uniquely identify the content document within the EPUB container.
937    /// If the identifier is not unique, only one content document will be included in the EPUB container;
938    /// and the other content document will be ignored.  
939    pub id: String,
940
941    blocks: Vec<Block>,
942    language: String,
943    title: String,
944
945    pub(crate) temp_dir: PathBuf,
946}
947
948impl ContentBuilder {
949    /// Creates a new ContentBuilder instance
950    ///
951    /// Initializes a ContentBuilder with the specified language code.
952    /// A temporary directory is automatically created to store media files during construction.
953    ///
954    /// ## Parameters
955    /// - `language`: The language code for the document
956    pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
957        let temp_dir = env::temp_dir().join(local_time());
958        fs::create_dir(&temp_dir)?;
959
960        Ok(Self {
961            id: id.to_string(),
962            blocks: vec![],
963            language: language.to_string(),
964            title: String::new(),
965            temp_dir,
966        })
967    }
968
969    /// Sets the title of the document
970    ///
971    /// Sets the title that will be displayed in the document's head section.
972    ///
973    /// ## Parameters
974    /// - `title`: The title text for the document
975    pub fn set_title(&mut self, title: &str) -> &mut Self {
976        self.title = title.to_string();
977        self
978    }
979
980    /// Adds a block to the document
981    ///
982    /// Adds a constructed Block to the document.
983    ///
984    /// ## Parameters
985    /// - `block`: The Block to add to the document
986    pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
987        self.blocks.push(block);
988
989        match self.blocks.last() {
990            Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
991                self.handle_resource()?
992            }
993
994            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
995                self.handle_resource()?;
996            }
997
998            _ => {}
999        }
1000
1001        Ok(self)
1002    }
1003
1004    /// Adds a text block to the document
1005    ///
1006    /// Convenience method that creates and adds a Text block using the provided content and footnotes.
1007    ///
1008    /// ## Parameters
1009    /// - `content`: The text content of the paragraph
1010    /// - `footnotes`: A vector of footnotes associated with the text
1011    pub fn add_text_block(
1012        &mut self,
1013        content: &str,
1014        footnotes: Vec<Footnote>,
1015    ) -> Result<&mut Self, EpubError> {
1016        let mut builder = BlockBuilder::new(BlockType::Text);
1017        builder.set_content(content).set_footnotes(footnotes);
1018
1019        self.blocks.push(builder.build()?);
1020        Ok(self)
1021    }
1022
1023    /// Adds a quote block to the document
1024    ///
1025    /// Convenience method that creates and adds a Quote block using the provided content and footnotes.
1026    ///
1027    /// ## Parameters
1028    /// - `content`: The quoted text
1029    /// - `footnotes`: A vector of footnotes associated with the quote
1030    pub fn add_quote_block(
1031        &mut self,
1032        content: &str,
1033        footnotes: Vec<Footnote>,
1034    ) -> Result<&mut Self, EpubError> {
1035        let mut builder = BlockBuilder::new(BlockType::Quote);
1036        builder.set_content(content).set_footnotes(footnotes);
1037
1038        self.blocks.push(builder.build()?);
1039        Ok(self)
1040    }
1041
1042    /// Adds a heading block to the document
1043    ///
1044    /// Convenience method that creates and adds a Title block with the specified level.
1045    ///
1046    /// ## Parameters
1047    /// - `content`: The heading text
1048    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
1049    /// - `footnotes`: A vector of footnotes associated with the heading
1050    pub fn add_title_block(
1051        &mut self,
1052        content: &str,
1053        level: usize,
1054        footnotes: Vec<Footnote>,
1055    ) -> Result<&mut Self, EpubError> {
1056        let mut builder = BlockBuilder::new(BlockType::Title);
1057        builder
1058            .set_content(content)
1059            .set_title_level(level)
1060            .set_footnotes(footnotes);
1061
1062        self.blocks.push(builder.build()?);
1063        Ok(self)
1064    }
1065
1066    /// Adds an image block to the document
1067    ///
1068    /// Convenience method that creates and adds an Image block with optional alt text,
1069    /// caption, and footnotes.
1070    ///
1071    /// ## Parameters
1072    /// - `url`: The path to the image file
1073    /// - `alt`: Optional alternative text for the image (displayed when image cannot load)
1074    /// - `caption`: Optional caption text to display below the image
1075    /// - `footnotes`: A vector of footnotes associated with the caption or image
1076    pub fn add_image_block(
1077        &mut self,
1078        url: PathBuf,
1079        alt: Option<String>,
1080        caption: Option<String>,
1081        footnotes: Vec<Footnote>,
1082    ) -> Result<&mut Self, EpubError> {
1083        let mut builder = BlockBuilder::new(BlockType::Image);
1084        builder.set_url(&url)?.set_footnotes(footnotes);
1085
1086        if let Some(alt) = &alt {
1087            builder.set_alt(alt);
1088        }
1089
1090        if let Some(caption) = &caption {
1091            builder.set_caption(caption);
1092        }
1093
1094        self.blocks.push(builder.build()?);
1095        self.handle_resource()?;
1096        Ok(self)
1097    }
1098
1099    /// Adds an audio block to the document
1100    ///
1101    /// Convenience method that creates and adds an Audio block with fallback text,
1102    /// optional caption, and footnotes.
1103    ///
1104    /// ## Parameters
1105    /// - `url`: The path to the audio file
1106    /// - `fallback`: Fallback text displayed when the audio cannot be played
1107    /// - `caption`: Optional caption text to display below the audio player
1108    /// - `footnotes`: A vector of footnotes associated with the caption or audio
1109    pub fn add_audio_block(
1110        &mut self,
1111        url: PathBuf,
1112        fallback: String,
1113        caption: Option<String>,
1114        footnotes: Vec<Footnote>,
1115    ) -> Result<&mut Self, EpubError> {
1116        let mut builder = BlockBuilder::new(BlockType::Audio);
1117        builder
1118            .set_url(&url)?
1119            .set_fallback(&fallback)
1120            .set_footnotes(footnotes);
1121
1122        if let Some(caption) = &caption {
1123            builder.set_caption(caption);
1124        }
1125
1126        self.blocks.push(builder.build()?);
1127        self.handle_resource()?;
1128        Ok(self)
1129    }
1130
1131    /// Adds a video block to the document
1132    ///
1133    /// Convenience method that creates and adds a Video block with fallback text,
1134    /// optional caption, and footnotes.
1135    ///
1136    /// ## Parameters
1137    /// - `url`: The path to the video file
1138    /// - `fallback`: Fallback text displayed when the video cannot be played
1139    /// - `caption`: Optional caption text to display below the video player
1140    /// - `footnotes`: A vector of footnotes associated with the caption or video
1141    pub fn add_video_block(
1142        &mut self,
1143        url: PathBuf,
1144        fallback: String,
1145        caption: Option<String>,
1146        footnotes: Vec<Footnote>,
1147    ) -> Result<&mut Self, EpubError> {
1148        let mut builder = BlockBuilder::new(BlockType::Video);
1149        builder
1150            .set_url(&url)?
1151            .set_fallback(&fallback)
1152            .set_footnotes(footnotes);
1153
1154        if let Some(caption) = &caption {
1155            builder.set_caption(caption);
1156        }
1157
1158        self.blocks.push(builder.build()?);
1159        self.handle_resource()?;
1160        Ok(self)
1161    }
1162
1163    /// Adds a MathML block to the document
1164    ///
1165    /// Convenience method that creates and adds a MathML block with optional fallback image,
1166    /// caption, and footnotes.
1167    ///
1168    /// ## Parameters
1169    /// - `element_str`: The raw MathML markup string
1170    /// - `fallback_image`: Optional path to a fallback image displayed when MathML cannot render
1171    /// - `caption`: Optional caption text to display below the MathML element
1172    /// - `footnotes`: A vector of footnotes associated with the caption or equation
1173    pub fn add_mathml_block(
1174        &mut self,
1175        element_str: String,
1176        fallback_image: Option<PathBuf>,
1177        caption: Option<String>,
1178        footnotes: Vec<Footnote>,
1179    ) -> Result<&mut Self, EpubError> {
1180        let mut builder = BlockBuilder::new(BlockType::MathML);
1181        builder
1182            .set_mathml_element(&element_str)
1183            .set_footnotes(footnotes);
1184
1185        if let Some(fallback_image) = fallback_image {
1186            builder.set_fallback_image(fallback_image)?;
1187        }
1188
1189        if let Some(caption) = &caption {
1190            builder.set_caption(caption);
1191        }
1192
1193        self.blocks.push(builder.build()?);
1194        self.handle_resource()?;
1195        Ok(self)
1196    }
1197
1198    /// Removes the last block from the document
1199    ///
1200    /// Discards the most recently added block. If no blocks exist, this method has no effect.
1201    pub fn remove_last_block(&mut self) -> &mut Self {
1202        self.blocks.pop();
1203        self
1204    }
1205
1206    /// Takes ownership of the last block
1207    ///
1208    /// Removes and returns the most recently added block without consuming the builder.
1209    /// This allows you to extract a block while keeping the builder alive.
1210    ///
1211    /// ## Return
1212    /// - `Some(Block)`: If a block exists
1213    /// - `None`: If the blocks collection is empty
1214    pub fn take_last_block(&mut self) -> Option<Block> {
1215        self.blocks.pop()
1216    }
1217
1218    /// Clears all blocks from the document
1219    ///
1220    /// Removes all blocks from the document while keeping the language and title settings intact.
1221    pub fn clear_blocks(&mut self) -> &mut Self {
1222        self.blocks.clear();
1223        self
1224    }
1225
1226    /// Builds content document
1227    ///
1228    /// ## Parameters
1229    /// - `target`: The file path where the document should be written
1230    ///
1231    /// ## Return
1232    /// - `Ok(Vec<PathBuf>)`: A vector of paths to all resources used in the document
1233    /// - `Err(EpubError)`: Error occurred during the making process
1234    pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1235        let mut result = Vec::new();
1236
1237        // Handle target directory, create if it doesn't exist
1238        let target_dir = match target.as_ref().parent() {
1239            Some(path) => {
1240                fs::create_dir_all(path)?;
1241                path.to_path_buf()
1242            }
1243            None => {
1244                return Err(EpubBuilderError::InvalidTargetPath {
1245                    target_path: target.as_ref().to_string_lossy().to_string(),
1246                }
1247                .into());
1248            }
1249        };
1250
1251        self.make_content(&target)?;
1252        result.push(target.as_ref().to_path_buf());
1253
1254        // Copy all resource files (images, audio, video) from temp directory to target directory
1255        for resource_type in ["img", "audio", "video"] {
1256            let source = self.temp_dir.join(resource_type);
1257            if source.exists() && source.is_dir() {
1258                let target = target_dir.join(resource_type);
1259                fs::create_dir_all(&target)?;
1260
1261                for entry in fs::read_dir(&source)? {
1262                    let entry = entry?;
1263                    if entry.file_type()?.is_file() {
1264                        let file_name = entry.file_name();
1265                        let target = target.join(&file_name);
1266
1267                        fs::copy(source.join(&file_name), &target)?;
1268                        result.push(target);
1269                    }
1270                }
1271            }
1272        }
1273
1274        Ok(result)
1275    }
1276
1277    /// Write the document to a file
1278    ///
1279    /// Constructs the final XHTML document from all added blocks and writes it to the specified output path.
1280    ///
1281    /// ## Parameters
1282    /// - `target_path`: The file path where the XHTML document should be written
1283    fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1284        let mut writer = Writer::new(Cursor::new(Vec::new()));
1285
1286        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1287        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1288            ("xmlns", "http://www.w3.org/1999/xhtml"),
1289            ("xml:lang", self.language.as_str()),
1290        ])))?;
1291
1292        // make head
1293        writer.write_event(Event::Start(BytesStart::new("head")))?;
1294        writer.write_event(Event::Start(BytesStart::new("title")))?;
1295        writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1296        writer.write_event(Event::End(BytesEnd::new("title")))?;
1297        writer.write_event(Event::End(BytesEnd::new("head")))?;
1298
1299        // make body
1300        writer.write_event(Event::Start(BytesStart::new("body")))?;
1301
1302        let mut footnote_index = 1;
1303        let mut footnotes = Vec::new();
1304        for block in self.blocks.iter_mut() {
1305            block.make(&mut writer, footnote_index)?;
1306
1307            footnotes.append(&mut block.take_footnotes());
1308            footnote_index = footnotes.len() + 1;
1309        }
1310
1311        Self::make_footnotes(&mut writer, footnotes)?;
1312        writer.write_event(Event::End(BytesEnd::new("body")))?;
1313        writer.write_event(Event::End(BytesEnd::new("html")))?;
1314
1315        let file_path = PathBuf::from(target_path.as_ref());
1316        let file_data = writer.into_inner().into_inner();
1317        fs::write(file_path, file_data)?;
1318
1319        Ok(())
1320    }
1321
1322    /// Generates the footnotes section in the document
1323    ///
1324    /// Creates an aside element containing an unordered list of all footnotes.
1325    /// Each footnote is rendered as a list item with a backlink to its reference in the text.
1326    fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1327        writer.write_event(Event::Start(BytesStart::new("aside")))?;
1328        writer.write_event(Event::Start(BytesStart::new("ul")))?;
1329
1330        let mut index = 1;
1331        for footnote in footnotes.into_iter() {
1332            writer.write_event(Event::Start(
1333                BytesStart::new("li")
1334                    .with_attributes([("id", format!("footnote-{}", index).as_str())]),
1335            ))?;
1336            writer.write_event(Event::Start(BytesStart::new("p")))?;
1337
1338            writer.write_event(Event::Start(
1339                BytesStart::new("a")
1340                    .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1341            ))?;
1342            writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1343            writer.write_event(Event::End(BytesEnd::new("a")))?;
1344            writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1345
1346            writer.write_event(Event::End(BytesEnd::new("p")))?;
1347            writer.write_event(Event::End(BytesEnd::new("li")))?;
1348
1349            index += 1;
1350        }
1351
1352        writer.write_event(Event::End(BytesEnd::new("ul")))?;
1353        writer.write_event(Event::End(BytesEnd::new("aside")))?;
1354
1355        Ok(())
1356    }
1357
1358    /// Automatically handles media resources
1359    fn handle_resource(&mut self) -> Result<(), EpubError> {
1360        match self.blocks.last() {
1361            Some(Block::Image { url, .. }) => {
1362                let target_dir = self.temp_dir.join("img");
1363                fs::create_dir_all(&target_dir)?;
1364
1365                let target_path = target_dir.join(url.file_name().unwrap());
1366                fs::copy(url, &target_path)?;
1367            }
1368
1369            Some(Block::Video { url, .. }) => {
1370                let target_dir = self.temp_dir.join("video");
1371                fs::create_dir_all(&target_dir)?;
1372
1373                let target_path = target_dir.join(url.file_name().unwrap());
1374                fs::copy(url, &target_path)?;
1375            }
1376
1377            Some(Block::Audio { url, .. }) => {
1378                let target_dir = self.temp_dir.join("audio");
1379                fs::create_dir_all(&target_dir)?;
1380
1381                let target_path = target_dir.join(url.file_name().unwrap());
1382                fs::copy(url, &target_path)?;
1383            }
1384
1385            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1386                let target_dir = self.temp_dir.join("img");
1387                fs::create_dir_all(&target_dir)?;
1388
1389                let target_path =
1390                    target_dir.join(fallback_image.as_ref().unwrap().file_name().unwrap());
1391
1392                fs::copy(fallback_image.as_ref().unwrap(), &target_path)?;
1393            }
1394
1395            Some(_) => {}
1396            None => {}
1397        }
1398
1399        Ok(())
1400    }
1401}
1402
1403impl Drop for ContentBuilder {
1404    fn drop(&mut self) {
1405        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1406            warn!("{}", err);
1407        };
1408    }
1409}