Skip to main content

lib_epub/builder/
content.rs

1//! Epub content build functionality
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//!
35//! - Support more types of content `Block`
36//!
37//! ## Notes
38//!
39//! - Requires `content_builder` functionality to use this module.
40
41use std::{
42    collections::HashMap,
43    env,
44    fs::{self, File},
45    io::{Cursor, Read},
46    path::{Path, PathBuf},
47};
48
49use infer::{Infer, MatcherType};
50use log::warn;
51use quick_xml::{
52    Reader, Writer,
53    events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
54};
55
56use crate::{
57    builder::XmlWriter,
58    error::{EpubBuilderError, EpubError},
59    types::{BlockType, Footnote},
60    utils::local_time,
61};
62
63/// Content Block
64///
65/// The content block is the basic unit of content in a content document.
66/// It can be one of the following types: Text, Quote, Title, Image, Audio, Video, MathML.
67#[non_exhaustive]
68#[derive(Debug)]
69pub enum Block {
70    /// Text paragraph
71    ///
72    /// This block represents a paragraph of text.
73    #[non_exhaustive]
74    Text {
75        content: String,
76        footnotes: Vec<Footnote>,
77    },
78
79    /// Quote paragraph
80    ///
81    /// This block represents a paragraph of quoted text.
82    #[non_exhaustive]
83    Quote {
84        content: String,
85        footnotes: Vec<Footnote>,
86    },
87
88    /// Heading
89    #[non_exhaustive]
90    Title {
91        content: String,
92        footnotes: Vec<Footnote>,
93
94        /// Heading level
95        ///
96        /// The valid range is 1 to 6.
97        level: usize,
98    },
99
100    /// Image block
101    #[non_exhaustive]
102    Image {
103        /// Image file path
104        url: PathBuf,
105
106        /// Alternative text for the image
107        alt: Option<String>,
108
109        /// Caption for the image
110        caption: Option<String>,
111
112        footnotes: Vec<Footnote>,
113    },
114
115    /// Audio block
116    #[non_exhaustive]
117    Audio {
118        /// Audio file path
119        url: PathBuf,
120
121        /// Fallback text for the audio
122        ///
123        /// This is used when the audio file cannot be played.
124        fallback: String,
125
126        /// Caption for the audio
127        caption: Option<String>,
128
129        footnotes: Vec<Footnote>,
130    },
131
132    /// Video block
133    #[non_exhaustive]
134    Video {
135        /// Video file path
136        url: PathBuf,
137
138        /// Fallback text for the video
139        ///
140        /// This is used when the video file cannot be played.
141        fallback: String,
142
143        /// Caption for the video
144        caption: Option<String>,
145
146        footnotes: Vec<Footnote>,
147    },
148
149    /// MathML block
150    #[non_exhaustive]
151    MathML {
152        /// MathML element raw data
153        ///
154        /// This field stores the raw data of the MathML markup, which we do not verify,
155        /// and the user needs to make sure it is correct.
156        element_str: String,
157
158        /// Fallback image for the MathML block
159        ///
160        /// This field stores the path to the fallback image, which will be displayed
161        /// when the MathML markup cannot be rendered.
162        fallback_image: Option<PathBuf>,
163
164        /// Caption for the MathML block
165        caption: Option<String>,
166
167        footnotes: Vec<Footnote>,
168    },
169}
170
171impl Block {
172    /// Make the block
173    ///
174    /// Convert block data to xhtml markup.
175    pub(crate) fn make(
176        &mut self,
177        writer: &mut XmlWriter,
178        start_index: usize,
179    ) -> Result<(), EpubError> {
180        match self {
181            Block::Text { content, footnotes } => {
182                writer.write_event(Event::Start(
183                    BytesStart::new("p").with_attributes([("class", "content-block")]),
184                ))?;
185
186                Self::make_text(writer, content, footnotes, start_index)?;
187
188                writer.write_event(Event::End(BytesEnd::new("p")))?;
189            }
190
191            Block::Quote { content, footnotes } => {
192                writer.write_event(Event::Start(BytesStart::new("blockquote").with_attributes(
193                    [
194                        ("class", "content-block"),
195                        ("cite", "SOME ATTR NEED TO BE SET"),
196                    ],
197                )))?;
198                writer.write_event(Event::Start(BytesStart::new("p")))?;
199
200                Self::make_text(writer, content, footnotes, start_index)?;
201
202                writer.write_event(Event::End(BytesEnd::new("p")))?;
203                writer.write_event(Event::End(BytesEnd::new("blockquote")))?;
204            }
205
206            Block::Title { content, footnotes, level } => {
207                let tag_name = format!("h{}", level);
208                writer.write_event(Event::Start(
209                    BytesStart::new(tag_name.as_str())
210                        .with_attributes([("class", "content-block")]),
211                ))?;
212
213                Self::make_text(writer, content, footnotes, start_index)?;
214
215                writer.write_event(Event::End(BytesEnd::new(tag_name)))?;
216            }
217
218            Block::Image { url, alt, caption, footnotes } => {
219                let url = format!("./img/{}", url.file_name().unwrap().to_string_lossy());
220
221                let mut attr = Vec::new();
222                attr.push(("src", url.as_str()));
223                attr.push(("class", "image-block"));
224                if let Some(alt) = alt {
225                    attr.push(("alt", alt.as_str()));
226                }
227
228                writer.write_event(Event::Start(
229                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
230                ))?;
231                writer.write_event(Event::Empty(BytesStart::new("img").with_attributes(attr)))?;
232
233                if let Some(caption) = caption {
234                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
235
236                    Self::make_text(writer, caption, footnotes, start_index)?;
237
238                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
239                }
240
241                writer.write_event(Event::End(BytesEnd::new("figure")))?;
242            }
243
244            Block::Audio { url, fallback, caption, footnotes } => {
245                let url = format!("./audio/{}", url.file_name().unwrap().to_string_lossy());
246
247                let attr = vec![
248                    ("src", url.as_str()),
249                    ("class", "audio-block"),
250                    ("controls", "controls"), // attribute special spelling for xhtml
251                ];
252
253                writer.write_event(Event::Start(
254                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
255                ))?;
256                writer.write_event(Event::Start(BytesStart::new("audio").with_attributes(attr)))?;
257
258                writer.write_event(Event::Start(BytesStart::new("p")))?;
259                writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
260                writer.write_event(Event::End(BytesEnd::new("p")))?;
261
262                writer.write_event(Event::End(BytesEnd::new("audio")))?;
263
264                if let Some(caption) = caption {
265                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
266
267                    Self::make_text(writer, caption, footnotes, start_index)?;
268
269                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
270                }
271
272                writer.write_event(Event::End(BytesEnd::new("figure")))?;
273            }
274
275            Block::Video { url, fallback, caption, footnotes } => {
276                let url = format!("./video/{}", url.file_name().unwrap().to_string_lossy());
277
278                let attr = vec![
279                    ("src", url.as_str()),
280                    ("class", "video-block"),
281                    ("controls", "controls"), // attribute special spelling for xhtml
282                ];
283
284                writer.write_event(Event::Start(
285                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
286                ))?;
287                writer.write_event(Event::Start(BytesStart::new("video").with_attributes(attr)))?;
288
289                writer.write_event(Event::Start(BytesStart::new("p")))?;
290                writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
291                writer.write_event(Event::End(BytesEnd::new("p")))?;
292
293                writer.write_event(Event::End(BytesEnd::new("video")))?;
294
295                if let Some(caption) = caption {
296                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
297
298                    Self::make_text(writer, caption, footnotes, start_index)?;
299
300                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
301                }
302
303                writer.write_event(Event::End(BytesEnd::new("figure")))?;
304            }
305
306            Block::MathML {
307                element_str,
308                fallback_image,
309                caption,
310                footnotes,
311            } => {
312                writer.write_event(Event::Start(
313                    BytesStart::new("figure").with_attributes([("class", "content-block")]),
314                ))?;
315
316                Self::write_mathml_element(writer, element_str)?;
317
318                if let Some(fallback_path) = fallback_image {
319                    let img_url = format!(
320                        "./img/{}",
321                        fallback_path.file_name().unwrap().to_string_lossy()
322                    );
323
324                    writer.write_event(Event::Empty(BytesStart::new("img").with_attributes([
325                        ("src", img_url.as_str()),
326                        ("class", "mathml-fallback"),
327                        ("alt", "Mathematical formula"),
328                    ])))?;
329                }
330
331                if let Some(caption) = caption {
332                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
333
334                    Self::make_text(writer, caption, footnotes, start_index)?;
335
336                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
337                }
338
339                writer.write_event(Event::End(BytesEnd::new("figure")))?;
340            }
341        }
342
343        Ok(())
344    }
345
346    pub fn take_footnotes(&self) -> Vec<Footnote> {
347        match self {
348            Block::Text { footnotes, .. } => footnotes.to_vec(),
349            Block::Quote { footnotes, .. } => footnotes.to_vec(),
350            Block::Title { footnotes, .. } => footnotes.to_vec(),
351            Block::Image { footnotes, .. } => footnotes.to_vec(),
352            Block::Audio { footnotes, .. } => footnotes.to_vec(),
353            Block::Video { footnotes, .. } => footnotes.to_vec(),
354            Block::MathML { footnotes, .. } => footnotes.to_vec(),
355        }
356    }
357
358    /// Split content by footnote locate
359    ///
360    /// ## Parameters
361    /// - `content`: The content to split
362    /// - `index_list`: The locations of footnotes
363    fn split_content_by_index(content: &str, index_list: &[usize]) -> Vec<String> {
364        if index_list.is_empty() {
365            return vec![content.to_string()];
366        }
367
368        // index_list.len() footnote splits content into (index_list.len() + 1) parts.
369        let mut result = Vec::with_capacity(index_list.len() + 1);
370        let mut char_iter = content.chars().enumerate();
371
372        let mut current_char_idx = 0;
373        for &target_idx in index_list {
374            let mut segment = String::new();
375
376            // The starting range is the last location or 0,
377            // and the ending range is the current location.
378            while current_char_idx < target_idx {
379                if let Some((_, ch)) = char_iter.next() {
380                    segment.push(ch);
381                    current_char_idx += 1;
382                } else {
383                    break;
384                }
385            }
386
387            if !segment.is_empty() {
388                result.push(segment);
389            }
390        }
391
392        let remainder = char_iter.map(|(_, ch)| ch).collect::<String>();
393        if !remainder.is_empty() {
394            result.push(remainder);
395        }
396
397        result
398    }
399
400    /// Make text
401    ///
402    /// This function is used to format text content and footnote markup.
403    ///
404    /// ## Parameters
405    /// - `writer`: The writer to write XML events
406    /// - `content`: The text content to format
407    /// - `footnotes`: The footnotes to format
408    /// - `start_index`: The starting value of footnote number
409    fn make_text(
410        writer: &mut XmlWriter,
411        content: &str,
412        footnotes: &mut [Footnote],
413        start_index: usize,
414    ) -> Result<(), EpubError> {
415        if footnotes.is_empty() {
416            writer.write_event(Event::Text(BytesText::new(content)))?;
417            return Ok(());
418        }
419
420        footnotes.sort_unstable();
421
422        // statistical footnote locate and quantity
423        let mut position_to_count = HashMap::new();
424        for footnote in footnotes.iter() {
425            *position_to_count.entry(footnote.locate).or_insert(0usize) += 1;
426        }
427
428        let mut positions = position_to_count.keys().copied().collect::<Vec<usize>>();
429        positions.sort_unstable();
430
431        let mut current_index = start_index;
432        let content_list = Self::split_content_by_index(content, &positions);
433        for (index, segment) in content_list.iter().enumerate() {
434            writer.write_event(Event::Text(BytesText::new(segment)))?;
435
436            // get the locate of the index-th footnote
437            if let Some(&position) = positions.get(index) {
438                // get the quantity of the index-th footnote
439                if let Some(&count) = position_to_count.get(&position) {
440                    for _ in 0..count {
441                        Self::make_footnotes(writer, current_index)?;
442                        current_index += 1;
443                    }
444                }
445            }
446        }
447
448        Ok(())
449    }
450
451    /// Make footnote markup
452    #[inline]
453    fn make_footnotes(writer: &mut XmlWriter, index: usize) -> Result<(), EpubError> {
454        writer.write_event(Event::Start(BytesStart::new("a").with_attributes([
455            ("href", format!("#footnote-{}", index).as_str()),
456            ("id", format!("ref-{}", index).as_str()),
457            ("class", "footnote-ref"),
458        ])))?;
459        writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index))))?;
460        writer.write_event(Event::End(BytesEnd::new("a")))?;
461
462        Ok(())
463    }
464
465    /// Write MathML element
466    ///
467    /// This function will parse the MathML element string and write it to the writer.
468    fn write_mathml_element(writer: &mut XmlWriter, element_str: &str) -> Result<(), EpubError> {
469        let mut reader = Reader::from_str(element_str);
470
471        loop {
472            match reader.read_event() {
473                Ok(Event::Eof) => break,
474
475                Ok(event) => writer.write_event(event)?,
476
477                Err(err) => {
478                    return Err(
479                        EpubBuilderError::InvalidMathMLFormat { error: err.to_string() }.into(),
480                    );
481                }
482            }
483        }
484
485        Ok(())
486    }
487}
488
489/// Block Builder
490///
491/// A builder for constructing content blocks of various types.
492///
493/// ## Example
494/// ```rust
495/// # #[cfg(feature = "builder")]
496/// # fn main() -> Result<(), lib_epub::error::EpubError> {
497/// use lib_epub::{builder::content::BlockBuilder, types::{BlockType, Footnote}};
498///
499/// let mut builder = BlockBuilder::new(BlockType::Text);
500/// builder.set_content("Hello, world!").add_footnote(Footnote {
501///     content: "This is a footnote.".to_string(),
502///     locate: 13,               
503/// });
504///
505/// builder.build()?;
506/// # Ok(())
507/// # }
508/// ```
509///
510/// ## Notes
511/// - Not all fields are required for all block types. Required fields vary by block type.
512/// - The `build()` method will validate that required fields are set for the specified block type.
513pub struct BlockBuilder {
514    /// The type of block to construct
515    block_type: BlockType,
516
517    /// Content text for Text, Quote, and Title blocks
518    content: Option<String>,
519
520    /// Heading level (1-6) for Title blocks
521    level: Option<usize>,
522
523    /// File path to media for Image, Audio, and Video blocks
524    url: Option<PathBuf>,
525
526    /// Alternative text for Image blocks
527    alt: Option<String>,
528
529    /// Caption text for Image, Audio, Video, and MathML blocks
530    caption: Option<String>,
531
532    /// Fallback text for Audio and Video blocks (displayed when media cannot be played)
533    fallback: Option<String>,
534
535    /// Raw MathML markup string for MathML blocks
536    element_str: Option<String>,
537
538    /// Fallback image path for MathML blocks (displayed when MathML cannot be rendered)
539    fallback_image: Option<PathBuf>,
540
541    /// Footnotes associated with the block content
542    footnotes: Vec<Footnote>,
543}
544
545impl BlockBuilder {
546    /// Creates a new BlockBuilder instance
547    ///
548    /// Initializes a BlockBuilder with the specified block type.
549    ///
550    /// ## Parameters
551    /// - `block_type`: The type of block to construct
552    pub fn new(block_type: BlockType) -> Self {
553        Self {
554            block_type,
555            content: None,
556            level: None,
557            url: None,
558            alt: None,
559            caption: None,
560            fallback: None,
561            element_str: None,
562            fallback_image: None,
563            footnotes: vec![],
564        }
565    }
566
567    /// Sets the text content of the block
568    ///
569    /// Used for Text, Quote, and Title block types.
570    ///
571    /// ## Parameters
572    /// - `content`: The text content to set
573    pub fn set_content(&mut self, content: &str) -> &mut Self {
574        self.content = Some(content.to_string());
575        self
576    }
577
578    /// Sets the heading level for a Title block
579    ///
580    /// Only applicable to Title block types. Valid range is 1 to 6.
581    /// If the level is outside the valid range, this method silently ignores the setting
582    /// and returns self unchanged.
583    ///
584    /// ## Parameters
585    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
586    pub fn set_title_level(&mut self, level: usize) -> &mut Self {
587        if !(1..=6).contains(&level) {
588            return self;
589        }
590
591        self.level = Some(level);
592        self
593    }
594
595    /// Sets the media file path
596    ///
597    /// Used for Image, Audio, and Video block types. This method validates that
598    /// the file is a recognized image, audio, or video type.
599    ///
600    /// ## Parameters
601    /// - `url`: The path to the media file
602    ///
603    /// ## Return
604    /// - `Ok(&mut self)`: If the file type is valid
605    /// - `Err(EpubError)`: The file does not exist or the file format is not image, audio, or video
606    pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
607        match Self::is_target_type(
608            url,
609            vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
610        ) {
611            Ok(_) => {
612                self.url = Some(url.to_path_buf());
613                Ok(self)
614            }
615            Err(err) => Err(err),
616        }
617    }
618
619    /// Sets the alternative text for an image
620    ///
621    /// Only applicable to Image block types.
622    /// Alternative text is displayed when the image cannot be loaded.
623    ///
624    /// ## Parameters
625    /// - `alt`: The alternative text for the image
626    pub fn set_alt(&mut self, alt: &str) -> &mut Self {
627        self.alt = Some(alt.to_string());
628        self
629    }
630
631    /// Sets the caption for the block
632    ///
633    /// Used for Image, Audio, Video, and MathML block types.
634    /// The caption is displayed below the media or element.
635    ///
636    /// ## Parameters
637    /// - `caption`: The caption text to display
638    pub fn set_caption(&mut self, caption: &str) -> &mut Self {
639        self.caption = Some(caption.to_string());
640        self
641    }
642
643    /// Sets the fallback text for audio or video content
644    ///
645    /// Used for Audio and Video block types.
646    /// The fallback text is displayed when the media file cannot be played.
647    ///
648    /// ## Parameters
649    /// - `fallback`: The fallback text content
650    pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
651        self.fallback = Some(fallback.to_string());
652        self
653    }
654
655    /// Sets the raw MathML element string
656    ///
657    /// Only applicable to MathML block types.
658    /// This method accepts the raw MathML markup data without validation.
659    /// The user is responsible for ensuring the MathML is well-formed.
660    ///
661    /// ## Parameters
662    /// - `element_str`: The raw MathML markup string
663    pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
664        self.element_str = Some(element_str.to_string());
665        self
666    }
667
668    /// Sets the fallback image for MathML content
669    ///
670    /// Only applicable to MathML block types.
671    /// The fallback image is displayed when the MathML markup cannot be rendered.
672    /// This method validates that the file is a recognized image type.
673    ///
674    /// ## Parameters
675    /// - `fallback_image`: The path to the fallback image file
676    ///
677    /// ## Return
678    /// - `Ok(self)`: If the file type is valid
679    /// - `Err(EpubError)`: If validation fails
680    pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
681        match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
682            Ok(_) => {
683                self.fallback_image = Some(fallback_image);
684                Ok(self)
685            }
686            Err(err) => Err(err),
687        }
688    }
689
690    /// Adds a footnote to the block
691    ///
692    /// Adds a single footnote to the block's footnotes collection.
693    /// The footnote must reference a valid position within the content.
694    ///
695    /// ## Parameters
696    /// - `footnote`: The footnote to add
697    pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
698        self.footnotes.push(footnote);
699        self
700    }
701
702    /// Sets all footnotes for the block
703    ///
704    /// Replaces the current footnotes collection with the provided one.
705    /// All footnotes must reference valid positions within the content.
706    ///
707    /// ## Parameters
708    /// - `footnotes`: The vector of footnotes to set
709    pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
710        self.footnotes = footnotes;
711        self
712    }
713
714    /// Removes the last footnote
715    ///
716    /// Removes and discards the last footnote from the footnotes collection.
717    /// If the collection is empty, this method has no effect.
718    pub fn remove_last_footnote(&mut self) -> &mut Self {
719        self.footnotes.pop();
720        self
721    }
722
723    /// Clears all footnotes
724    ///
725    /// Removes all footnotes from the block's footnotes collection.
726    pub fn clear_footnotes(&mut self) -> &mut Self {
727        self.footnotes.clear();
728        self
729    }
730
731    /// Builds the block
732    ///
733    /// Constructs a Block instance based on the configured parameters and block type.
734    /// This method validates that all required fields are set for the specified block type
735    /// and validates the footnotes to ensure they reference valid content positions.
736    ///
737    /// ## Return
738    /// - `Ok(Block)`: Build successful
739    /// - `Err(EpubError)`: Error occurred during the build process
740    pub fn build(self) -> Result<Block, EpubError> {
741        let block = match self.block_type {
742            BlockType::Text => {
743                if let Some(content) = self.content {
744                    Block::Text { content, footnotes: self.footnotes }
745                } else {
746                    return Err(EpubBuilderError::MissingNecessaryBlockData {
747                        block_type: "Text".to_string(),
748                        missing_data: "'content'".to_string(),
749                    }
750                    .into());
751                }
752            }
753
754            BlockType::Quote => {
755                if let Some(content) = self.content {
756                    Block::Quote { content, footnotes: self.footnotes }
757                } else {
758                    return Err(EpubBuilderError::MissingNecessaryBlockData {
759                        block_type: "Quote".to_string(),
760                        missing_data: "'content'".to_string(),
761                    }
762                    .into());
763                }
764            }
765
766            BlockType::Title => match (self.content, self.level) {
767                (Some(content), Some(level)) => Block::Title {
768                    content,
769                    level,
770                    footnotes: self.footnotes,
771                },
772                _ => {
773                    return Err(EpubBuilderError::MissingNecessaryBlockData {
774                        block_type: "Title".to_string(),
775                        missing_data: "'content' or 'level'".to_string(),
776                    }
777                    .into());
778                }
779            },
780
781            BlockType::Image => {
782                if let Some(url) = self.url {
783                    Block::Image {
784                        url,
785                        alt: self.alt,
786                        caption: self.caption,
787                        footnotes: self.footnotes,
788                    }
789                } else {
790                    return Err(EpubBuilderError::MissingNecessaryBlockData {
791                        block_type: "Image".to_string(),
792                        missing_data: "'url'".to_string(),
793                    }
794                    .into());
795                }
796            }
797
798            BlockType::Audio => match (self.url, self.fallback) {
799                (Some(url), Some(fallback)) => Block::Audio {
800                    url,
801                    fallback,
802                    caption: self.caption,
803                    footnotes: self.footnotes,
804                },
805                _ => {
806                    return Err(EpubBuilderError::MissingNecessaryBlockData {
807                        block_type: "Audio".to_string(),
808                        missing_data: "'url' or 'fallback'".to_string(),
809                    }
810                    .into());
811                }
812            },
813
814            BlockType::Video => match (self.url, self.fallback) {
815                (Some(url), Some(fallback)) => Block::Video {
816                    url,
817                    fallback,
818                    caption: self.caption,
819                    footnotes: self.footnotes,
820                },
821                _ => {
822                    return Err(EpubBuilderError::MissingNecessaryBlockData {
823                        block_type: "Video".to_string(),
824                        missing_data: "'url' or 'fallback'".to_string(),
825                    }
826                    .into());
827                }
828            },
829
830            BlockType::MathML => {
831                if let Some(element_str) = self.element_str {
832                    Block::MathML {
833                        element_str,
834                        fallback_image: self.fallback_image,
835                        caption: self.caption,
836                        footnotes: self.footnotes,
837                    }
838                } else {
839                    return Err(EpubBuilderError::MissingNecessaryBlockData {
840                        block_type: "MathML".to_string(),
841                        missing_data: "'element_str'".to_string(),
842                    }
843                    .into());
844                }
845            }
846        };
847
848        Self::validate_footnotes(&block)?;
849        Ok(block)
850    }
851
852    /// Validates that the file type matches expected types
853    ///
854    /// Identifies the file type by reading the file header and validates whether
855    /// it belongs to one of the expected types. Uses file magic numbers for
856    /// reliable type detection.
857    ///
858    /// ## Parameters
859    /// - `path`: The path to the file to check
860    /// - `types`: The vector of expected file types
861    fn is_target_type(path: &PathBuf, types: Vec<MatcherType>) -> Result<(), EpubError> {
862        if !path.is_file() {
863            return Err(EpubBuilderError::TargetIsNotFile {
864                target_path: path.to_string_lossy().to_string(),
865            }
866            .into());
867        }
868
869        let mut file = File::open(path)?;
870        let mut buf = [0; 512];
871        let read_size = file.read(&mut buf)?;
872        let header_bytes = &buf[..read_size];
873
874        match Infer::new().get(header_bytes) {
875            Some(file_type) if !types.contains(&file_type.matcher_type()) => {
876                Err(EpubBuilderError::NotExpectedFileFormat.into())
877            }
878
879            None => Err(EpubBuilderError::UnknownFileFormat {
880                file_path: path.to_string_lossy().to_string(),
881            }
882            .into()),
883
884            _ => Ok(()),
885        }
886    }
887
888    /// Validates the footnotes in a block
889    ///
890    /// Ensures all footnotes reference valid positions within the content.
891    /// For Text, Quote, and Title blocks, footnotes must be within the character count of the content.
892    /// For Image, Audio, Video, and MathML blocks, footnotes must be within the character count
893    /// of the caption (if a caption is set). Blocks with media but no caption cannot have footnotes.
894    fn validate_footnotes(block: &Block) -> Result<(), EpubError> {
895        match block {
896            Block::Text { content, footnotes }
897            | Block::Quote { content, footnotes }
898            | Block::Title { content, footnotes, .. } => {
899                let max_locate = content.chars().count();
900                for footnote in footnotes.iter() {
901                    if footnote.locate == 0 || footnote.locate > content.chars().count() {
902                        return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
903                    }
904                }
905
906                Ok(())
907            }
908
909            Block::Image { caption, footnotes, .. }
910            | Block::MathML { caption, footnotes, .. }
911            | Block::Video { caption, footnotes, .. }
912            | Block::Audio { caption, footnotes, .. } => {
913                if let Some(caption) = caption {
914                    let max_locate = caption.chars().count();
915                    for footnote in footnotes.iter() {
916                        if footnote.locate == 0 || footnote.locate > caption.chars().count() {
917                            return Err(
918                                EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
919                            );
920                        }
921                    }
922                } else if !footnotes.is_empty() {
923                    return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
924                }
925
926                Ok(())
927            }
928        }
929    }
930}
931
932/// Content Builder
933///
934/// A builder for constructing EPUB content documents with various block types.
935/// This builder manages the creation and organization of content blocks including
936/// text, quotes, headings, images, audio, video, and MathML content.
937#[derive(Debug)]
938pub struct ContentBuilder {
939    /// The unique identifier for the content document
940    ///
941    /// This identifier is used to uniquely identify the content document within the EPUB container.
942    /// If the identifier is not unique, only one content document will be included in the EPUB container;
943    /// and the other content document will be ignored.  
944    pub id: String,
945
946    blocks: Vec<Block>,
947    language: String,
948    title: String,
949
950    pub(crate) temp_dir: PathBuf,
951}
952
953impl ContentBuilder {
954    /// Creates a new ContentBuilder instance
955    ///
956    /// Initializes a ContentBuilder with the specified language code.
957    /// A temporary directory is automatically created to store media files during construction.
958    ///
959    /// ## Parameters
960    /// - `language`: The language code for the document
961    pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
962        let temp_dir = env::temp_dir().join(local_time());
963        fs::create_dir(&temp_dir)?;
964
965        Ok(Self {
966            id: id.to_string(),
967            blocks: vec![],
968            language: language.to_string(),
969            title: String::new(),
970            temp_dir,
971        })
972    }
973
974    /// Sets the title of the document
975    ///
976    /// Sets the title that will be displayed in the document's head section.
977    ///
978    /// ## Parameters
979    /// - `title`: The title text for the document
980    pub fn set_title(&mut self, title: &str) -> &mut Self {
981        self.title = title.to_string();
982        self
983    }
984
985    /// Adds a block to the document
986    ///
987    /// Adds a constructed Block to the document.
988    ///
989    /// ## Parameters
990    /// - `block`: The Block to add to the document
991    pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
992        self.blocks.push(block);
993
994        match self.blocks.last() {
995            Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
996                self.handle_resource()?
997            }
998
999            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1000                self.handle_resource()?;
1001            }
1002
1003            _ => {}
1004        }
1005
1006        Ok(self)
1007    }
1008
1009    /// Adds a text block to the document
1010    ///
1011    /// Convenience method that creates and adds a Text block using the provided content and footnotes.
1012    ///
1013    /// ## Parameters
1014    /// - `content`: The text content of the paragraph
1015    /// - `footnotes`: A vector of footnotes associated with the text
1016    pub fn add_text_block(
1017        &mut self,
1018        content: &str,
1019        footnotes: Vec<Footnote>,
1020    ) -> Result<&mut Self, EpubError> {
1021        let mut builder = BlockBuilder::new(BlockType::Text);
1022        builder.set_content(content).set_footnotes(footnotes);
1023
1024        self.blocks.push(builder.build()?);
1025        Ok(self)
1026    }
1027
1028    /// Adds a quote block to the document
1029    ///
1030    /// Convenience method that creates and adds a Quote block using the provided content and footnotes.
1031    ///
1032    /// ## Parameters
1033    /// - `content`: The quoted text
1034    /// - `footnotes`: A vector of footnotes associated with the quote
1035    pub fn add_quote_block(
1036        &mut self,
1037        content: &str,
1038        footnotes: Vec<Footnote>,
1039    ) -> Result<&mut Self, EpubError> {
1040        let mut builder = BlockBuilder::new(BlockType::Quote);
1041        builder.set_content(content).set_footnotes(footnotes);
1042
1043        self.blocks.push(builder.build()?);
1044        Ok(self)
1045    }
1046
1047    /// Adds a heading block to the document
1048    ///
1049    /// Convenience method that creates and adds a Title block with the specified level.
1050    ///
1051    /// ## Parameters
1052    /// - `content`: The heading text
1053    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
1054    /// - `footnotes`: A vector of footnotes associated with the heading
1055    pub fn add_title_block(
1056        &mut self,
1057        content: &str,
1058        level: usize,
1059        footnotes: Vec<Footnote>,
1060    ) -> Result<&mut Self, EpubError> {
1061        let mut builder = BlockBuilder::new(BlockType::Title);
1062        builder
1063            .set_content(content)
1064            .set_title_level(level)
1065            .set_footnotes(footnotes);
1066
1067        self.blocks.push(builder.build()?);
1068        Ok(self)
1069    }
1070
1071    /// Adds an image block to the document
1072    ///
1073    /// Convenience method that creates and adds an Image block with optional alt text,
1074    /// caption, and footnotes.
1075    ///
1076    /// ## Parameters
1077    /// - `url`: The path to the image file
1078    /// - `alt`: Optional alternative text for the image (displayed when image cannot load)
1079    /// - `caption`: Optional caption text to display below the image
1080    /// - `footnotes`: A vector of footnotes associated with the caption or image
1081    pub fn add_image_block(
1082        &mut self,
1083        url: PathBuf,
1084        alt: Option<String>,
1085        caption: Option<String>,
1086        footnotes: Vec<Footnote>,
1087    ) -> Result<&mut Self, EpubError> {
1088        let mut builder = BlockBuilder::new(BlockType::Image);
1089        builder.set_url(&url)?.set_footnotes(footnotes);
1090
1091        if let Some(alt) = &alt {
1092            builder.set_alt(alt);
1093        }
1094
1095        if let Some(caption) = &caption {
1096            builder.set_caption(caption);
1097        }
1098
1099        self.blocks.push(builder.build()?);
1100        self.handle_resource()?;
1101        Ok(self)
1102    }
1103
1104    /// Adds an audio block to the document
1105    ///
1106    /// Convenience method that creates and adds an Audio block with fallback text,
1107    /// optional caption, and footnotes.
1108    ///
1109    /// ## Parameters
1110    /// - `url`: The path to the audio file
1111    /// - `fallback`: Fallback text displayed when the audio cannot be played
1112    /// - `caption`: Optional caption text to display below the audio player
1113    /// - `footnotes`: A vector of footnotes associated with the caption or audio
1114    pub fn add_audio_block(
1115        &mut self,
1116        url: PathBuf,
1117        fallback: String,
1118        caption: Option<String>,
1119        footnotes: Vec<Footnote>,
1120    ) -> Result<&mut Self, EpubError> {
1121        let mut builder = BlockBuilder::new(BlockType::Audio);
1122        builder
1123            .set_url(&url)?
1124            .set_fallback(&fallback)
1125            .set_footnotes(footnotes);
1126
1127        if let Some(caption) = &caption {
1128            builder.set_caption(caption);
1129        }
1130
1131        self.blocks.push(builder.build()?);
1132        self.handle_resource()?;
1133        Ok(self)
1134    }
1135
1136    /// Adds a video block to the document
1137    ///
1138    /// Convenience method that creates and adds a Video block with fallback text,
1139    /// optional caption, and footnotes.
1140    ///
1141    /// ## Parameters
1142    /// - `url`: The path to the video file
1143    /// - `fallback`: Fallback text displayed when the video cannot be played
1144    /// - `caption`: Optional caption text to display below the video player
1145    /// - `footnotes`: A vector of footnotes associated with the caption or video
1146    pub fn add_video_block(
1147        &mut self,
1148        url: PathBuf,
1149        fallback: String,
1150        caption: Option<String>,
1151        footnotes: Vec<Footnote>,
1152    ) -> Result<&mut Self, EpubError> {
1153        let mut builder = BlockBuilder::new(BlockType::Video);
1154        builder
1155            .set_url(&url)?
1156            .set_fallback(&fallback)
1157            .set_footnotes(footnotes);
1158
1159        if let Some(caption) = &caption {
1160            builder.set_caption(caption);
1161        }
1162
1163        self.blocks.push(builder.build()?);
1164        self.handle_resource()?;
1165        Ok(self)
1166    }
1167
1168    /// Adds a MathML block to the document
1169    ///
1170    /// Convenience method that creates and adds a MathML block with optional fallback image,
1171    /// caption, and footnotes.
1172    ///
1173    /// ## Parameters
1174    /// - `element_str`: The raw MathML markup string
1175    /// - `fallback_image`: Optional path to a fallback image displayed when MathML cannot render
1176    /// - `caption`: Optional caption text to display below the MathML element
1177    /// - `footnotes`: A vector of footnotes associated with the caption or equation
1178    pub fn add_mathml_block(
1179        &mut self,
1180        element_str: String,
1181        fallback_image: Option<PathBuf>,
1182        caption: Option<String>,
1183        footnotes: Vec<Footnote>,
1184    ) -> Result<&mut Self, EpubError> {
1185        let mut builder = BlockBuilder::new(BlockType::MathML);
1186        builder
1187            .set_mathml_element(&element_str)
1188            .set_footnotes(footnotes);
1189
1190        if let Some(fallback_image) = fallback_image {
1191            builder.set_fallback_image(fallback_image)?;
1192        }
1193
1194        if let Some(caption) = &caption {
1195            builder.set_caption(caption);
1196        }
1197
1198        self.blocks.push(builder.build()?);
1199        self.handle_resource()?;
1200        Ok(self)
1201    }
1202
1203    /// Removes the last block from the document
1204    ///
1205    /// Discards the most recently added block. If no blocks exist, this method has no effect.
1206    pub fn remove_last_block(&mut self) -> &mut Self {
1207        self.blocks.pop();
1208        self
1209    }
1210
1211    /// Takes ownership of the last block
1212    ///
1213    /// Removes and returns the most recently added block without consuming the builder.
1214    /// This allows you to extract a block while keeping the builder alive.
1215    ///
1216    /// ## Return
1217    /// - `Some(Block)`: If a block exists
1218    /// - `None`: If the blocks collection is empty
1219    pub fn take_last_block(&mut self) -> Option<Block> {
1220        self.blocks.pop()
1221    }
1222
1223    /// Clears all blocks from the document
1224    ///
1225    /// Removes all blocks from the document while keeping the language and title settings intact.
1226    pub fn clear_blocks(&mut self) -> &mut Self {
1227        self.blocks.clear();
1228        self
1229    }
1230
1231    /// Builds content document
1232    ///
1233    /// ## Parameters
1234    /// - `target`: The file path where the document should be written
1235    ///
1236    /// ## Return
1237    /// - `Ok(Vec<PathBuf>)`: A vector of paths to all resources used in the document
1238    /// - `Err(EpubError)`: Error occurred during the making process
1239    pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1240        let mut result = Vec::new();
1241
1242        // Handle target directory, create if it doesn't exist
1243        let target_dir = match target.as_ref().parent() {
1244            Some(path) => {
1245                fs::create_dir_all(path)?;
1246                path.to_path_buf()
1247            }
1248            None => {
1249                return Err(EpubBuilderError::InvalidTargetPath {
1250                    target_path: target.as_ref().to_string_lossy().to_string(),
1251                }
1252                .into());
1253            }
1254        };
1255
1256        self.make_content(&target)?;
1257        result.push(target.as_ref().to_path_buf());
1258
1259        // Copy all resource files (images, audio, video) from temp directory to target directory
1260        for resource_type in ["img", "audio", "video"] {
1261            let source = self.temp_dir.join(resource_type);
1262            if source.exists() && source.is_dir() {
1263                let target = target_dir.join(resource_type);
1264                fs::create_dir_all(&target)?;
1265
1266                for entry in fs::read_dir(&source)? {
1267                    let entry = entry?;
1268                    if entry.file_type()?.is_file() {
1269                        let file_name = entry.file_name();
1270                        let target = target.join(&file_name);
1271
1272                        fs::copy(source.join(&file_name), &target)?;
1273                        result.push(target);
1274                    }
1275                }
1276            }
1277        }
1278
1279        Ok(result)
1280    }
1281
1282    /// Write the document to a file
1283    ///
1284    /// Constructs the final XHTML document from all added blocks and writes it to the specified output path.
1285    ///
1286    /// ## Parameters
1287    /// - `target_path`: The file path where the XHTML document should be written
1288    fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1289        let mut writer = Writer::new(Cursor::new(Vec::new()));
1290
1291        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1292        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1293            ("xmlns", "http://www.w3.org/1999/xhtml"),
1294            ("xml:lang", self.language.as_str()),
1295        ])))?;
1296
1297        // make head
1298        writer.write_event(Event::Start(BytesStart::new("head")))?;
1299        writer.write_event(Event::Start(BytesStart::new("title")))?;
1300        writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1301        writer.write_event(Event::End(BytesEnd::new("title")))?;
1302        writer.write_event(Event::End(BytesEnd::new("head")))?;
1303
1304        // make body
1305        writer.write_event(Event::Start(BytesStart::new("body")))?;
1306
1307        let mut footnote_index = 1;
1308        let mut footnotes = Vec::new();
1309        for block in self.blocks.iter_mut() {
1310            block.make(&mut writer, footnote_index)?;
1311
1312            footnotes.append(&mut block.take_footnotes());
1313            footnote_index = footnotes.len() + 1;
1314        }
1315
1316        Self::make_footnotes(&mut writer, footnotes)?;
1317        writer.write_event(Event::End(BytesEnd::new("body")))?;
1318        writer.write_event(Event::End(BytesEnd::new("html")))?;
1319
1320        let file_path = PathBuf::from(target_path.as_ref());
1321        let file_data = writer.into_inner().into_inner();
1322        fs::write(file_path, file_data)?;
1323
1324        Ok(())
1325    }
1326
1327    /// Generates the footnotes section in the document
1328    ///
1329    /// Creates an aside element containing an unordered list of all footnotes.
1330    /// Each footnote is rendered as a list item with a backlink to its reference in the text.
1331    fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1332        writer.write_event(Event::Start(BytesStart::new("aside")))?;
1333        writer.write_event(Event::Start(BytesStart::new("ul")))?;
1334
1335        let mut index = 1;
1336        for footnote in footnotes.into_iter() {
1337            writer.write_event(Event::Start(
1338                BytesStart::new("li")
1339                    .with_attributes([("id", format!("footnote-{}", index).as_str())]),
1340            ))?;
1341            writer.write_event(Event::Start(BytesStart::new("p")))?;
1342
1343            writer.write_event(Event::Start(
1344                BytesStart::new("a")
1345                    .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1346            ))?;
1347            writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1348            writer.write_event(Event::End(BytesEnd::new("a")))?;
1349            writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1350
1351            writer.write_event(Event::End(BytesEnd::new("p")))?;
1352            writer.write_event(Event::End(BytesEnd::new("li")))?;
1353
1354            index += 1;
1355        }
1356
1357        writer.write_event(Event::End(BytesEnd::new("ul")))?;
1358        writer.write_event(Event::End(BytesEnd::new("aside")))?;
1359
1360        Ok(())
1361    }
1362
1363    /// Automatically handles media resources
1364    fn handle_resource(&mut self) -> Result<(), EpubError> {
1365        match self.blocks.last() {
1366            Some(Block::Image { url, .. }) => {
1367                let target_dir = self.temp_dir.join("img");
1368                fs::create_dir_all(&target_dir)?;
1369
1370                let target_path = target_dir.join(url.file_name().unwrap());
1371                fs::copy(url, &target_path)?;
1372            }
1373
1374            Some(Block::Video { url, .. }) => {
1375                let target_dir = self.temp_dir.join("video");
1376                fs::create_dir_all(&target_dir)?;
1377
1378                let target_path = target_dir.join(url.file_name().unwrap());
1379                fs::copy(url, &target_path)?;
1380            }
1381
1382            Some(Block::Audio { url, .. }) => {
1383                let target_dir = self.temp_dir.join("audio");
1384                fs::create_dir_all(&target_dir)?;
1385
1386                let target_path = target_dir.join(url.file_name().unwrap());
1387                fs::copy(url, &target_path)?;
1388            }
1389
1390            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1391                let target_dir = self.temp_dir.join("img");
1392                fs::create_dir_all(&target_dir)?;
1393
1394                let target_path =
1395                    target_dir.join(fallback_image.as_ref().unwrap().file_name().unwrap());
1396
1397                fs::copy(fallback_image.as_ref().unwrap(), &target_path)?;
1398            }
1399
1400            Some(_) => {}
1401            None => {}
1402        }
1403
1404        Ok(())
1405    }
1406}
1407
1408impl Drop for ContentBuilder {
1409    fn drop(&mut self) {
1410        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1411            warn!("{}", err);
1412        };
1413    }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    mod block_builder_tests {
1419        use std::path::PathBuf;
1420
1421        use crate::{
1422            builder::content::{Block, BlockBuilder},
1423            error::EpubBuilderError,
1424            types::{BlockType, Footnote},
1425        };
1426
1427        #[test]
1428        fn test_create_text_block() {
1429            let mut builder = BlockBuilder::new(BlockType::Text);
1430            builder.set_content("Hello, World!");
1431
1432            let block = builder.build();
1433            assert!(block.is_ok());
1434
1435            let block = block.unwrap();
1436            match block {
1437                Block::Text { content, footnotes } => {
1438                    assert_eq!(content, "Hello, World!");
1439                    assert!(footnotes.is_empty());
1440                }
1441                _ => unreachable!(),
1442            }
1443        }
1444
1445        #[test]
1446        fn test_create_text_block_missing_content() {
1447            let builder = BlockBuilder::new(BlockType::Text);
1448
1449            let block = builder.build();
1450            assert!(block.is_err());
1451
1452            let result = block.unwrap_err();
1453            assert_eq!(
1454                result,
1455                EpubBuilderError::MissingNecessaryBlockData {
1456                    block_type: "Text".to_string(),
1457                    missing_data: "'content'".to_string()
1458                }
1459                .into()
1460            )
1461        }
1462
1463        #[test]
1464        fn test_create_quote_block() {
1465            let mut builder = BlockBuilder::new(BlockType::Quote);
1466            builder.set_content("To be or not to be");
1467
1468            let block = builder.build();
1469            assert!(block.is_ok());
1470
1471            let block = block.unwrap();
1472            match block {
1473                Block::Quote { content, footnotes } => {
1474                    assert_eq!(content, "To be or not to be");
1475                    assert!(footnotes.is_empty());
1476                }
1477                _ => unreachable!(),
1478            }
1479        }
1480
1481        #[test]
1482        fn test_create_title_block() {
1483            let mut builder = BlockBuilder::new(BlockType::Title);
1484            builder.set_content("Chapter 1").set_title_level(2);
1485
1486            let block = builder.build();
1487            assert!(block.is_ok());
1488
1489            let block = block.unwrap();
1490            match block {
1491                Block::Title { content, level, footnotes } => {
1492                    assert_eq!(content, "Chapter 1");
1493                    assert_eq!(level, 2);
1494                    assert!(footnotes.is_empty());
1495                }
1496                _ => unreachable!(),
1497            }
1498        }
1499
1500        #[test]
1501        fn test_create_title_block_invalid_level() {
1502            let mut builder = BlockBuilder::new(BlockType::Title);
1503            builder.set_content("Chapter 1").set_title_level(10);
1504
1505            let result = builder.build();
1506            assert!(result.is_err());
1507
1508            let result = result.unwrap_err();
1509            assert_eq!(
1510                result,
1511                EpubBuilderError::MissingNecessaryBlockData {
1512                    block_type: "Title".to_string(),
1513                    missing_data: "'content' or 'level'".to_string(),
1514                }
1515                .into()
1516            );
1517        }
1518
1519        #[test]
1520        fn test_create_image_block() {
1521            let img_path = PathBuf::from("./test_case/image.jpg");
1522            let mut builder = BlockBuilder::new(BlockType::Image);
1523            builder
1524                .set_url(&img_path)
1525                .unwrap()
1526                .set_alt("Test Image")
1527                .set_caption("A test image");
1528
1529            let block = builder.build();
1530            assert!(block.is_ok());
1531
1532            let block = block.unwrap();
1533            match block {
1534                Block::Image { url, alt, caption, footnotes } => {
1535                    assert_eq!(url.file_name().unwrap(), "image.jpg");
1536                    assert_eq!(alt, Some("Test Image".to_string()));
1537                    assert_eq!(caption, Some("A test image".to_string()));
1538                    assert!(footnotes.is_empty());
1539                }
1540                _ => unreachable!(),
1541            }
1542        }
1543
1544        #[test]
1545        fn test_create_image_block_missing_url() {
1546            let builder = BlockBuilder::new(BlockType::Image);
1547
1548            let block = builder.build();
1549            assert!(block.is_err());
1550
1551            let result = block.unwrap_err();
1552            assert_eq!(
1553                result,
1554                EpubBuilderError::MissingNecessaryBlockData {
1555                    block_type: "Image".to_string(),
1556                    missing_data: "'url'".to_string(),
1557                }
1558                .into()
1559            );
1560        }
1561
1562        #[test]
1563        fn test_create_audio_block() {
1564            let audio_path = PathBuf::from("./test_case/audio.mp3");
1565            let mut builder = BlockBuilder::new(BlockType::Audio);
1566            builder
1567                .set_url(&audio_path)
1568                .unwrap()
1569                .set_fallback("Audio not supported")
1570                .set_caption("Background music");
1571
1572            let block = builder.build();
1573            assert!(block.is_ok());
1574
1575            let block = block.unwrap();
1576            match block {
1577                Block::Audio { url, fallback, caption, footnotes } => {
1578                    assert_eq!(url.file_name().unwrap(), "audio.mp3");
1579                    assert_eq!(fallback, "Audio not supported");
1580                    assert_eq!(caption, Some("Background music".to_string()));
1581                    assert!(footnotes.is_empty());
1582                }
1583                _ => unreachable!(),
1584            }
1585        }
1586
1587        #[test]
1588        fn test_create_video_block() {
1589            let video_path = PathBuf::from("./test_case/video.mp4");
1590            let mut builder = BlockBuilder::new(BlockType::Video);
1591            builder
1592                .set_url(&video_path)
1593                .unwrap()
1594                .set_fallback("Video not supported")
1595                .set_caption("Demo video");
1596
1597            let block = builder.build();
1598            assert!(block.is_ok());
1599
1600            let block = block.unwrap();
1601            match block {
1602                Block::Video { url, fallback, caption, footnotes } => {
1603                    assert_eq!(url.file_name().unwrap(), "video.mp4");
1604                    assert_eq!(fallback, "Video not supported");
1605                    assert_eq!(caption, Some("Demo video".to_string()));
1606                    assert!(footnotes.is_empty());
1607                }
1608                _ => unreachable!(),
1609            }
1610        }
1611
1612        #[test]
1613        fn test_create_mathml_block() {
1614            let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
1615            let mut builder = BlockBuilder::new(BlockType::MathML);
1616            builder
1617                .set_mathml_element(mathml_content)
1618                .set_caption("Simple equation");
1619
1620            let block = builder.build();
1621            assert!(block.is_ok());
1622
1623            let block = block.unwrap();
1624            match block {
1625                Block::MathML {
1626                    element_str,
1627                    fallback_image,
1628                    caption,
1629                    footnotes,
1630                } => {
1631                    assert_eq!(element_str, mathml_content);
1632                    assert!(fallback_image.is_none());
1633                    assert_eq!(caption, Some("Simple equation".to_string()));
1634                    assert!(footnotes.is_empty());
1635                }
1636                _ => unreachable!(),
1637            }
1638        }
1639
1640        #[test]
1641        fn test_create_mathml_block_with_fallback() {
1642            let img_path = PathBuf::from("./test_case/image.jpg");
1643            let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
1644
1645            let mut builder = BlockBuilder::new(BlockType::MathML);
1646            builder
1647                .set_mathml_element(mathml_content)
1648                .set_fallback_image(img_path.clone())
1649                .unwrap();
1650
1651            let block = builder.build();
1652            assert!(block.is_ok());
1653
1654            let block = block.unwrap();
1655            match block {
1656                Block::MathML { element_str, fallback_image, .. } => {
1657                    assert_eq!(element_str, mathml_content);
1658                    assert!(fallback_image.is_some());
1659                }
1660                _ => unreachable!(),
1661            }
1662        }
1663
1664        #[test]
1665        fn test_footnote_management() {
1666            let mut builder = BlockBuilder::new(BlockType::Text);
1667            builder.set_content("This is a test");
1668
1669            let note1 = Footnote {
1670                locate: 5,
1671                content: "First footnote".to_string(),
1672            };
1673            let note2 = Footnote {
1674                locate: 10,
1675                content: "Second footnote".to_string(),
1676            };
1677
1678            builder.add_footnote(note1).add_footnote(note2);
1679
1680            let block = builder.build();
1681            assert!(block.is_ok());
1682
1683            let block = block.unwrap();
1684            match block {
1685                Block::Text { footnotes, .. } => {
1686                    assert_eq!(footnotes.len(), 2);
1687                }
1688                _ => unreachable!(),
1689            }
1690        }
1691
1692        #[test]
1693        fn test_remove_last_footnote() {
1694            let mut builder = BlockBuilder::new(BlockType::Text);
1695            builder.set_content("This is a test");
1696
1697            builder.add_footnote(Footnote { locate: 5, content: "Note 1".to_string() });
1698            builder.add_footnote(Footnote {
1699                locate: 10,
1700                content: "Note 2".to_string(),
1701            });
1702            builder.remove_last_footnote();
1703
1704            let block = builder.build();
1705            assert!(block.is_ok());
1706
1707            let block = block.unwrap();
1708            match block {
1709                Block::Text { footnotes, .. } => {
1710                    assert_eq!(footnotes.len(), 1);
1711                    assert!(footnotes[0].content == "Note 1");
1712                }
1713                _ => unreachable!(),
1714            }
1715        }
1716
1717        #[test]
1718        fn test_clear_footnotes() {
1719            let mut builder = BlockBuilder::new(BlockType::Text);
1720            builder.set_content("This is a test");
1721
1722            builder.add_footnote(Footnote { locate: 5, content: "Note".to_string() });
1723
1724            builder.clear_footnotes();
1725
1726            let block = builder.build();
1727            assert!(block.is_ok());
1728
1729            let block = block.unwrap();
1730            match block {
1731                Block::Text { footnotes, .. } => {
1732                    assert!(footnotes.is_empty());
1733                }
1734                _ => unreachable!(),
1735            }
1736        }
1737
1738        #[test]
1739        fn test_invalid_footnote_locate() {
1740            let mut builder = BlockBuilder::new(BlockType::Text);
1741            builder.set_content("Hello");
1742
1743            // Footnote locate exceeds content length
1744            builder.add_footnote(Footnote {
1745                locate: 100,
1746                content: "Invalid footnote".to_string(),
1747            });
1748
1749            let result = builder.build();
1750            assert!(result.is_err());
1751
1752            let result = result.unwrap_err();
1753            assert_eq!(
1754                result,
1755                EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
1756            );
1757        }
1758
1759        #[test]
1760        fn test_footnote_on_media_without_caption() {
1761            let img_path = PathBuf::from("./test_case/image.jpg");
1762            let mut builder = BlockBuilder::new(BlockType::Image);
1763            builder.set_url(&img_path).unwrap();
1764
1765            builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
1766
1767            let result = builder.build();
1768            assert!(result.is_err());
1769
1770            let result = result.unwrap_err();
1771            assert_eq!(
1772                result,
1773                EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
1774            );
1775        }
1776    }
1777
1778    mod content_builder_tests {
1779        use std::{env, fs, path::PathBuf};
1780
1781        use crate::{
1782            builder::content::{Block, ContentBuilder},
1783            types::Footnote,
1784            utils::local_time,
1785        };
1786
1787        #[test]
1788        fn test_create_content_builder() {
1789            let builder = ContentBuilder::new("chapter1", "en");
1790            assert!(builder.is_ok());
1791
1792            let builder = builder.unwrap();
1793            assert_eq!(builder.id, "chapter1");
1794        }
1795
1796        #[test]
1797        fn test_set_title() {
1798            let builder = ContentBuilder::new("chapter1", "en");
1799            assert!(builder.is_ok());
1800
1801            let mut builder = builder.unwrap();
1802            builder.set_title("My Chapter").set_title("Another Title");
1803
1804            assert_eq!(builder.title, "Another Title");
1805        }
1806
1807        #[test]
1808        fn test_add_text_block() {
1809            let builder = ContentBuilder::new("chapter1", "en");
1810            assert!(builder.is_ok());
1811
1812            let mut builder = builder.unwrap();
1813            let result = builder.add_text_block("This is a paragraph", vec![]);
1814            assert!(result.is_ok());
1815        }
1816
1817        #[test]
1818        fn test_add_quote_block() {
1819            let builder = ContentBuilder::new("chapter1", "en");
1820            assert!(builder.is_ok());
1821
1822            let mut builder = builder.unwrap();
1823            let result = builder.add_quote_block("A quoted text", vec![]);
1824            assert!(result.is_ok());
1825        }
1826
1827        #[test]
1828        fn test_add_title_block() {
1829            let builder = ContentBuilder::new("chapter1", "en");
1830            assert!(builder.is_ok());
1831
1832            let mut builder = builder.unwrap();
1833            let result = builder.add_title_block("Section Title", 2, vec![]);
1834            assert!(result.is_ok());
1835        }
1836
1837        #[test]
1838        fn test_add_image_block() {
1839            let img_path = PathBuf::from("./test_case/image.jpg");
1840            let builder = ContentBuilder::new("chapter1", "en");
1841            assert!(builder.is_ok());
1842
1843            let mut builder = builder.unwrap();
1844            let result = builder.add_image_block(
1845                img_path,
1846                Some("Alt text".to_string()),
1847                Some("Figure 1: An image".to_string()),
1848                vec![],
1849            );
1850
1851            assert!(result.is_ok());
1852        }
1853
1854        #[test]
1855        fn test_add_audio_block() {
1856            let audio_path = PathBuf::from("./test_case/audio.mp3");
1857            let builder = ContentBuilder::new("chapter1", "en");
1858            assert!(builder.is_ok());
1859
1860            let mut builder = builder.unwrap();
1861            let result = builder.add_audio_block(
1862                audio_path,
1863                "Your browser doesn't support audio".to_string(),
1864                Some("Background music".to_string()),
1865                vec![],
1866            );
1867
1868            assert!(result.is_ok());
1869        }
1870
1871        #[test]
1872        fn test_add_video_block() {
1873            let video_path = PathBuf::from("./test_case/video.mp4");
1874            let builder = ContentBuilder::new("chapter1", "en");
1875            assert!(builder.is_ok());
1876
1877            let mut builder = builder.unwrap();
1878            let result = builder.add_video_block(
1879                video_path,
1880                "Your browser doesn't support video".to_string(),
1881                Some("Tutorial video".to_string()),
1882                vec![],
1883            );
1884
1885            assert!(result.is_ok());
1886        }
1887
1888        #[test]
1889        fn test_add_mathml_block() {
1890            let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
1891            let builder = ContentBuilder::new("chapter1", "en");
1892            assert!(builder.is_ok());
1893
1894            let mut builder = builder.unwrap();
1895            let result = builder.add_mathml_block(
1896                mathml.to_string(),
1897                None,
1898                Some("Equation 1".to_string()),
1899                vec![],
1900            );
1901
1902            assert!(result.is_ok());
1903        }
1904
1905        #[test]
1906        fn test_remove_last_block() {
1907            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
1908
1909            builder.add_text_block("First block", vec![]).unwrap();
1910            builder.add_text_block("Second block", vec![]).unwrap();
1911            assert_eq!(builder.blocks.len(), 2);
1912
1913            builder.remove_last_block();
1914            assert_eq!(builder.blocks.len(), 1);
1915        }
1916
1917        #[test]
1918        fn test_take_last_block() {
1919            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
1920
1921            builder.add_text_block("Block content", vec![]).unwrap();
1922
1923            let block = builder.take_last_block();
1924            assert!(block.is_some());
1925
1926            let block = block.unwrap();
1927            match block {
1928                Block::Text { content, .. } => {
1929                    assert_eq!(content, "Block content");
1930                }
1931                _ => unreachable!(),
1932            }
1933
1934            let block2 = builder.take_last_block();
1935            assert!(block2.is_none());
1936        }
1937
1938        #[test]
1939        fn test_clear_blocks() {
1940            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
1941
1942            builder.add_text_block("Block 1", vec![]).unwrap();
1943            builder.add_text_block("Block 2", vec![]).unwrap();
1944            assert_eq!(builder.blocks.len(), 2);
1945
1946            builder.clear_blocks();
1947
1948            let block = builder.take_last_block();
1949            assert!(block.is_none());
1950        }
1951
1952        #[test]
1953        fn test_make_content_document() {
1954            let temp_dir = env::temp_dir().join(local_time());
1955            assert!(fs::create_dir_all(&temp_dir).is_ok());
1956
1957            let output_path = temp_dir.join("chapter.xhtml");
1958
1959            let builder = ContentBuilder::new("chapter1", "en");
1960            assert!(builder.is_ok());
1961
1962            let mut builder = builder.unwrap();
1963            builder
1964                .set_title("My Chapter")
1965                .add_text_block("This is the first paragraph.", vec![])
1966                .unwrap()
1967                .add_text_block("This is the second paragraph.", vec![])
1968                .unwrap();
1969
1970            let result = builder.make(&output_path);
1971            assert!(result.is_ok());
1972            assert!(output_path.exists());
1973            assert!(fs::remove_dir_all(temp_dir).is_ok());
1974        }
1975
1976        #[test]
1977        fn test_make_content_with_media() {
1978            let temp_dir = env::temp_dir().join(local_time());
1979            assert!(fs::create_dir_all(&temp_dir).is_ok());
1980
1981            let output_path = temp_dir.join("chapter.xhtml");
1982            let img_path = PathBuf::from("./test_case/image.jpg");
1983
1984            let builder = ContentBuilder::new("chapter1", "en");
1985            assert!(builder.is_ok());
1986
1987            let mut builder = builder.unwrap();
1988            builder
1989                .set_title("Chapter with Media")
1990                .add_text_block("See image below:", vec![])
1991                .unwrap()
1992                .add_image_block(
1993                    img_path,
1994                    Some("Test".to_string()),
1995                    Some("Figure 1".to_string()),
1996                    vec![],
1997                )
1998                .unwrap();
1999
2000            let result = builder.make(&output_path);
2001            assert!(result.is_ok());
2002
2003            let img_dir = temp_dir.join("img");
2004            assert!(img_dir.exists());
2005            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2006        }
2007
2008        #[test]
2009        fn test_make_content_with_footnotes() {
2010            let temp_dir = env::temp_dir().join(local_time());
2011            assert!(fs::create_dir_all(&temp_dir).is_ok());
2012
2013            let output_path = temp_dir.join("chapter.xhtml");
2014
2015            let footnotes = vec![
2016                Footnote {
2017                    locate: 10,
2018                    content: "This is a footnote".to_string(),
2019                },
2020                Footnote {
2021                    locate: 15,
2022                    content: "Another footnote".to_string(),
2023                },
2024            ];
2025
2026            let builder = ContentBuilder::new("chapter1", "en");
2027            assert!(builder.is_ok());
2028
2029            let mut builder = builder.unwrap();
2030            builder
2031                .set_title("Chapter with Notes")
2032                .add_text_block("This is a paragraph with notes.", footnotes)
2033                .unwrap();
2034
2035            let result = builder.make(&output_path);
2036            assert!(result.is_ok());
2037            assert!(output_path.exists());
2038            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2039        }
2040    }
2041
2042    mod block_tests {
2043        use std::path::PathBuf;
2044
2045        use crate::{builder::content::Block, types::Footnote};
2046
2047        #[test]
2048        fn test_take_footnotes_from_text_block() {
2049            let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
2050
2051            let block = Block::Text {
2052                content: "Hello world".to_string(),
2053                footnotes: footnotes.clone(),
2054            };
2055
2056            let taken = block.take_footnotes();
2057            assert_eq!(taken.len(), 1);
2058            assert_eq!(taken[0].content, "Note");
2059        }
2060
2061        #[test]
2062        fn test_take_footnotes_from_quote_block() {
2063            let footnotes = vec![
2064                Footnote { locate: 3, content: "First".to_string() },
2065                Footnote { locate: 8, content: "Second".to_string() },
2066            ];
2067
2068            let block = Block::Quote {
2069                content: "Test quote".to_string(),
2070                footnotes: footnotes.clone(),
2071            };
2072
2073            let taken = block.take_footnotes();
2074            assert_eq!(taken.len(), 2);
2075        }
2076
2077        #[test]
2078        fn test_take_footnotes_from_image_block() {
2079            let img_path = PathBuf::from("test.png");
2080            let footnotes = vec![Footnote {
2081                locate: 2,
2082                content: "Image note".to_string(),
2083            }];
2084
2085            let block = Block::Image {
2086                url: img_path,
2087                alt: None,
2088                caption: Some("A caption".to_string()),
2089                footnotes: footnotes.clone(),
2090            };
2091
2092            let taken = block.take_footnotes();
2093            assert_eq!(taken.len(), 1);
2094        }
2095
2096        #[test]
2097        fn test_block_with_empty_footnotes() {
2098            let block = Block::Text {
2099                content: "No footnotes here".to_string(),
2100                footnotes: vec![],
2101            };
2102
2103            let taken = block.take_footnotes();
2104            assert!(taken.is_empty());
2105        }
2106    }
2107
2108    mod content_rendering_tests {
2109        use crate::builder::content::Block;
2110
2111        #[test]
2112        fn test_split_content_by_index_empty() {
2113            let result = Block::split_content_by_index("Hello", &[]);
2114            assert_eq!(result, vec!["Hello"]);
2115        }
2116
2117        #[test]
2118        fn test_split_content_by_single_index() {
2119            let result = Block::split_content_by_index("Hello World", &[5]);
2120            assert_eq!(result.len(), 2);
2121            assert_eq!(result[0], "Hello");
2122            assert_eq!(result[1], " World");
2123        }
2124
2125        #[test]
2126        fn test_split_content_by_multiple_indices() {
2127            let result = Block::split_content_by_index("One Two Three", &[3, 7]);
2128            assert_eq!(result.len(), 3);
2129            assert_eq!(result[0], "One");
2130            assert_eq!(result[1], " Two");
2131            assert_eq!(result[2], " Three");
2132        }
2133
2134        #[test]
2135        fn test_split_content_unicode() {
2136            let content = "你好世界";
2137            let result = Block::split_content_by_index(content, &[2]);
2138            assert_eq!(result.len(), 2);
2139            assert_eq!(result[0], "你好");
2140            assert_eq!(result[1], "世界");
2141        }
2142    }
2143}