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///
68/// For each type of block, we can add a footnote to it, where Text, Quote and Title's
69/// footnote will be added to the content and Image, Audio, Video and MathML's footnote
70/// will be added to the caption.
71///
72/// Each block type has its own structure and required fields. We show the structure
73/// of each block so that you can manually write css files for Content for a more
74/// beautiful interface.
75///
76/// In addition, the footnote index in the body has the following structure:
77///
78/// ```xhtml
79/// <a href="#footnote-1" id="ref-1" class="footnote-ref">[1]</a>
80/// ```
81#[non_exhaustive]
82#[derive(Debug)]
83pub enum Block {
84    /// Text paragraph
85    ///
86    /// This block represents a paragraph of text. The block structure is as follows:
87    ///
88    /// ```html
89    /// <p class="content-block text-block">
90    ///     {{ text.content }}
91    /// </p>
92    /// ```
93    #[non_exhaustive]
94    Text {
95        content: String,
96        footnotes: Vec<Footnote>,
97    },
98
99    /// Quote paragraph
100    ///
101    /// This block represents a paragraph of quoted text. The block structure is as follows:
102    ///
103    /// ```xhtml
104    /// <blockquote class="content-block quote-block">
105    ///     {{ quote.content }}
106    /// </blockquote>
107    /// ```
108    #[non_exhaustive]
109    Quote {
110        content: String,
111        footnotes: Vec<Footnote>,
112    },
113
114    /// Heading
115    ///
116    /// The block structure is as follows:
117    /// ```xhtml
118    /// <h1 class="content-block title-block">
119    ///     {{ title.content }}
120    /// </h1>
121    /// ```
122    #[non_exhaustive]
123    Title {
124        content: String,
125        footnotes: Vec<Footnote>,
126
127        /// Heading level
128        ///
129        /// The valid range is 1 to 6.
130        level: usize,
131    },
132
133    /// Image block
134    ///
135    /// The block structure is as follows:
136    /// ```xhtml
137    /// <figure class="content-block image-block">
138    ///     <img src="{{ image.url }}" alt="{{ image.alt }}" />
139    ///     <figcaption>
140    ///         {{ image.caption }}
141    ///     </figcaption>
142    /// </figure>
143    /// ```
144    #[non_exhaustive]
145    Image {
146        /// Image file path
147        url: PathBuf,
148
149        /// Alternative text for the image
150        alt: Option<String>,
151
152        /// Caption for the image
153        caption: Option<String>,
154
155        footnotes: Vec<Footnote>,
156    },
157
158    /// Audio block
159    ///
160    /// The block structure is as follows:
161    /// ```xhtml
162    /// <figure class="content-block audio-block">
163    ///     <audio src="{{ audio.url }}" controls>
164    ///        <p>{{ audio.fallback }}</p>
165    ///    </audio>
166    ///    <figcaption>
167    ///       Audio caption text
168    ///   </figcaption>
169    /// </figure>
170    /// ```
171    #[non_exhaustive]
172    Audio {
173        /// Audio file path
174        url: PathBuf,
175
176        /// Fallback text for the audio
177        ///
178        /// This is used when the audio file cannot be played.
179        fallback: String,
180
181        /// Caption for the audio
182        caption: Option<String>,
183
184        footnotes: Vec<Footnote>,
185    },
186
187    /// Video block
188    ///
189    /// The block structure is as follows:
190    /// ```xhtml
191    /// <figure class="content-block video-block">
192    ///     <video src="{{ video.url }}" controls>
193    ///         <p>{{ video.fallback }}</p>
194    ///     </video>
195    ///     <figcaption>
196    ///         {{ video.caption }}
197    ///     </figcaption>
198    /// </figure>
199    /// ```
200    #[non_exhaustive]
201    Video {
202        /// Video file path
203        url: PathBuf,
204
205        /// Fallback text for the video
206        ///
207        /// This is used when the video file cannot be played.
208        fallback: String,
209
210        /// Caption for the video
211        caption: Option<String>,
212
213        footnotes: Vec<Footnote>,
214    },
215
216    /// MathML block
217    ///
218    /// The block structure is as follows:
219    /// ```xhtml
220    /// <figure class="content-block mathml-block">
221    ///     {{ mathml.element_str as innerHTML }}
222    ///     <img src="{{ mathml.fallback_image }}" class="mathml-fallback" />
223    ///     <figcaption>
224    ///         {{ mathml.caption }}
225    ///     </figcaption>
226    /// </figure>
227    #[non_exhaustive]
228    MathML {
229        /// MathML element raw data
230        ///
231        /// This field stores the raw data of the MathML markup, which we do not verify,
232        /// and the user needs to make sure it is correct.
233        element_str: String,
234
235        /// Fallback image for the MathML block
236        ///
237        /// This field stores the path to the fallback image, which will be displayed
238        /// when the MathML markup cannot be rendered.
239        fallback_image: Option<PathBuf>,
240
241        /// Caption for the MathML block
242        caption: Option<String>,
243
244        footnotes: Vec<Footnote>,
245    },
246}
247
248impl Block {
249    /// Make the block
250    ///
251    /// Convert block data to xhtml markup.
252    pub(crate) fn make(
253        &mut self,
254        writer: &mut XmlWriter,
255        start_index: usize,
256    ) -> Result<(), EpubError> {
257        match self {
258            Block::Text { content, footnotes } => {
259                writer.write_event(Event::Start(
260                    BytesStart::new("p").with_attributes([("class", "content-block text-block")]),
261                ))?;
262
263                Self::make_text(writer, content, footnotes, start_index)?;
264
265                writer.write_event(Event::End(BytesEnd::new("p")))?;
266            }
267
268            Block::Quote { content, footnotes } => {
269                writer.write_event(Event::Start(BytesStart::new("blockquote").with_attributes(
270                    [
271                        ("class", "content-block quote-block"),
272                        ("cite", "SOME ATTR NEED TO BE SET"),
273                    ],
274                )))?;
275                writer.write_event(Event::Start(BytesStart::new("p")))?;
276
277                Self::make_text(writer, content, footnotes, start_index)?;
278
279                writer.write_event(Event::End(BytesEnd::new("p")))?;
280                writer.write_event(Event::End(BytesEnd::new("blockquote")))?;
281            }
282
283            Block::Title { content, footnotes, level } => {
284                let tag_name = format!("h{}", level);
285                writer.write_event(Event::Start(
286                    BytesStart::new(tag_name.as_str())
287                        .with_attributes([("class", "content-block title-block")]),
288                ))?;
289
290                Self::make_text(writer, content, footnotes, start_index)?;
291
292                writer.write_event(Event::End(BytesEnd::new(tag_name)))?;
293            }
294
295            Block::Image { url, alt, caption, footnotes } => {
296                let url = format!("./img/{}", url.file_name().unwrap().to_string_lossy());
297
298                let mut attr = Vec::new();
299                attr.push(("src", url.as_str()));
300                if let Some(alt) = alt {
301                    attr.push(("alt", alt.as_str()));
302                }
303
304                writer.write_event(Event::Start(
305                    BytesStart::new("figure")
306                        .with_attributes([("class", "content-block image-block")]),
307                ))?;
308                writer.write_event(Event::Empty(BytesStart::new("img").with_attributes(attr)))?;
309
310                if let Some(caption) = caption {
311                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
312
313                    Self::make_text(writer, caption, footnotes, start_index)?;
314
315                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
316                }
317
318                writer.write_event(Event::End(BytesEnd::new("figure")))?;
319            }
320
321            Block::Audio { url, fallback, caption, footnotes } => {
322                let url = format!("./audio/{}", url.file_name().unwrap().to_string_lossy());
323
324                let attr = vec![
325                    ("src", url.as_str()),
326                    ("controls", "controls"), // attribute special spelling for xhtml
327                ];
328
329                writer.write_event(Event::Start(
330                    BytesStart::new("figure")
331                        .with_attributes([("class", "content-block audio-block")]),
332                ))?;
333                writer.write_event(Event::Start(BytesStart::new("audio").with_attributes(attr)))?;
334
335                writer.write_event(Event::Start(BytesStart::new("p")))?;
336                writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
337                writer.write_event(Event::End(BytesEnd::new("p")))?;
338
339                writer.write_event(Event::End(BytesEnd::new("audio")))?;
340
341                if let Some(caption) = caption {
342                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
343
344                    Self::make_text(writer, caption, footnotes, start_index)?;
345
346                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
347                }
348
349                writer.write_event(Event::End(BytesEnd::new("figure")))?;
350            }
351
352            Block::Video { url, fallback, caption, footnotes } => {
353                let url = format!("./video/{}", url.file_name().unwrap().to_string_lossy());
354
355                let attr = vec![
356                    ("src", url.as_str()),
357                    ("controls", "controls"), // attribute special spelling for xhtml
358                ];
359
360                writer.write_event(Event::Start(
361                    BytesStart::new("figure")
362                        .with_attributes([("class", "content-block video-block")]),
363                ))?;
364                writer.write_event(Event::Start(BytesStart::new("video").with_attributes(attr)))?;
365
366                writer.write_event(Event::Start(BytesStart::new("p")))?;
367                writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
368                writer.write_event(Event::End(BytesEnd::new("p")))?;
369
370                writer.write_event(Event::End(BytesEnd::new("video")))?;
371
372                if let Some(caption) = caption {
373                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
374
375                    Self::make_text(writer, caption, footnotes, start_index)?;
376
377                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
378                }
379
380                writer.write_event(Event::End(BytesEnd::new("figure")))?;
381            }
382
383            Block::MathML {
384                element_str,
385                fallback_image,
386                caption,
387                footnotes,
388            } => {
389                writer.write_event(Event::Start(
390                    BytesStart::new("figure")
391                        .with_attributes([("class", "content-block mathml-block")]),
392                ))?;
393
394                Self::write_mathml_element(writer, element_str)?;
395
396                if let Some(fallback_path) = fallback_image {
397                    let img_url = format!(
398                        "./img/{}",
399                        fallback_path.file_name().unwrap().to_string_lossy()
400                    );
401
402                    writer.write_event(Event::Empty(BytesStart::new("img").with_attributes([
403                        ("src", img_url.as_str()),
404                        ("class", "mathml-fallback"),
405                        ("alt", "Mathematical formula"),
406                    ])))?;
407                }
408
409                if let Some(caption) = caption {
410                    writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
411
412                    Self::make_text(writer, caption, footnotes, start_index)?;
413
414                    writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
415                }
416
417                writer.write_event(Event::End(BytesEnd::new("figure")))?;
418            }
419        }
420
421        Ok(())
422    }
423
424    pub fn take_footnotes(&self) -> Vec<Footnote> {
425        match self {
426            Block::Text { footnotes, .. } => footnotes.to_vec(),
427            Block::Quote { footnotes, .. } => footnotes.to_vec(),
428            Block::Title { footnotes, .. } => footnotes.to_vec(),
429            Block::Image { footnotes, .. } => footnotes.to_vec(),
430            Block::Audio { footnotes, .. } => footnotes.to_vec(),
431            Block::Video { footnotes, .. } => footnotes.to_vec(),
432            Block::MathML { footnotes, .. } => footnotes.to_vec(),
433        }
434    }
435
436    /// Split content by footnote locate
437    ///
438    /// ## Parameters
439    /// - `content`: The content to split
440    /// - `index_list`: The locations of footnotes
441    fn split_content_by_index(content: &str, index_list: &[usize]) -> Vec<String> {
442        if index_list.is_empty() {
443            return vec![content.to_string()];
444        }
445
446        // index_list.len() footnote splits content into (index_list.len() + 1) parts.
447        let mut result = Vec::with_capacity(index_list.len() + 1);
448        let mut char_iter = content.chars().enumerate();
449
450        let mut current_char_idx = 0;
451        for &target_idx in index_list {
452            let mut segment = String::new();
453
454            // The starting range is the last location or 0,
455            // and the ending range is the current location.
456            while current_char_idx < target_idx {
457                if let Some((_, ch)) = char_iter.next() {
458                    segment.push(ch);
459                    current_char_idx += 1;
460                } else {
461                    break;
462                }
463            }
464
465            if !segment.is_empty() {
466                result.push(segment);
467            }
468        }
469
470        let remainder = char_iter.map(|(_, ch)| ch).collect::<String>();
471        if !remainder.is_empty() {
472            result.push(remainder);
473        }
474
475        result
476    }
477
478    /// Make text
479    ///
480    /// This function is used to format text content and footnote markup.
481    ///
482    /// ## Parameters
483    /// - `writer`: The writer to write XML events
484    /// - `content`: The text content to format
485    /// - `footnotes`: The footnotes to format
486    /// - `start_index`: The starting value of footnote number
487    fn make_text(
488        writer: &mut XmlWriter,
489        content: &str,
490        footnotes: &mut [Footnote],
491        start_index: usize,
492    ) -> Result<(), EpubError> {
493        if footnotes.is_empty() {
494            writer.write_event(Event::Text(BytesText::new(content)))?;
495            return Ok(());
496        }
497
498        footnotes.sort_unstable();
499
500        // statistical footnote locate and quantity
501        let mut position_to_count = HashMap::new();
502        for footnote in footnotes.iter() {
503            *position_to_count.entry(footnote.locate).or_insert(0usize) += 1;
504        }
505
506        let mut positions = position_to_count.keys().copied().collect::<Vec<usize>>();
507        positions.sort_unstable();
508
509        let mut current_index = start_index;
510        let content_list = Self::split_content_by_index(content, &positions);
511        for (index, segment) in content_list.iter().enumerate() {
512            writer.write_event(Event::Text(BytesText::new(segment)))?;
513
514            // get the locate of the index-th footnote
515            if let Some(&position) = positions.get(index) {
516                // get the quantity of the index-th footnote
517                if let Some(&count) = position_to_count.get(&position) {
518                    for _ in 0..count {
519                        Self::make_footnotes(writer, current_index)?;
520                        current_index += 1;
521                    }
522                }
523            }
524        }
525
526        Ok(())
527    }
528
529    /// Makes footnote reference markup
530    #[inline]
531    fn make_footnotes(writer: &mut XmlWriter, index: usize) -> Result<(), EpubError> {
532        writer.write_event(Event::Start(BytesStart::new("a").with_attributes([
533            ("href", format!("#footnote-{}", index).as_str()),
534            ("id", format!("ref-{}", index).as_str()),
535            ("class", "footnote-ref"),
536        ])))?;
537        writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index))))?;
538        writer.write_event(Event::End(BytesEnd::new("a")))?;
539
540        Ok(())
541    }
542
543    /// Write MathML element
544    ///
545    /// This function will parse the MathML element string and write it to the writer.
546    fn write_mathml_element(writer: &mut XmlWriter, element_str: &str) -> Result<(), EpubError> {
547        let mut reader = Reader::from_str(element_str);
548
549        loop {
550            match reader.read_event() {
551                Ok(Event::Eof) => break,
552
553                Ok(event) => writer.write_event(event)?,
554
555                Err(err) => {
556                    return Err(
557                        EpubBuilderError::InvalidMathMLFormat { error: err.to_string() }.into(),
558                    );
559                }
560            }
561        }
562
563        Ok(())
564    }
565}
566
567/// Block Builder
568///
569/// A builder for constructing content blocks of various types.
570///
571/// ## Example
572/// ```rust
573/// # #[cfg(feature = "builder")]
574/// # fn main() -> Result<(), lib_epub::error::EpubError> {
575/// use lib_epub::{builder::content::BlockBuilder, types::{BlockType, Footnote}};
576///
577/// let mut builder = BlockBuilder::new(BlockType::Text);
578/// builder.set_content("Hello, world!").add_footnote(Footnote {
579///     content: "This is a footnote.".to_string(),
580///     locate: 13,
581/// });
582///
583/// builder.build()?;
584/// # Ok(())
585/// # }
586/// ```
587///
588/// ## Notes
589/// - Not all fields are required for all block types. Required fields vary by block type.
590/// - The `build()` method will validate that required fields are set for the specified block type.
591#[derive(Debug)]
592pub struct BlockBuilder {
593    /// The type of block to construct
594    block_type: BlockType,
595
596    /// Content text for Text, Quote, and Title blocks
597    content: Option<String>,
598
599    /// Heading level (1-6) for Title blocks
600    level: Option<usize>,
601
602    /// File path to media for Image, Audio, and Video blocks
603    url: Option<PathBuf>,
604
605    /// Alternative text for Image blocks
606    alt: Option<String>,
607
608    /// Caption text for Image, Audio, Video, and MathML blocks
609    caption: Option<String>,
610
611    /// Fallback text for Audio and Video blocks (displayed when media cannot be played)
612    fallback: Option<String>,
613
614    /// Raw MathML markup string for MathML blocks
615    element_str: Option<String>,
616
617    /// Fallback image path for MathML blocks (displayed when MathML cannot be rendered)
618    fallback_image: Option<PathBuf>,
619
620    /// Footnotes associated with the block content
621    footnotes: Vec<Footnote>,
622}
623
624impl BlockBuilder {
625    /// Creates a new BlockBuilder instance
626    ///
627    /// Initializes a BlockBuilder with the specified block type.
628    ///
629    /// ## Parameters
630    /// - `block_type`: The type of block to construct
631    pub fn new(block_type: BlockType) -> Self {
632        Self {
633            block_type,
634            content: None,
635            level: None,
636            url: None,
637            alt: None,
638            caption: None,
639            fallback: None,
640            element_str: None,
641            fallback_image: None,
642            footnotes: vec![],
643        }
644    }
645
646    /// Sets the text content of the block
647    ///
648    /// Used for Text, Quote, and Title block types.
649    ///
650    /// ## Parameters
651    /// - `content`: The text content to set
652    pub fn set_content(&mut self, content: &str) -> &mut Self {
653        self.content = Some(content.to_string());
654        self
655    }
656
657    /// Sets the heading level for a Title block
658    ///
659    /// Only applicable to Title block types. Valid range is 1 to 6.
660    /// If the level is outside the valid range, this method silently ignores the setting
661    /// and returns self unchanged.
662    ///
663    /// ## Parameters
664    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
665    pub fn set_title_level(&mut self, level: usize) -> &mut Self {
666        if !(1..=6).contains(&level) {
667            return self;
668        }
669
670        self.level = Some(level);
671        self
672    }
673
674    /// Sets the media file path
675    ///
676    /// Used for Image, Audio, and Video block types. This method validates that
677    /// the file is a recognized image, audio, or video type.
678    ///
679    /// ## Parameters
680    /// - `url`: The path to the media file
681    ///
682    /// ## Return
683    /// - `Ok(&mut self)`: If the file type is valid
684    /// - `Err(EpubError)`: The file does not exist or the file format is not image, audio, or video
685    pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
686        match Self::is_target_type(
687            url,
688            vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
689        ) {
690            Ok(_) => {
691                self.url = Some(url.to_path_buf());
692                Ok(self)
693            }
694            Err(err) => Err(err),
695        }
696    }
697
698    /// Sets the alternative text for an image
699    ///
700    /// Only applicable to Image block types.
701    /// Alternative text is displayed when the image cannot be loaded.
702    ///
703    /// ## Parameters
704    /// - `alt`: The alternative text for the image
705    pub fn set_alt(&mut self, alt: &str) -> &mut Self {
706        self.alt = Some(alt.to_string());
707        self
708    }
709
710    /// Sets the caption for the block
711    ///
712    /// Used for Image, Audio, Video, and MathML block types.
713    /// The caption is displayed below the media or element.
714    ///
715    /// ## Parameters
716    /// - `caption`: The caption text to display
717    pub fn set_caption(&mut self, caption: &str) -> &mut Self {
718        self.caption = Some(caption.to_string());
719        self
720    }
721
722    /// Sets the fallback text for audio or video content
723    ///
724    /// Used for Audio and Video block types.
725    /// The fallback text is displayed when the media file cannot be played.
726    ///
727    /// ## Parameters
728    /// - `fallback`: The fallback text content
729    pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
730        self.fallback = Some(fallback.to_string());
731        self
732    }
733
734    /// Sets the raw MathML element string
735    ///
736    /// Only applicable to MathML block types.
737    /// This method accepts the raw MathML markup data without validation.
738    /// The user is responsible for ensuring the MathML is well-formed.
739    ///
740    /// ## Parameters
741    /// - `element_str`: The raw MathML markup string
742    pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
743        self.element_str = Some(element_str.to_string());
744        self
745    }
746
747    /// Sets the fallback image for MathML content
748    ///
749    /// Only applicable to MathML block types.
750    /// The fallback image is displayed when the MathML markup cannot be rendered.
751    /// This method validates that the file is a recognized image type.
752    ///
753    /// ## Parameters
754    /// - `fallback_image`: The path to the fallback image file
755    ///
756    /// ## Return
757    /// - `Ok(self)`: If the file type is valid
758    /// - `Err(EpubError)`: If validation fails
759    pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
760        match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
761            Ok(_) => {
762                self.fallback_image = Some(fallback_image);
763                Ok(self)
764            }
765            Err(err) => Err(err),
766        }
767    }
768
769    /// Adds a footnote to the block
770    ///
771    /// Adds a single footnote to the block's footnotes collection.
772    /// The footnote must reference a valid position within the content.
773    ///
774    /// ## Parameters
775    /// - `footnote`: The footnote to add
776    pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
777        self.footnotes.push(footnote);
778        self
779    }
780
781    /// Sets all footnotes for the block
782    ///
783    /// Replaces the current footnotes collection with the provided one.
784    /// All footnotes must reference valid positions within the content.
785    ///
786    /// ## Parameters
787    /// - `footnotes`: The vector of footnotes to set
788    pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
789        self.footnotes = footnotes;
790        self
791    }
792
793    /// Removes the last footnote
794    ///
795    /// Removes and discards the last footnote from the footnotes collection.
796    /// If the collection is empty, this method has no effect.
797    pub fn remove_last_footnote(&mut self) -> &mut Self {
798        self.footnotes.pop();
799        self
800    }
801
802    /// Clears all footnotes
803    ///
804    /// Removes all footnotes from the block's footnotes collection.
805    pub fn clear_footnotes(&mut self) -> &mut Self {
806        self.footnotes.clear();
807        self
808    }
809
810    /// Builds the block
811    ///
812    /// Constructs a Block instance based on the configured parameters and block type.
813    /// This method validates that all required fields are set for the specified block type
814    /// and validates the footnotes to ensure they reference valid content positions.
815    ///
816    /// ## Return
817    /// - `Ok(Block)`: Build successful
818    /// - `Err(EpubError)`: Error occurred during the build process
819    pub fn build(self) -> Result<Block, EpubError> {
820        let block = match self.block_type {
821            BlockType::Text => {
822                if let Some(content) = self.content {
823                    Block::Text { content, footnotes: self.footnotes }
824                } else {
825                    return Err(EpubBuilderError::MissingNecessaryBlockData {
826                        block_type: "Text".to_string(),
827                        missing_data: "'content'".to_string(),
828                    }
829                    .into());
830                }
831            }
832
833            BlockType::Quote => {
834                if let Some(content) = self.content {
835                    Block::Quote { content, footnotes: self.footnotes }
836                } else {
837                    return Err(EpubBuilderError::MissingNecessaryBlockData {
838                        block_type: "Quote".to_string(),
839                        missing_data: "'content'".to_string(),
840                    }
841                    .into());
842                }
843            }
844
845            BlockType::Title => match (self.content, self.level) {
846                (Some(content), Some(level)) => Block::Title {
847                    content,
848                    level,
849                    footnotes: self.footnotes,
850                },
851                _ => {
852                    return Err(EpubBuilderError::MissingNecessaryBlockData {
853                        block_type: "Title".to_string(),
854                        missing_data: "'content' or 'level'".to_string(),
855                    }
856                    .into());
857                }
858            },
859
860            BlockType::Image => {
861                if let Some(url) = self.url {
862                    Block::Image {
863                        url,
864                        alt: self.alt,
865                        caption: self.caption,
866                        footnotes: self.footnotes,
867                    }
868                } else {
869                    return Err(EpubBuilderError::MissingNecessaryBlockData {
870                        block_type: "Image".to_string(),
871                        missing_data: "'url'".to_string(),
872                    }
873                    .into());
874                }
875            }
876
877            BlockType::Audio => match (self.url, self.fallback) {
878                (Some(url), Some(fallback)) => Block::Audio {
879                    url,
880                    fallback,
881                    caption: self.caption,
882                    footnotes: self.footnotes,
883                },
884                _ => {
885                    return Err(EpubBuilderError::MissingNecessaryBlockData {
886                        block_type: "Audio".to_string(),
887                        missing_data: "'url' or 'fallback'".to_string(),
888                    }
889                    .into());
890                }
891            },
892
893            BlockType::Video => match (self.url, self.fallback) {
894                (Some(url), Some(fallback)) => Block::Video {
895                    url,
896                    fallback,
897                    caption: self.caption,
898                    footnotes: self.footnotes,
899                },
900                _ => {
901                    return Err(EpubBuilderError::MissingNecessaryBlockData {
902                        block_type: "Video".to_string(),
903                        missing_data: "'url' or 'fallback'".to_string(),
904                    }
905                    .into());
906                }
907            },
908
909            BlockType::MathML => {
910                if let Some(element_str) = self.element_str {
911                    Block::MathML {
912                        element_str,
913                        fallback_image: self.fallback_image,
914                        caption: self.caption,
915                        footnotes: self.footnotes,
916                    }
917                } else {
918                    return Err(EpubBuilderError::MissingNecessaryBlockData {
919                        block_type: "MathML".to_string(),
920                        missing_data: "'element_str'".to_string(),
921                    }
922                    .into());
923                }
924            }
925        };
926
927        Self::validate_footnotes(&block)?;
928        Ok(block)
929    }
930
931    /// Validates that the file type matches expected types
932    ///
933    /// Identifies the file type by reading the file header and validates whether
934    /// it belongs to one of the expected types. Uses file magic numbers for
935    /// reliable type detection.
936    ///
937    /// ## Parameters
938    /// - `path`: The path to the file to check
939    /// - `types`: The vector of expected file types
940    fn is_target_type(path: &PathBuf, types: Vec<MatcherType>) -> Result<(), EpubError> {
941        if !path.is_file() {
942            return Err(EpubBuilderError::TargetIsNotFile {
943                target_path: path.to_string_lossy().to_string(),
944            }
945            .into());
946        }
947
948        let mut file = File::open(path)?;
949        let mut buf = [0; 512];
950        let read_size = file.read(&mut buf)?;
951        let header_bytes = &buf[..read_size];
952
953        match Infer::new().get(header_bytes) {
954            Some(file_type) if !types.contains(&file_type.matcher_type()) => {
955                Err(EpubBuilderError::NotExpectedFileFormat.into())
956            }
957
958            None => Err(EpubBuilderError::UnknownFileFormat {
959                file_path: path.to_string_lossy().to_string(),
960            }
961            .into()),
962
963            _ => Ok(()),
964        }
965    }
966
967    /// Validates the footnotes in a block
968    ///
969    /// Ensures all footnotes reference valid positions within the content.
970    /// For Text, Quote, and Title blocks, footnotes must be within the character count of the content.
971    /// For Image, Audio, Video, and MathML blocks, footnotes must be within the character count
972    /// of the caption (if a caption is set). Blocks with media but no caption cannot have footnotes.
973    fn validate_footnotes(block: &Block) -> Result<(), EpubError> {
974        match block {
975            Block::Text { content, footnotes }
976            | Block::Quote { content, footnotes }
977            | Block::Title { content, footnotes, .. } => {
978                let max_locate = content.chars().count();
979                for footnote in footnotes.iter() {
980                    if footnote.locate == 0 || footnote.locate > content.chars().count() {
981                        return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
982                    }
983                }
984
985                Ok(())
986            }
987
988            Block::Image { caption, footnotes, .. }
989            | Block::MathML { caption, footnotes, .. }
990            | Block::Video { caption, footnotes, .. }
991            | Block::Audio { caption, footnotes, .. } => {
992                if let Some(caption) = caption {
993                    let max_locate = caption.chars().count();
994                    for footnote in footnotes.iter() {
995                        if footnote.locate == 0 || footnote.locate > caption.chars().count() {
996                            return Err(
997                                EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
998                            );
999                        }
1000                    }
1001                } else if !footnotes.is_empty() {
1002                    return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
1003                }
1004
1005                Ok(())
1006            }
1007        }
1008    }
1009}
1010
1011/// Content Builder
1012///
1013/// A builder for constructing EPUB content documents with various block types.
1014/// This builder manages the creation and organization of content blocks including
1015/// text, quotes, headings, images, audio, video, and MathML content.
1016///
1017/// This builder can add simple interface styles via StyleOption or modify document
1018/// styles by manually write css files.
1019/// The final constructed content document has the following structure:
1020///
1021/// ```xhtml
1022/// <body>
1023///     <main>
1024///         <!-- The specific block structure can be queried in the Block docs. -->
1025///     </main>
1026///     <aside>
1027///         <ul class="footnote-list">
1028///             <!-- Each footnote has the same structure. -->
1029///             <li class="footnote-item" id="footnote-{{ index }}">
1030///                 <p>
1031///                     <a herf="ref-{{ index }}">[{{ index }}]</a>
1032///                     {{ footnote.content }}
1033///                 </p>
1034///             </li>
1035///         </ul>
1036///     </aside>
1037/// </body>
1038/// ```
1039#[derive(Debug)]
1040pub struct ContentBuilder {
1041    /// The unique identifier for the content document
1042    ///
1043    /// This identifier is used to uniquely identify the content document within the EPUB container.
1044    /// If the identifier is not unique, only one content document will be included in the EPUB container;
1045    /// and the other content document will be ignored.
1046    pub id: String,
1047
1048    blocks: Vec<Block>,
1049    language: String,
1050    title: String,
1051    styles: StyleOptions,
1052
1053    pub(crate) temp_dir: PathBuf,
1054    pub(crate) css_files: Vec<PathBuf>,
1055}
1056
1057impl ContentBuilder {
1058    // TODO: Handle resource naming conflicts
1059
1060    /// Creates a new ContentBuilder instance
1061    ///
1062    /// Initializes a ContentBuilder with the specified language code.
1063    /// A temporary directory is automatically created to store media files during construction.
1064    ///
1065    /// ## Parameters
1066    /// - `language`: The language code for the document
1067    pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
1068        let temp_dir = env::temp_dir().join(local_time());
1069        fs::create_dir(&temp_dir)?;
1070
1071        Ok(Self {
1072            id: id.to_string(),
1073            blocks: vec![],
1074            language: language.to_string(),
1075            title: String::new(),
1076            styles: StyleOptions::default(),
1077            temp_dir,
1078            css_files: vec![],
1079        })
1080    }
1081
1082    /// Sets the title of the document
1083    ///
1084    /// Sets the title that will be displayed in the document's head section.
1085    ///
1086    /// ## Parameters
1087    /// - `title`: The title text for the document
1088    pub fn set_title(&mut self, title: &str) -> &mut Self {
1089        self.title = title.to_string();
1090        self
1091    }
1092
1093    /// Sets the styles for the document
1094    ///
1095    /// ## Parameters
1096    /// - `styles`: The StyleOptions to set for the document
1097    pub fn set_styles(&mut self, styles: StyleOptions) -> &mut Self {
1098        self.styles = styles;
1099        self
1100    }
1101
1102    /// Adds a CSS file to the document
1103    ///
1104    /// Copies the CSS file to a temporary directory for inclusion in the EPUB package.
1105    /// The CSS file will be linked in the document's head section when generating the output.
1106    ///
1107    /// ## Parameters
1108    /// - `css_path`: The path to the CSS file to add
1109    ///
1110    /// ## Return
1111    /// - `Ok(&mut self)`: If the file exists and is accessible
1112    /// - `Err(EpubError)`: If the file does not exist or is not accessible
1113    pub fn add_css_file(&mut self, css_path: PathBuf) -> Result<&mut Self, EpubError> {
1114        if !css_path.is_file() {
1115            return Err(EpubBuilderError::TargetIsNotFile {
1116                target_path: css_path.to_string_lossy().to_string(),
1117            }
1118            .into());
1119        }
1120
1121        // we can assert that this path target to a file, so unwrap is safe here
1122        let file_name = css_path.file_name().unwrap().to_string_lossy().to_string();
1123        let target_dir = self.temp_dir.join("css");
1124        fs::create_dir_all(&target_dir)?;
1125
1126        let target_path = target_dir.join(&file_name);
1127        fs::copy(&css_path, &target_path)?;
1128        self.css_files.push(target_path);
1129        Ok(self)
1130    }
1131
1132    /// Removes the last CSS file
1133    ///
1134    /// Removes and discards the last CSS file from the collection.
1135    /// If the collection is empty, this method has no effect.
1136    pub fn remove_last_css_file(&mut self) -> &mut Self {
1137        let path = self.css_files.pop();
1138        if let Some(path) = path {
1139            // the better way is to handle the error
1140            let _ = fs::remove_file(path);
1141        }
1142        self
1143    }
1144
1145    /// Clears all CSS files
1146    ///
1147    /// Removes all CSS files from the document's collection.
1148    pub fn clear_css_files(&mut self) -> &mut Self {
1149        for path in self.css_files.iter() {
1150            // the better way is to handle the error
1151            let _ = fs::remove_file(path);
1152        }
1153        self.css_files.clear();
1154
1155        self
1156    }
1157
1158    /// Adds a block to the document
1159    ///
1160    /// Adds a constructed Block to the document.
1161    ///
1162    /// ## Parameters
1163    /// - `block`: The Block to add to the document
1164    pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
1165        self.blocks.push(block);
1166
1167        match self.blocks.last() {
1168            Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
1169                self.handle_resource()?
1170            }
1171
1172            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1173                self.handle_resource()?;
1174            }
1175
1176            _ => {}
1177        }
1178
1179        Ok(self)
1180    }
1181
1182    /// Adds a text block to the document
1183    ///
1184    /// Convenience method that creates and adds a Text block using the provided content and footnotes.
1185    ///
1186    /// ## Parameters
1187    /// - `content`: The text content of the paragraph
1188    /// - `footnotes`: A vector of footnotes associated with the text
1189    pub fn add_text_block(
1190        &mut self,
1191        content: &str,
1192        footnotes: Vec<Footnote>,
1193    ) -> Result<&mut Self, EpubError> {
1194        let mut builder = BlockBuilder::new(BlockType::Text);
1195        builder.set_content(content).set_footnotes(footnotes);
1196
1197        self.blocks.push(builder.build()?);
1198        Ok(self)
1199    }
1200
1201    /// Adds a quote block to the document
1202    ///
1203    /// Convenience method that creates and adds a Quote block using the provided content and footnotes.
1204    ///
1205    /// ## Parameters
1206    /// - `content`: The quoted text
1207    /// - `footnotes`: A vector of footnotes associated with the quote
1208    pub fn add_quote_block(
1209        &mut self,
1210        content: &str,
1211        footnotes: Vec<Footnote>,
1212    ) -> Result<&mut Self, EpubError> {
1213        let mut builder = BlockBuilder::new(BlockType::Quote);
1214        builder.set_content(content).set_footnotes(footnotes);
1215
1216        self.blocks.push(builder.build()?);
1217        Ok(self)
1218    }
1219
1220    /// Adds a heading block to the document
1221    ///
1222    /// Convenience method that creates and adds a Title block with the specified level.
1223    ///
1224    /// ## Parameters
1225    /// - `content`: The heading text
1226    /// - `level`: The heading level (1-6), corresponding to h1-h6 HTML tags
1227    /// - `footnotes`: A vector of footnotes associated with the heading
1228    pub fn add_title_block(
1229        &mut self,
1230        content: &str,
1231        level: usize,
1232        footnotes: Vec<Footnote>,
1233    ) -> Result<&mut Self, EpubError> {
1234        let mut builder = BlockBuilder::new(BlockType::Title);
1235        builder
1236            .set_content(content)
1237            .set_title_level(level)
1238            .set_footnotes(footnotes);
1239
1240        self.blocks.push(builder.build()?);
1241        Ok(self)
1242    }
1243
1244    /// Adds an image block to the document
1245    ///
1246    /// Convenience method that creates and adds an Image block with optional alt text,
1247    /// caption, and footnotes.
1248    ///
1249    /// ## Parameters
1250    /// - `url`: The path to the image file
1251    /// - `alt`: Optional alternative text for the image (displayed when image cannot load)
1252    /// - `caption`: Optional caption text to display below the image
1253    /// - `footnotes`: A vector of footnotes associated with the caption or image
1254    pub fn add_image_block(
1255        &mut self,
1256        url: PathBuf,
1257        alt: Option<String>,
1258        caption: Option<String>,
1259        footnotes: Vec<Footnote>,
1260    ) -> Result<&mut Self, EpubError> {
1261        let mut builder = BlockBuilder::new(BlockType::Image);
1262        builder.set_url(&url)?.set_footnotes(footnotes);
1263
1264        if let Some(alt) = &alt {
1265            builder.set_alt(alt);
1266        }
1267
1268        if let Some(caption) = &caption {
1269            builder.set_caption(caption);
1270        }
1271
1272        self.blocks.push(builder.build()?);
1273        self.handle_resource()?;
1274        Ok(self)
1275    }
1276
1277    /// Adds an audio block to the document
1278    ///
1279    /// Convenience method that creates and adds an Audio block with fallback text,
1280    /// optional caption, and footnotes.
1281    ///
1282    /// ## Parameters
1283    /// - `url`: The path to the audio file
1284    /// - `fallback`: Fallback text displayed when the audio cannot be played
1285    /// - `caption`: Optional caption text to display below the audio player
1286    /// - `footnotes`: A vector of footnotes associated with the caption or audio
1287    pub fn add_audio_block(
1288        &mut self,
1289        url: PathBuf,
1290        fallback: String,
1291        caption: Option<String>,
1292        footnotes: Vec<Footnote>,
1293    ) -> Result<&mut Self, EpubError> {
1294        let mut builder = BlockBuilder::new(BlockType::Audio);
1295        builder
1296            .set_url(&url)?
1297            .set_fallback(&fallback)
1298            .set_footnotes(footnotes);
1299
1300        if let Some(caption) = &caption {
1301            builder.set_caption(caption);
1302        }
1303
1304        self.blocks.push(builder.build()?);
1305        self.handle_resource()?;
1306        Ok(self)
1307    }
1308
1309    /// Adds a video block to the document
1310    ///
1311    /// Convenience method that creates and adds a Video block with fallback text,
1312    /// optional caption, and footnotes.
1313    ///
1314    /// ## Parameters
1315    /// - `url`: The path to the video file
1316    /// - `fallback`: Fallback text displayed when the video cannot be played
1317    /// - `caption`: Optional caption text to display below the video player
1318    /// - `footnotes`: A vector of footnotes associated with the caption or video
1319    pub fn add_video_block(
1320        &mut self,
1321        url: PathBuf,
1322        fallback: String,
1323        caption: Option<String>,
1324        footnotes: Vec<Footnote>,
1325    ) -> Result<&mut Self, EpubError> {
1326        let mut builder = BlockBuilder::new(BlockType::Video);
1327        builder
1328            .set_url(&url)?
1329            .set_fallback(&fallback)
1330            .set_footnotes(footnotes);
1331
1332        if let Some(caption) = &caption {
1333            builder.set_caption(caption);
1334        }
1335
1336        self.blocks.push(builder.build()?);
1337        self.handle_resource()?;
1338        Ok(self)
1339    }
1340
1341    /// Adds a MathML block to the document
1342    ///
1343    /// Convenience method that creates and adds a MathML block with optional fallback image,
1344    /// caption, and footnotes.
1345    ///
1346    /// ## Parameters
1347    /// - `element_str`: The raw MathML markup string
1348    /// - `fallback_image`: Optional path to a fallback image displayed when MathML cannot render
1349    /// - `caption`: Optional caption text to display below the MathML element
1350    /// - `footnotes`: A vector of footnotes associated with the caption or equation
1351    pub fn add_mathml_block(
1352        &mut self,
1353        element_str: String,
1354        fallback_image: Option<PathBuf>,
1355        caption: Option<String>,
1356        footnotes: Vec<Footnote>,
1357    ) -> Result<&mut Self, EpubError> {
1358        let mut builder = BlockBuilder::new(BlockType::MathML);
1359        builder
1360            .set_mathml_element(&element_str)
1361            .set_footnotes(footnotes);
1362
1363        if let Some(fallback_image) = fallback_image {
1364            builder.set_fallback_image(fallback_image)?;
1365        }
1366
1367        if let Some(caption) = &caption {
1368            builder.set_caption(caption);
1369        }
1370
1371        self.blocks.push(builder.build()?);
1372        self.handle_resource()?;
1373        Ok(self)
1374    }
1375
1376    /// Removes the last block from the document
1377    ///
1378    /// Discards the most recently added block. If no blocks exist, this method has no effect.
1379    pub fn remove_last_block(&mut self) -> &mut Self {
1380        self.blocks.pop();
1381        self
1382    }
1383
1384    /// Takes ownership of the last block
1385    ///
1386    /// Removes and returns the most recently added block without consuming the builder.
1387    /// This allows you to extract a block while keeping the builder alive.
1388    ///
1389    /// ## Return
1390    /// - `Some(Block)`: If a block exists
1391    /// - `None`: If the blocks collection is empty
1392    pub fn take_last_block(&mut self) -> Option<Block> {
1393        self.blocks.pop()
1394    }
1395
1396    /// Clears all blocks from the document
1397    ///
1398    /// Removes all blocks from the document while keeping the language and title settings intact.
1399    pub fn clear_blocks(&mut self) -> &mut Self {
1400        self.blocks.clear();
1401        self
1402    }
1403
1404    /// Builds content document
1405    ///
1406    /// ## Parameters
1407    /// - `target`: The file path where the document should be written
1408    ///
1409    /// ## Return
1410    /// - `Ok(Vec<PathBuf>)`: A vector of paths to all resources used in the document
1411    /// - `Err(EpubError)`: Error occurred during the making process
1412    pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1413        let mut result = Vec::new();
1414
1415        // Handle target directory, create if it doesn't exist
1416        let target_dir = match target.as_ref().parent() {
1417            Some(path) => {
1418                fs::create_dir_all(path)?;
1419                path.to_path_buf()
1420            }
1421            None => {
1422                return Err(EpubBuilderError::InvalidTargetPath {
1423                    target_path: target.as_ref().to_string_lossy().to_string(),
1424                }
1425                .into());
1426            }
1427        };
1428
1429        self.make_content(&target)?;
1430        result.push(target.as_ref().to_path_buf());
1431
1432        // Copy all resource files (images, audio, video) from temp directory to target directory
1433        for resource_type in ["img", "audio", "video", "css"] {
1434            let source = self.temp_dir.join(resource_type);
1435            if source.exists() && source.is_dir() {
1436                let target = target_dir.join(resource_type);
1437                fs::create_dir_all(&target)?;
1438
1439                for entry in fs::read_dir(&source)? {
1440                    let entry = entry?;
1441                    if entry.file_type()?.is_file() {
1442                        let file_name = entry.file_name();
1443                        let target = target.join(&file_name);
1444
1445                        fs::copy(source.join(&file_name), &target)?;
1446                        result.push(target);
1447                    }
1448                }
1449            }
1450        }
1451
1452        Ok(result)
1453    }
1454
1455    /// Write the document to a file
1456    ///
1457    /// Constructs the final XHTML document from all added blocks and writes it to the specified output path.
1458    ///
1459    /// ## Parameters
1460    /// - `target_path`: The file path where the XHTML document should be written
1461    fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1462        let mut writer = Writer::new(Cursor::new(Vec::new()));
1463
1464        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1465        writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1466            ("xmlns", "http://www.w3.org/1999/xhtml"),
1467            ("xml:lang", self.language.as_str()),
1468        ])))?;
1469
1470        // make head
1471        writer.write_event(Event::Start(BytesStart::new("head")))?;
1472        writer.write_event(Event::Start(BytesStart::new("title")))?;
1473        writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1474        writer.write_event(Event::End(BytesEnd::new("title")))?;
1475
1476        if self.css_files.is_empty() {
1477            self.make_style(&mut writer)?;
1478        } else {
1479            for css_file in self.css_files.iter() {
1480                // we can assert that this path target to a file, so unwrap is safe here
1481                let file_name = css_file.file_name().unwrap().to_string_lossy().to_string();
1482
1483                writer.write_event(Event::Empty(BytesStart::new("link").with_attributes([
1484                    ("href", format!("./css/{}", file_name).as_str()),
1485                    ("rel", "stylesheet"),
1486                    ("type", "text/css"),
1487                ])))?;
1488            }
1489        }
1490
1491        writer.write_event(Event::End(BytesEnd::new("head")))?;
1492
1493        // make body
1494        writer.write_event(Event::Start(BytesStart::new("body")))?;
1495        writer.write_event(Event::Start(BytesStart::new("main")))?;
1496
1497        let mut footnote_index = 1;
1498        let mut footnotes = Vec::new();
1499        for block in self.blocks.iter_mut() {
1500            block.make(&mut writer, footnote_index)?;
1501
1502            footnotes.append(&mut block.take_footnotes());
1503            footnote_index = footnotes.len() + 1;
1504        }
1505
1506        writer.write_event(Event::End(BytesEnd::new("main")))?;
1507
1508        Self::make_footnotes(&mut writer, footnotes)?;
1509        writer.write_event(Event::End(BytesEnd::new("body")))?;
1510        writer.write_event(Event::End(BytesEnd::new("html")))?;
1511
1512        let file_path = PathBuf::from(target_path.as_ref());
1513        let file_data = writer.into_inner().into_inner();
1514        fs::write(file_path, file_data)?;
1515
1516        Ok(())
1517    }
1518
1519    /// Generates CSS styles for the document
1520    fn make_style(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
1521        let style = format!(
1522            r#"
1523            * {{
1524                margin: 0;
1525                padding: 0;
1526                font-family: {font_family};
1527                text-align: {text_align};
1528                background-color: {background};
1529                color: {text};
1530            }}
1531            body, p, div, span, li, td, th {{
1532                font-size: {font_size}rem;
1533                line-height: {line_height}em;
1534                font-weight: {font_weight};
1535                font-style: {font_style};
1536                letter-spacing: {letter_spacing};
1537            }}
1538            body {{ margin: {margin}px; }}
1539            p {{ text-indent: {text_indent}em; }}
1540            a {{ color: {link_color}; text-decoration: none; }}
1541            figcaption {{ text-align: center; line-height: 1em; }}
1542            blockquote {{ padding: 1em 2em; }}
1543            blockquote > p {{ font-style: italic; }}
1544            .content-block {{ margin-bottom: {paragraph_spacing}px; }}
1545            .image-block > img,
1546            .audio-block > audio,
1547            .video-block > video {{ width: 100%; }}
1548            .footnote-ref {{ font-size: 0.5em; vertical-align: super; }}
1549            .footnote-list {{ list-style: none; padding: 0; }}
1550            .footnote-item > p {{ text-indent: 0; }}
1551            "#,
1552            font_family = self.styles.text.font_family,
1553            text_align = self.styles.layout.text_align,
1554            background = self.styles.color_scheme.background,
1555            text = self.styles.color_scheme.text,
1556            font_size = self.styles.text.font_size,
1557            line_height = self.styles.text.line_height,
1558            font_weight = self.styles.text.font_weight,
1559            font_style = self.styles.text.font_style,
1560            letter_spacing = self.styles.text.letter_spacing,
1561            margin = self.styles.layout.margin,
1562            text_indent = self.styles.text.text_indent,
1563            link_color = self.styles.color_scheme.link,
1564            paragraph_spacing = self.styles.layout.paragraph_spacing,
1565        );
1566
1567        writer.write_event(Event::Start(BytesStart::new("style")))?;
1568        writer.write_event(Event::Text(BytesText::new(&style)))?;
1569        writer.write_event(Event::End(BytesEnd::new("style")))?;
1570
1571        Ok(())
1572    }
1573
1574    /// Generates the footnotes section in the document
1575    ///
1576    /// Creates an aside element containing an unordered list of all footnotes.
1577    /// Each footnote is rendered as a list item with a backlink to its reference in the text.
1578    fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1579        writer.write_event(Event::Start(BytesStart::new("aside")))?;
1580        writer.write_event(Event::Start(
1581            BytesStart::new("ul").with_attributes([("class", "footnote-list")]),
1582        ))?;
1583
1584        let mut index = 1;
1585        for footnote in footnotes.into_iter() {
1586            writer.write_event(Event::Start(BytesStart::new("li").with_attributes([
1587                ("id", format!("footnote-{}", index).as_str()),
1588                ("class", "footnote-item"),
1589            ])))?;
1590            writer.write_event(Event::Start(BytesStart::new("p")))?;
1591
1592            writer.write_event(Event::Start(
1593                BytesStart::new("a")
1594                    .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1595            ))?;
1596            writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1597            writer.write_event(Event::End(BytesEnd::new("a")))?;
1598            writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1599
1600            writer.write_event(Event::End(BytesEnd::new("p")))?;
1601            writer.write_event(Event::End(BytesEnd::new("li")))?;
1602
1603            index += 1;
1604        }
1605
1606        writer.write_event(Event::End(BytesEnd::new("ul")))?;
1607        writer.write_event(Event::End(BytesEnd::new("aside")))?;
1608
1609        Ok(())
1610    }
1611
1612    /// Automatically handles media resources
1613    ///
1614    /// Copies media files (images, audio, video) from their original locations
1615    /// to the temporary directory for inclusion in the EPUB package.
1616    fn handle_resource(&mut self) -> Result<(), EpubError> {
1617        match self.blocks.last() {
1618            Some(Block::Image { url, .. }) => {
1619                let target_dir = self.temp_dir.join("img");
1620                fs::create_dir_all(&target_dir)?;
1621
1622                let target_path = target_dir.join(url.file_name().unwrap());
1623                fs::copy(url, &target_path)?;
1624            }
1625
1626            Some(Block::Video { url, .. }) => {
1627                let target_dir = self.temp_dir.join("video");
1628                fs::create_dir_all(&target_dir)?;
1629
1630                let target_path = target_dir.join(url.file_name().unwrap());
1631                fs::copy(url, &target_path)?;
1632            }
1633
1634            Some(Block::Audio { url, .. }) => {
1635                let target_dir = self.temp_dir.join("audio");
1636                fs::create_dir_all(&target_dir)?;
1637
1638                let target_path = target_dir.join(url.file_name().unwrap());
1639                fs::copy(url, &target_path)?;
1640            }
1641
1642            Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1643                let target_dir = self.temp_dir.join("img");
1644                fs::create_dir_all(&target_dir)?;
1645
1646                let target_path =
1647                    target_dir.join(fallback_image.as_ref().unwrap().file_name().unwrap());
1648
1649                fs::copy(fallback_image.as_ref().unwrap(), &target_path)?;
1650            }
1651
1652            Some(_) => {}
1653            None => {}
1654        }
1655
1656        Ok(())
1657    }
1658}
1659
1660impl Drop for ContentBuilder {
1661    fn drop(&mut self) {
1662        if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1663            warn!("{}", err);
1664        };
1665    }
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670    // use std::{path::PathBuf, vec};
1671
1672    // use crate::{
1673    //     builder::content::{ContentBuilder, Footnote},
1674    //     error::EpubError,
1675    // };
1676
1677    // #[test]
1678    // fn test() -> Result<(), EpubError> {
1679    //     let ele_string = r#"
1680    //     <math xmlns="http://www.w3.org/1998/Math/MathML">
1681    //       <mrow>
1682    //         <munderover>
1683    //           <mo>∑</mo>
1684    //           <mrow>
1685    //             <mi>n</mi>
1686    //             <mo>=</mo>
1687    //             <mn>1</mn>
1688    //           </mrow>
1689    //           <mrow>
1690    //             <mo>+</mo>
1691    //             <mn>∞</mn>
1692    //           </mrow>
1693    //         </munderover>
1694    //         <mfrac>
1695    //           <mn>1</mn>
1696    //           <msup>
1697    //             <mi>n</mi>
1698    //             <mn>2</mn>
1699    //           </msup>
1700    //         </mfrac>
1701    //       </mrow>
1702    //     </math>"#;
1703
1704    //     let content = ContentBuilder::new("test", "zh-CN")?
1705    //         .set_title("Test")
1706    //         .add_title_block(
1707    //             "This is a title",
1708    //             2,
1709    //             vec![
1710    //                 Footnote {
1711    //                     content: "This is a footnote for title.".to_string(),
1712    //                     locate: 15,
1713    //                 },
1714    //                 Footnote {
1715    //                     content: "This is another footnote for title.".to_string(),
1716    //                     locate: 4,
1717    //                 },
1718    //             ],
1719    //         )?
1720    //         .add_text_block(
1721    //             "This is a paragraph.",
1722    //             vec![
1723    //                 Footnote {
1724    //                     content: "This is a footnote.".to_string(),
1725    //                     locate: 4,
1726    //                 },
1727    //                 Footnote {
1728    //                     content: "This is another footnote.".to_string(),
1729    //                     locate: 20,
1730    //                 },
1731    //                 Footnote {
1732    //                     content: "This is a third footnote.".to_string(),
1733    //                     locate: 4,
1734    //                 },
1735    //             ],
1736    //         )?
1737    //         .add_image_block(
1738    //             PathBuf::from("C:\\Users\\Kikki\\Desktop\\background.jpg"),
1739    //             None,
1740    //             Some("this is an image".to_string()),
1741    //             vec![Footnote {
1742    //                 content: "This is a footnote for image.".to_string(),
1743    //                 locate: 16,
1744    //             }],
1745    //         )?
1746    //         .add_quote_block(
1747    //             "Quote a text.",
1748    //             vec![Footnote {
1749    //                 content: "This is a footnote for quote.".to_string(),
1750    //                 locate: 13,
1751    //             }],
1752    //         )?
1753    //         .add_audio_block(
1754    //             PathBuf::from("C:\\Users\\Kikki\\Desktop\\audio.mp3"),
1755    //             "This a fallback string".to_string(),
1756    //             Some("this is an audio".to_string()),
1757    //             vec![Footnote {
1758    //                 content: "This is a footnote for audio.".to_string(),
1759    //                 locate: 4,
1760    //             }],
1761    //         )?
1762    //         .add_video_block(
1763    //             PathBuf::from("C:\\Users\\Kikki\\Desktop\\秋日何时来2024BD1080P.mp4"),
1764    //             "This a fallback string".to_string(),
1765    //             Some("this a video".to_string()),
1766    //             vec![Footnote {
1767    //                 content: "This is a footnote for video.".to_string(),
1768    //                 locate: 12,
1769    //             }],
1770    //         )?
1771    //         .add_mathml_block(
1772    //             ele_string.to_owned(),
1773    //             None,
1774    //             Some("this is a formula".to_string()),
1775    //             vec![Footnote {
1776    //                 content: "This is a footnote for formula.".to_string(),
1777    //                 locate: 17,
1778    //             }],
1779    //         )?
1780    //         .make("C:\\Users\\Kikki\\Desktop\\test.xhtml");
1781    //     assert!(content.is_ok());
1782    //     Ok(())
1783    // }
1784
1785    mod block_builder_tests {
1786        use std::path::PathBuf;
1787
1788        use crate::{
1789            builder::content::{Block, BlockBuilder},
1790            error::EpubBuilderError,
1791            types::{BlockType, Footnote},
1792        };
1793
1794        #[test]
1795        fn test_create_text_block() {
1796            let mut builder = BlockBuilder::new(BlockType::Text);
1797            builder.set_content("Hello, World!");
1798
1799            let block = builder.build();
1800            assert!(block.is_ok());
1801
1802            let block = block.unwrap();
1803            match block {
1804                Block::Text { content, footnotes } => {
1805                    assert_eq!(content, "Hello, World!");
1806                    assert!(footnotes.is_empty());
1807                }
1808                _ => unreachable!(),
1809            }
1810        }
1811
1812        #[test]
1813        fn test_create_text_block_missing_content() {
1814            let builder = BlockBuilder::new(BlockType::Text);
1815
1816            let block = builder.build();
1817            assert!(block.is_err());
1818
1819            let result = block.unwrap_err();
1820            assert_eq!(
1821                result,
1822                EpubBuilderError::MissingNecessaryBlockData {
1823                    block_type: "Text".to_string(),
1824                    missing_data: "'content'".to_string()
1825                }
1826                .into()
1827            )
1828        }
1829
1830        #[test]
1831        fn test_create_quote_block() {
1832            let mut builder = BlockBuilder::new(BlockType::Quote);
1833            builder.set_content("To be or not to be");
1834
1835            let block = builder.build();
1836            assert!(block.is_ok());
1837
1838            let block = block.unwrap();
1839            match block {
1840                Block::Quote { content, footnotes } => {
1841                    assert_eq!(content, "To be or not to be");
1842                    assert!(footnotes.is_empty());
1843                }
1844                _ => unreachable!(),
1845            }
1846        }
1847
1848        #[test]
1849        fn test_create_title_block() {
1850            let mut builder = BlockBuilder::new(BlockType::Title);
1851            builder.set_content("Chapter 1").set_title_level(2);
1852
1853            let block = builder.build();
1854            assert!(block.is_ok());
1855
1856            let block = block.unwrap();
1857            match block {
1858                Block::Title { content, level, footnotes } => {
1859                    assert_eq!(content, "Chapter 1");
1860                    assert_eq!(level, 2);
1861                    assert!(footnotes.is_empty());
1862                }
1863                _ => unreachable!(),
1864            }
1865        }
1866
1867        #[test]
1868        fn test_create_title_block_invalid_level() {
1869            let mut builder = BlockBuilder::new(BlockType::Title);
1870            builder.set_content("Chapter 1").set_title_level(10);
1871
1872            let result = builder.build();
1873            assert!(result.is_err());
1874
1875            let result = result.unwrap_err();
1876            assert_eq!(
1877                result,
1878                EpubBuilderError::MissingNecessaryBlockData {
1879                    block_type: "Title".to_string(),
1880                    missing_data: "'content' or 'level'".to_string(),
1881                }
1882                .into()
1883            );
1884        }
1885
1886        #[test]
1887        fn test_create_image_block() {
1888            let img_path = PathBuf::from("./test_case/image.jpg");
1889            let mut builder = BlockBuilder::new(BlockType::Image);
1890            builder
1891                .set_url(&img_path)
1892                .unwrap()
1893                .set_alt("Test Image")
1894                .set_caption("A test image");
1895
1896            let block = builder.build();
1897            assert!(block.is_ok());
1898
1899            let block = block.unwrap();
1900            match block {
1901                Block::Image { url, alt, caption, footnotes } => {
1902                    assert_eq!(url.file_name().unwrap(), "image.jpg");
1903                    assert_eq!(alt, Some("Test Image".to_string()));
1904                    assert_eq!(caption, Some("A test image".to_string()));
1905                    assert!(footnotes.is_empty());
1906                }
1907                _ => unreachable!(),
1908            }
1909        }
1910
1911        #[test]
1912        fn test_create_image_block_missing_url() {
1913            let builder = BlockBuilder::new(BlockType::Image);
1914
1915            let block = builder.build();
1916            assert!(block.is_err());
1917
1918            let result = block.unwrap_err();
1919            assert_eq!(
1920                result,
1921                EpubBuilderError::MissingNecessaryBlockData {
1922                    block_type: "Image".to_string(),
1923                    missing_data: "'url'".to_string(),
1924                }
1925                .into()
1926            );
1927        }
1928
1929        #[test]
1930        fn test_create_audio_block() {
1931            let audio_path = PathBuf::from("./test_case/audio.mp3");
1932            let mut builder = BlockBuilder::new(BlockType::Audio);
1933            builder
1934                .set_url(&audio_path)
1935                .unwrap()
1936                .set_fallback("Audio not supported")
1937                .set_caption("Background music");
1938
1939            let block = builder.build();
1940            assert!(block.is_ok());
1941
1942            let block = block.unwrap();
1943            match block {
1944                Block::Audio { url, fallback, caption, footnotes } => {
1945                    assert_eq!(url.file_name().unwrap(), "audio.mp3");
1946                    assert_eq!(fallback, "Audio not supported");
1947                    assert_eq!(caption, Some("Background music".to_string()));
1948                    assert!(footnotes.is_empty());
1949                }
1950                _ => unreachable!(),
1951            }
1952        }
1953
1954        #[test]
1955        fn test_set_url_invalid_file_type() {
1956            let xhtml_path = PathBuf::from("./test_case/Overview.xhtml");
1957            let mut builder = BlockBuilder::new(BlockType::Image);
1958            let result = builder.set_url(&xhtml_path);
1959            assert!(result.is_err());
1960
1961            let err = result.unwrap_err();
1962            assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1963        }
1964
1965        #[test]
1966        fn test_set_url_nonexistent_file() {
1967            let nonexistent_path = PathBuf::from("./test_case/nonexistent.jpg");
1968            let mut builder = BlockBuilder::new(BlockType::Image);
1969            let result = builder.set_url(&nonexistent_path);
1970            assert!(result.is_err());
1971
1972            let err = result.unwrap_err();
1973            assert_eq!(
1974                err,
1975                EpubBuilderError::TargetIsNotFile {
1976                    target_path: "./test_case/nonexistent.jpg".to_string()
1977                }
1978                .into()
1979            );
1980        }
1981
1982        #[test]
1983        fn test_set_fallback_image_invalid_type() {
1984            let audio_path = PathBuf::from("./test_case/audio.mp3");
1985            let mut builder = BlockBuilder::new(BlockType::MathML);
1986            builder.set_mathml_element("<math/>");
1987            let result = builder.set_fallback_image(audio_path);
1988            assert!(result.is_err());
1989
1990            let err = result.unwrap_err();
1991            assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1992        }
1993
1994        #[test]
1995        fn test_set_fallback_image_nonexistent() {
1996            let nonexistent_path = PathBuf::from("./test_case/nonexistent.png");
1997            let mut builder = BlockBuilder::new(BlockType::MathML);
1998            builder.set_mathml_element("<math/>");
1999            let result = builder.set_fallback_image(nonexistent_path);
2000            assert!(result.is_err());
2001
2002            let err = result.unwrap_err();
2003            assert_eq!(
2004                err,
2005                EpubBuilderError::TargetIsNotFile {
2006                    target_path: "./test_case/nonexistent.png".to_string()
2007                }
2008                .into()
2009            );
2010        }
2011
2012        #[test]
2013        fn test_create_video_block() {
2014            let video_path = PathBuf::from("./test_case/video.mp4");
2015            let mut builder = BlockBuilder::new(BlockType::Video);
2016            builder
2017                .set_url(&video_path)
2018                .unwrap()
2019                .set_fallback("Video not supported")
2020                .set_caption("Demo video");
2021
2022            let block = builder.build();
2023            assert!(block.is_ok());
2024
2025            let block = block.unwrap();
2026            match block {
2027                Block::Video { url, fallback, caption, footnotes } => {
2028                    assert_eq!(url.file_name().unwrap(), "video.mp4");
2029                    assert_eq!(fallback, "Video not supported");
2030                    assert_eq!(caption, Some("Demo video".to_string()));
2031                    assert!(footnotes.is_empty());
2032                }
2033                _ => unreachable!(),
2034            }
2035        }
2036
2037        #[test]
2038        fn test_create_mathml_block() {
2039            let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
2040            let mut builder = BlockBuilder::new(BlockType::MathML);
2041            builder
2042                .set_mathml_element(mathml_content)
2043                .set_caption("Simple equation");
2044
2045            let block = builder.build();
2046            assert!(block.is_ok());
2047
2048            let block = block.unwrap();
2049            match block {
2050                Block::MathML {
2051                    element_str,
2052                    fallback_image,
2053                    caption,
2054                    footnotes,
2055                } => {
2056                    assert_eq!(element_str, mathml_content);
2057                    assert!(fallback_image.is_none());
2058                    assert_eq!(caption, Some("Simple equation".to_string()));
2059                    assert!(footnotes.is_empty());
2060                }
2061                _ => unreachable!(),
2062            }
2063        }
2064
2065        #[test]
2066        fn test_create_mathml_block_with_fallback() {
2067            let img_path = PathBuf::from("./test_case/image.jpg");
2068            let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
2069
2070            let mut builder = BlockBuilder::new(BlockType::MathML);
2071            builder
2072                .set_mathml_element(mathml_content)
2073                .set_fallback_image(img_path.clone())
2074                .unwrap();
2075
2076            let block = builder.build();
2077            assert!(block.is_ok());
2078
2079            let block = block.unwrap();
2080            match block {
2081                Block::MathML { element_str, fallback_image, .. } => {
2082                    assert_eq!(element_str, mathml_content);
2083                    assert!(fallback_image.is_some());
2084                }
2085                _ => unreachable!(),
2086            }
2087        }
2088
2089        #[test]
2090        fn test_footnote_management() {
2091            let mut builder = BlockBuilder::new(BlockType::Text);
2092            builder.set_content("This is a test");
2093
2094            let note1 = Footnote {
2095                locate: 5,
2096                content: "First footnote".to_string(),
2097            };
2098            let note2 = Footnote {
2099                locate: 10,
2100                content: "Second footnote".to_string(),
2101            };
2102
2103            builder.add_footnote(note1).add_footnote(note2);
2104
2105            let block = builder.build();
2106            assert!(block.is_ok());
2107
2108            let block = block.unwrap();
2109            match block {
2110                Block::Text { footnotes, .. } => {
2111                    assert_eq!(footnotes.len(), 2);
2112                }
2113                _ => unreachable!(),
2114            }
2115        }
2116
2117        #[test]
2118        fn test_remove_last_footnote() {
2119            let mut builder = BlockBuilder::new(BlockType::Text);
2120            builder.set_content("This is a test");
2121
2122            builder.add_footnote(Footnote { locate: 5, content: "Note 1".to_string() });
2123            builder.add_footnote(Footnote {
2124                locate: 10,
2125                content: "Note 2".to_string(),
2126            });
2127            builder.remove_last_footnote();
2128
2129            let block = builder.build();
2130            assert!(block.is_ok());
2131
2132            let block = block.unwrap();
2133            match block {
2134                Block::Text { footnotes, .. } => {
2135                    assert_eq!(footnotes.len(), 1);
2136                    assert!(footnotes[0].content == "Note 1");
2137                }
2138                _ => unreachable!(),
2139            }
2140        }
2141
2142        #[test]
2143        fn test_clear_footnotes() {
2144            let mut builder = BlockBuilder::new(BlockType::Text);
2145            builder.set_content("This is a test");
2146
2147            builder.add_footnote(Footnote { locate: 5, content: "Note".to_string() });
2148
2149            builder.clear_footnotes();
2150
2151            let block = builder.build();
2152            assert!(block.is_ok());
2153
2154            let block = block.unwrap();
2155            match block {
2156                Block::Text { footnotes, .. } => {
2157                    assert!(footnotes.is_empty());
2158                }
2159                _ => unreachable!(),
2160            }
2161        }
2162
2163        #[test]
2164        fn test_invalid_footnote_locate() {
2165            let mut builder = BlockBuilder::new(BlockType::Text);
2166            builder.set_content("Hello");
2167
2168            // Footnote locate exceeds content length
2169            builder.add_footnote(Footnote {
2170                locate: 100,
2171                content: "Invalid footnote".to_string(),
2172            });
2173
2174            let result = builder.build();
2175            assert!(result.is_err());
2176
2177            let result = result.unwrap_err();
2178            assert_eq!(
2179                result,
2180                EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
2181            );
2182        }
2183
2184        #[test]
2185        fn test_footnote_on_media_without_caption() {
2186            let img_path = PathBuf::from("./test_case/image.jpg");
2187            let mut builder = BlockBuilder::new(BlockType::Image);
2188            builder.set_url(&img_path).unwrap();
2189
2190            builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
2191
2192            let result = builder.build();
2193            assert!(result.is_err());
2194
2195            let result = result.unwrap_err();
2196            assert_eq!(
2197                result,
2198                EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
2199            );
2200        }
2201    }
2202
2203    mod content_builder_tests {
2204        use std::{env, fs, path::PathBuf};
2205
2206        use crate::{
2207            builder::content::{Block, ContentBuilder},
2208            types::{ColorScheme, Footnote, PageLayout, TextAlign, TextStyle},
2209            utils::local_time,
2210        };
2211
2212        #[test]
2213        fn test_create_content_builder() {
2214            let builder = ContentBuilder::new("chapter1", "en");
2215            assert!(builder.is_ok());
2216
2217            let builder = builder.unwrap();
2218            assert_eq!(builder.id, "chapter1");
2219        }
2220
2221        #[test]
2222        fn test_set_title() {
2223            let builder = ContentBuilder::new("chapter1", "en");
2224            assert!(builder.is_ok());
2225
2226            let mut builder = builder.unwrap();
2227            builder.set_title("My Chapter").set_title("Another Title");
2228
2229            assert_eq!(builder.title, "Another Title");
2230        }
2231
2232        #[test]
2233        fn test_add_text_block() {
2234            let builder = ContentBuilder::new("chapter1", "en");
2235            assert!(builder.is_ok());
2236
2237            let mut builder = builder.unwrap();
2238            let result = builder.add_text_block("This is a paragraph", vec![]);
2239            assert!(result.is_ok());
2240        }
2241
2242        #[test]
2243        fn test_add_quote_block() {
2244            let builder = ContentBuilder::new("chapter1", "en");
2245            assert!(builder.is_ok());
2246
2247            let mut builder = builder.unwrap();
2248            let result = builder.add_quote_block("A quoted text", vec![]);
2249            assert!(result.is_ok());
2250        }
2251
2252        #[test]
2253        fn test_set_styles() {
2254            let builder = ContentBuilder::new("chapter1", "en");
2255            assert!(builder.is_ok());
2256
2257            let custom_styles = crate::types::StyleOptions {
2258                text: TextStyle {
2259                    font_size: 1.5,
2260                    line_height: 1.8,
2261                    font_family: "Georgia, serif".to_string(),
2262                    font_weight: "bold".to_string(),
2263                    font_style: "italic".to_string(),
2264                    letter_spacing: "0.1em".to_string(),
2265                    text_indent: 1.5,
2266                },
2267                color_scheme: ColorScheme {
2268                    background: "#F5F5F5".to_string(),
2269                    text: "#333333".to_string(),
2270                    link: "#0066CC".to_string(),
2271                },
2272                layout: PageLayout {
2273                    margin: 30,
2274                    text_align: TextAlign::Center,
2275                    paragraph_spacing: 20,
2276                },
2277            };
2278
2279            let mut builder = builder.unwrap();
2280            builder.set_styles(custom_styles);
2281
2282            assert_eq!(builder.styles.text.font_size, 1.5);
2283            assert_eq!(builder.styles.text.font_weight, "bold");
2284            assert_eq!(builder.styles.color_scheme.background, "#F5F5F5");
2285            assert_eq!(builder.styles.layout.text_align, TextAlign::Center);
2286        }
2287
2288        #[test]
2289        fn test_add_title_block() {
2290            let builder = ContentBuilder::new("chapter1", "en");
2291            assert!(builder.is_ok());
2292
2293            let mut builder = builder.unwrap();
2294            let result = builder.add_title_block("Section Title", 2, vec![]);
2295            assert!(result.is_ok());
2296        }
2297
2298        #[test]
2299        fn test_add_image_block() {
2300            let img_path = PathBuf::from("./test_case/image.jpg");
2301            let builder = ContentBuilder::new("chapter1", "en");
2302            assert!(builder.is_ok());
2303
2304            let mut builder = builder.unwrap();
2305            let result = builder.add_image_block(
2306                img_path,
2307                Some("Alt text".to_string()),
2308                Some("Figure 1: An image".to_string()),
2309                vec![],
2310            );
2311
2312            assert!(result.is_ok());
2313        }
2314
2315        #[test]
2316        fn test_add_audio_block() {
2317            let audio_path = PathBuf::from("./test_case/audio.mp3");
2318            let builder = ContentBuilder::new("chapter1", "en");
2319            assert!(builder.is_ok());
2320
2321            let mut builder = builder.unwrap();
2322            let result = builder.add_audio_block(
2323                audio_path,
2324                "Your browser doesn't support audio".to_string(),
2325                Some("Background music".to_string()),
2326                vec![],
2327            );
2328
2329            assert!(result.is_ok());
2330        }
2331
2332        #[test]
2333        fn test_add_video_block() {
2334            let video_path = PathBuf::from("./test_case/video.mp4");
2335            let builder = ContentBuilder::new("chapter1", "en");
2336            assert!(builder.is_ok());
2337
2338            let mut builder = builder.unwrap();
2339            let result = builder.add_video_block(
2340                video_path,
2341                "Your browser doesn't support video".to_string(),
2342                Some("Tutorial video".to_string()),
2343                vec![],
2344            );
2345
2346            assert!(result.is_ok());
2347        }
2348
2349        #[test]
2350        fn test_add_mathml_block() {
2351            let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
2352            let builder = ContentBuilder::new("chapter1", "en");
2353            assert!(builder.is_ok());
2354
2355            let mut builder = builder.unwrap();
2356            let result = builder.add_mathml_block(
2357                mathml.to_string(),
2358                None,
2359                Some("Equation 1".to_string()),
2360                vec![],
2361            );
2362
2363            assert!(result.is_ok());
2364        }
2365
2366        #[test]
2367        fn test_remove_last_block() {
2368            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2369
2370            builder.add_text_block("First block", vec![]).unwrap();
2371            builder.add_text_block("Second block", vec![]).unwrap();
2372            assert_eq!(builder.blocks.len(), 2);
2373
2374            builder.remove_last_block();
2375            assert_eq!(builder.blocks.len(), 1);
2376        }
2377
2378        #[test]
2379        fn test_take_last_block() {
2380            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2381
2382            builder.add_text_block("Block content", vec![]).unwrap();
2383
2384            let block = builder.take_last_block();
2385            assert!(block.is_some());
2386
2387            let block = block.unwrap();
2388            match block {
2389                Block::Text { content, .. } => {
2390                    assert_eq!(content, "Block content");
2391                }
2392                _ => unreachable!(),
2393            }
2394
2395            let block2 = builder.take_last_block();
2396            assert!(block2.is_none());
2397        }
2398
2399        #[test]
2400        fn test_clear_blocks() {
2401            let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2402
2403            builder.add_text_block("Block 1", vec![]).unwrap();
2404            builder.add_text_block("Block 2", vec![]).unwrap();
2405            assert_eq!(builder.blocks.len(), 2);
2406
2407            builder.clear_blocks();
2408
2409            let block = builder.take_last_block();
2410            assert!(block.is_none());
2411        }
2412
2413        #[test]
2414        fn test_make_content_document() {
2415            let temp_dir = env::temp_dir().join(local_time());
2416            assert!(fs::create_dir_all(&temp_dir).is_ok());
2417
2418            let output_path = temp_dir.join("chapter.xhtml");
2419
2420            let builder = ContentBuilder::new("chapter1", "en");
2421            assert!(builder.is_ok());
2422
2423            let mut builder = builder.unwrap();
2424            builder
2425                .set_title("My Chapter")
2426                .add_text_block("This is the first paragraph.", vec![])
2427                .unwrap()
2428                .add_text_block("This is the second paragraph.", vec![])
2429                .unwrap();
2430
2431            let result = builder.make(&output_path);
2432            assert!(result.is_ok());
2433            assert!(output_path.exists());
2434            assert!(fs::remove_dir_all(temp_dir).is_ok());
2435        }
2436
2437        #[test]
2438        fn test_make_content_with_media() {
2439            let temp_dir = env::temp_dir().join(local_time());
2440            assert!(fs::create_dir_all(&temp_dir).is_ok());
2441
2442            let output_path = temp_dir.join("chapter.xhtml");
2443            let img_path = PathBuf::from("./test_case/image.jpg");
2444
2445            let builder = ContentBuilder::new("chapter1", "en");
2446            assert!(builder.is_ok());
2447
2448            let mut builder = builder.unwrap();
2449            builder
2450                .set_title("Chapter with Media")
2451                .add_text_block("See image below:", vec![])
2452                .unwrap()
2453                .add_image_block(
2454                    img_path,
2455                    Some("Test".to_string()),
2456                    Some("Figure 1".to_string()),
2457                    vec![],
2458                )
2459                .unwrap();
2460
2461            let result = builder.make(&output_path);
2462            assert!(result.is_ok());
2463
2464            let img_dir = temp_dir.join("img");
2465            assert!(img_dir.exists());
2466            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2467        }
2468
2469        #[test]
2470        fn test_make_content_with_footnotes() {
2471            let temp_dir = env::temp_dir().join(local_time());
2472            assert!(fs::create_dir_all(&temp_dir).is_ok());
2473
2474            let output_path = temp_dir.join("chapter.xhtml");
2475
2476            let footnotes = vec![
2477                Footnote {
2478                    locate: 10,
2479                    content: "This is a footnote".to_string(),
2480                },
2481                Footnote {
2482                    locate: 15,
2483                    content: "Another footnote".to_string(),
2484                },
2485            ];
2486
2487            let builder = ContentBuilder::new("chapter1", "en");
2488            assert!(builder.is_ok());
2489
2490            let mut builder = builder.unwrap();
2491            builder
2492                .set_title("Chapter with Notes")
2493                .add_text_block("This is a paragraph with notes.", footnotes)
2494                .unwrap();
2495
2496            let result = builder.make(&output_path);
2497            assert!(result.is_ok());
2498            assert!(output_path.exists());
2499            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2500        }
2501
2502        #[test]
2503        fn test_add_css_file() {
2504            let builder = ContentBuilder::new("chapter1", "en");
2505            assert!(builder.is_ok());
2506
2507            let mut builder = builder.unwrap();
2508            let result = builder.add_css_file(PathBuf::from("./test_case/style.css"));
2509
2510            assert!(result.is_ok());
2511            assert_eq!(builder.css_files.len(), 1);
2512        }
2513
2514        #[test]
2515        fn test_add_css_file_nonexistent() {
2516            let builder = ContentBuilder::new("chapter1", "en");
2517            assert!(builder.is_ok());
2518
2519            let mut builder = builder.unwrap();
2520            let result = builder.add_css_file(PathBuf::from("nonexistent.css"));
2521            assert!(result.is_err());
2522        }
2523
2524        #[test]
2525        fn test_add_multiple_css_files() {
2526            let temp_dir = env::temp_dir().join(local_time());
2527            assert!(fs::create_dir_all(&temp_dir).is_ok());
2528
2529            let css_path1 = temp_dir.join("style1.css");
2530            let css_path2 = temp_dir.join("style2.css");
2531            assert!(fs::write(&css_path1, "body { color: red; }").is_ok());
2532            assert!(fs::write(&css_path2, "p { font-size: 16px; }").is_ok());
2533
2534            let builder = ContentBuilder::new("chapter1", "en");
2535            assert!(builder.is_ok());
2536
2537            let mut builder = builder.unwrap();
2538            assert!(builder.add_css_file(css_path1).is_ok());
2539            assert!(builder.add_css_file(css_path2).is_ok());
2540
2541            assert_eq!(builder.css_files.len(), 2);
2542
2543            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2544        }
2545
2546        #[test]
2547        fn test_remove_last_css_file() {
2548            let builder = ContentBuilder::new("chapter1", "en");
2549            assert!(builder.is_ok());
2550
2551            let mut builder = builder.unwrap();
2552            builder
2553                .add_css_file(PathBuf::from("./test_case/style.css"))
2554                .unwrap();
2555            assert_eq!(builder.css_files.len(), 1);
2556
2557            builder.remove_last_css_file();
2558            assert!(builder.css_files.is_empty());
2559
2560            builder.remove_last_css_file();
2561            assert!(builder.css_files.is_empty());
2562        }
2563
2564        #[test]
2565        fn test_clear_css_files() {
2566            let temp_dir = env::temp_dir().join(local_time());
2567            assert!(fs::create_dir_all(&temp_dir).is_ok());
2568
2569            let css_path1 = temp_dir.join("style1.css");
2570            let css_path2 = temp_dir.join("style2.css");
2571            assert!(fs::write(&css_path1, "body { color: red; }").is_ok());
2572            assert!(fs::write(&css_path2, "p { font-size: 16px; }").is_ok());
2573
2574            let builder = ContentBuilder::new("chapter1", "en");
2575            assert!(builder.is_ok());
2576
2577            let mut builder = builder.unwrap();
2578            assert!(builder.add_css_file(css_path1).is_ok());
2579            assert!(builder.add_css_file(css_path2).is_ok());
2580            assert_eq!(builder.css_files.len(), 2);
2581
2582            builder.clear_css_files();
2583            assert!(builder.css_files.is_empty());
2584
2585            assert!(fs::remove_dir_all(&temp_dir).is_ok());
2586        }
2587    }
2588
2589    mod block_tests {
2590        use std::path::PathBuf;
2591
2592        use crate::{builder::content::Block, types::Footnote};
2593
2594        #[test]
2595        fn test_take_footnotes_from_text_block() {
2596            let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
2597
2598            let block = Block::Text {
2599                content: "Hello world".to_string(),
2600                footnotes: footnotes.clone(),
2601            };
2602
2603            let taken = block.take_footnotes();
2604            assert_eq!(taken.len(), 1);
2605            assert_eq!(taken[0].content, "Note");
2606        }
2607
2608        #[test]
2609        fn test_take_footnotes_from_quote_block() {
2610            let footnotes = vec![
2611                Footnote { locate: 3, content: "First".to_string() },
2612                Footnote { locate: 8, content: "Second".to_string() },
2613            ];
2614
2615            let block = Block::Quote {
2616                content: "Test quote".to_string(),
2617                footnotes: footnotes.clone(),
2618            };
2619
2620            let taken = block.take_footnotes();
2621            assert_eq!(taken.len(), 2);
2622        }
2623
2624        #[test]
2625        fn test_take_footnotes_from_image_block() {
2626            let img_path = PathBuf::from("test.png");
2627            let footnotes = vec![Footnote {
2628                locate: 2,
2629                content: "Image note".to_string(),
2630            }];
2631
2632            let block = Block::Image {
2633                url: img_path,
2634                alt: None,
2635                caption: Some("A caption".to_string()),
2636                footnotes: footnotes.clone(),
2637            };
2638
2639            let taken = block.take_footnotes();
2640            assert_eq!(taken.len(), 1);
2641        }
2642
2643        #[test]
2644        fn test_block_with_empty_footnotes() {
2645            let block = Block::Text {
2646                content: "No footnotes here".to_string(),
2647                footnotes: vec![],
2648            };
2649
2650            let taken = block.take_footnotes();
2651            assert!(taken.is_empty());
2652        }
2653    }
2654
2655    mod content_rendering_tests {
2656        use crate::builder::content::Block;
2657
2658        #[test]
2659        fn test_split_content_by_index_empty() {
2660            let result = Block::split_content_by_index("Hello", &[]);
2661            assert_eq!(result, vec!["Hello"]);
2662        }
2663
2664        #[test]
2665        fn test_split_content_by_single_index() {
2666            let result = Block::split_content_by_index("Hello World", &[5]);
2667            assert_eq!(result.len(), 2);
2668            assert_eq!(result[0], "Hello");
2669            assert_eq!(result[1], " World");
2670        }
2671
2672        #[test]
2673        fn test_split_content_by_multiple_indices() {
2674            let result = Block::split_content_by_index("One Two Three", &[3, 7]);
2675            assert_eq!(result.len(), 3);
2676            assert_eq!(result[0], "One");
2677            assert_eq!(result[1], " Two");
2678            assert_eq!(result[2], " Three");
2679        }
2680
2681        #[test]
2682        fn test_split_content_unicode() {
2683            let content = "你好世界";
2684            let result = Block::split_content_by_index(content, &[2]);
2685            assert_eq!(result.len(), 2);
2686            assert_eq!(result[0], "你好");
2687            assert_eq!(result[1], "世界");
2688        }
2689    }
2690}