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