use std::{
collections::HashMap,
env,
fs::{self, File},
io::{Cursor, Read},
path::{Path, PathBuf},
};
use infer::{Infer, MatcherType};
use log::warn;
use quick_xml::{
Reader, Writer,
events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
};
use walkdir::WalkDir;
use crate::{
builder::XmlWriter,
error::{EpubBuilderError, EpubError},
types::{BlockType, Footnote, StyleOptions},
utils::local_time,
};
#[non_exhaustive]
#[derive(Debug)]
pub enum Block {
#[non_exhaustive]
Text {
content: String,
footnotes: Vec<Footnote>,
},
#[non_exhaustive]
Quote {
content: String,
footnotes: Vec<Footnote>,
},
#[non_exhaustive]
Title {
content: String,
footnotes: Vec<Footnote>,
level: usize,
},
#[non_exhaustive]
Image {
url: PathBuf,
alt: Option<String>,
caption: Option<String>,
footnotes: Vec<Footnote>,
},
#[non_exhaustive]
Audio {
url: PathBuf,
fallback: String,
caption: Option<String>,
footnotes: Vec<Footnote>,
},
#[non_exhaustive]
Video {
url: PathBuf,
fallback: String,
caption: Option<String>,
footnotes: Vec<Footnote>,
},
#[non_exhaustive]
MathML {
element_str: String,
fallback_image: Option<PathBuf>,
caption: Option<String>,
footnotes: Vec<Footnote>,
},
}
impl Block {
pub(crate) fn make(
&mut self,
writer: &mut XmlWriter,
start_index: usize,
) -> Result<(), EpubError> {
match self {
Block::Text { content, footnotes } => {
writer.write_event(Event::Start(
BytesStart::new("p").with_attributes([("class", "content-block text-block")]),
))?;
Self::make_text(writer, content, footnotes, start_index)?;
writer.write_event(Event::End(BytesEnd::new("p")))?;
}
Block::Quote { content, footnotes } => {
writer.write_event(Event::Start(BytesStart::new("blockquote").with_attributes(
[
("class", "content-block quote-block"),
("cite", "SOME ATTR NEED TO BE SET"),
],
)))?;
writer.write_event(Event::Start(BytesStart::new("p")))?;
Self::make_text(writer, content, footnotes, start_index)?;
writer.write_event(Event::End(BytesEnd::new("p")))?;
writer.write_event(Event::End(BytesEnd::new("blockquote")))?;
}
Block::Title { content, footnotes, level } => {
let tag_name = format!("h{}", level);
writer.write_event(Event::Start(
BytesStart::new(tag_name.as_str())
.with_attributes([("class", "content-block title-block")]),
))?;
Self::make_text(writer, content, footnotes, start_index)?;
writer.write_event(Event::End(BytesEnd::new(tag_name)))?;
}
Block::Image { url, alt, caption, footnotes } => {
let url = format!("./img/{}", url.file_name().unwrap().to_string_lossy());
let mut attr = Vec::new();
attr.push(("src", url.as_str()));
if let Some(alt) = alt {
attr.push(("alt", alt.as_str()));
}
writer.write_event(Event::Start(
BytesStart::new("figure")
.with_attributes([("class", "content-block image-block")]),
))?;
writer.write_event(Event::Empty(BytesStart::new("img").with_attributes(attr)))?;
if let Some(caption) = caption {
writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
Self::make_text(writer, caption, footnotes, start_index)?;
writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
}
writer.write_event(Event::End(BytesEnd::new("figure")))?;
}
Block::Audio { url, fallback, caption, footnotes } => {
let url = format!("./audio/{}", url.file_name().unwrap().to_string_lossy());
let attr = vec![
("src", url.as_str()),
("controls", "controls"), ];
writer.write_event(Event::Start(
BytesStart::new("figure")
.with_attributes([("class", "content-block audio-block")]),
))?;
writer.write_event(Event::Start(BytesStart::new("audio").with_attributes(attr)))?;
writer.write_event(Event::Start(BytesStart::new("p")))?;
writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
writer.write_event(Event::End(BytesEnd::new("p")))?;
writer.write_event(Event::End(BytesEnd::new("audio")))?;
if let Some(caption) = caption {
writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
Self::make_text(writer, caption, footnotes, start_index)?;
writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
}
writer.write_event(Event::End(BytesEnd::new("figure")))?;
}
Block::Video { url, fallback, caption, footnotes } => {
let url = format!("./video/{}", url.file_name().unwrap().to_string_lossy());
let attr = vec![
("src", url.as_str()),
("controls", "controls"), ];
writer.write_event(Event::Start(
BytesStart::new("figure")
.with_attributes([("class", "content-block video-block")]),
))?;
writer.write_event(Event::Start(BytesStart::new("video").with_attributes(attr)))?;
writer.write_event(Event::Start(BytesStart::new("p")))?;
writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
writer.write_event(Event::End(BytesEnd::new("p")))?;
writer.write_event(Event::End(BytesEnd::new("video")))?;
if let Some(caption) = caption {
writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
Self::make_text(writer, caption, footnotes, start_index)?;
writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
}
writer.write_event(Event::End(BytesEnd::new("figure")))?;
}
Block::MathML {
element_str,
fallback_image,
caption,
footnotes,
} => {
writer.write_event(Event::Start(
BytesStart::new("figure")
.with_attributes([("class", "content-block mathml-block")]),
))?;
Self::write_mathml_element(writer, element_str)?;
if let Some(fallback_path) = fallback_image {
let img_url = format!(
"./img/{}",
fallback_path.file_name().unwrap().to_string_lossy()
);
writer.write_event(Event::Empty(BytesStart::new("img").with_attributes([
("src", img_url.as_str()),
("class", "mathml-fallback"),
("alt", "Mathematical formula"),
])))?;
}
if let Some(caption) = caption {
writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
Self::make_text(writer, caption, footnotes, start_index)?;
writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
}
writer.write_event(Event::End(BytesEnd::new("figure")))?;
}
}
Ok(())
}
pub fn take_footnotes(&self) -> Vec<Footnote> {
match self {
Block::Text { footnotes, .. }
| Block::Quote { footnotes, .. }
| Block::Title { footnotes, .. }
| Block::Image { footnotes, .. }
| Block::Audio { footnotes, .. }
| Block::Video { footnotes, .. }
| Block::MathML { footnotes, .. } => footnotes.to_vec(),
}
}
fn split_content_by_index(content: &str, index_list: &[usize]) -> Vec<String> {
if index_list.is_empty() {
return vec![content.to_string()];
}
let mut result = Vec::with_capacity(index_list.len() + 1);
let mut char_iter = content.chars().enumerate();
let mut current_char_idx = 0;
for &target_idx in index_list {
let mut segment = String::new();
while current_char_idx < target_idx {
if let Some((_, ch)) = char_iter.next() {
segment.push(ch);
current_char_idx += 1;
} else {
break;
}
}
if !segment.is_empty() {
result.push(segment);
}
}
let remainder = char_iter.map(|(_, ch)| ch).collect::<String>();
if !remainder.is_empty() {
result.push(remainder);
}
result
}
fn make_text(
writer: &mut XmlWriter,
content: &str,
footnotes: &mut [Footnote],
start_index: usize,
) -> Result<(), EpubError> {
if footnotes.is_empty() {
writer.write_event(Event::Text(BytesText::new(content)))?;
return Ok(());
}
footnotes.sort_unstable();
let mut position_to_count = HashMap::new();
for footnote in footnotes.iter() {
*position_to_count.entry(footnote.locate).or_insert(0usize) += 1;
}
let mut positions = position_to_count.keys().copied().collect::<Vec<usize>>();
positions.sort_unstable();
let mut current_index = start_index;
let content_list = Self::split_content_by_index(content, &positions);
for (index, segment) in content_list.iter().enumerate() {
writer.write_event(Event::Text(BytesText::new(segment)))?;
if let Some(&position) = positions.get(index) {
if let Some(&count) = position_to_count.get(&position) {
for _ in 0..count {
Self::make_footnotes(writer, current_index)?;
current_index += 1;
}
}
}
}
Ok(())
}
#[inline]
fn make_footnotes(writer: &mut XmlWriter, index: usize) -> Result<(), EpubError> {
writer.write_event(Event::Start(BytesStart::new("a").with_attributes([
("href", format!("#footnote-{}", index).as_str()),
("id", format!("ref-{}", index).as_str()),
("class", "footnote-ref"),
])))?;
writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index))))?;
writer.write_event(Event::End(BytesEnd::new("a")))?;
Ok(())
}
fn write_mathml_element(writer: &mut XmlWriter, element_str: &str) -> Result<(), EpubError> {
let mut reader = Reader::from_str(element_str);
loop {
match reader.read_event() {
Ok(Event::Eof) => break,
Ok(event) => writer.write_event(event)?,
Err(err) => {
return Err(
EpubBuilderError::InvalidMathMLFormat { error: err.to_string() }.into(),
);
}
}
}
Ok(())
}
fn validate_footnotes(&self) -> Result<(), EpubError> {
match self {
Block::Text { content, footnotes }
| Block::Quote { content, footnotes }
| Block::Title { content, footnotes, .. } => {
let max_locate = content.chars().count();
for footnote in footnotes.iter() {
if footnote.locate == 0 || footnote.locate > max_locate {
return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
}
}
Ok(())
}
Block::Image { caption, footnotes, .. }
| Block::MathML { caption, footnotes, .. }
| Block::Video { caption, footnotes, .. }
| Block::Audio { caption, footnotes, .. } => {
if let Some(caption) = caption {
let max_locate = caption.chars().count();
for footnote in footnotes.iter() {
if footnote.locate == 0 || footnote.locate > caption.chars().count() {
return Err(
EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
);
}
}
} else if !footnotes.is_empty() {
return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
}
Ok(())
}
}
}
fn missing_error(block_type: BlockType, missing_data: &str) -> EpubError {
EpubBuilderError::MissingNecessaryBlockData {
block_type: block_type.to_string(),
missing_data: format!("'{}'", missing_data),
}
.into()
}
}
impl TryFrom<BlockBuilder> for Block {
type Error = EpubError;
fn try_from(builder: BlockBuilder) -> Result<Self, Self::Error> {
let block = match builder.block_type {
BlockType::Text => {
let content = builder
.content
.ok_or_else(|| Self::missing_error(builder.block_type, "content"))?;
Block::Text { content, footnotes: builder.footnotes }
}
BlockType::Quote => {
let content = builder
.content
.ok_or_else(|| Self::missing_error(builder.block_type, "content"))?;
Block::Quote { content, footnotes: builder.footnotes }
}
BlockType::Title => {
let content = builder
.content
.ok_or_else(|| Self::missing_error(builder.block_type, "content"))?;
let level = builder
.level
.ok_or_else(|| Self::missing_error(builder.block_type, "level"))?;
Block::Title {
content,
footnotes: builder.footnotes,
level,
}
}
BlockType::Image => {
let url = builder
.url
.ok_or_else(|| Self::missing_error(builder.block_type, "url"))?;
Block::Image {
url,
alt: builder.alt,
caption: builder.caption,
footnotes: builder.footnotes,
}
}
BlockType::Audio => {
let url = builder
.url
.ok_or_else(|| Self::missing_error(builder.block_type, "url"))?;
let fallback = builder
.fallback
.ok_or_else(|| Self::missing_error(builder.block_type, "fallback"))?;
Block::Audio {
url,
fallback,
caption: builder.caption,
footnotes: builder.footnotes,
}
}
BlockType::Video => {
let url = builder
.url
.ok_or_else(|| Self::missing_error(builder.block_type, "url"))?;
let fallback = builder
.fallback
.ok_or_else(|| Self::missing_error(builder.block_type, "fallback"))?;
Block::Video {
url,
fallback,
caption: builder.caption,
footnotes: builder.footnotes,
}
}
BlockType::MathML => {
let element_str = builder
.element_str
.ok_or_else(|| Self::missing_error(builder.block_type, "element_str"))?;
Block::MathML {
element_str,
fallback_image: builder.fallback_image,
caption: builder.caption,
footnotes: builder.footnotes,
}
}
};
block.validate_footnotes()?;
Ok(block)
}
}
#[derive(Debug)]
pub struct BlockBuilder {
block_type: BlockType,
content: Option<String>,
level: Option<usize>,
url: Option<PathBuf>,
alt: Option<String>,
caption: Option<String>,
fallback: Option<String>,
element_str: Option<String>,
fallback_image: Option<PathBuf>,
footnotes: Vec<Footnote>,
}
impl BlockBuilder {
pub fn new(block_type: BlockType) -> Self {
Self {
block_type,
content: None,
level: None,
url: None,
alt: None,
caption: None,
fallback: None,
element_str: None,
fallback_image: None,
footnotes: vec![],
}
}
pub fn set_content(&mut self, content: &str) -> &mut Self {
self.content = Some(content.to_string());
self
}
pub fn set_title_level(&mut self, level: usize) -> &mut Self {
if !(1..=6).contains(&level) {
return self;
}
self.level = Some(level);
self
}
pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
match Self::is_target_type(
url,
vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
) {
Ok(_) => {
self.url = Some(url.to_path_buf());
Ok(self)
}
Err(err) => Err(err),
}
}
pub fn set_alt(&mut self, alt: &str) -> &mut Self {
self.alt = Some(alt.to_string());
self
}
pub fn set_caption(&mut self, caption: &str) -> &mut Self {
self.caption = Some(caption.to_string());
self
}
pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
self.fallback = Some(fallback.to_string());
self
}
pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
self.element_str = Some(element_str.to_string());
self
}
pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
Ok(_) => {
self.fallback_image = Some(fallback_image);
Ok(self)
}
Err(err) => Err(err),
}
}
pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
self.footnotes.push(footnote);
self
}
pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
self.footnotes = footnotes;
self
}
#[deprecated(since = "0.2.0", note = "use `try_into()` instead")]
pub fn build(self) -> Result<Block, EpubError> {
self.try_into()
}
fn is_target_type(path: impl AsRef<Path>, types: Vec<MatcherType>) -> Result<(), EpubError> {
let path = path.as_ref();
if !path.is_file() {
return Err(EpubBuilderError::TargetIsNotFile {
target_path: path.to_string_lossy().to_string(),
}
.into());
}
let mut file = File::open(path)?;
let mut buf = [0; 512];
let read_size = file.read(&mut buf)?;
let header_bytes = &buf[..read_size];
match Infer::new().get(header_bytes) {
Some(file_type) if !types.contains(&file_type.matcher_type()) => {
Err(EpubBuilderError::NotExpectedFileFormat.into())
}
None => Err(EpubBuilderError::UnknownFileFormat {
file_path: path.to_string_lossy().to_string(),
}
.into()),
_ => Ok(()),
}
}
}
#[derive(Debug)]
pub struct ContentBuilder {
pub id: String,
blocks: Vec<Block>,
language: String,
title: String,
styles: StyleOptions,
pub(crate) temp_dir: PathBuf,
pub(crate) css_files: Vec<PathBuf>,
}
impl ContentBuilder {
pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
let temp_dir = env::temp_dir().join(local_time());
fs::create_dir(&temp_dir)?;
Ok(Self {
id: id.to_string(),
blocks: vec![],
language: language.to_string(),
title: String::new(),
styles: StyleOptions::default(),
temp_dir,
css_files: vec![],
})
}
pub fn set_title(&mut self, title: &str) -> &mut Self {
self.title = title.to_string();
self
}
pub fn set_styles(&mut self, styles: StyleOptions) -> &mut Self {
self.styles = styles;
self
}
pub fn add_css_file(&mut self, css_path: PathBuf) -> Result<&mut Self, EpubError> {
if !css_path.is_file() {
return Err(EpubBuilderError::TargetIsNotFile {
target_path: css_path.to_string_lossy().to_string(),
}
.into());
}
let file_name = css_path.file_name().unwrap().to_string_lossy().to_string();
let target_dir = self.temp_dir.join("css");
fs::create_dir_all(&target_dir)?;
let target_path = target_dir.join(&file_name);
fs::copy(&css_path, &target_path)?;
self.css_files.push(target_path);
Ok(self)
}
pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
self.blocks.push(block);
match self.blocks.last() {
Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
self.handle_resource()?
}
Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
self.handle_resource()?;
}
_ => {}
}
Ok(self)
}
pub fn add_text_block(
&mut self,
content: &str,
footnotes: Vec<Footnote>,
) -> Result<&mut Self, EpubError> {
let mut builder = BlockBuilder::new(BlockType::Text);
builder.set_content(content).set_footnotes(footnotes);
self.blocks.push(builder.try_into()?);
Ok(self)
}
pub fn add_quote_block(
&mut self,
content: &str,
footnotes: Vec<Footnote>,
) -> Result<&mut Self, EpubError> {
let mut builder = BlockBuilder::new(BlockType::Quote);
builder.set_content(content).set_footnotes(footnotes);
self.blocks.push(builder.try_into()?);
Ok(self)
}
pub fn add_title_block(
&mut self,
content: &str,
level: usize,
footnotes: Vec<Footnote>,
) -> Result<&mut Self, EpubError> {
let mut builder = BlockBuilder::new(BlockType::Title);
builder
.set_content(content)
.set_title_level(level)
.set_footnotes(footnotes);
self.blocks.push(builder.try_into()?);
Ok(self)
}
pub fn add_image_block(
&mut self,
url: PathBuf,
alt: Option<String>,
caption: Option<String>,
footnotes: Vec<Footnote>,
) -> Result<&mut Self, EpubError> {
let mut builder = BlockBuilder::new(BlockType::Image);
builder.set_url(&url)?.set_footnotes(footnotes);
if let Some(alt) = &alt {
builder.set_alt(alt);
}
if let Some(caption) = &caption {
builder.set_caption(caption);
}
self.blocks.push(builder.try_into()?);
self.handle_resource()?;
Ok(self)
}
pub fn add_audio_block(
&mut self,
url: PathBuf,
fallback: String,
caption: Option<String>,
footnotes: Vec<Footnote>,
) -> Result<&mut Self, EpubError> {
let mut builder = BlockBuilder::new(BlockType::Audio);
builder
.set_url(&url)?
.set_fallback(&fallback)
.set_footnotes(footnotes);
if let Some(caption) = &caption {
builder.set_caption(caption);
}
self.blocks.push(builder.try_into()?);
self.handle_resource()?;
Ok(self)
}
pub fn add_video_block(
&mut self,
url: PathBuf,
fallback: String,
caption: Option<String>,
footnotes: Vec<Footnote>,
) -> Result<&mut Self, EpubError> {
let mut builder = BlockBuilder::new(BlockType::Video);
builder
.set_url(&url)?
.set_fallback(&fallback)
.set_footnotes(footnotes);
if let Some(caption) = &caption {
builder.set_caption(caption);
}
self.blocks.push(builder.try_into()?);
self.handle_resource()?;
Ok(self)
}
pub fn add_mathml_block(
&mut self,
element_str: String,
fallback_image: Option<PathBuf>,
caption: Option<String>,
footnotes: Vec<Footnote>,
) -> Result<&mut Self, EpubError> {
let mut builder = BlockBuilder::new(BlockType::MathML);
builder
.set_mathml_element(&element_str)
.set_footnotes(footnotes);
if let Some(fallback_image) = fallback_image {
builder.set_fallback_image(fallback_image)?;
}
if let Some(caption) = &caption {
builder.set_caption(caption);
}
self.blocks.push(builder.try_into()?);
self.handle_resource()?;
Ok(self)
}
pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
let mut result = Vec::new();
let target_dir = match target.as_ref().parent() {
Some(path) => {
fs::create_dir_all(path)?;
path.to_path_buf()
}
None => {
return Err(EpubBuilderError::InvalidTargetPath {
target_path: target.as_ref().to_string_lossy().to_string(),
}
.into());
}
};
self.make_content(&target)?;
result.push(target.as_ref().to_path_buf());
for resource_type in ["img", "audio", "video", "css"] {
let source = self.temp_dir.join(resource_type);
if !source.is_dir() {
continue;
}
let target = target_dir.join(resource_type);
fs::create_dir_all(&target)?;
for entry in WalkDir::new(&source)
.min_depth(1)
.into_iter()
.filter_map(|result| result.ok())
.filter(|entry| entry.file_type().is_file())
{
let file_name = entry.file_name();
let target = target.join(file_name);
fs::copy(entry.path(), &target)?;
result.push(target);
}
}
Ok(result)
}
fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
let mut writer = Writer::new(Cursor::new(Vec::new()));
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
("xmlns", "http://www.w3.org/1999/xhtml"),
("xml:lang", self.language.as_str()),
])))?;
writer.write_event(Event::Start(BytesStart::new("head")))?;
writer.write_event(Event::Start(BytesStart::new("title")))?;
writer.write_event(Event::Text(BytesText::new(&self.title)))?;
writer.write_event(Event::End(BytesEnd::new("title")))?;
if self.css_files.is_empty() {
self.make_style(&mut writer)?;
} else {
for css_file in self.css_files.iter() {
let file_name = css_file.file_name().unwrap().to_string_lossy().to_string();
writer.write_event(Event::Empty(BytesStart::new("link").with_attributes([
("href", format!("./css/{}", file_name).as_str()),
("rel", "stylesheet"),
("type", "text/css"),
])))?;
}
}
writer.write_event(Event::End(BytesEnd::new("head")))?;
writer.write_event(Event::Start(BytesStart::new("body")))?;
writer.write_event(Event::Start(BytesStart::new("main")))?;
let mut footnote_index = 1;
let mut footnotes = Vec::new();
for block in self.blocks.iter_mut() {
block.make(&mut writer, footnote_index)?;
footnotes.append(&mut block.take_footnotes());
footnote_index = footnotes.len() + 1;
}
writer.write_event(Event::End(BytesEnd::new("main")))?;
Self::make_footnotes(&mut writer, footnotes)?;
writer.write_event(Event::End(BytesEnd::new("body")))?;
writer.write_event(Event::End(BytesEnd::new("html")))?;
let file_path = PathBuf::from(target_path.as_ref());
let file_data = writer.into_inner().into_inner();
fs::write(file_path, file_data)?;
Ok(())
}
fn make_style(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
let style = format!(
r#"
* {{
margin: 0;
padding: 0;
font-family: {font_family};
text-align: {text_align};
background-color: {background};
color: {text};
}}
body, p, div, span, li, td, th {{
font-size: {font_size}rem;
line-height: {line_height}em;
font-weight: {font_weight};
font-style: {font_style};
letter-spacing: {letter_spacing};
}}
body {{ margin: {margin}px; }}
p {{ text-indent: {text_indent}em; }}
a {{ color: {link_color}; text-decoration: none; }}
figcaption {{ text-align: center; line-height: 1em; }}
blockquote {{ padding: 1em 2em; }}
blockquote > p {{ font-style: italic; }}
.content-block {{ margin-bottom: {paragraph_spacing}px; }}
.image-block > img,
.audio-block > audio,
.video-block > video {{ width: 100%; }}
.footnote-ref {{ font-size: 0.5em; vertical-align: super; }}
.footnote-list {{ list-style: none; padding: 0; }}
.footnote-item > p {{ text-indent: 0; }}
"#,
font_family = self.styles.text.font_family,
text_align = self.styles.layout.text_align,
background = self.styles.color_scheme.background,
text = self.styles.color_scheme.text,
font_size = self.styles.text.font_size,
line_height = self.styles.text.line_height,
font_weight = self.styles.text.font_weight,
font_style = self.styles.text.font_style,
letter_spacing = self.styles.text.letter_spacing,
margin = self.styles.layout.margin,
text_indent = self.styles.text.text_indent,
link_color = self.styles.color_scheme.link,
paragraph_spacing = self.styles.layout.paragraph_spacing,
);
writer.write_event(Event::Start(BytesStart::new("style")))?;
writer.write_event(Event::Text(BytesText::new(&style)))?;
writer.write_event(Event::End(BytesEnd::new("style")))?;
Ok(())
}
fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
writer.write_event(Event::Start(BytesStart::new("aside")))?;
writer.write_event(Event::Start(
BytesStart::new("ul").with_attributes([("class", "footnote-list")]),
))?;
let mut index = 1;
for footnote in footnotes.into_iter() {
writer.write_event(Event::Start(BytesStart::new("li").with_attributes([
("id", format!("footnote-{}", index).as_str()),
("class", "footnote-item"),
])))?;
writer.write_event(Event::Start(BytesStart::new("p")))?;
writer.write_event(Event::Start(
BytesStart::new("a")
.with_attributes([("href", format!("#ref-{}", index).as_str())]),
))?;
writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
writer.write_event(Event::End(BytesEnd::new("a")))?;
writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
writer.write_event(Event::End(BytesEnd::new("p")))?;
writer.write_event(Event::End(BytesEnd::new("li")))?;
index += 1;
}
writer.write_event(Event::End(BytesEnd::new("ul")))?;
writer.write_event(Event::End(BytesEnd::new("aside")))?;
Ok(())
}
fn handle_resource(&mut self) -> Result<(), EpubError> {
match self.blocks.last() {
Some(Block::Image { url, .. }) => self.copy_to_temp(url, "img")?,
Some(Block::Video { url, .. }) => self.copy_to_temp(url, "video")?,
Some(Block::Audio { url, .. }) => self.copy_to_temp(url, "audio")?,
Some(Block::MathML { fallback_image: Some(url), .. }) => {
self.copy_to_temp(url, "img")?
}
_ => {}
}
Ok(())
}
#[inline]
fn copy_to_temp(&self, source: impl AsRef<Path>, resource_type: &str) -> Result<(), EpubError> {
let target_dir = self.temp_dir.join(resource_type);
fs::create_dir_all(&target_dir)?;
let source = source.as_ref();
let target_path = target_dir.join(source.file_name().unwrap());
fs::copy(source, &target_path)?;
Ok(())
}
}
impl Drop for ContentBuilder {
fn drop(&mut self) {
if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
warn!("{}", err);
};
}
}
#[cfg(test)]
mod tests {
mod block_builder_tests {
use std::path::PathBuf;
use crate::{
builder::content::{Block, BlockBuilder},
error::{EpubBuilderError, EpubError},
types::{BlockType, Footnote},
};
#[test]
fn test_create_text_block() {
let mut builder = BlockBuilder::new(BlockType::Text);
builder.set_content("Hello, World!");
let block = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::Text { content, footnotes } => {
assert_eq!(content, "Hello, World!");
assert!(footnotes.is_empty());
}
_ => unreachable!(),
}
}
#[test]
fn test_create_text_block_missing_content() {
let builder = BlockBuilder::new(BlockType::Text);
let block: Result<Block, EpubError> = builder.try_into();
assert!(block.is_err());
let result = block.unwrap_err();
assert_eq!(
result,
EpubBuilderError::MissingNecessaryBlockData {
block_type: "Text".to_string(),
missing_data: "'content'".to_string()
}
.into()
)
}
#[test]
fn test_create_quote_block() {
let mut builder = BlockBuilder::new(BlockType::Quote);
builder.set_content("To be or not to be");
let block: Result<Block, EpubError> = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::Quote { content, footnotes } => {
assert_eq!(content, "To be or not to be");
assert!(footnotes.is_empty());
}
_ => unreachable!(),
}
}
#[test]
fn test_create_title_block() {
let mut builder = BlockBuilder::new(BlockType::Title);
builder.set_content("Chapter 1").set_title_level(2);
let block: Result<Block, EpubError> = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::Title { content, level, footnotes } => {
assert_eq!(content, "Chapter 1");
assert_eq!(level, 2);
assert!(footnotes.is_empty());
}
_ => unreachable!(),
}
}
#[test]
fn test_create_title_block_invalid_level() {
let mut builder = BlockBuilder::new(BlockType::Title);
builder.set_content("Chapter 1").set_title_level(10);
let result: Result<Block, EpubError> = builder.try_into();
assert!(result.is_err());
let result = result.unwrap_err();
assert_eq!(
result,
EpubBuilderError::MissingNecessaryBlockData {
block_type: "Title".to_string(),
missing_data: "'level'".to_string(),
}
.into()
);
}
#[test]
fn test_create_image_block() {
let img_path = PathBuf::from("./test_case/image.jpg");
let mut builder = BlockBuilder::new(BlockType::Image);
builder
.set_url(&img_path)
.unwrap()
.set_alt("Test Image")
.set_caption("A test image");
let block: Result<Block, EpubError> = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::Image { url, alt, caption, footnotes } => {
assert_eq!(url.file_name().unwrap(), "image.jpg");
assert_eq!(alt, Some("Test Image".to_string()));
assert_eq!(caption, Some("A test image".to_string()));
assert!(footnotes.is_empty());
}
_ => unreachable!(),
}
}
#[test]
fn test_create_image_block_missing_url() {
let builder = BlockBuilder::new(BlockType::Image);
let block: Result<Block, EpubError> = builder.try_into();
assert!(block.is_err());
let result = block.unwrap_err();
assert_eq!(
result,
EpubBuilderError::MissingNecessaryBlockData {
block_type: "Image".to_string(),
missing_data: "'url'".to_string(),
}
.into()
);
}
#[test]
fn test_create_audio_block() {
let audio_path = PathBuf::from("./test_case/audio.mp3");
let mut builder = BlockBuilder::new(BlockType::Audio);
builder
.set_url(&audio_path)
.unwrap()
.set_fallback("Audio not supported")
.set_caption("Background music");
let block = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::Audio { url, fallback, caption, footnotes } => {
assert_eq!(url.file_name().unwrap(), "audio.mp3");
assert_eq!(fallback, "Audio not supported");
assert_eq!(caption, Some("Background music".to_string()));
assert!(footnotes.is_empty());
}
_ => unreachable!(),
}
}
#[test]
fn test_set_url_invalid_file_type() {
let xhtml_path = PathBuf::from("./test_case/Overview.xhtml");
let mut builder = BlockBuilder::new(BlockType::Image);
let result = builder.set_url(&xhtml_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
}
#[test]
fn test_set_url_nonexistent_file() {
let nonexistent_path = PathBuf::from("./test_case/nonexistent.jpg");
let mut builder = BlockBuilder::new(BlockType::Image);
let result = builder.set_url(&nonexistent_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(
err,
EpubBuilderError::TargetIsNotFile {
target_path: "./test_case/nonexistent.jpg".to_string()
}
.into()
);
}
#[test]
fn test_set_fallback_image_invalid_type() {
let audio_path = PathBuf::from("./test_case/audio.mp3");
let mut builder = BlockBuilder::new(BlockType::MathML);
builder.set_mathml_element("<math/>");
let result = builder.set_fallback_image(audio_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
}
#[test]
fn test_set_fallback_image_nonexistent() {
let nonexistent_path = PathBuf::from("./test_case/nonexistent.png");
let mut builder = BlockBuilder::new(BlockType::MathML);
builder.set_mathml_element("<math/>");
let result = builder.set_fallback_image(nonexistent_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(
err,
EpubBuilderError::TargetIsNotFile {
target_path: "./test_case/nonexistent.png".to_string()
}
.into()
);
}
#[test]
fn test_create_video_block() {
let video_path = PathBuf::from("./test_case/video.mp4");
let mut builder = BlockBuilder::new(BlockType::Video);
builder
.set_url(&video_path)
.unwrap()
.set_fallback("Video not supported")
.set_caption("Demo video");
let block = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::Video { url, fallback, caption, footnotes } => {
assert_eq!(url.file_name().unwrap(), "video.mp4");
assert_eq!(fallback, "Video not supported");
assert_eq!(caption, Some("Demo video".to_string()));
assert!(footnotes.is_empty());
}
_ => unreachable!(),
}
}
#[test]
fn test_create_mathml_block() {
let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
let mut builder = BlockBuilder::new(BlockType::MathML);
builder
.set_mathml_element(mathml_content)
.set_caption("Simple equation");
let block = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::MathML {
element_str,
fallback_image,
caption,
footnotes,
} => {
assert_eq!(element_str, mathml_content);
assert!(fallback_image.is_none());
assert_eq!(caption, Some("Simple equation".to_string()));
assert!(footnotes.is_empty());
}
_ => unreachable!(),
}
}
#[test]
fn test_create_mathml_block_with_fallback() {
let img_path = PathBuf::from("./test_case/image.jpg");
let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
let mut builder = BlockBuilder::new(BlockType::MathML);
builder
.set_mathml_element(mathml_content)
.set_fallback_image(img_path.clone())
.unwrap();
let block = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::MathML { element_str, fallback_image, .. } => {
assert_eq!(element_str, mathml_content);
assert!(fallback_image.is_some());
}
_ => unreachable!(),
}
}
#[test]
fn test_footnote_management() {
let mut builder = BlockBuilder::new(BlockType::Text);
builder.set_content("This is a test");
let note1 = Footnote {
locate: 5,
content: "First footnote".to_string(),
};
let note2 = Footnote {
locate: 10,
content: "Second footnote".to_string(),
};
builder.add_footnote(note1).add_footnote(note2);
let block = builder.try_into();
assert!(block.is_ok());
let block = block.unwrap();
match block {
Block::Text { footnotes, .. } => {
assert_eq!(footnotes.len(), 2);
}
_ => unreachable!(),
}
}
#[test]
fn test_invalid_footnote_locate() {
let mut builder = BlockBuilder::new(BlockType::Text);
builder.set_content("Hello");
builder.add_footnote(Footnote {
locate: 100,
content: "Invalid footnote".to_string(),
});
let result: Result<Block, EpubError> = builder.try_into();
assert!(result.is_err());
let result = result.unwrap_err();
assert_eq!(
result,
EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
);
}
#[test]
fn test_footnote_on_media_without_caption() {
let img_path = PathBuf::from("./test_case/image.jpg");
let mut builder = BlockBuilder::new(BlockType::Image);
builder.set_url(&img_path).unwrap();
builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
let result: Result<Block, EpubError> = builder.try_into();
assert!(result.is_err());
let result = result.unwrap_err();
assert_eq!(
result,
EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
);
}
}
mod content_builder_tests {
use std::{env, fs, path::PathBuf};
use crate::{
builder::content::ContentBuilder,
types::{ColorScheme, Footnote, PageLayout, TextAlign, TextStyle},
utils::local_time,
};
#[test]
fn test_create_content_builder() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let builder = builder.unwrap();
assert_eq!(builder.id, "chapter1");
}
#[test]
fn test_set_title() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
builder.set_title("My Chapter").set_title("Another Title");
assert_eq!(builder.title, "Another Title");
}
#[test]
fn test_add_text_block() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_text_block("This is a paragraph", vec![]);
assert!(result.is_ok());
}
#[test]
fn test_add_quote_block() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_quote_block("A quoted text", vec![]);
assert!(result.is_ok());
}
#[test]
fn test_set_styles() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let custom_styles = crate::types::StyleOptions {
text: TextStyle {
font_size: 1.5,
line_height: 1.8,
font_family: "Georgia, serif".to_string(),
font_weight: "bold".to_string(),
font_style: "italic".to_string(),
letter_spacing: "0.1em".to_string(),
text_indent: 1.5,
},
color_scheme: ColorScheme {
background: "#F5F5F5".to_string(),
text: "#333333".to_string(),
link: "#0066CC".to_string(),
},
layout: PageLayout {
margin: 30,
text_align: TextAlign::Center,
paragraph_spacing: 20,
},
};
let mut builder = builder.unwrap();
builder.set_styles(custom_styles);
assert_eq!(builder.styles.text.font_size, 1.5);
assert_eq!(builder.styles.text.font_weight, "bold");
assert_eq!(builder.styles.color_scheme.background, "#F5F5F5");
assert_eq!(builder.styles.layout.text_align, TextAlign::Center);
}
#[test]
fn test_add_title_block() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_title_block("Section Title", 2, vec![]);
assert!(result.is_ok());
}
#[test]
fn test_add_image_block() {
let img_path = PathBuf::from("./test_case/image.jpg");
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_image_block(
img_path,
Some("Alt text".to_string()),
Some("Figure 1: An image".to_string()),
vec![],
);
assert!(result.is_ok());
}
#[test]
fn test_add_audio_block() {
let audio_path = PathBuf::from("./test_case/audio.mp3");
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_audio_block(
audio_path,
"Your browser doesn't support audio".to_string(),
Some("Background music".to_string()),
vec![],
);
assert!(result.is_ok());
}
#[test]
fn test_add_video_block() {
let video_path = PathBuf::from("./test_case/video.mp4");
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_video_block(
video_path,
"Your browser doesn't support video".to_string(),
Some("Tutorial video".to_string()),
vec![],
);
assert!(result.is_ok());
}
#[test]
fn test_add_mathml_block() {
let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_mathml_block(
mathml.to_string(),
None,
Some("Equation 1".to_string()),
vec![],
);
assert!(result.is_ok());
}
#[test]
fn test_make_content_document() {
let temp_dir = env::temp_dir().join(local_time());
assert!(fs::create_dir_all(&temp_dir).is_ok());
let output_path = temp_dir.join("chapter.xhtml");
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
builder
.set_title("My Chapter")
.add_text_block("This is the first paragraph.", vec![])
.unwrap()
.add_text_block("This is the second paragraph.", vec![])
.unwrap();
let result = builder.make(&output_path);
assert!(result.is_ok());
assert!(output_path.exists());
assert!(fs::remove_dir_all(temp_dir).is_ok());
}
#[test]
fn test_make_content_with_media() {
let temp_dir = env::temp_dir().join(local_time());
assert!(fs::create_dir_all(&temp_dir).is_ok());
let output_path = temp_dir.join("chapter.xhtml");
let img_path = PathBuf::from("./test_case/image.jpg");
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
builder
.set_title("Chapter with Media")
.add_text_block("See image below:", vec![])
.unwrap()
.add_image_block(
img_path,
Some("Test".to_string()),
Some("Figure 1".to_string()),
vec![],
)
.unwrap();
let result = builder.make(&output_path);
assert!(result.is_ok());
let img_dir = temp_dir.join("img");
assert!(img_dir.exists());
assert!(fs::remove_dir_all(&temp_dir).is_ok());
}
#[test]
fn test_make_content_with_footnotes() {
let temp_dir = env::temp_dir().join(local_time());
assert!(fs::create_dir_all(&temp_dir).is_ok());
let output_path = temp_dir.join("chapter.xhtml");
let footnotes = vec![
Footnote {
locate: 10,
content: "This is a footnote".to_string(),
},
Footnote {
locate: 15,
content: "Another footnote".to_string(),
},
];
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
builder
.set_title("Chapter with Notes")
.add_text_block("This is a paragraph with notes.", footnotes)
.unwrap();
let result = builder.make(&output_path);
assert!(result.is_ok());
assert!(output_path.exists());
assert!(fs::remove_dir_all(&temp_dir).is_ok());
}
#[test]
fn test_add_css_file() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_css_file(PathBuf::from("./test_case/style.css"));
assert!(result.is_ok());
assert_eq!(builder.css_files.len(), 1);
}
#[test]
fn test_add_css_file_nonexistent() {
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
let result = builder.add_css_file(PathBuf::from("nonexistent.css"));
assert!(result.is_err());
}
#[test]
fn test_add_multiple_css_files() {
let temp_dir = env::temp_dir().join(local_time());
assert!(fs::create_dir_all(&temp_dir).is_ok());
let css_path1 = temp_dir.join("style1.css");
let css_path2 = temp_dir.join("style2.css");
assert!(fs::write(&css_path1, "body { color: red; }").is_ok());
assert!(fs::write(&css_path2, "p { font-size: 16px; }").is_ok());
let builder = ContentBuilder::new("chapter1", "en");
assert!(builder.is_ok());
let mut builder = builder.unwrap();
assert!(builder.add_css_file(css_path1).is_ok());
assert!(builder.add_css_file(css_path2).is_ok());
assert_eq!(builder.css_files.len(), 2);
assert!(fs::remove_dir_all(&temp_dir).is_ok());
}
}
mod block_tests {
use std::path::PathBuf;
use crate::{builder::content::Block, types::Footnote};
#[test]
fn test_take_footnotes_from_text_block() {
let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
let block = Block::Text {
content: "Hello world".to_string(),
footnotes: footnotes.clone(),
};
let taken = block.take_footnotes();
assert_eq!(taken.len(), 1);
assert_eq!(taken[0].content, "Note");
}
#[test]
fn test_take_footnotes_from_quote_block() {
let footnotes = vec![
Footnote { locate: 3, content: "First".to_string() },
Footnote { locate: 8, content: "Second".to_string() },
];
let block = Block::Quote {
content: "Test quote".to_string(),
footnotes: footnotes.clone(),
};
let taken = block.take_footnotes();
assert_eq!(taken.len(), 2);
}
#[test]
fn test_take_footnotes_from_image_block() {
let img_path = PathBuf::from("test.png");
let footnotes = vec![Footnote {
locate: 2,
content: "Image note".to_string(),
}];
let block = Block::Image {
url: img_path,
alt: None,
caption: Some("A caption".to_string()),
footnotes: footnotes.clone(),
};
let taken = block.take_footnotes();
assert_eq!(taken.len(), 1);
}
#[test]
fn test_block_with_empty_footnotes() {
let block = Block::Text {
content: "No footnotes here".to_string(),
footnotes: vec![],
};
let taken = block.take_footnotes();
assert!(taken.is_empty());
}
}
mod content_rendering_tests {
use crate::builder::content::Block;
#[test]
fn test_split_content_by_index_empty() {
let result = Block::split_content_by_index("Hello", &[]);
assert_eq!(result, vec!["Hello"]);
}
#[test]
fn test_split_content_by_single_index() {
let result = Block::split_content_by_index("Hello World", &[5]);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "Hello");
assert_eq!(result[1], " World");
}
#[test]
fn test_split_content_by_multiple_indices() {
let result = Block::split_content_by_index("One Two Three", &[3, 7]);
assert_eq!(result.len(), 3);
assert_eq!(result[0], "One");
assert_eq!(result[1], " Two");
assert_eq!(result[2], " Three");
}
#[test]
fn test_split_content_unicode() {
let content = "你好世界";
let result = Block::split_content_by_index(content, &[2]);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "你好");
assert_eq!(result[1], "世界");
}
}
}