1use std::{
42 collections::HashMap,
43 env,
44 fs::{self, File},
45 io::{Cursor, Read},
46 path::{Path, PathBuf},
47};
48
49use infer::{Infer, MatcherType};
50use log::warn;
51use quick_xml::{
52 Reader, Writer,
53 events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
54};
55use walkdir::WalkDir;
56
57use crate::{
58 builder::XmlWriter,
59 error::{EpubBuilderError, EpubError},
60 types::{BlockType, Footnote, StyleOptions},
61 utils::local_time,
62};
63
64#[non_exhaustive]
83#[derive(Debug)]
84pub enum Block {
85 #[non_exhaustive]
95 Text {
96 content: String,
97 footnotes: Vec<Footnote>,
98 },
99
100 #[non_exhaustive]
110 Quote {
111 content: String,
112 footnotes: Vec<Footnote>,
113 },
114
115 #[non_exhaustive]
124 Title {
125 content: String,
126 footnotes: Vec<Footnote>,
127
128 level: usize,
132 },
133
134 #[non_exhaustive]
146 Image {
147 url: PathBuf,
149
150 alt: Option<String>,
152
153 caption: Option<String>,
155
156 footnotes: Vec<Footnote>,
157 },
158
159 #[non_exhaustive]
173 Audio {
174 url: PathBuf,
176
177 fallback: String,
181
182 caption: Option<String>,
184
185 footnotes: Vec<Footnote>,
186 },
187
188 #[non_exhaustive]
202 Video {
203 url: PathBuf,
205
206 fallback: String,
210
211 caption: Option<String>,
213
214 footnotes: Vec<Footnote>,
215 },
216
217 #[non_exhaustive]
229 MathML {
230 element_str: String,
235
236 fallback_image: Option<PathBuf>,
241
242 caption: Option<String>,
244
245 footnotes: Vec<Footnote>,
246 },
247}
248
249impl Block {
250 pub(crate) fn make(
254 &mut self,
255 writer: &mut XmlWriter,
256 start_index: usize,
257 ) -> Result<(), EpubError> {
258 match self {
259 Block::Text { content, footnotes } => {
260 writer.write_event(Event::Start(
261 BytesStart::new("p").with_attributes([("class", "content-block text-block")]),
262 ))?;
263
264 Self::make_text(writer, content, footnotes, start_index)?;
265
266 writer.write_event(Event::End(BytesEnd::new("p")))?;
267 }
268
269 Block::Quote { content, footnotes } => {
270 writer.write_event(Event::Start(BytesStart::new("blockquote").with_attributes(
271 [
272 ("class", "content-block quote-block"),
273 ("cite", "SOME ATTR NEED TO BE SET"),
274 ],
275 )))?;
276 writer.write_event(Event::Start(BytesStart::new("p")))?;
277
278 Self::make_text(writer, content, footnotes, start_index)?;
279
280 writer.write_event(Event::End(BytesEnd::new("p")))?;
281 writer.write_event(Event::End(BytesEnd::new("blockquote")))?;
282 }
283
284 Block::Title { content, footnotes, level } => {
285 let tag_name = format!("h{}", level);
286 writer.write_event(Event::Start(
287 BytesStart::new(tag_name.as_str())
288 .with_attributes([("class", "content-block title-block")]),
289 ))?;
290
291 Self::make_text(writer, content, footnotes, start_index)?;
292
293 writer.write_event(Event::End(BytesEnd::new(tag_name)))?;
294 }
295
296 Block::Image { url, alt, caption, footnotes } => {
297 let url = format!("./img/{}", url.file_name().unwrap().to_string_lossy());
298
299 let mut attr = Vec::new();
300 attr.push(("src", url.as_str()));
301 if let Some(alt) = alt {
302 attr.push(("alt", alt.as_str()));
303 }
304
305 writer.write_event(Event::Start(
306 BytesStart::new("figure")
307 .with_attributes([("class", "content-block image-block")]),
308 ))?;
309 writer.write_event(Event::Empty(BytesStart::new("img").with_attributes(attr)))?;
310
311 if let Some(caption) = caption {
312 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
313
314 Self::make_text(writer, caption, footnotes, start_index)?;
315
316 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
317 }
318
319 writer.write_event(Event::End(BytesEnd::new("figure")))?;
320 }
321
322 Block::Audio { url, fallback, caption, footnotes } => {
323 let url = format!("./audio/{}", url.file_name().unwrap().to_string_lossy());
324
325 let attr = vec![
326 ("src", url.as_str()),
327 ("controls", "controls"), ];
329
330 writer.write_event(Event::Start(
331 BytesStart::new("figure")
332 .with_attributes([("class", "content-block audio-block")]),
333 ))?;
334 writer.write_event(Event::Start(BytesStart::new("audio").with_attributes(attr)))?;
335
336 writer.write_event(Event::Start(BytesStart::new("p")))?;
337 writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
338 writer.write_event(Event::End(BytesEnd::new("p")))?;
339
340 writer.write_event(Event::End(BytesEnd::new("audio")))?;
341
342 if let Some(caption) = caption {
343 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
344
345 Self::make_text(writer, caption, footnotes, start_index)?;
346
347 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
348 }
349
350 writer.write_event(Event::End(BytesEnd::new("figure")))?;
351 }
352
353 Block::Video { url, fallback, caption, footnotes } => {
354 let url = format!("./video/{}", url.file_name().unwrap().to_string_lossy());
355
356 let attr = vec![
357 ("src", url.as_str()),
358 ("controls", "controls"), ];
360
361 writer.write_event(Event::Start(
362 BytesStart::new("figure")
363 .with_attributes([("class", "content-block video-block")]),
364 ))?;
365 writer.write_event(Event::Start(BytesStart::new("video").with_attributes(attr)))?;
366
367 writer.write_event(Event::Start(BytesStart::new("p")))?;
368 writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
369 writer.write_event(Event::End(BytesEnd::new("p")))?;
370
371 writer.write_event(Event::End(BytesEnd::new("video")))?;
372
373 if let Some(caption) = caption {
374 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
375
376 Self::make_text(writer, caption, footnotes, start_index)?;
377
378 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
379 }
380
381 writer.write_event(Event::End(BytesEnd::new("figure")))?;
382 }
383
384 Block::MathML {
385 element_str,
386 fallback_image,
387 caption,
388 footnotes,
389 } => {
390 writer.write_event(Event::Start(
391 BytesStart::new("figure")
392 .with_attributes([("class", "content-block mathml-block")]),
393 ))?;
394
395 Self::write_mathml_element(writer, element_str)?;
396
397 if let Some(fallback_path) = fallback_image {
398 let img_url = format!(
399 "./img/{}",
400 fallback_path.file_name().unwrap().to_string_lossy()
401 );
402
403 writer.write_event(Event::Empty(BytesStart::new("img").with_attributes([
404 ("src", img_url.as_str()),
405 ("class", "mathml-fallback"),
406 ("alt", "Mathematical formula"),
407 ])))?;
408 }
409
410 if let Some(caption) = caption {
411 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
412
413 Self::make_text(writer, caption, footnotes, start_index)?;
414
415 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
416 }
417
418 writer.write_event(Event::End(BytesEnd::new("figure")))?;
419 }
420 }
421
422 Ok(())
423 }
424
425 pub fn take_footnotes(&self) -> Vec<Footnote> {
426 match self {
427 Block::Text { footnotes, .. } => footnotes.to_vec(),
428 Block::Quote { footnotes, .. } => footnotes.to_vec(),
429 Block::Title { footnotes, .. } => footnotes.to_vec(),
430 Block::Image { footnotes, .. } => footnotes.to_vec(),
431 Block::Audio { footnotes, .. } => footnotes.to_vec(),
432 Block::Video { footnotes, .. } => footnotes.to_vec(),
433 Block::MathML { footnotes, .. } => footnotes.to_vec(),
434 }
435 }
436
437 fn split_content_by_index(content: &str, index_list: &[usize]) -> Vec<String> {
443 if index_list.is_empty() {
444 return vec![content.to_string()];
445 }
446
447 let mut result = Vec::with_capacity(index_list.len() + 1);
449 let mut char_iter = content.chars().enumerate();
450
451 let mut current_char_idx = 0;
452 for &target_idx in index_list {
453 let mut segment = String::new();
454
455 while current_char_idx < target_idx {
458 if let Some((_, ch)) = char_iter.next() {
459 segment.push(ch);
460 current_char_idx += 1;
461 } else {
462 break;
463 }
464 }
465
466 if !segment.is_empty() {
467 result.push(segment);
468 }
469 }
470
471 let remainder = char_iter.map(|(_, ch)| ch).collect::<String>();
472 if !remainder.is_empty() {
473 result.push(remainder);
474 }
475
476 result
477 }
478
479 fn make_text(
489 writer: &mut XmlWriter,
490 content: &str,
491 footnotes: &mut [Footnote],
492 start_index: usize,
493 ) -> Result<(), EpubError> {
494 if footnotes.is_empty() {
495 writer.write_event(Event::Text(BytesText::new(content)))?;
496 return Ok(());
497 }
498
499 footnotes.sort_unstable();
500
501 let mut position_to_count = HashMap::new();
503 for footnote in footnotes.iter() {
504 *position_to_count.entry(footnote.locate).or_insert(0usize) += 1;
505 }
506
507 let mut positions = position_to_count.keys().copied().collect::<Vec<usize>>();
508 positions.sort_unstable();
509
510 let mut current_index = start_index;
511 let content_list = Self::split_content_by_index(content, &positions);
512 for (index, segment) in content_list.iter().enumerate() {
513 writer.write_event(Event::Text(BytesText::new(segment)))?;
514
515 if let Some(&position) = positions.get(index) {
517 if let Some(&count) = position_to_count.get(&position) {
519 for _ in 0..count {
520 Self::make_footnotes(writer, current_index)?;
521 current_index += 1;
522 }
523 }
524 }
525 }
526
527 Ok(())
528 }
529
530 #[inline]
532 fn make_footnotes(writer: &mut XmlWriter, index: usize) -> Result<(), EpubError> {
533 writer.write_event(Event::Start(BytesStart::new("a").with_attributes([
534 ("href", format!("#footnote-{}", index).as_str()),
535 ("id", format!("ref-{}", index).as_str()),
536 ("class", "footnote-ref"),
537 ])))?;
538 writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index))))?;
539 writer.write_event(Event::End(BytesEnd::new("a")))?;
540
541 Ok(())
542 }
543
544 fn write_mathml_element(writer: &mut XmlWriter, element_str: &str) -> Result<(), EpubError> {
548 let mut reader = Reader::from_str(element_str);
549
550 loop {
551 match reader.read_event() {
552 Ok(Event::Eof) => break,
553
554 Ok(event) => writer.write_event(event)?,
555
556 Err(err) => {
557 return Err(
558 EpubBuilderError::InvalidMathMLFormat { error: err.to_string() }.into(),
559 );
560 }
561 }
562 }
563
564 Ok(())
565 }
566}
567
568#[derive(Debug)]
593pub struct BlockBuilder {
594 block_type: BlockType,
596
597 content: Option<String>,
599
600 level: Option<usize>,
602
603 url: Option<PathBuf>,
605
606 alt: Option<String>,
608
609 caption: Option<String>,
611
612 fallback: Option<String>,
614
615 element_str: Option<String>,
617
618 fallback_image: Option<PathBuf>,
620
621 footnotes: Vec<Footnote>,
623}
624
625impl BlockBuilder {
626 pub fn new(block_type: BlockType) -> Self {
633 Self {
634 block_type,
635 content: None,
636 level: None,
637 url: None,
638 alt: None,
639 caption: None,
640 fallback: None,
641 element_str: None,
642 fallback_image: None,
643 footnotes: vec![],
644 }
645 }
646
647 pub fn set_content(&mut self, content: &str) -> &mut Self {
654 self.content = Some(content.to_string());
655 self
656 }
657
658 pub fn set_title_level(&mut self, level: usize) -> &mut Self {
667 if !(1..=6).contains(&level) {
668 return self;
669 }
670
671 self.level = Some(level);
672 self
673 }
674
675 pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
687 match Self::is_target_type(
688 url,
689 vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
690 ) {
691 Ok(_) => {
692 self.url = Some(url.to_path_buf());
693 Ok(self)
694 }
695 Err(err) => Err(err),
696 }
697 }
698
699 pub fn set_alt(&mut self, alt: &str) -> &mut Self {
707 self.alt = Some(alt.to_string());
708 self
709 }
710
711 pub fn set_caption(&mut self, caption: &str) -> &mut Self {
719 self.caption = Some(caption.to_string());
720 self
721 }
722
723 pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
731 self.fallback = Some(fallback.to_string());
732 self
733 }
734
735 pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
744 self.element_str = Some(element_str.to_string());
745 self
746 }
747
748 pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
761 match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
762 Ok(_) => {
763 self.fallback_image = Some(fallback_image);
764 Ok(self)
765 }
766 Err(err) => Err(err),
767 }
768 }
769
770 pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
778 self.footnotes.push(footnote);
779 self
780 }
781
782 pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
790 self.footnotes = footnotes;
791 self
792 }
793
794 pub fn remove_last_footnote(&mut self) -> &mut Self {
799 self.footnotes.pop();
800 self
801 }
802
803 pub fn clear_footnotes(&mut self) -> &mut Self {
807 self.footnotes.clear();
808 self
809 }
810
811 pub fn build(self) -> Result<Block, EpubError> {
821 let block = match self.block_type {
822 BlockType::Text => {
823 if let Some(content) = self.content {
824 Block::Text { content, footnotes: self.footnotes }
825 } else {
826 return Err(EpubBuilderError::MissingNecessaryBlockData {
827 block_type: "Text".to_string(),
828 missing_data: "'content'".to_string(),
829 }
830 .into());
831 }
832 }
833
834 BlockType::Quote => {
835 if let Some(content) = self.content {
836 Block::Quote { content, footnotes: self.footnotes }
837 } else {
838 return Err(EpubBuilderError::MissingNecessaryBlockData {
839 block_type: "Quote".to_string(),
840 missing_data: "'content'".to_string(),
841 }
842 .into());
843 }
844 }
845
846 BlockType::Title => match (self.content, self.level) {
847 (Some(content), Some(level)) => Block::Title {
848 content,
849 level,
850 footnotes: self.footnotes,
851 },
852 _ => {
853 return Err(EpubBuilderError::MissingNecessaryBlockData {
854 block_type: "Title".to_string(),
855 missing_data: "'content' or 'level'".to_string(),
856 }
857 .into());
858 }
859 },
860
861 BlockType::Image => {
862 if let Some(url) = self.url {
863 Block::Image {
864 url,
865 alt: self.alt,
866 caption: self.caption,
867 footnotes: self.footnotes,
868 }
869 } else {
870 return Err(EpubBuilderError::MissingNecessaryBlockData {
871 block_type: "Image".to_string(),
872 missing_data: "'url'".to_string(),
873 }
874 .into());
875 }
876 }
877
878 BlockType::Audio => match (self.url, self.fallback) {
879 (Some(url), Some(fallback)) => Block::Audio {
880 url,
881 fallback,
882 caption: self.caption,
883 footnotes: self.footnotes,
884 },
885 _ => {
886 return Err(EpubBuilderError::MissingNecessaryBlockData {
887 block_type: "Audio".to_string(),
888 missing_data: "'url' or 'fallback'".to_string(),
889 }
890 .into());
891 }
892 },
893
894 BlockType::Video => match (self.url, self.fallback) {
895 (Some(url), Some(fallback)) => Block::Video {
896 url,
897 fallback,
898 caption: self.caption,
899 footnotes: self.footnotes,
900 },
901 _ => {
902 return Err(EpubBuilderError::MissingNecessaryBlockData {
903 block_type: "Video".to_string(),
904 missing_data: "'url' or 'fallback'".to_string(),
905 }
906 .into());
907 }
908 },
909
910 BlockType::MathML => {
911 if let Some(element_str) = self.element_str {
912 Block::MathML {
913 element_str,
914 fallback_image: self.fallback_image,
915 caption: self.caption,
916 footnotes: self.footnotes,
917 }
918 } else {
919 return Err(EpubBuilderError::MissingNecessaryBlockData {
920 block_type: "MathML".to_string(),
921 missing_data: "'element_str'".to_string(),
922 }
923 .into());
924 }
925 }
926 };
927
928 Self::validate_footnotes(&block)?;
929 Ok(block)
930 }
931
932 fn is_target_type(path: &PathBuf, types: Vec<MatcherType>) -> Result<(), EpubError> {
942 if !path.is_file() {
943 return Err(EpubBuilderError::TargetIsNotFile {
944 target_path: path.to_string_lossy().to_string(),
945 }
946 .into());
947 }
948
949 let mut file = File::open(path)?;
950 let mut buf = [0; 512];
951 let read_size = file.read(&mut buf)?;
952 let header_bytes = &buf[..read_size];
953
954 match Infer::new().get(header_bytes) {
955 Some(file_type) if !types.contains(&file_type.matcher_type()) => {
956 Err(EpubBuilderError::NotExpectedFileFormat.into())
957 }
958
959 None => Err(EpubBuilderError::UnknownFileFormat {
960 file_path: path.to_string_lossy().to_string(),
961 }
962 .into()),
963
964 _ => Ok(()),
965 }
966 }
967
968 fn validate_footnotes(block: &Block) -> Result<(), EpubError> {
975 match block {
976 Block::Text { content, footnotes }
977 | Block::Quote { content, footnotes }
978 | Block::Title { content, footnotes, .. } => {
979 let max_locate = content.chars().count();
980 for footnote in footnotes.iter() {
981 if footnote.locate == 0 || footnote.locate > content.chars().count() {
982 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
983 }
984 }
985
986 Ok(())
987 }
988
989 Block::Image { caption, footnotes, .. }
990 | Block::MathML { caption, footnotes, .. }
991 | Block::Video { caption, footnotes, .. }
992 | Block::Audio { caption, footnotes, .. } => {
993 if let Some(caption) = caption {
994 let max_locate = caption.chars().count();
995 for footnote in footnotes.iter() {
996 if footnote.locate == 0 || footnote.locate > caption.chars().count() {
997 return Err(
998 EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
999 );
1000 }
1001 }
1002 } else if !footnotes.is_empty() {
1003 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
1004 }
1005
1006 Ok(())
1007 }
1008 }
1009 }
1010}
1011
1012#[derive(Debug)]
1041pub struct ContentBuilder {
1042 pub id: String,
1048
1049 blocks: Vec<Block>,
1050 language: String,
1051 title: String,
1052 styles: StyleOptions,
1053
1054 pub(crate) temp_dir: PathBuf,
1055 pub(crate) css_files: Vec<PathBuf>,
1056}
1057
1058impl ContentBuilder {
1059 pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
1069 let temp_dir = env::temp_dir().join(local_time());
1070 fs::create_dir(&temp_dir)?;
1071
1072 Ok(Self {
1073 id: id.to_string(),
1074 blocks: vec![],
1075 language: language.to_string(),
1076 title: String::new(),
1077 styles: StyleOptions::default(),
1078 temp_dir,
1079 css_files: vec![],
1080 })
1081 }
1082
1083 pub fn set_title(&mut self, title: &str) -> &mut Self {
1090 self.title = title.to_string();
1091 self
1092 }
1093
1094 pub fn set_styles(&mut self, styles: StyleOptions) -> &mut Self {
1099 self.styles = styles;
1100 self
1101 }
1102
1103 pub fn add_css_file(&mut self, css_path: PathBuf) -> Result<&mut Self, EpubError> {
1115 if !css_path.is_file() {
1116 return Err(EpubBuilderError::TargetIsNotFile {
1117 target_path: css_path.to_string_lossy().to_string(),
1118 }
1119 .into());
1120 }
1121
1122 let file_name = css_path.file_name().unwrap().to_string_lossy().to_string();
1124 let target_dir = self.temp_dir.join("css");
1125 fs::create_dir_all(&target_dir)?;
1126
1127 let target_path = target_dir.join(&file_name);
1128 fs::copy(&css_path, &target_path)?;
1129 self.css_files.push(target_path);
1130 Ok(self)
1131 }
1132
1133 pub fn remove_last_css_file(&mut self) -> &mut Self {
1138 let path = self.css_files.pop();
1139 if let Some(path) = path {
1140 if let Err(err) = fs::remove_file(path) {
1141 log::warn!("{err}");
1142 };
1143 }
1144 self
1145 }
1146
1147 pub fn clear_css_files(&mut self) -> &mut Self {
1151 for path in self.css_files.iter() {
1152 if let Err(err) = fs::remove_file(path) {
1153 log::warn!("{err}");
1154 };
1155 }
1156 self.css_files.clear();
1157
1158 self
1159 }
1160
1161 pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
1168 self.blocks.push(block);
1169
1170 match self.blocks.last() {
1171 Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
1172 self.handle_resource()?
1173 }
1174
1175 Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1176 self.handle_resource()?;
1177 }
1178
1179 _ => {}
1180 }
1181
1182 Ok(self)
1183 }
1184
1185 pub fn add_text_block(
1193 &mut self,
1194 content: &str,
1195 footnotes: Vec<Footnote>,
1196 ) -> Result<&mut Self, EpubError> {
1197 let mut builder = BlockBuilder::new(BlockType::Text);
1198 builder.set_content(content).set_footnotes(footnotes);
1199
1200 self.blocks.push(builder.build()?);
1201 Ok(self)
1202 }
1203
1204 pub fn add_quote_block(
1212 &mut self,
1213 content: &str,
1214 footnotes: Vec<Footnote>,
1215 ) -> Result<&mut Self, EpubError> {
1216 let mut builder = BlockBuilder::new(BlockType::Quote);
1217 builder.set_content(content).set_footnotes(footnotes);
1218
1219 self.blocks.push(builder.build()?);
1220 Ok(self)
1221 }
1222
1223 pub fn add_title_block(
1232 &mut self,
1233 content: &str,
1234 level: usize,
1235 footnotes: Vec<Footnote>,
1236 ) -> Result<&mut Self, EpubError> {
1237 let mut builder = BlockBuilder::new(BlockType::Title);
1238 builder
1239 .set_content(content)
1240 .set_title_level(level)
1241 .set_footnotes(footnotes);
1242
1243 self.blocks.push(builder.build()?);
1244 Ok(self)
1245 }
1246
1247 pub fn add_image_block(
1258 &mut self,
1259 url: PathBuf,
1260 alt: Option<String>,
1261 caption: Option<String>,
1262 footnotes: Vec<Footnote>,
1263 ) -> Result<&mut Self, EpubError> {
1264 let mut builder = BlockBuilder::new(BlockType::Image);
1265 builder.set_url(&url)?.set_footnotes(footnotes);
1266
1267 if let Some(alt) = &alt {
1268 builder.set_alt(alt);
1269 }
1270
1271 if let Some(caption) = &caption {
1272 builder.set_caption(caption);
1273 }
1274
1275 self.blocks.push(builder.build()?);
1276 self.handle_resource()?;
1277 Ok(self)
1278 }
1279
1280 pub fn add_audio_block(
1291 &mut self,
1292 url: PathBuf,
1293 fallback: String,
1294 caption: Option<String>,
1295 footnotes: Vec<Footnote>,
1296 ) -> Result<&mut Self, EpubError> {
1297 let mut builder = BlockBuilder::new(BlockType::Audio);
1298 builder
1299 .set_url(&url)?
1300 .set_fallback(&fallback)
1301 .set_footnotes(footnotes);
1302
1303 if let Some(caption) = &caption {
1304 builder.set_caption(caption);
1305 }
1306
1307 self.blocks.push(builder.build()?);
1308 self.handle_resource()?;
1309 Ok(self)
1310 }
1311
1312 pub fn add_video_block(
1323 &mut self,
1324 url: PathBuf,
1325 fallback: String,
1326 caption: Option<String>,
1327 footnotes: Vec<Footnote>,
1328 ) -> Result<&mut Self, EpubError> {
1329 let mut builder = BlockBuilder::new(BlockType::Video);
1330 builder
1331 .set_url(&url)?
1332 .set_fallback(&fallback)
1333 .set_footnotes(footnotes);
1334
1335 if let Some(caption) = &caption {
1336 builder.set_caption(caption);
1337 }
1338
1339 self.blocks.push(builder.build()?);
1340 self.handle_resource()?;
1341 Ok(self)
1342 }
1343
1344 pub fn add_mathml_block(
1355 &mut self,
1356 element_str: String,
1357 fallback_image: Option<PathBuf>,
1358 caption: Option<String>,
1359 footnotes: Vec<Footnote>,
1360 ) -> Result<&mut Self, EpubError> {
1361 let mut builder = BlockBuilder::new(BlockType::MathML);
1362 builder
1363 .set_mathml_element(&element_str)
1364 .set_footnotes(footnotes);
1365
1366 if let Some(fallback_image) = fallback_image {
1367 builder.set_fallback_image(fallback_image)?;
1368 }
1369
1370 if let Some(caption) = &caption {
1371 builder.set_caption(caption);
1372 }
1373
1374 self.blocks.push(builder.build()?);
1375 self.handle_resource()?;
1376 Ok(self)
1377 }
1378
1379 pub fn remove_last_block(&mut self) -> &mut Self {
1383 self.blocks.pop();
1384 self
1385 }
1386
1387 pub fn take_last_block(&mut self) -> Option<Block> {
1396 self.blocks.pop()
1397 }
1398
1399 pub fn clear_blocks(&mut self) -> &mut Self {
1403 self.blocks.clear();
1404 self
1405 }
1406
1407 pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1416 let mut result = Vec::new();
1417
1418 let target_dir = match target.as_ref().parent() {
1420 Some(path) => {
1421 fs::create_dir_all(path)?;
1422 path.to_path_buf()
1423 }
1424 None => {
1425 return Err(EpubBuilderError::InvalidTargetPath {
1426 target_path: target.as_ref().to_string_lossy().to_string(),
1427 }
1428 .into());
1429 }
1430 };
1431
1432 self.make_content(&target)?;
1433 result.push(target.as_ref().to_path_buf());
1434
1435 for resource_type in ["img", "audio", "video", "css"] {
1437 let source = self.temp_dir.join(resource_type);
1438 if !source.is_dir() {
1439 continue;
1440 }
1441
1442 let target = target_dir.join(resource_type);
1443 fs::create_dir_all(&target)?;
1444
1445 for entry in WalkDir::new(&source)
1446 .min_depth(1)
1447 .into_iter()
1448 .filter_map(|result| result.ok())
1449 .filter(|entry| entry.file_type().is_file())
1450 {
1451 let file_name = entry.file_name();
1452 let target = target.join(file_name);
1453
1454 fs::copy(entry.path(), &target)?;
1455 result.push(target);
1456 }
1457 }
1458
1459 Ok(result)
1460 }
1461
1462 fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1469 let mut writer = Writer::new(Cursor::new(Vec::new()));
1470
1471 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1472 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1473 ("xmlns", "http://www.w3.org/1999/xhtml"),
1474 ("xml:lang", self.language.as_str()),
1475 ])))?;
1476
1477 writer.write_event(Event::Start(BytesStart::new("head")))?;
1479 writer.write_event(Event::Start(BytesStart::new("title")))?;
1480 writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1481 writer.write_event(Event::End(BytesEnd::new("title")))?;
1482
1483 if self.css_files.is_empty() {
1484 self.make_style(&mut writer)?;
1485 } else {
1486 for css_file in self.css_files.iter() {
1487 let file_name = css_file.file_name().unwrap().to_string_lossy().to_string();
1489
1490 writer.write_event(Event::Empty(BytesStart::new("link").with_attributes([
1491 ("href", format!("./css/{}", file_name).as_str()),
1492 ("rel", "stylesheet"),
1493 ("type", "text/css"),
1494 ])))?;
1495 }
1496 }
1497
1498 writer.write_event(Event::End(BytesEnd::new("head")))?;
1499
1500 writer.write_event(Event::Start(BytesStart::new("body")))?;
1502 writer.write_event(Event::Start(BytesStart::new("main")))?;
1503
1504 let mut footnote_index = 1;
1505 let mut footnotes = Vec::new();
1506 for block in self.blocks.iter_mut() {
1507 block.make(&mut writer, footnote_index)?;
1508
1509 footnotes.append(&mut block.take_footnotes());
1510 footnote_index = footnotes.len() + 1;
1511 }
1512
1513 writer.write_event(Event::End(BytesEnd::new("main")))?;
1514
1515 Self::make_footnotes(&mut writer, footnotes)?;
1516 writer.write_event(Event::End(BytesEnd::new("body")))?;
1517 writer.write_event(Event::End(BytesEnd::new("html")))?;
1518
1519 let file_path = PathBuf::from(target_path.as_ref());
1520 let file_data = writer.into_inner().into_inner();
1521 fs::write(file_path, file_data)?;
1522
1523 Ok(())
1524 }
1525
1526 fn make_style(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
1528 let style = format!(
1529 r#"
1530 * {{
1531 margin: 0;
1532 padding: 0;
1533 font-family: {font_family};
1534 text-align: {text_align};
1535 background-color: {background};
1536 color: {text};
1537 }}
1538 body, p, div, span, li, td, th {{
1539 font-size: {font_size}rem;
1540 line-height: {line_height}em;
1541 font-weight: {font_weight};
1542 font-style: {font_style};
1543 letter-spacing: {letter_spacing};
1544 }}
1545 body {{ margin: {margin}px; }}
1546 p {{ text-indent: {text_indent}em; }}
1547 a {{ color: {link_color}; text-decoration: none; }}
1548 figcaption {{ text-align: center; line-height: 1em; }}
1549 blockquote {{ padding: 1em 2em; }}
1550 blockquote > p {{ font-style: italic; }}
1551 .content-block {{ margin-bottom: {paragraph_spacing}px; }}
1552 .image-block > img,
1553 .audio-block > audio,
1554 .video-block > video {{ width: 100%; }}
1555 .footnote-ref {{ font-size: 0.5em; vertical-align: super; }}
1556 .footnote-list {{ list-style: none; padding: 0; }}
1557 .footnote-item > p {{ text-indent: 0; }}
1558 "#,
1559 font_family = self.styles.text.font_family,
1560 text_align = self.styles.layout.text_align,
1561 background = self.styles.color_scheme.background,
1562 text = self.styles.color_scheme.text,
1563 font_size = self.styles.text.font_size,
1564 line_height = self.styles.text.line_height,
1565 font_weight = self.styles.text.font_weight,
1566 font_style = self.styles.text.font_style,
1567 letter_spacing = self.styles.text.letter_spacing,
1568 margin = self.styles.layout.margin,
1569 text_indent = self.styles.text.text_indent,
1570 link_color = self.styles.color_scheme.link,
1571 paragraph_spacing = self.styles.layout.paragraph_spacing,
1572 );
1573
1574 writer.write_event(Event::Start(BytesStart::new("style")))?;
1575 writer.write_event(Event::Text(BytesText::new(&style)))?;
1576 writer.write_event(Event::End(BytesEnd::new("style")))?;
1577
1578 Ok(())
1579 }
1580
1581 fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1586 writer.write_event(Event::Start(BytesStart::new("aside")))?;
1587 writer.write_event(Event::Start(
1588 BytesStart::new("ul").with_attributes([("class", "footnote-list")]),
1589 ))?;
1590
1591 let mut index = 1;
1592 for footnote in footnotes.into_iter() {
1593 writer.write_event(Event::Start(BytesStart::new("li").with_attributes([
1594 ("id", format!("footnote-{}", index).as_str()),
1595 ("class", "footnote-item"),
1596 ])))?;
1597 writer.write_event(Event::Start(BytesStart::new("p")))?;
1598
1599 writer.write_event(Event::Start(
1600 BytesStart::new("a")
1601 .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1602 ))?;
1603 writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1604 writer.write_event(Event::End(BytesEnd::new("a")))?;
1605 writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1606
1607 writer.write_event(Event::End(BytesEnd::new("p")))?;
1608 writer.write_event(Event::End(BytesEnd::new("li")))?;
1609
1610 index += 1;
1611 }
1612
1613 writer.write_event(Event::End(BytesEnd::new("ul")))?;
1614 writer.write_event(Event::End(BytesEnd::new("aside")))?;
1615
1616 Ok(())
1617 }
1618
1619 fn handle_resource(&mut self) -> Result<(), EpubError> {
1624 match self.blocks.last() {
1625 Some(Block::Image { url, .. }) => {
1626 let target_dir = self.temp_dir.join("img");
1627 fs::create_dir_all(&target_dir)?;
1628
1629 let target_path = target_dir.join(url.file_name().unwrap());
1630 fs::copy(url, &target_path)?;
1631 }
1632
1633 Some(Block::Video { url, .. }) => {
1634 let target_dir = self.temp_dir.join("video");
1635 fs::create_dir_all(&target_dir)?;
1636
1637 let target_path = target_dir.join(url.file_name().unwrap());
1638 fs::copy(url, &target_path)?;
1639 }
1640
1641 Some(Block::Audio { url, .. }) => {
1642 let target_dir = self.temp_dir.join("audio");
1643 fs::create_dir_all(&target_dir)?;
1644
1645 let target_path = target_dir.join(url.file_name().unwrap());
1646 fs::copy(url, &target_path)?;
1647 }
1648
1649 Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1650 let target_dir = self.temp_dir.join("img");
1651 fs::create_dir_all(&target_dir)?;
1652
1653 let target_path =
1654 target_dir.join(fallback_image.as_ref().unwrap().file_name().unwrap());
1655
1656 fs::copy(fallback_image.as_ref().unwrap(), &target_path)?;
1657 }
1658
1659 Some(_) => {}
1660 None => {}
1661 }
1662
1663 Ok(())
1664 }
1665}
1666
1667impl Drop for ContentBuilder {
1668 fn drop(&mut self) {
1669 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1670 warn!("{}", err);
1671 };
1672 }
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677 mod block_builder_tests {
1793 use std::path::PathBuf;
1794
1795 use crate::{
1796 builder::content::{Block, BlockBuilder},
1797 error::EpubBuilderError,
1798 types::{BlockType, Footnote},
1799 };
1800
1801 #[test]
1802 fn test_create_text_block() {
1803 let mut builder = BlockBuilder::new(BlockType::Text);
1804 builder.set_content("Hello, World!");
1805
1806 let block = builder.build();
1807 assert!(block.is_ok());
1808
1809 let block = block.unwrap();
1810 match block {
1811 Block::Text { content, footnotes } => {
1812 assert_eq!(content, "Hello, World!");
1813 assert!(footnotes.is_empty());
1814 }
1815 _ => unreachable!(),
1816 }
1817 }
1818
1819 #[test]
1820 fn test_create_text_block_missing_content() {
1821 let builder = BlockBuilder::new(BlockType::Text);
1822
1823 let block = builder.build();
1824 assert!(block.is_err());
1825
1826 let result = block.unwrap_err();
1827 assert_eq!(
1828 result,
1829 EpubBuilderError::MissingNecessaryBlockData {
1830 block_type: "Text".to_string(),
1831 missing_data: "'content'".to_string()
1832 }
1833 .into()
1834 )
1835 }
1836
1837 #[test]
1838 fn test_create_quote_block() {
1839 let mut builder = BlockBuilder::new(BlockType::Quote);
1840 builder.set_content("To be or not to be");
1841
1842 let block = builder.build();
1843 assert!(block.is_ok());
1844
1845 let block = block.unwrap();
1846 match block {
1847 Block::Quote { content, footnotes } => {
1848 assert_eq!(content, "To be or not to be");
1849 assert!(footnotes.is_empty());
1850 }
1851 _ => unreachable!(),
1852 }
1853 }
1854
1855 #[test]
1856 fn test_create_title_block() {
1857 let mut builder = BlockBuilder::new(BlockType::Title);
1858 builder.set_content("Chapter 1").set_title_level(2);
1859
1860 let block = builder.build();
1861 assert!(block.is_ok());
1862
1863 let block = block.unwrap();
1864 match block {
1865 Block::Title { content, level, footnotes } => {
1866 assert_eq!(content, "Chapter 1");
1867 assert_eq!(level, 2);
1868 assert!(footnotes.is_empty());
1869 }
1870 _ => unreachable!(),
1871 }
1872 }
1873
1874 #[test]
1875 fn test_create_title_block_invalid_level() {
1876 let mut builder = BlockBuilder::new(BlockType::Title);
1877 builder.set_content("Chapter 1").set_title_level(10);
1878
1879 let result = builder.build();
1880 assert!(result.is_err());
1881
1882 let result = result.unwrap_err();
1883 assert_eq!(
1884 result,
1885 EpubBuilderError::MissingNecessaryBlockData {
1886 block_type: "Title".to_string(),
1887 missing_data: "'content' or 'level'".to_string(),
1888 }
1889 .into()
1890 );
1891 }
1892
1893 #[test]
1894 fn test_create_image_block() {
1895 let img_path = PathBuf::from("./test_case/image.jpg");
1896 let mut builder = BlockBuilder::new(BlockType::Image);
1897 builder
1898 .set_url(&img_path)
1899 .unwrap()
1900 .set_alt("Test Image")
1901 .set_caption("A test image");
1902
1903 let block = builder.build();
1904 assert!(block.is_ok());
1905
1906 let block = block.unwrap();
1907 match block {
1908 Block::Image { url, alt, caption, footnotes } => {
1909 assert_eq!(url.file_name().unwrap(), "image.jpg");
1910 assert_eq!(alt, Some("Test Image".to_string()));
1911 assert_eq!(caption, Some("A test image".to_string()));
1912 assert!(footnotes.is_empty());
1913 }
1914 _ => unreachable!(),
1915 }
1916 }
1917
1918 #[test]
1919 fn test_create_image_block_missing_url() {
1920 let builder = BlockBuilder::new(BlockType::Image);
1921
1922 let block = builder.build();
1923 assert!(block.is_err());
1924
1925 let result = block.unwrap_err();
1926 assert_eq!(
1927 result,
1928 EpubBuilderError::MissingNecessaryBlockData {
1929 block_type: "Image".to_string(),
1930 missing_data: "'url'".to_string(),
1931 }
1932 .into()
1933 );
1934 }
1935
1936 #[test]
1937 fn test_create_audio_block() {
1938 let audio_path = PathBuf::from("./test_case/audio.mp3");
1939 let mut builder = BlockBuilder::new(BlockType::Audio);
1940 builder
1941 .set_url(&audio_path)
1942 .unwrap()
1943 .set_fallback("Audio not supported")
1944 .set_caption("Background music");
1945
1946 let block = builder.build();
1947 assert!(block.is_ok());
1948
1949 let block = block.unwrap();
1950 match block {
1951 Block::Audio { url, fallback, caption, footnotes } => {
1952 assert_eq!(url.file_name().unwrap(), "audio.mp3");
1953 assert_eq!(fallback, "Audio not supported");
1954 assert_eq!(caption, Some("Background music".to_string()));
1955 assert!(footnotes.is_empty());
1956 }
1957 _ => unreachable!(),
1958 }
1959 }
1960
1961 #[test]
1962 fn test_set_url_invalid_file_type() {
1963 let xhtml_path = PathBuf::from("./test_case/Overview.xhtml");
1964 let mut builder = BlockBuilder::new(BlockType::Image);
1965 let result = builder.set_url(&xhtml_path);
1966 assert!(result.is_err());
1967
1968 let err = result.unwrap_err();
1969 assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1970 }
1971
1972 #[test]
1973 fn test_set_url_nonexistent_file() {
1974 let nonexistent_path = PathBuf::from("./test_case/nonexistent.jpg");
1975 let mut builder = BlockBuilder::new(BlockType::Image);
1976 let result = builder.set_url(&nonexistent_path);
1977 assert!(result.is_err());
1978
1979 let err = result.unwrap_err();
1980 assert_eq!(
1981 err,
1982 EpubBuilderError::TargetIsNotFile {
1983 target_path: "./test_case/nonexistent.jpg".to_string()
1984 }
1985 .into()
1986 );
1987 }
1988
1989 #[test]
1990 fn test_set_fallback_image_invalid_type() {
1991 let audio_path = PathBuf::from("./test_case/audio.mp3");
1992 let mut builder = BlockBuilder::new(BlockType::MathML);
1993 builder.set_mathml_element("<math/>");
1994 let result = builder.set_fallback_image(audio_path);
1995 assert!(result.is_err());
1996
1997 let err = result.unwrap_err();
1998 assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1999 }
2000
2001 #[test]
2002 fn test_set_fallback_image_nonexistent() {
2003 let nonexistent_path = PathBuf::from("./test_case/nonexistent.png");
2004 let mut builder = BlockBuilder::new(BlockType::MathML);
2005 builder.set_mathml_element("<math/>");
2006 let result = builder.set_fallback_image(nonexistent_path);
2007 assert!(result.is_err());
2008
2009 let err = result.unwrap_err();
2010 assert_eq!(
2011 err,
2012 EpubBuilderError::TargetIsNotFile {
2013 target_path: "./test_case/nonexistent.png".to_string()
2014 }
2015 .into()
2016 );
2017 }
2018
2019 #[test]
2020 fn test_create_video_block() {
2021 let video_path = PathBuf::from("./test_case/video.mp4");
2022 let mut builder = BlockBuilder::new(BlockType::Video);
2023 builder
2024 .set_url(&video_path)
2025 .unwrap()
2026 .set_fallback("Video not supported")
2027 .set_caption("Demo video");
2028
2029 let block = builder.build();
2030 assert!(block.is_ok());
2031
2032 let block = block.unwrap();
2033 match block {
2034 Block::Video { url, fallback, caption, footnotes } => {
2035 assert_eq!(url.file_name().unwrap(), "video.mp4");
2036 assert_eq!(fallback, "Video not supported");
2037 assert_eq!(caption, Some("Demo video".to_string()));
2038 assert!(footnotes.is_empty());
2039 }
2040 _ => unreachable!(),
2041 }
2042 }
2043
2044 #[test]
2045 fn test_create_mathml_block() {
2046 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
2047 let mut builder = BlockBuilder::new(BlockType::MathML);
2048 builder
2049 .set_mathml_element(mathml_content)
2050 .set_caption("Simple equation");
2051
2052 let block = builder.build();
2053 assert!(block.is_ok());
2054
2055 let block = block.unwrap();
2056 match block {
2057 Block::MathML {
2058 element_str,
2059 fallback_image,
2060 caption,
2061 footnotes,
2062 } => {
2063 assert_eq!(element_str, mathml_content);
2064 assert!(fallback_image.is_none());
2065 assert_eq!(caption, Some("Simple equation".to_string()));
2066 assert!(footnotes.is_empty());
2067 }
2068 _ => unreachable!(),
2069 }
2070 }
2071
2072 #[test]
2073 fn test_create_mathml_block_with_fallback() {
2074 let img_path = PathBuf::from("./test_case/image.jpg");
2075 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
2076
2077 let mut builder = BlockBuilder::new(BlockType::MathML);
2078 builder
2079 .set_mathml_element(mathml_content)
2080 .set_fallback_image(img_path.clone())
2081 .unwrap();
2082
2083 let block = builder.build();
2084 assert!(block.is_ok());
2085
2086 let block = block.unwrap();
2087 match block {
2088 Block::MathML { element_str, fallback_image, .. } => {
2089 assert_eq!(element_str, mathml_content);
2090 assert!(fallback_image.is_some());
2091 }
2092 _ => unreachable!(),
2093 }
2094 }
2095
2096 #[test]
2097 fn test_footnote_management() {
2098 let mut builder = BlockBuilder::new(BlockType::Text);
2099 builder.set_content("This is a test");
2100
2101 let note1 = Footnote {
2102 locate: 5,
2103 content: "First footnote".to_string(),
2104 };
2105 let note2 = Footnote {
2106 locate: 10,
2107 content: "Second footnote".to_string(),
2108 };
2109
2110 builder.add_footnote(note1).add_footnote(note2);
2111
2112 let block = builder.build();
2113 assert!(block.is_ok());
2114
2115 let block = block.unwrap();
2116 match block {
2117 Block::Text { footnotes, .. } => {
2118 assert_eq!(footnotes.len(), 2);
2119 }
2120 _ => unreachable!(),
2121 }
2122 }
2123
2124 #[test]
2125 fn test_remove_last_footnote() {
2126 let mut builder = BlockBuilder::new(BlockType::Text);
2127 builder.set_content("This is a test");
2128
2129 builder.add_footnote(Footnote { locate: 5, content: "Note 1".to_string() });
2130 builder.add_footnote(Footnote {
2131 locate: 10,
2132 content: "Note 2".to_string(),
2133 });
2134 builder.remove_last_footnote();
2135
2136 let block = builder.build();
2137 assert!(block.is_ok());
2138
2139 let block = block.unwrap();
2140 match block {
2141 Block::Text { footnotes, .. } => {
2142 assert_eq!(footnotes.len(), 1);
2143 assert!(footnotes[0].content == "Note 1");
2144 }
2145 _ => unreachable!(),
2146 }
2147 }
2148
2149 #[test]
2150 fn test_clear_footnotes() {
2151 let mut builder = BlockBuilder::new(BlockType::Text);
2152 builder.set_content("This is a test");
2153
2154 builder.add_footnote(Footnote { locate: 5, content: "Note".to_string() });
2155
2156 builder.clear_footnotes();
2157
2158 let block = builder.build();
2159 assert!(block.is_ok());
2160
2161 let block = block.unwrap();
2162 match block {
2163 Block::Text { footnotes, .. } => {
2164 assert!(footnotes.is_empty());
2165 }
2166 _ => unreachable!(),
2167 }
2168 }
2169
2170 #[test]
2171 fn test_invalid_footnote_locate() {
2172 let mut builder = BlockBuilder::new(BlockType::Text);
2173 builder.set_content("Hello");
2174
2175 builder.add_footnote(Footnote {
2177 locate: 100,
2178 content: "Invalid footnote".to_string(),
2179 });
2180
2181 let result = builder.build();
2182 assert!(result.is_err());
2183
2184 let result = result.unwrap_err();
2185 assert_eq!(
2186 result,
2187 EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
2188 );
2189 }
2190
2191 #[test]
2192 fn test_footnote_on_media_without_caption() {
2193 let img_path = PathBuf::from("./test_case/image.jpg");
2194 let mut builder = BlockBuilder::new(BlockType::Image);
2195 builder.set_url(&img_path).unwrap();
2196
2197 builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
2198
2199 let result = builder.build();
2200 assert!(result.is_err());
2201
2202 let result = result.unwrap_err();
2203 assert_eq!(
2204 result,
2205 EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
2206 );
2207 }
2208 }
2209
2210 mod content_builder_tests {
2211 use std::{env, fs, path::PathBuf};
2212
2213 use crate::{
2214 builder::content::{Block, ContentBuilder},
2215 types::{ColorScheme, Footnote, PageLayout, TextAlign, TextStyle},
2216 utils::local_time,
2217 };
2218
2219 #[test]
2220 fn test_create_content_builder() {
2221 let builder = ContentBuilder::new("chapter1", "en");
2222 assert!(builder.is_ok());
2223
2224 let builder = builder.unwrap();
2225 assert_eq!(builder.id, "chapter1");
2226 }
2227
2228 #[test]
2229 fn test_set_title() {
2230 let builder = ContentBuilder::new("chapter1", "en");
2231 assert!(builder.is_ok());
2232
2233 let mut builder = builder.unwrap();
2234 builder.set_title("My Chapter").set_title("Another Title");
2235
2236 assert_eq!(builder.title, "Another Title");
2237 }
2238
2239 #[test]
2240 fn test_add_text_block() {
2241 let builder = ContentBuilder::new("chapter1", "en");
2242 assert!(builder.is_ok());
2243
2244 let mut builder = builder.unwrap();
2245 let result = builder.add_text_block("This is a paragraph", vec![]);
2246 assert!(result.is_ok());
2247 }
2248
2249 #[test]
2250 fn test_add_quote_block() {
2251 let builder = ContentBuilder::new("chapter1", "en");
2252 assert!(builder.is_ok());
2253
2254 let mut builder = builder.unwrap();
2255 let result = builder.add_quote_block("A quoted text", vec![]);
2256 assert!(result.is_ok());
2257 }
2258
2259 #[test]
2260 fn test_set_styles() {
2261 let builder = ContentBuilder::new("chapter1", "en");
2262 assert!(builder.is_ok());
2263
2264 let custom_styles = crate::types::StyleOptions {
2265 text: TextStyle {
2266 font_size: 1.5,
2267 line_height: 1.8,
2268 font_family: "Georgia, serif".to_string(),
2269 font_weight: "bold".to_string(),
2270 font_style: "italic".to_string(),
2271 letter_spacing: "0.1em".to_string(),
2272 text_indent: 1.5,
2273 },
2274 color_scheme: ColorScheme {
2275 background: "#F5F5F5".to_string(),
2276 text: "#333333".to_string(),
2277 link: "#0066CC".to_string(),
2278 },
2279 layout: PageLayout {
2280 margin: 30,
2281 text_align: TextAlign::Center,
2282 paragraph_spacing: 20,
2283 },
2284 };
2285
2286 let mut builder = builder.unwrap();
2287 builder.set_styles(custom_styles);
2288
2289 assert_eq!(builder.styles.text.font_size, 1.5);
2290 assert_eq!(builder.styles.text.font_weight, "bold");
2291 assert_eq!(builder.styles.color_scheme.background, "#F5F5F5");
2292 assert_eq!(builder.styles.layout.text_align, TextAlign::Center);
2293 }
2294
2295 #[test]
2296 fn test_add_title_block() {
2297 let builder = ContentBuilder::new("chapter1", "en");
2298 assert!(builder.is_ok());
2299
2300 let mut builder = builder.unwrap();
2301 let result = builder.add_title_block("Section Title", 2, vec![]);
2302 assert!(result.is_ok());
2303 }
2304
2305 #[test]
2306 fn test_add_image_block() {
2307 let img_path = PathBuf::from("./test_case/image.jpg");
2308 let builder = ContentBuilder::new("chapter1", "en");
2309 assert!(builder.is_ok());
2310
2311 let mut builder = builder.unwrap();
2312 let result = builder.add_image_block(
2313 img_path,
2314 Some("Alt text".to_string()),
2315 Some("Figure 1: An image".to_string()),
2316 vec![],
2317 );
2318
2319 assert!(result.is_ok());
2320 }
2321
2322 #[test]
2323 fn test_add_audio_block() {
2324 let audio_path = PathBuf::from("./test_case/audio.mp3");
2325 let builder = ContentBuilder::new("chapter1", "en");
2326 assert!(builder.is_ok());
2327
2328 let mut builder = builder.unwrap();
2329 let result = builder.add_audio_block(
2330 audio_path,
2331 "Your browser doesn't support audio".to_string(),
2332 Some("Background music".to_string()),
2333 vec![],
2334 );
2335
2336 assert!(result.is_ok());
2337 }
2338
2339 #[test]
2340 fn test_add_video_block() {
2341 let video_path = PathBuf::from("./test_case/video.mp4");
2342 let builder = ContentBuilder::new("chapter1", "en");
2343 assert!(builder.is_ok());
2344
2345 let mut builder = builder.unwrap();
2346 let result = builder.add_video_block(
2347 video_path,
2348 "Your browser doesn't support video".to_string(),
2349 Some("Tutorial video".to_string()),
2350 vec![],
2351 );
2352
2353 assert!(result.is_ok());
2354 }
2355
2356 #[test]
2357 fn test_add_mathml_block() {
2358 let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
2359 let builder = ContentBuilder::new("chapter1", "en");
2360 assert!(builder.is_ok());
2361
2362 let mut builder = builder.unwrap();
2363 let result = builder.add_mathml_block(
2364 mathml.to_string(),
2365 None,
2366 Some("Equation 1".to_string()),
2367 vec![],
2368 );
2369
2370 assert!(result.is_ok());
2371 }
2372
2373 #[test]
2374 fn test_remove_last_block() {
2375 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2376
2377 builder.add_text_block("First block", vec![]).unwrap();
2378 builder.add_text_block("Second block", vec![]).unwrap();
2379 assert_eq!(builder.blocks.len(), 2);
2380
2381 builder.remove_last_block();
2382 assert_eq!(builder.blocks.len(), 1);
2383 }
2384
2385 #[test]
2386 fn test_take_last_block() {
2387 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2388
2389 builder.add_text_block("Block content", vec![]).unwrap();
2390
2391 let block = builder.take_last_block();
2392 assert!(block.is_some());
2393
2394 let block = block.unwrap();
2395 match block {
2396 Block::Text { content, .. } => {
2397 assert_eq!(content, "Block content");
2398 }
2399 _ => unreachable!(),
2400 }
2401
2402 let block2 = builder.take_last_block();
2403 assert!(block2.is_none());
2404 }
2405
2406 #[test]
2407 fn test_clear_blocks() {
2408 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2409
2410 builder.add_text_block("Block 1", vec![]).unwrap();
2411 builder.add_text_block("Block 2", vec![]).unwrap();
2412 assert_eq!(builder.blocks.len(), 2);
2413
2414 builder.clear_blocks();
2415
2416 let block = builder.take_last_block();
2417 assert!(block.is_none());
2418 }
2419
2420 #[test]
2421 fn test_make_content_document() {
2422 let temp_dir = env::temp_dir().join(local_time());
2423 assert!(fs::create_dir_all(&temp_dir).is_ok());
2424
2425 let output_path = temp_dir.join("chapter.xhtml");
2426
2427 let builder = ContentBuilder::new("chapter1", "en");
2428 assert!(builder.is_ok());
2429
2430 let mut builder = builder.unwrap();
2431 builder
2432 .set_title("My Chapter")
2433 .add_text_block("This is the first paragraph.", vec![])
2434 .unwrap()
2435 .add_text_block("This is the second paragraph.", vec![])
2436 .unwrap();
2437
2438 let result = builder.make(&output_path);
2439 assert!(result.is_ok());
2440 assert!(output_path.exists());
2441 assert!(fs::remove_dir_all(temp_dir).is_ok());
2442 }
2443
2444 #[test]
2445 fn test_make_content_with_media() {
2446 let temp_dir = env::temp_dir().join(local_time());
2447 assert!(fs::create_dir_all(&temp_dir).is_ok());
2448
2449 let output_path = temp_dir.join("chapter.xhtml");
2450 let img_path = PathBuf::from("./test_case/image.jpg");
2451
2452 let builder = ContentBuilder::new("chapter1", "en");
2453 assert!(builder.is_ok());
2454
2455 let mut builder = builder.unwrap();
2456 builder
2457 .set_title("Chapter with Media")
2458 .add_text_block("See image below:", vec![])
2459 .unwrap()
2460 .add_image_block(
2461 img_path,
2462 Some("Test".to_string()),
2463 Some("Figure 1".to_string()),
2464 vec![],
2465 )
2466 .unwrap();
2467
2468 let result = builder.make(&output_path);
2469 assert!(result.is_ok());
2470
2471 let img_dir = temp_dir.join("img");
2472 assert!(img_dir.exists());
2473 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2474 }
2475
2476 #[test]
2477 fn test_make_content_with_footnotes() {
2478 let temp_dir = env::temp_dir().join(local_time());
2479 assert!(fs::create_dir_all(&temp_dir).is_ok());
2480
2481 let output_path = temp_dir.join("chapter.xhtml");
2482
2483 let footnotes = vec![
2484 Footnote {
2485 locate: 10,
2486 content: "This is a footnote".to_string(),
2487 },
2488 Footnote {
2489 locate: 15,
2490 content: "Another footnote".to_string(),
2491 },
2492 ];
2493
2494 let builder = ContentBuilder::new("chapter1", "en");
2495 assert!(builder.is_ok());
2496
2497 let mut builder = builder.unwrap();
2498 builder
2499 .set_title("Chapter with Notes")
2500 .add_text_block("This is a paragraph with notes.", footnotes)
2501 .unwrap();
2502
2503 let result = builder.make(&output_path);
2504 assert!(result.is_ok());
2505 assert!(output_path.exists());
2506 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2507 }
2508
2509 #[test]
2510 fn test_add_css_file() {
2511 let builder = ContentBuilder::new("chapter1", "en");
2512 assert!(builder.is_ok());
2513
2514 let mut builder = builder.unwrap();
2515 let result = builder.add_css_file(PathBuf::from("./test_case/style.css"));
2516
2517 assert!(result.is_ok());
2518 assert_eq!(builder.css_files.len(), 1);
2519 }
2520
2521 #[test]
2522 fn test_add_css_file_nonexistent() {
2523 let builder = ContentBuilder::new("chapter1", "en");
2524 assert!(builder.is_ok());
2525
2526 let mut builder = builder.unwrap();
2527 let result = builder.add_css_file(PathBuf::from("nonexistent.css"));
2528 assert!(result.is_err());
2529 }
2530
2531 #[test]
2532 fn test_add_multiple_css_files() {
2533 let temp_dir = env::temp_dir().join(local_time());
2534 assert!(fs::create_dir_all(&temp_dir).is_ok());
2535
2536 let css_path1 = temp_dir.join("style1.css");
2537 let css_path2 = temp_dir.join("style2.css");
2538 assert!(fs::write(&css_path1, "body { color: red; }").is_ok());
2539 assert!(fs::write(&css_path2, "p { font-size: 16px; }").is_ok());
2540
2541 let builder = ContentBuilder::new("chapter1", "en");
2542 assert!(builder.is_ok());
2543
2544 let mut builder = builder.unwrap();
2545 assert!(builder.add_css_file(css_path1).is_ok());
2546 assert!(builder.add_css_file(css_path2).is_ok());
2547
2548 assert_eq!(builder.css_files.len(), 2);
2549
2550 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2551 }
2552
2553 #[test]
2554 fn test_remove_last_css_file() {
2555 let builder = ContentBuilder::new("chapter1", "en");
2556 assert!(builder.is_ok());
2557
2558 let mut builder = builder.unwrap();
2559 builder
2560 .add_css_file(PathBuf::from("./test_case/style.css"))
2561 .unwrap();
2562 assert_eq!(builder.css_files.len(), 1);
2563
2564 builder.remove_last_css_file();
2565 assert!(builder.css_files.is_empty());
2566
2567 builder.remove_last_css_file();
2568 assert!(builder.css_files.is_empty());
2569 }
2570
2571 #[test]
2572 fn test_clear_css_files() {
2573 let temp_dir = env::temp_dir().join(local_time());
2574 assert!(fs::create_dir_all(&temp_dir).is_ok());
2575
2576 let css_path1 = temp_dir.join("style1.css");
2577 let css_path2 = temp_dir.join("style2.css");
2578 assert!(fs::write(&css_path1, "body { color: red; }").is_ok());
2579 assert!(fs::write(&css_path2, "p { font-size: 16px; }").is_ok());
2580
2581 let builder = ContentBuilder::new("chapter1", "en");
2582 assert!(builder.is_ok());
2583
2584 let mut builder = builder.unwrap();
2585 assert!(builder.add_css_file(css_path1).is_ok());
2586 assert!(builder.add_css_file(css_path2).is_ok());
2587 assert_eq!(builder.css_files.len(), 2);
2588
2589 builder.clear_css_files();
2590 assert!(builder.css_files.is_empty());
2591
2592 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2593 }
2594 }
2595
2596 mod block_tests {
2597 use std::path::PathBuf;
2598
2599 use crate::{builder::content::Block, types::Footnote};
2600
2601 #[test]
2602 fn test_take_footnotes_from_text_block() {
2603 let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
2604
2605 let block = Block::Text {
2606 content: "Hello world".to_string(),
2607 footnotes: footnotes.clone(),
2608 };
2609
2610 let taken = block.take_footnotes();
2611 assert_eq!(taken.len(), 1);
2612 assert_eq!(taken[0].content, "Note");
2613 }
2614
2615 #[test]
2616 fn test_take_footnotes_from_quote_block() {
2617 let footnotes = vec![
2618 Footnote { locate: 3, content: "First".to_string() },
2619 Footnote { locate: 8, content: "Second".to_string() },
2620 ];
2621
2622 let block = Block::Quote {
2623 content: "Test quote".to_string(),
2624 footnotes: footnotes.clone(),
2625 };
2626
2627 let taken = block.take_footnotes();
2628 assert_eq!(taken.len(), 2);
2629 }
2630
2631 #[test]
2632 fn test_take_footnotes_from_image_block() {
2633 let img_path = PathBuf::from("test.png");
2634 let footnotes = vec![Footnote {
2635 locate: 2,
2636 content: "Image note".to_string(),
2637 }];
2638
2639 let block = Block::Image {
2640 url: img_path,
2641 alt: None,
2642 caption: Some("A caption".to_string()),
2643 footnotes: footnotes.clone(),
2644 };
2645
2646 let taken = block.take_footnotes();
2647 assert_eq!(taken.len(), 1);
2648 }
2649
2650 #[test]
2651 fn test_block_with_empty_footnotes() {
2652 let block = Block::Text {
2653 content: "No footnotes here".to_string(),
2654 footnotes: vec![],
2655 };
2656
2657 let taken = block.take_footnotes();
2658 assert!(taken.is_empty());
2659 }
2660 }
2661
2662 mod content_rendering_tests {
2663 use crate::builder::content::Block;
2664
2665 #[test]
2666 fn test_split_content_by_index_empty() {
2667 let result = Block::split_content_by_index("Hello", &[]);
2668 assert_eq!(result, vec!["Hello"]);
2669 }
2670
2671 #[test]
2672 fn test_split_content_by_single_index() {
2673 let result = Block::split_content_by_index("Hello World", &[5]);
2674 assert_eq!(result.len(), 2);
2675 assert_eq!(result[0], "Hello");
2676 assert_eq!(result[1], " World");
2677 }
2678
2679 #[test]
2680 fn test_split_content_by_multiple_indices() {
2681 let result = Block::split_content_by_index("One Two Three", &[3, 7]);
2682 assert_eq!(result.len(), 3);
2683 assert_eq!(result[0], "One");
2684 assert_eq!(result[1], " Two");
2685 assert_eq!(result[2], " Three");
2686 }
2687
2688 #[test]
2689 fn test_split_content_unicode() {
2690 let content = "你好世界";
2691 let result = Block::split_content_by_index(content, &[2]);
2692 assert_eq!(result.len(), 2);
2693 assert_eq!(result[0], "你好");
2694 assert_eq!(result[1], "世界");
2695 }
2696 }
2697}