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, StyleOptions},
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    /// Makes footnote reference 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.
513#[derive(Debug)]
514pub struct BlockBuilder {
515    /// The type of block to construct
516    block_type: BlockType,
517
518    /// Content text for Text, Quote, and Title blocks
519    content: Option<String>,
520
521    /// Heading level (1-6) for Title blocks
522    level: Option<usize>,
523
524    /// File path to media for Image, Audio, and Video blocks
525    url: Option<PathBuf>,
526
527    /// Alternative text for Image blocks
528    alt: Option<String>,
529
530    /// Caption text for Image, Audio, Video, and MathML blocks
531    caption: Option<String>,
532
533    /// Fallback text for Audio and Video blocks (displayed when media cannot be played)
534    fallback: Option<String>,
535
536    /// Raw MathML markup string for MathML blocks
537    element_str: Option<String>,
538
539    /// Fallback image path for MathML blocks (displayed when MathML cannot be rendered)
540    fallback_image: Option<PathBuf>,
541
542    /// Footnotes associated with the block content
543    footnotes: Vec<Footnote>,
544}
545
546impl BlockBuilder {
547    /// Creates a new BlockBuilder instance
548    ///
549    /// Initializes a BlockBuilder with the specified block type.
550    ///
551    /// ## Parameters
552    /// - `block_type`: The type of block to construct
553    pub fn new(block_type: BlockType) -> Self {
554        Self {
555            block_type,
556            content: None,
557            level: None,
558            url: None,
559            alt: None,
560            caption: None,
561            fallback: None,
562            element_str: None,
563            fallback_image: None,
564            footnotes: vec![],
565        }
566    }
567
568    /// Sets the text content of the block
569    ///
570    /// Used for Text, Quote, and Title block types.
571    ///
572    /// ## Parameters
573    /// - `content`: The text content to set
574    pub fn set_content(&mut self, content: &str) -> &mut Self {
575        self.content = Some(content.to_string());
576        self
577    }
578
579    /// Sets the heading level for a Title block
580    ///
581    /// Only applicable to Title block types. Valid range is 1 to 6.
582    /// If the level is outside the valid range, this method silently ignores the setting
583    /// and returns self unchanged.
584    ///
585    /// ## Parameters
586    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
587    pub fn set_title_level(&mut self, level: usize) -> &mut Self {
588        if !(1..=6).contains(&level) {
589            return self;
590        }
591
592        self.level = Some(level);
593        self
594    }
595
596    /// Sets the media file path
597    ///
598    /// Used for Image, Audio, and Video block types. This method validates that
599    /// the file is a recognized image, audio, or video type.
600    ///
601    /// ## Parameters
602    /// - `url`: The path to the media file
603    ///
604    /// ## Return
605    /// - `Ok(&mut self)`: If the file type is valid
606    /// - `Err(EpubError)`: The file does not exist or the file format is not image, audio, or video
607    pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
608        match Self::is_target_type(
609            url,
610            vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
611        ) {
612            Ok(_) => {
613                self.url = Some(url.to_path_buf());
614                Ok(self)
615            }
616            Err(err) => Err(err),
617        }
618    }
619
620    /// Sets the alternative text for an image
621    ///
622    /// Only applicable to Image block types.
623    /// Alternative text is displayed when the image cannot be loaded.
624    ///
625    /// ## Parameters
626    /// - `alt`: The alternative text for the image
627    pub fn set_alt(&mut self, alt: &str) -> &mut Self {
628        self.alt = Some(alt.to_string());
629        self
630    }
631
632    /// Sets the caption for the block
633    ///
634    /// Used for Image, Audio, Video, and MathML block types.
635    /// The caption is displayed below the media or element.
636    ///
637    /// ## Parameters
638    /// - `caption`: The caption text to display
639    pub fn set_caption(&mut self, caption: &str) -> &mut Self {
640        self.caption = Some(caption.to_string());
641        self
642    }
643
644    /// Sets the fallback text for audio or video content
645    ///
646    /// Used for Audio and Video block types.
647    /// The fallback text is displayed when the media file cannot be played.
648    ///
649    /// ## Parameters
650    /// - `fallback`: The fallback text content
651    pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
652        self.fallback = Some(fallback.to_string());
653        self
654    }
655
656    /// Sets the raw MathML element string
657    ///
658    /// Only applicable to MathML block types.
659    /// This method accepts the raw MathML markup data without validation.
660    /// The user is responsible for ensuring the MathML is well-formed.
661    ///
662    /// ## Parameters
663    /// - `element_str`: The raw MathML markup string
664    pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
665        self.element_str = Some(element_str.to_string());
666        self
667    }
668
669    /// Sets the fallback image for MathML content
670    ///
671    /// Only applicable to MathML block types.
672    /// The fallback image is displayed when the MathML markup cannot be rendered.
673    /// This method validates that the file is a recognized image type.
674    ///
675    /// ## Parameters
676    /// - `fallback_image`: The path to the fallback image file
677    ///
678    /// ## Return
679    /// - `Ok(self)`: If the file type is valid
680    /// - `Err(EpubError)`: If validation fails
681    pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
682        match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
683            Ok(_) => {
684                self.fallback_image = Some(fallback_image);
685                Ok(self)
686            }
687            Err(err) => Err(err),
688        }
689    }
690
691    /// Adds a footnote to the block
692    ///
693    /// Adds a single footnote to the block's footnotes collection.
694    /// The footnote must reference a valid position within the content.
695    ///
696    /// ## Parameters
697    /// - `footnote`: The footnote to add
698    pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
699        self.footnotes.push(footnote);
700        self
701    }
702
703    /// Sets all footnotes for the block
704    ///
705    /// Replaces the current footnotes collection with the provided one.
706    /// All footnotes must reference valid positions within the content.
707    ///
708    /// ## Parameters
709    /// - `footnotes`: The vector of footnotes to set
710    pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
711        self.footnotes = footnotes;
712        self
713    }
714
715    /// Removes the last footnote
716    ///
717    /// Removes and discards the last footnote from the footnotes collection.
718    /// If the collection is empty, this method has no effect.
719    pub fn remove_last_footnote(&mut self) -> &mut Self {
720        self.footnotes.pop();
721        self
722    }
723
724    /// Clears all footnotes
725    ///
726    /// Removes all footnotes from the block's footnotes collection.
727    pub fn clear_footnotes(&mut self) -> &mut Self {
728        self.footnotes.clear();
729        self
730    }
731
732    /// Builds the block
733    ///
734    /// Constructs a Block instance based on the configured parameters and block type.
735    /// This method validates that all required fields are set for the specified block type
736    /// and validates the footnotes to ensure they reference valid content positions.
737    ///
738    /// ## Return
739    /// - `Ok(Block)`: Build successful
740    /// - `Err(EpubError)`: Error occurred during the build process
741    pub fn build(self) -> Result<Block, EpubError> {
742        let block = match self.block_type {
743            BlockType::Text => {
744                if let Some(content) = self.content {
745                    Block::Text { content, footnotes: self.footnotes }
746                } else {
747                    return Err(EpubBuilderError::MissingNecessaryBlockData {
748                        block_type: "Text".to_string(),
749                        missing_data: "'content'".to_string(),
750                    }
751                    .into());
752                }
753            }
754
755            BlockType::Quote => {
756                if let Some(content) = self.content {
757                    Block::Quote { content, footnotes: self.footnotes }
758                } else {
759                    return Err(EpubBuilderError::MissingNecessaryBlockData {
760                        block_type: "Quote".to_string(),
761                        missing_data: "'content'".to_string(),
762                    }
763                    .into());
764                }
765            }
766
767            BlockType::Title => match (self.content, self.level) {
768                (Some(content), Some(level)) => Block::Title {
769                    content,
770                    level,
771                    footnotes: self.footnotes,
772                },
773                _ => {
774                    return Err(EpubBuilderError::MissingNecessaryBlockData {
775                        block_type: "Title".to_string(),
776                        missing_data: "'content' or 'level'".to_string(),
777                    }
778                    .into());
779                }
780            },
781
782            BlockType::Image => {
783                if let Some(url) = self.url {
784                    Block::Image {
785                        url,
786                        alt: self.alt,
787                        caption: self.caption,
788                        footnotes: self.footnotes,
789                    }
790                } else {
791                    return Err(EpubBuilderError::MissingNecessaryBlockData {
792                        block_type: "Image".to_string(),
793                        missing_data: "'url'".to_string(),
794                    }
795                    .into());
796                }
797            }
798
799            BlockType::Audio => match (self.url, self.fallback) {
800                (Some(url), Some(fallback)) => Block::Audio {
801                    url,
802                    fallback,
803                    caption: self.caption,
804                    footnotes: self.footnotes,
805                },
806                _ => {
807                    return Err(EpubBuilderError::MissingNecessaryBlockData {
808                        block_type: "Audio".to_string(),
809                        missing_data: "'url' or 'fallback'".to_string(),
810                    }
811                    .into());
812                }
813            },
814
815            BlockType::Video => match (self.url, self.fallback) {
816                (Some(url), Some(fallback)) => Block::Video {
817                    url,
818                    fallback,
819                    caption: self.caption,
820                    footnotes: self.footnotes,
821                },
822                _ => {
823                    return Err(EpubBuilderError::MissingNecessaryBlockData {
824                        block_type: "Video".to_string(),
825                        missing_data: "'url' or 'fallback'".to_string(),
826                    }
827                    .into());
828                }
829            },
830
831            BlockType::MathML => {
832                if let Some(element_str) = self.element_str {
833                    Block::MathML {
834                        element_str,
835                        fallback_image: self.fallback_image,
836                        caption: self.caption,
837                        footnotes: self.footnotes,
838                    }
839                } else {
840                    return Err(EpubBuilderError::MissingNecessaryBlockData {
841                        block_type: "MathML".to_string(),
842                        missing_data: "'element_str'".to_string(),
843                    }
844                    .into());
845                }
846            }
847        };
848
849        Self::validate_footnotes(&block)?;
850        Ok(block)
851    }
852
853    /// Validates that the file type matches expected types
854    ///
855    /// Identifies the file type by reading the file header and validates whether
856    /// it belongs to one of the expected types. Uses file magic numbers for
857    /// reliable type detection.
858    ///
859    /// ## Parameters
860    /// - `path`: The path to the file to check
861    /// - `types`: The vector of expected file types
862    fn is_target_type(path: &PathBuf, types: Vec<MatcherType>) -> Result<(), EpubError> {
863        if !path.is_file() {
864            return Err(EpubBuilderError::TargetIsNotFile {
865                target_path: path.to_string_lossy().to_string(),
866            }
867            .into());
868        }
869
870        let mut file = File::open(path)?;
871        let mut buf = [0; 512];
872        let read_size = file.read(&mut buf)?;
873        let header_bytes = &buf[..read_size];
874
875        match Infer::new().get(header_bytes) {
876            Some(file_type) if !types.contains(&file_type.matcher_type()) => {
877                Err(EpubBuilderError::NotExpectedFileFormat.into())
878            }
879
880            None => Err(EpubBuilderError::UnknownFileFormat {
881                file_path: path.to_string_lossy().to_string(),
882            }
883            .into()),
884
885            _ => Ok(()),
886        }
887    }
888
889    /// Validates the footnotes in a block
890    ///
891    /// Ensures all footnotes reference valid positions within the content.
892    /// For Text, Quote, and Title blocks, footnotes must be within the character count of the content.
893    /// For Image, Audio, Video, and MathML blocks, footnotes must be within the character count
894    /// of the caption (if a caption is set). Blocks with media but no caption cannot have footnotes.
895    fn validate_footnotes(block: &Block) -> Result<(), EpubError> {
896        match block {
897            Block::Text { content, footnotes }
898            | Block::Quote { content, footnotes }
899            | Block::Title { content, footnotes, .. } => {
900                let max_locate = content.chars().count();
901                for footnote in footnotes.iter() {
902                    if footnote.locate == 0 || footnote.locate > content.chars().count() {
903                        return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
904                    }
905                }
906
907                Ok(())
908            }
909
910            Block::Image { caption, footnotes, .. }
911            | Block::MathML { caption, footnotes, .. }
912            | Block::Video { caption, footnotes, .. }
913            | Block::Audio { caption, footnotes, .. } => {
914                if let Some(caption) = caption {
915                    let max_locate = caption.chars().count();
916                    for footnote in footnotes.iter() {
917                        if footnote.locate == 0 || footnote.locate > caption.chars().count() {
918                            return Err(
919                                EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
920                            );
921                        }
922                    }
923                } else if !footnotes.is_empty() {
924                    return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
925                }
926
927                Ok(())
928            }
929        }
930    }
931}
932
933/// Content Builder
934///
935/// A builder for constructing EPUB content documents with various block types.
936/// This builder manages the creation and organization of content blocks including
937/// text, quotes, headings, images, audio, video, and MathML content.
938#[derive(Debug)]
939pub struct ContentBuilder {
940    /// The unique identifier for the content document
941    ///
942    /// This identifier is used to uniquely identify the content document within the EPUB container.
943    /// If the identifier is not unique, only one content document will be included in the EPUB container;
944    /// and the other content document will be ignored.
945    pub id: String,
946
947    blocks: Vec<Block>,
948    language: String,
949    title: String,
950    styles: StyleOptions,
951
952    pub(crate) temp_dir: PathBuf,
953}
954
955impl ContentBuilder {
956    /// Creates a new ContentBuilder instance
957    ///
958    /// Initializes a ContentBuilder with the specified language code.
959    /// A temporary directory is automatically created to store media files during construction.
960    ///
961    /// ## Parameters
962    /// - `language`: The language code for the document
963    pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
964        let temp_dir = env::temp_dir().join(local_time());
965        fs::create_dir(&temp_dir)?;
966
967        Ok(Self {
968            id: id.to_string(),
969            blocks: vec![],
970            language: language.to_string(),
971            title: String::new(),
972            styles: StyleOptions::default(),
973            temp_dir,
974        })
975    }
976
977    /// Sets the title of the document
978    ///
979    /// Sets the title that will be displayed in the document's head section.
980    ///
981    /// ## Parameters
982    /// - `title`: The title text for the document
983    pub fn set_title(&mut self, title: &str) -> &mut Self {
984        self.title = title.to_string();
985        self
986    }
987
988    /// Sets the styles for the document
989    ///
990    /// ## Parameters
991    /// - `styles`: The StyleOptions to set for the document
992    pub fn set_styles(&mut self, styles: StyleOptions) -> &mut Self {
993        self.styles = styles;
994        self
995    }
996
997    /// Adds a block to the document
998    ///
999    /// Adds a constructed Block to the document.
1000    ///
1001    /// ## Parameters
1002    /// - `block`: The Block to add to the document
1003    pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
1004        self.blocks.push(block);
1005
1006        match self.blocks.last() {
1007            Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
1008                self.handle_resource()?
1009            }
1010
1011            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1012                self.handle_resource()?;
1013            }
1014
1015            _ => {}
1016        }
1017
1018        Ok(self)
1019    }
1020
1021    /// Adds a text block to the document
1022    ///
1023    /// Convenience method that creates and adds a Text block using the provided content and footnotes.
1024    ///
1025    /// ## Parameters
1026    /// - `content`: The text content of the paragraph
1027    /// - `footnotes`: A vector of footnotes associated with the text
1028    pub fn add_text_block(
1029        &mut self,
1030        content: &str,
1031        footnotes: Vec<Footnote>,
1032    ) -> Result<&mut Self, EpubError> {
1033        let mut builder = BlockBuilder::new(BlockType::Text);
1034        builder.set_content(content).set_footnotes(footnotes);
1035
1036        self.blocks.push(builder.build()?);
1037        Ok(self)
1038    }
1039
1040    /// Adds a quote block to the document
1041    ///
1042    /// Convenience method that creates and adds a Quote block using the provided content and footnotes.
1043    ///
1044    /// ## Parameters
1045    /// - `content`: The quoted text
1046    /// - `footnotes`: A vector of footnotes associated with the quote
1047    pub fn add_quote_block(
1048        &mut self,
1049        content: &str,
1050        footnotes: Vec<Footnote>,
1051    ) -> Result<&mut Self, EpubError> {
1052        let mut builder = BlockBuilder::new(BlockType::Quote);
1053        builder.set_content(content).set_footnotes(footnotes);
1054
1055        self.blocks.push(builder.build()?);
1056        Ok(self)
1057    }
1058
1059    /// Adds a heading block to the document
1060    ///
1061    /// Convenience method that creates and adds a Title block with the specified level.
1062    ///
1063    /// ## Parameters
1064    /// - `content`: The heading text
1065    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
1066    /// - `footnotes`: A vector of footnotes associated with the heading
1067    pub fn add_title_block(
1068        &mut self,
1069        content: &str,
1070        level: usize,
1071        footnotes: Vec<Footnote>,
1072    ) -> Result<&mut Self, EpubError> {
1073        let mut builder = BlockBuilder::new(BlockType::Title);
1074        builder
1075            .set_content(content)
1076            .set_title_level(level)
1077            .set_footnotes(footnotes);
1078
1079        self.blocks.push(builder.build()?);
1080        Ok(self)
1081    }
1082
1083    /// Adds an image block to the document
1084    ///
1085    /// Convenience method that creates and adds an Image block with optional alt text,
1086    /// caption, and footnotes.
1087    ///
1088    /// ## Parameters
1089    /// - `url`: The path to the image file
1090    /// - `alt`: Optional alternative text for the image (displayed when image cannot load)
1091    /// - `caption`: Optional caption text to display below the image
1092    /// - `footnotes`: A vector of footnotes associated with the caption or image
1093    pub fn add_image_block(
1094        &mut self,
1095        url: PathBuf,
1096        alt: Option<String>,
1097        caption: Option<String>,
1098        footnotes: Vec<Footnote>,
1099    ) -> Result<&mut Self, EpubError> {
1100        let mut builder = BlockBuilder::new(BlockType::Image);
1101        builder.set_url(&url)?.set_footnotes(footnotes);
1102
1103        if let Some(alt) = &alt {
1104            builder.set_alt(alt);
1105        }
1106
1107        if let Some(caption) = &caption {
1108            builder.set_caption(caption);
1109        }
1110
1111        self.blocks.push(builder.build()?);
1112        self.handle_resource()?;
1113        Ok(self)
1114    }
1115
1116    /// Adds an audio block to the document
1117    ///
1118    /// Convenience method that creates and adds an Audio block with fallback text,
1119    /// optional caption, and footnotes.
1120    ///
1121    /// ## Parameters
1122    /// - `url`: The path to the audio file
1123    /// - `fallback`: Fallback text displayed when the audio cannot be played
1124    /// - `caption`: Optional caption text to display below the audio player
1125    /// - `footnotes`: A vector of footnotes associated with the caption or audio
1126    pub fn add_audio_block(
1127        &mut self,
1128        url: PathBuf,
1129        fallback: String,
1130        caption: Option<String>,
1131        footnotes: Vec<Footnote>,
1132    ) -> Result<&mut Self, EpubError> {
1133        let mut builder = BlockBuilder::new(BlockType::Audio);
1134        builder
1135            .set_url(&url)?
1136            .set_fallback(&fallback)
1137            .set_footnotes(footnotes);
1138
1139        if let Some(caption) = &caption {
1140            builder.set_caption(caption);
1141        }
1142
1143        self.blocks.push(builder.build()?);
1144        self.handle_resource()?;
1145        Ok(self)
1146    }
1147
1148    /// Adds a video block to the document
1149    ///
1150    /// Convenience method that creates and adds a Video block with fallback text,
1151    /// optional caption, and footnotes.
1152    ///
1153    /// ## Parameters
1154    /// - `url`: The path to the video file
1155    /// - `fallback`: Fallback text displayed when the video cannot be played
1156    /// - `caption`: Optional caption text to display below the video player
1157    /// - `footnotes`: A vector of footnotes associated with the caption or video
1158    pub fn add_video_block(
1159        &mut self,
1160        url: PathBuf,
1161        fallback: String,
1162        caption: Option<String>,
1163        footnotes: Vec<Footnote>,
1164    ) -> Result<&mut Self, EpubError> {
1165        let mut builder = BlockBuilder::new(BlockType::Video);
1166        builder
1167            .set_url(&url)?
1168            .set_fallback(&fallback)
1169            .set_footnotes(footnotes);
1170
1171        if let Some(caption) = &caption {
1172            builder.set_caption(caption);
1173        }
1174
1175        self.blocks.push(builder.build()?);
1176        self.handle_resource()?;
1177        Ok(self)
1178    }
1179
1180    /// Adds a MathML block to the document
1181    ///
1182    /// Convenience method that creates and adds a MathML block with optional fallback image,
1183    /// caption, and footnotes.
1184    ///
1185    /// ## Parameters
1186    /// - `element_str`: The raw MathML markup string
1187    /// - `fallback_image`: Optional path to a fallback image displayed when MathML cannot render
1188    /// - `caption`: Optional caption text to display below the MathML element
1189    /// - `footnotes`: A vector of footnotes associated with the caption or equation
1190    pub fn add_mathml_block(
1191        &mut self,
1192        element_str: String,
1193        fallback_image: Option<PathBuf>,
1194        caption: Option<String>,
1195        footnotes: Vec<Footnote>,
1196    ) -> Result<&mut Self, EpubError> {
1197        let mut builder = BlockBuilder::new(BlockType::MathML);
1198        builder
1199            .set_mathml_element(&element_str)
1200            .set_footnotes(footnotes);
1201
1202        if let Some(fallback_image) = fallback_image {
1203            builder.set_fallback_image(fallback_image)?;
1204        }
1205
1206        if let Some(caption) = &caption {
1207            builder.set_caption(caption);
1208        }
1209
1210        self.blocks.push(builder.build()?);
1211        self.handle_resource()?;
1212        Ok(self)
1213    }
1214
1215    /// Removes the last block from the document
1216    ///
1217    /// Discards the most recently added block. If no blocks exist, this method has no effect.
1218    pub fn remove_last_block(&mut self) -> &mut Self {
1219        self.blocks.pop();
1220        self
1221    }
1222
1223    /// Takes ownership of the last block
1224    ///
1225    /// Removes and returns the most recently added block without consuming the builder.
1226    /// This allows you to extract a block while keeping the builder alive.
1227    ///
1228    /// ## Return
1229    /// - `Some(Block)`: If a block exists
1230    /// - `None`: If the blocks collection is empty
1231    pub fn take_last_block(&mut self) -> Option<Block> {
1232        self.blocks.pop()
1233    }
1234
1235    /// Clears all blocks from the document
1236    ///
1237    /// Removes all blocks from the document while keeping the language and title settings intact.
1238    pub fn clear_blocks(&mut self) -> &mut Self {
1239        self.blocks.clear();
1240        self
1241    }
1242
1243    /// Builds content document
1244    ///
1245    /// ## Parameters
1246    /// - `target`: The file path where the document should be written
1247    ///
1248    /// ## Return
1249    /// - `Ok(Vec<PathBuf>)`: A vector of paths to all resources used in the document
1250    /// - `Err(EpubError)`: Error occurred during the making process
1251    pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1252        let mut result = Vec::new();
1253
1254        // Handle target directory, create if it doesn't exist
1255        let target_dir = match target.as_ref().parent() {
1256            Some(path) => {
1257                fs::create_dir_all(path)?;
1258                path.to_path_buf()
1259            }
1260            None => {
1261                return Err(EpubBuilderError::InvalidTargetPath {
1262                    target_path: target.as_ref().to_string_lossy().to_string(),
1263                }
1264                .into());
1265            }
1266        };
1267
1268        self.make_content(&target)?;
1269        result.push(target.as_ref().to_path_buf());
1270
1271        // Copy all resource files (images, audio, video) from temp directory to target directory
1272        for resource_type in ["img", "audio", "video"] {
1273            let source = self.temp_dir.join(resource_type);
1274            if source.exists() && source.is_dir() {
1275                let target = target_dir.join(resource_type);
1276                fs::create_dir_all(&target)?;
1277
1278                for entry in fs::read_dir(&source)? {
1279                    let entry = entry?;
1280                    if entry.file_type()?.is_file() {
1281                        let file_name = entry.file_name();
1282                        let target = target.join(&file_name);
1283
1284                        fs::copy(source.join(&file_name), &target)?;
1285                        result.push(target);
1286                    }
1287                }
1288            }
1289        }
1290
1291        Ok(result)
1292    }
1293
1294    /// Write the document to a file
1295    ///
1296    /// Constructs the final XHTML document from all added blocks and writes it to the specified output path.
1297    ///
1298    /// ## Parameters
1299    /// - `target_path`: The file path where the XHTML document should be written
1300    fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1301        let mut writer = Writer::new(Cursor::new(Vec::new()));
1302
1303        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1304        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1305            ("xmlns", "http://www.w3.org/1999/xhtml"),
1306            ("xml:lang", self.language.as_str()),
1307        ])))?;
1308
1309        // make head
1310        writer.write_event(Event::Start(BytesStart::new("head")))?;
1311        writer.write_event(Event::Start(BytesStart::new("title")))?;
1312        writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1313        writer.write_event(Event::End(BytesEnd::new("title")))?;
1314
1315        self.make_style(&mut writer)?;
1316
1317        writer.write_event(Event::End(BytesEnd::new("head")))?;
1318
1319        // make body
1320        writer.write_event(Event::Start(BytesStart::new("body")))?;
1321        writer.write_event(Event::Start(BytesStart::new("main")))?;
1322
1323        let mut footnote_index = 1;
1324        let mut footnotes = Vec::new();
1325        for block in self.blocks.iter_mut() {
1326            block.make(&mut writer, footnote_index)?;
1327
1328            footnotes.append(&mut block.take_footnotes());
1329            footnote_index = footnotes.len() + 1;
1330        }
1331
1332        writer.write_event(Event::End(BytesEnd::new("main")))?;
1333
1334        Self::make_footnotes(&mut writer, footnotes)?;
1335        writer.write_event(Event::End(BytesEnd::new("body")))?;
1336        writer.write_event(Event::End(BytesEnd::new("html")))?;
1337
1338        let file_path = PathBuf::from(target_path.as_ref());
1339        let file_data = writer.into_inner().into_inner();
1340        fs::write(file_path, file_data)?;
1341
1342        Ok(())
1343    }
1344
1345    /// Generates CSS styles for the document
1346    fn make_style(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
1347        let style = format!(
1348            r#"
1349            * {{
1350                margin: 0;
1351                padding: 0;
1352                font-family: {font_family};
1353                text-align: {text_align};
1354                background-color: {background};
1355                color: {text};
1356            }}
1357            body, p, div, span, li, td, th {{
1358                font-size: {font_size}rem;
1359                line-height: {line_height}em;
1360                font-weight: {font_weight};
1361                font-style: {font_style};
1362                letter-spacing: {letter_spacing};
1363            }}
1364            body {{ margin: {margin}px; }}
1365            p {{ text-indent: {text_indent}em; }}
1366            a {{ color: {link_color}; text-decoration: none; }}
1367            figcaption {{ text-align: center; line-height: 1em; }}
1368            blockquote {{ padding: 1em 2em; }}
1369            blockquote > p {{ font-style: italic; }}
1370            .content-block {{ margin-bottom: {paragraph_spacing}px; }}
1371            .image-block, .audio-block, .video-block {{ width: 100%; }}
1372            .footnote-ref {{ font-size: 0.5em; vertical-align: super; }}
1373            .footnote-list {{ list-style: none; padding: 0; }}
1374            .footnote-item > p {{ text-indent: 0; }}
1375            "#,
1376            font_family = self.styles.text.font_family,
1377            text_align = self.styles.layout.text_align,
1378            background = self.styles.color_scheme.background,
1379            text = self.styles.color_scheme.text,
1380            font_size = self.styles.text.font_size,
1381            line_height = self.styles.text.line_height,
1382            font_weight = self.styles.text.font_weight,
1383            font_style = self.styles.text.font_style,
1384            letter_spacing = self.styles.text.letter_spacing,
1385            margin = self.styles.layout.margin,
1386            text_indent = self.styles.text.text_indent,
1387            link_color = self.styles.color_scheme.link,
1388            paragraph_spacing = self.styles.layout.paragraph_spacing,
1389        );
1390
1391        writer.write_event(Event::Start(BytesStart::new("style")))?;
1392        writer.write_event(Event::Text(BytesText::new(&style)))?;
1393        writer.write_event(Event::End(BytesEnd::new("style")))?;
1394
1395        Ok(())
1396    }
1397
1398    /// Generates the footnotes section in the document
1399    ///
1400    /// Creates an aside element containing an unordered list of all footnotes.
1401    /// Each footnote is rendered as a list item with a backlink to its reference in the text.
1402    fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1403        writer.write_event(Event::Start(BytesStart::new("aside")))?;
1404        writer.write_event(Event::Start(
1405            BytesStart::new("ul").with_attributes([("class", "footnote-list")]),
1406        ))?;
1407
1408        let mut index = 1;
1409        for footnote in footnotes.into_iter() {
1410            writer.write_event(Event::Start(BytesStart::new("li").with_attributes([
1411                ("id", format!("footnote-{}", index).as_str()),
1412                ("class", "footnote-item"),
1413            ])))?;
1414            writer.write_event(Event::Start(BytesStart::new("p")))?;
1415
1416            writer.write_event(Event::Start(
1417                BytesStart::new("a")
1418                    .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1419            ))?;
1420            writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1421            writer.write_event(Event::End(BytesEnd::new("a")))?;
1422            writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1423
1424            writer.write_event(Event::End(BytesEnd::new("p")))?;
1425            writer.write_event(Event::End(BytesEnd::new("li")))?;
1426
1427            index += 1;
1428        }
1429
1430        writer.write_event(Event::End(BytesEnd::new("ul")))?;
1431        writer.write_event(Event::End(BytesEnd::new("aside")))?;
1432
1433        Ok(())
1434    }
1435
1436    /// Automatically handles media resources
1437    ///
1438    /// Copies media files (images, audio, video) from their original locations
1439    /// to the temporary directory for inclusion in the EPUB package.
1440    fn handle_resource(&mut self) -> Result<(), EpubError> {
1441        match self.blocks.last() {
1442            Some(Block::Image { url, .. }) => {
1443                let target_dir = self.temp_dir.join("img");
1444                fs::create_dir_all(&target_dir)?;
1445
1446                let target_path = target_dir.join(url.file_name().unwrap());
1447                fs::copy(url, &target_path)?;
1448            }
1449
1450            Some(Block::Video { url, .. }) => {
1451                let target_dir = self.temp_dir.join("video");
1452                fs::create_dir_all(&target_dir)?;
1453
1454                let target_path = target_dir.join(url.file_name().unwrap());
1455                fs::copy(url, &target_path)?;
1456            }
1457
1458            Some(Block::Audio { url, .. }) => {
1459                let target_dir = self.temp_dir.join("audio");
1460                fs::create_dir_all(&target_dir)?;
1461
1462                let target_path = target_dir.join(url.file_name().unwrap());
1463                fs::copy(url, &target_path)?;
1464            }
1465
1466            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1467                let target_dir = self.temp_dir.join("img");
1468                fs::create_dir_all(&target_dir)?;
1469
1470                let target_path =
1471                    target_dir.join(fallback_image.as_ref().unwrap().file_name().unwrap());
1472
1473                fs::copy(fallback_image.as_ref().unwrap(), &target_path)?;
1474            }
1475
1476            Some(_) => {}
1477            None => {}
1478        }
1479
1480        Ok(())
1481    }
1482}
1483
1484impl Drop for ContentBuilder {
1485    fn drop(&mut self) {
1486        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1487            warn!("{}", err);
1488        };
1489    }
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494    // use std::{path::PathBuf, vec};
1495
1496    // use crate::{
1497    //     builder::content::{ContentBuilder, Footnote},
1498    //     error::EpubError,
1499    // };
1500
1501    // #[test]
1502    // fn test() -> Result<(), EpubError> {
1503    //     let ele_string = r#"
1504    //     <math xmlns="http://www.w3.org/1998/Math/MathML">
1505    //       <mrow>
1506    //         <munderover>
1507    //           <mo>∑</mo>
1508    //           <mrow>
1509    //             <mi>n</mi>
1510    //             <mo>=</mo>
1511    //             <mn>1</mn>
1512    //           </mrow>
1513    //           <mrow>
1514    //             <mo>+</mo>
1515    //             <mn>∞</mn>
1516    //           </mrow>
1517    //         </munderover>
1518    //         <mfrac>
1519    //           <mn>1</mn>
1520    //           <msup>
1521    //             <mi>n</mi>
1522    //             <mn>2</mn>
1523    //           </msup>
1524    //         </mfrac>
1525    //       </mrow>
1526    //     </math>"#;
1527
1528    //     let content = ContentBuilder::new("test", "zh-CN")?
1529    //         .set_title("Test")
1530    //         .add_title_block(
1531    //             "This is a title",
1532    //             2,
1533    //             vec![
1534    //                 Footnote {
1535    //                     content: "This is a footnote for title.".to_string(),
1536    //                     locate: 15,
1537    //                 },
1538    //                 Footnote {
1539    //                     content: "This is another footnote for title.".to_string(),
1540    //                     locate: 4,
1541    //                 },
1542    //             ],
1543    //         )?
1544    //         .add_text_block(
1545    //             "This is a paragraph.",
1546    //             vec![
1547    //                 Footnote {
1548    //                     content: "This is a footnote.".to_string(),
1549    //                     locate: 4,
1550    //                 },
1551    //                 Footnote {
1552    //                     content: "This is another footnote.".to_string(),
1553    //                     locate: 20,
1554    //                 },
1555    //                 Footnote {
1556    //                     content: "This is a third footnote.".to_string(),
1557    //                     locate: 4,
1558    //                 },
1559    //             ],
1560    //         )?
1561    //         .add_image_block(
1562    //             PathBuf::from("C:\\Users\\Kikki\\Desktop\\background.jpg"),
1563    //             None,
1564    //             Some("this is an image".to_string()),
1565    //             vec![Footnote {
1566    //                 content: "This is a footnote for image.".to_string(),
1567    //                 locate: 16,
1568    //             }],
1569    //         )?
1570    //         .add_quote_block(
1571    //             "Quote a text.",
1572    //             vec![Footnote {
1573    //                 content: "This is a footnote for quote.".to_string(),
1574    //                 locate: 13,
1575    //             }],
1576    //         )?
1577    //         .add_audio_block(
1578    //             PathBuf::from("C:\\Users\\Kikki\\Desktop\\audio.mp3"),
1579    //             "This a fallback string".to_string(),
1580    //             Some("this is an audio".to_string()),
1581    //             vec![Footnote {
1582    //                 content: "This is a footnote for audio.".to_string(),
1583    //                 locate: 4,
1584    //             }],
1585    //         )?
1586    //         .add_video_block(
1587    //             PathBuf::from("C:\\Users\\Kikki\\Desktop\\秋日何时来2024BD1080P.mp4"),
1588    //             "This a fallback string".to_string(),
1589    //             Some("this a video".to_string()),
1590    //             vec![Footnote {
1591    //                 content: "This is a footnote for video.".to_string(),
1592    //                 locate: 12,
1593    //             }],
1594    //         )?
1595    //         .add_mathml_block(
1596    //             ele_string.to_owned(),
1597    //             None,
1598    //             Some("this is a formula".to_string()),
1599    //             vec![Footnote {
1600    //                 content: "This is a footnote for formula.".to_string(),
1601    //                 locate: 17,
1602    //             }],
1603    //         )?
1604    //         .make("C:\\Users\\Kikki\\Desktop\\test.xhtml");
1605    //     assert!(content.is_ok());
1606    //     Ok(())
1607    // }
1608
1609    mod block_builder_tests {
1610        use std::path::PathBuf;
1611
1612        use crate::{
1613            builder::content::{Block, BlockBuilder},
1614            error::EpubBuilderError,
1615            types::{BlockType, Footnote},
1616        };
1617
1618        #[test]
1619        fn test_create_text_block() {
1620            let mut builder = BlockBuilder::new(BlockType::Text);
1621            builder.set_content("Hello, World!");
1622
1623            let block = builder.build();
1624            assert!(block.is_ok());
1625
1626            let block = block.unwrap();
1627            match block {
1628                Block::Text { content, footnotes } => {
1629                    assert_eq!(content, "Hello, World!");
1630                    assert!(footnotes.is_empty());
1631                }
1632                _ => unreachable!(),
1633            }
1634        }
1635
1636        #[test]
1637        fn test_create_text_block_missing_content() {
1638            let builder = BlockBuilder::new(BlockType::Text);
1639
1640            let block = builder.build();
1641            assert!(block.is_err());
1642
1643            let result = block.unwrap_err();
1644            assert_eq!(
1645                result,
1646                EpubBuilderError::MissingNecessaryBlockData {
1647                    block_type: "Text".to_string(),
1648                    missing_data: "'content'".to_string()
1649                }
1650                .into()
1651            )
1652        }
1653
1654        #[test]
1655        fn test_create_quote_block() {
1656            let mut builder = BlockBuilder::new(BlockType::Quote);
1657            builder.set_content("To be or not to be");
1658
1659            let block = builder.build();
1660            assert!(block.is_ok());
1661
1662            let block = block.unwrap();
1663            match block {
1664                Block::Quote { content, footnotes } => {
1665                    assert_eq!(content, "To be or not to be");
1666                    assert!(footnotes.is_empty());
1667                }
1668                _ => unreachable!(),
1669            }
1670        }
1671
1672        #[test]
1673        fn test_create_title_block() {
1674            let mut builder = BlockBuilder::new(BlockType::Title);
1675            builder.set_content("Chapter 1").set_title_level(2);
1676
1677            let block = builder.build();
1678            assert!(block.is_ok());
1679
1680            let block = block.unwrap();
1681            match block {
1682                Block::Title { content, level, footnotes } => {
1683                    assert_eq!(content, "Chapter 1");
1684                    assert_eq!(level, 2);
1685                    assert!(footnotes.is_empty());
1686                }
1687                _ => unreachable!(),
1688            }
1689        }
1690
1691        #[test]
1692        fn test_create_title_block_invalid_level() {
1693            let mut builder = BlockBuilder::new(BlockType::Title);
1694            builder.set_content("Chapter 1").set_title_level(10);
1695
1696            let result = builder.build();
1697            assert!(result.is_err());
1698
1699            let result = result.unwrap_err();
1700            assert_eq!(
1701                result,
1702                EpubBuilderError::MissingNecessaryBlockData {
1703                    block_type: "Title".to_string(),
1704                    missing_data: "'content' or 'level'".to_string(),
1705                }
1706                .into()
1707            );
1708        }
1709
1710        #[test]
1711        fn test_create_image_block() {
1712            let img_path = PathBuf::from("./test_case/image.jpg");
1713            let mut builder = BlockBuilder::new(BlockType::Image);
1714            builder
1715                .set_url(&img_path)
1716                .unwrap()
1717                .set_alt("Test Image")
1718                .set_caption("A test image");
1719
1720            let block = builder.build();
1721            assert!(block.is_ok());
1722
1723            let block = block.unwrap();
1724            match block {
1725                Block::Image { url, alt, caption, footnotes } => {
1726                    assert_eq!(url.file_name().unwrap(), "image.jpg");
1727                    assert_eq!(alt, Some("Test Image".to_string()));
1728                    assert_eq!(caption, Some("A test image".to_string()));
1729                    assert!(footnotes.is_empty());
1730                }
1731                _ => unreachable!(),
1732            }
1733        }
1734
1735        #[test]
1736        fn test_create_image_block_missing_url() {
1737            let builder = BlockBuilder::new(BlockType::Image);
1738
1739            let block = builder.build();
1740            assert!(block.is_err());
1741
1742            let result = block.unwrap_err();
1743            assert_eq!(
1744                result,
1745                EpubBuilderError::MissingNecessaryBlockData {
1746                    block_type: "Image".to_string(),
1747                    missing_data: "'url'".to_string(),
1748                }
1749                .into()
1750            );
1751        }
1752
1753        #[test]
1754        fn test_create_audio_block() {
1755            let audio_path = PathBuf::from("./test_case/audio.mp3");
1756            let mut builder = BlockBuilder::new(BlockType::Audio);
1757            builder
1758                .set_url(&audio_path)
1759                .unwrap()
1760                .set_fallback("Audio not supported")
1761                .set_caption("Background music");
1762
1763            let block = builder.build();
1764            assert!(block.is_ok());
1765
1766            let block = block.unwrap();
1767            match block {
1768                Block::Audio { url, fallback, caption, footnotes } => {
1769                    assert_eq!(url.file_name().unwrap(), "audio.mp3");
1770                    assert_eq!(fallback, "Audio not supported");
1771                    assert_eq!(caption, Some("Background music".to_string()));
1772                    assert!(footnotes.is_empty());
1773                }
1774                _ => unreachable!(),
1775            }
1776        }
1777
1778        #[test]
1779        fn test_set_url_invalid_file_type() {
1780            let xhtml_path = PathBuf::from("./test_case/Overview.xhtml");
1781            let mut builder = BlockBuilder::new(BlockType::Image);
1782            let result = builder.set_url(&xhtml_path);
1783            assert!(result.is_err());
1784
1785            let err = result.unwrap_err();
1786            assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1787        }
1788
1789        #[test]
1790        fn test_set_url_nonexistent_file() {
1791            let nonexistent_path = PathBuf::from("./test_case/nonexistent.jpg");
1792            let mut builder = BlockBuilder::new(BlockType::Image);
1793            let result = builder.set_url(&nonexistent_path);
1794            assert!(result.is_err());
1795
1796            let err = result.unwrap_err();
1797            assert_eq!(
1798                err,
1799                EpubBuilderError::TargetIsNotFile {
1800                    target_path: "./test_case/nonexistent.jpg".to_string()
1801                }
1802                .into()
1803            );
1804        }
1805
1806        #[test]
1807        fn test_set_fallback_image_invalid_type() {
1808            let audio_path = PathBuf::from("./test_case/audio.mp3");
1809            let mut builder = BlockBuilder::new(BlockType::MathML);
1810            builder.set_mathml_element("<math/>");
1811            let result = builder.set_fallback_image(audio_path);
1812            assert!(result.is_err());
1813
1814            let err = result.unwrap_err();
1815            assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1816        }
1817
1818        #[test]
1819        fn test_set_fallback_image_nonexistent() {
1820            let nonexistent_path = PathBuf::from("./test_case/nonexistent.png");
1821            let mut builder = BlockBuilder::new(BlockType::MathML);
1822            builder.set_mathml_element("<math/>");
1823            let result = builder.set_fallback_image(nonexistent_path);
1824            assert!(result.is_err());
1825
1826            let err = result.unwrap_err();
1827            assert_eq!(
1828                err,
1829                EpubBuilderError::TargetIsNotFile {
1830                    target_path: "./test_case/nonexistent.png".to_string()
1831                }
1832                .into()
1833            );
1834        }
1835
1836        #[test]
1837        fn test_create_video_block() {
1838            let video_path = PathBuf::from("./test_case/video.mp4");
1839            let mut builder = BlockBuilder::new(BlockType::Video);
1840            builder
1841                .set_url(&video_path)
1842                .unwrap()
1843                .set_fallback("Video not supported")
1844                .set_caption("Demo video");
1845
1846            let block = builder.build();
1847            assert!(block.is_ok());
1848
1849            let block = block.unwrap();
1850            match block {
1851                Block::Video { url, fallback, caption, footnotes } => {
1852                    assert_eq!(url.file_name().unwrap(), "video.mp4");
1853                    assert_eq!(fallback, "Video not supported");
1854                    assert_eq!(caption, Some("Demo video".to_string()));
1855                    assert!(footnotes.is_empty());
1856                }
1857                _ => unreachable!(),
1858            }
1859        }
1860
1861        #[test]
1862        fn test_create_mathml_block() {
1863            let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
1864            let mut builder = BlockBuilder::new(BlockType::MathML);
1865            builder
1866                .set_mathml_element(mathml_content)
1867                .set_caption("Simple equation");
1868
1869            let block = builder.build();
1870            assert!(block.is_ok());
1871
1872            let block = block.unwrap();
1873            match block {
1874                Block::MathML {
1875                    element_str,
1876                    fallback_image,
1877                    caption,
1878                    footnotes,
1879                } => {
1880                    assert_eq!(element_str, mathml_content);
1881                    assert!(fallback_image.is_none());
1882                    assert_eq!(caption, Some("Simple equation".to_string()));
1883                    assert!(footnotes.is_empty());
1884                }
1885                _ => unreachable!(),
1886            }
1887        }
1888
1889        #[test]
1890        fn test_create_mathml_block_with_fallback() {
1891            let img_path = PathBuf::from("./test_case/image.jpg");
1892            let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
1893
1894            let mut builder = BlockBuilder::new(BlockType::MathML);
1895            builder
1896                .set_mathml_element(mathml_content)
1897                .set_fallback_image(img_path.clone())
1898                .unwrap();
1899
1900            let block = builder.build();
1901            assert!(block.is_ok());
1902
1903            let block = block.unwrap();
1904            match block {
1905                Block::MathML { element_str, fallback_image, .. } => {
1906                    assert_eq!(element_str, mathml_content);
1907                    assert!(fallback_image.is_some());
1908                }
1909                _ => unreachable!(),
1910            }
1911        }
1912
1913        #[test]
1914        fn test_footnote_management() {
1915            let mut builder = BlockBuilder::new(BlockType::Text);
1916            builder.set_content("This is a test");
1917
1918            let note1 = Footnote {
1919                locate: 5,
1920                content: "First footnote".to_string(),
1921            };
1922            let note2 = Footnote {
1923                locate: 10,
1924                content: "Second footnote".to_string(),
1925            };
1926
1927            builder.add_footnote(note1).add_footnote(note2);
1928
1929            let block = builder.build();
1930            assert!(block.is_ok());
1931
1932            let block = block.unwrap();
1933            match block {
1934                Block::Text { footnotes, .. } => {
1935                    assert_eq!(footnotes.len(), 2);
1936                }
1937                _ => unreachable!(),
1938            }
1939        }
1940
1941        #[test]
1942        fn test_remove_last_footnote() {
1943            let mut builder = BlockBuilder::new(BlockType::Text);
1944            builder.set_content("This is a test");
1945
1946            builder.add_footnote(Footnote { locate: 5, content: "Note 1".to_string() });
1947            builder.add_footnote(Footnote {
1948                locate: 10,
1949                content: "Note 2".to_string(),
1950            });
1951            builder.remove_last_footnote();
1952
1953            let block = builder.build();
1954            assert!(block.is_ok());
1955
1956            let block = block.unwrap();
1957            match block {
1958                Block::Text { footnotes, .. } => {
1959                    assert_eq!(footnotes.len(), 1);
1960                    assert!(footnotes[0].content == "Note 1");
1961                }
1962                _ => unreachable!(),
1963            }
1964        }
1965
1966        #[test]
1967        fn test_clear_footnotes() {
1968            let mut builder = BlockBuilder::new(BlockType::Text);
1969            builder.set_content("This is a test");
1970
1971            builder.add_footnote(Footnote { locate: 5, content: "Note".to_string() });
1972
1973            builder.clear_footnotes();
1974
1975            let block = builder.build();
1976            assert!(block.is_ok());
1977
1978            let block = block.unwrap();
1979            match block {
1980                Block::Text { footnotes, .. } => {
1981                    assert!(footnotes.is_empty());
1982                }
1983                _ => unreachable!(),
1984            }
1985        }
1986
1987        #[test]
1988        fn test_invalid_footnote_locate() {
1989            let mut builder = BlockBuilder::new(BlockType::Text);
1990            builder.set_content("Hello");
1991
1992            // Footnote locate exceeds content length
1993            builder.add_footnote(Footnote {
1994                locate: 100,
1995                content: "Invalid footnote".to_string(),
1996            });
1997
1998            let result = builder.build();
1999            assert!(result.is_err());
2000
2001            let result = result.unwrap_err();
2002            assert_eq!(
2003                result,
2004                EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
2005            );
2006        }
2007
2008        #[test]
2009        fn test_footnote_on_media_without_caption() {
2010            let img_path = PathBuf::from("./test_case/image.jpg");
2011            let mut builder = BlockBuilder::new(BlockType::Image);
2012            builder.set_url(&img_path).unwrap();
2013
2014            builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
2015
2016            let result = builder.build();
2017            assert!(result.is_err());
2018
2019            let result = result.unwrap_err();
2020            assert_eq!(
2021                result,
2022                EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
2023            );
2024        }
2025    }
2026
2027    mod content_builder_tests {
2028        use std::{env, fs, path::PathBuf};
2029
2030        use crate::{
2031            builder::content::{Block, ContentBuilder},
2032            types::{ColorScheme, Footnote, PageLayout, TextAlign, TextStyle},
2033            utils::local_time,
2034        };
2035
2036        #[test]
2037        fn test_create_content_builder() {
2038            let builder = ContentBuilder::new("chapter1", "en");
2039            assert!(builder.is_ok());
2040
2041            let builder = builder.unwrap();
2042            assert_eq!(builder.id, "chapter1");
2043        }
2044
2045        #[test]
2046        fn test_set_title() {
2047            let builder = ContentBuilder::new("chapter1", "en");
2048            assert!(builder.is_ok());
2049
2050            let mut builder = builder.unwrap();
2051            builder.set_title("My Chapter").set_title("Another Title");
2052
2053            assert_eq!(builder.title, "Another Title");
2054        }
2055
2056        #[test]
2057        fn test_add_text_block() {
2058            let builder = ContentBuilder::new("chapter1", "en");
2059            assert!(builder.is_ok());
2060
2061            let mut builder = builder.unwrap();
2062            let result = builder.add_text_block("This is a paragraph", vec![]);
2063            assert!(result.is_ok());
2064        }
2065
2066        #[test]
2067        fn test_add_quote_block() {
2068            let builder = ContentBuilder::new("chapter1", "en");
2069            assert!(builder.is_ok());
2070
2071            let mut builder = builder.unwrap();
2072            let result = builder.add_quote_block("A quoted text", vec![]);
2073            assert!(result.is_ok());
2074        }
2075
2076        #[test]
2077        fn test_set_styles() {
2078            let builder = ContentBuilder::new("chapter1", "en");
2079            assert!(builder.is_ok());
2080
2081            let custom_styles = crate::types::StyleOptions {
2082                text: TextStyle {
2083                    font_size: 1.5,
2084                    line_height: 1.8,
2085                    font_family: "Georgia, serif".to_string(),
2086                    font_weight: "bold".to_string(),
2087                    font_style: "italic".to_string(),
2088                    letter_spacing: "0.1em".to_string(),
2089                    text_indent: 1.5,
2090                },
2091                color_scheme: ColorScheme {
2092                    background: "#F5F5F5".to_string(),
2093                    text: "#333333".to_string(),
2094                    link: "#0066CC".to_string(),
2095                },
2096                layout: PageLayout {
2097                    margin: 30,
2098                    text_align: TextAlign::Center,
2099                    paragraph_spacing: 20,
2100                },
2101            };
2102
2103            let mut builder = builder.unwrap();
2104            builder.set_styles(custom_styles);
2105
2106            assert_eq!(builder.styles.text.font_size, 1.5);
2107            assert_eq!(builder.styles.text.font_weight, "bold");
2108            assert_eq!(builder.styles.color_scheme.background, "#F5F5F5");
2109            assert_eq!(builder.styles.layout.text_align, TextAlign::Center);
2110        }
2111
2112        #[test]
2113        fn test_add_title_block() {
2114            let builder = ContentBuilder::new("chapter1", "en");
2115            assert!(builder.is_ok());
2116
2117            let mut builder = builder.unwrap();
2118            let result = builder.add_title_block("Section Title", 2, vec![]);
2119            assert!(result.is_ok());
2120        }
2121
2122        #[test]
2123        fn test_add_image_block() {
2124            let img_path = PathBuf::from("./test_case/image.jpg");
2125            let builder = ContentBuilder::new("chapter1", "en");
2126            assert!(builder.is_ok());
2127
2128            let mut builder = builder.unwrap();
2129            let result = builder.add_image_block(
2130                img_path,
2131                Some("Alt text".to_string()),
2132                Some("Figure 1: An image".to_string()),
2133                vec![],
2134            );
2135
2136            assert!(result.is_ok());
2137        }
2138
2139        #[test]
2140        fn test_add_audio_block() {
2141            let audio_path = PathBuf::from("./test_case/audio.mp3");
2142            let builder = ContentBuilder::new("chapter1", "en");
2143            assert!(builder.is_ok());
2144
2145            let mut builder = builder.unwrap();
2146            let result = builder.add_audio_block(
2147                audio_path,
2148                "Your browser doesn't support audio".to_string(),
2149                Some("Background music".to_string()),
2150                vec![],
2151            );
2152
2153            assert!(result.is_ok());
2154        }
2155
2156        #[test]
2157        fn test_add_video_block() {
2158            let video_path = PathBuf::from("./test_case/video.mp4");
2159            let builder = ContentBuilder::new("chapter1", "en");
2160            assert!(builder.is_ok());
2161
2162            let mut builder = builder.unwrap();
2163            let result = builder.add_video_block(
2164                video_path,
2165                "Your browser doesn't support video".to_string(),
2166                Some("Tutorial video".to_string()),
2167                vec![],
2168            );
2169
2170            assert!(result.is_ok());
2171        }
2172
2173        #[test]
2174        fn test_add_mathml_block() {
2175            let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
2176            let builder = ContentBuilder::new("chapter1", "en");
2177            assert!(builder.is_ok());
2178
2179            let mut builder = builder.unwrap();
2180            let result = builder.add_mathml_block(
2181                mathml.to_string(),
2182                None,
2183                Some("Equation 1".to_string()),
2184                vec![],
2185            );
2186
2187            assert!(result.is_ok());
2188        }
2189
2190        #[test]
2191        fn test_remove_last_block() {
2192            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2193
2194            builder.add_text_block("First block", vec![]).unwrap();
2195            builder.add_text_block("Second block", vec![]).unwrap();
2196            assert_eq!(builder.blocks.len(), 2);
2197
2198            builder.remove_last_block();
2199            assert_eq!(builder.blocks.len(), 1);
2200        }
2201
2202        #[test]
2203        fn test_take_last_block() {
2204            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2205
2206            builder.add_text_block("Block content", vec![]).unwrap();
2207
2208            let block = builder.take_last_block();
2209            assert!(block.is_some());
2210
2211            let block = block.unwrap();
2212            match block {
2213                Block::Text { content, .. } => {
2214                    assert_eq!(content, "Block content");
2215                }
2216                _ => unreachable!(),
2217            }
2218
2219            let block2 = builder.take_last_block();
2220            assert!(block2.is_none());
2221        }
2222
2223        #[test]
2224        fn test_clear_blocks() {
2225            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2226
2227            builder.add_text_block("Block 1", vec![]).unwrap();
2228            builder.add_text_block("Block 2", vec![]).unwrap();
2229            assert_eq!(builder.blocks.len(), 2);
2230
2231            builder.clear_blocks();
2232
2233            let block = builder.take_last_block();
2234            assert!(block.is_none());
2235        }
2236
2237        #[test]
2238        fn test_make_content_document() {
2239            let temp_dir = env::temp_dir().join(local_time());
2240            assert!(fs::create_dir_all(&temp_dir).is_ok());
2241
2242            let output_path = temp_dir.join("chapter.xhtml");
2243
2244            let builder = ContentBuilder::new("chapter1", "en");
2245            assert!(builder.is_ok());
2246
2247            let mut builder = builder.unwrap();
2248            builder
2249                .set_title("My Chapter")
2250                .add_text_block("This is the first paragraph.", vec![])
2251                .unwrap()
2252                .add_text_block("This is the second paragraph.", vec![])
2253                .unwrap();
2254
2255            let result = builder.make(&output_path);
2256            assert!(result.is_ok());
2257            assert!(output_path.exists());
2258            assert!(fs::remove_dir_all(temp_dir).is_ok());
2259        }
2260
2261        #[test]
2262        fn test_make_content_with_media() {
2263            let temp_dir = env::temp_dir().join(local_time());
2264            assert!(fs::create_dir_all(&temp_dir).is_ok());
2265
2266            let output_path = temp_dir.join("chapter.xhtml");
2267            let img_path = PathBuf::from("./test_case/image.jpg");
2268
2269            let builder = ContentBuilder::new("chapter1", "en");
2270            assert!(builder.is_ok());
2271
2272            let mut builder = builder.unwrap();
2273            builder
2274                .set_title("Chapter with Media")
2275                .add_text_block("See image below:", vec![])
2276                .unwrap()
2277                .add_image_block(
2278                    img_path,
2279                    Some("Test".to_string()),
2280                    Some("Figure 1".to_string()),
2281                    vec![],
2282                )
2283                .unwrap();
2284
2285            let result = builder.make(&output_path);
2286            assert!(result.is_ok());
2287
2288            let img_dir = temp_dir.join("img");
2289            assert!(img_dir.exists());
2290            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2291        }
2292
2293        #[test]
2294        fn test_make_content_with_footnotes() {
2295            let temp_dir = env::temp_dir().join(local_time());
2296            assert!(fs::create_dir_all(&temp_dir).is_ok());
2297
2298            let output_path = temp_dir.join("chapter.xhtml");
2299
2300            let footnotes = vec![
2301                Footnote {
2302                    locate: 10,
2303                    content: "This is a footnote".to_string(),
2304                },
2305                Footnote {
2306                    locate: 15,
2307                    content: "Another footnote".to_string(),
2308                },
2309            ];
2310
2311            let builder = ContentBuilder::new("chapter1", "en");
2312            assert!(builder.is_ok());
2313
2314            let mut builder = builder.unwrap();
2315            builder
2316                .set_title("Chapter with Notes")
2317                .add_text_block("This is a paragraph with notes.", footnotes)
2318                .unwrap();
2319
2320            let result = builder.make(&output_path);
2321            assert!(result.is_ok());
2322            assert!(output_path.exists());
2323            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2324        }
2325    }
2326
2327    mod block_tests {
2328        use std::path::PathBuf;
2329
2330        use crate::{builder::content::Block, types::Footnote};
2331
2332        #[test]
2333        fn test_take_footnotes_from_text_block() {
2334            let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
2335
2336            let block = Block::Text {
2337                content: "Hello world".to_string(),
2338                footnotes: footnotes.clone(),
2339            };
2340
2341            let taken = block.take_footnotes();
2342            assert_eq!(taken.len(), 1);
2343            assert_eq!(taken[0].content, "Note");
2344        }
2345
2346        #[test]
2347        fn test_take_footnotes_from_quote_block() {
2348            let footnotes = vec![
2349                Footnote { locate: 3, content: "First".to_string() },
2350                Footnote { locate: 8, content: "Second".to_string() },
2351            ];
2352
2353            let block = Block::Quote {
2354                content: "Test quote".to_string(),
2355                footnotes: footnotes.clone(),
2356            };
2357
2358            let taken = block.take_footnotes();
2359            assert_eq!(taken.len(), 2);
2360        }
2361
2362        #[test]
2363        fn test_take_footnotes_from_image_block() {
2364            let img_path = PathBuf::from("test.png");
2365            let footnotes = vec![Footnote {
2366                locate: 2,
2367                content: "Image note".to_string(),
2368            }];
2369
2370            let block = Block::Image {
2371                url: img_path,
2372                alt: None,
2373                caption: Some("A caption".to_string()),
2374                footnotes: footnotes.clone(),
2375            };
2376
2377            let taken = block.take_footnotes();
2378            assert_eq!(taken.len(), 1);
2379        }
2380
2381        #[test]
2382        fn test_block_with_empty_footnotes() {
2383            let block = Block::Text {
2384                content: "No footnotes here".to_string(),
2385                footnotes: vec![],
2386            };
2387
2388            let taken = block.take_footnotes();
2389            assert!(taken.is_empty());
2390        }
2391    }
2392
2393    mod content_rendering_tests {
2394        use crate::builder::content::Block;
2395
2396        #[test]
2397        fn test_split_content_by_index_empty() {
2398            let result = Block::split_content_by_index("Hello", &[]);
2399            assert_eq!(result, vec!["Hello"]);
2400        }
2401
2402        #[test]
2403        fn test_split_content_by_single_index() {
2404            let result = Block::split_content_by_index("Hello World", &[5]);
2405            assert_eq!(result.len(), 2);
2406            assert_eq!(result[0], "Hello");
2407            assert_eq!(result[1], " World");
2408        }
2409
2410        #[test]
2411        fn test_split_content_by_multiple_indices() {
2412            let result = Block::split_content_by_index("One Two Three", &[3, 7]);
2413            assert_eq!(result.len(), 3);
2414            assert_eq!(result[0], "One");
2415            assert_eq!(result[1], " Two");
2416            assert_eq!(result[2], " Three");
2417        }
2418
2419        #[test]
2420        fn test_split_content_unicode() {
2421            let content = "你好世界";
2422            let result = Block::split_content_by_index(content, &[2]);
2423            assert_eq!(result.len(), 2);
2424            assert_eq!(result[0], "你好");
2425            assert_eq!(result[1], "世界");
2426        }
2427    }
2428}