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