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