1use std::{
44 collections::HashMap,
45 env,
46 fs::{self, File},
47 io::{Cursor, Read},
48 path::{Path, PathBuf},
49};
50
51use infer::{Infer, MatcherType};
52use log::warn;
53use quick_xml::{
54 Reader, Writer,
55 events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
56};
57use walkdir::WalkDir;
58
59use crate::{
60 builder::XmlWriter,
61 error::{EpubBuilderError, EpubError},
62 types::{BlockType, Footnote, StyleOptions},
63 utils::local_time,
64};
65
66#[non_exhaustive]
85#[derive(Debug)]
86pub enum Block {
87 #[non_exhaustive]
97 Text {
98 content: String,
99 footnotes: Vec<Footnote>,
100 },
101
102 #[non_exhaustive]
112 Quote {
113 content: String,
114 footnotes: Vec<Footnote>,
115 },
116
117 #[non_exhaustive]
126 Title {
127 content: String,
128 footnotes: Vec<Footnote>,
129
130 level: usize,
134 },
135
136 #[non_exhaustive]
148 Image {
149 url: PathBuf,
151
152 alt: Option<String>,
154
155 caption: Option<String>,
157
158 footnotes: Vec<Footnote>,
159 },
160
161 #[non_exhaustive]
175 Audio {
176 url: PathBuf,
178
179 fallback: String,
183
184 caption: Option<String>,
186
187 footnotes: Vec<Footnote>,
188 },
189
190 #[non_exhaustive]
204 Video {
205 url: PathBuf,
207
208 fallback: String,
212
213 caption: Option<String>,
215
216 footnotes: Vec<Footnote>,
217 },
218
219 #[non_exhaustive]
237 MathML {
238 element_str: String,
243
244 fallback_image: Option<PathBuf>,
249
250 caption: Option<String>,
252
253 footnotes: Vec<Footnote>,
254 },
255}
256
257impl Block {
258 pub(crate) fn make(
262 &mut self,
263 writer: &mut XmlWriter,
264 start_index: usize,
265 ) -> Result<(), EpubError> {
266 match self {
267 Block::Text { content, footnotes } => {
268 writer.write_event(Event::Start(
269 BytesStart::new("p").with_attributes([("class", "content-block text-block")]),
270 ))?;
271
272 Self::make_text(writer, content, footnotes, start_index)?;
273
274 writer.write_event(Event::End(BytesEnd::new("p")))?;
275 }
276
277 Block::Quote { content, footnotes } => {
278 writer.write_event(Event::Start(BytesStart::new("blockquote").with_attributes(
279 [
280 ("class", "content-block quote-block"),
281 ("cite", "SOME ATTR NEED TO BE SET"),
282 ],
283 )))?;
284 writer.write_event(Event::Start(BytesStart::new("p")))?;
285
286 Self::make_text(writer, content, footnotes, start_index)?;
287
288 writer.write_event(Event::End(BytesEnd::new("p")))?;
289 writer.write_event(Event::End(BytesEnd::new("blockquote")))?;
290 }
291
292 Block::Title { content, footnotes, level } => {
293 let tag_name = format!("h{}", level);
294 writer.write_event(Event::Start(
295 BytesStart::new(tag_name.as_str())
296 .with_attributes([("class", "content-block title-block")]),
297 ))?;
298
299 Self::make_text(writer, content, footnotes, start_index)?;
300
301 writer.write_event(Event::End(BytesEnd::new(tag_name)))?;
302 }
303
304 Block::Image { url, alt, caption, footnotes } => {
305 let url = format!("./img/{}", url.file_name().unwrap().to_string_lossy());
306
307 let mut attr = Vec::new();
308 attr.push(("src", url.as_str()));
309 if let Some(alt) = alt {
310 attr.push(("alt", alt.as_str()));
311 }
312
313 writer.write_event(Event::Start(
314 BytesStart::new("figure")
315 .with_attributes([("class", "content-block image-block")]),
316 ))?;
317 writer.write_event(Event::Empty(BytesStart::new("img").with_attributes(attr)))?;
318
319 if let Some(caption) = caption {
320 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
321
322 Self::make_text(writer, caption, footnotes, start_index)?;
323
324 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
325 }
326
327 writer.write_event(Event::End(BytesEnd::new("figure")))?;
328 }
329
330 Block::Audio { url, fallback, caption, footnotes } => {
331 let url = format!("./audio/{}", url.file_name().unwrap().to_string_lossy());
332
333 let attr = vec![
334 ("src", url.as_str()),
335 ("controls", "controls"), ];
337
338 writer.write_event(Event::Start(
339 BytesStart::new("figure")
340 .with_attributes([("class", "content-block audio-block")]),
341 ))?;
342 writer.write_event(Event::Start(BytesStart::new("audio").with_attributes(attr)))?;
343
344 writer.write_event(Event::Start(BytesStart::new("p")))?;
345 writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
346 writer.write_event(Event::End(BytesEnd::new("p")))?;
347
348 writer.write_event(Event::End(BytesEnd::new("audio")))?;
349
350 if let Some(caption) = caption {
351 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
352
353 Self::make_text(writer, caption, footnotes, start_index)?;
354
355 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
356 }
357
358 writer.write_event(Event::End(BytesEnd::new("figure")))?;
359 }
360
361 Block::Video { url, fallback, caption, footnotes } => {
362 let url = format!("./video/{}", url.file_name().unwrap().to_string_lossy());
363
364 let attr = vec![
365 ("src", url.as_str()),
366 ("controls", "controls"), ];
368
369 writer.write_event(Event::Start(
370 BytesStart::new("figure")
371 .with_attributes([("class", "content-block video-block")]),
372 ))?;
373 writer.write_event(Event::Start(BytesStart::new("video").with_attributes(attr)))?;
374
375 writer.write_event(Event::Start(BytesStart::new("p")))?;
376 writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
377 writer.write_event(Event::End(BytesEnd::new("p")))?;
378
379 writer.write_event(Event::End(BytesEnd::new("video")))?;
380
381 if let Some(caption) = caption {
382 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
383
384 Self::make_text(writer, caption, footnotes, start_index)?;
385
386 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
387 }
388
389 writer.write_event(Event::End(BytesEnd::new("figure")))?;
390 }
391
392 Block::MathML {
393 element_str,
394 fallback_image,
395 caption,
396 footnotes,
397 } => {
398 writer.write_event(Event::Start(
399 BytesStart::new("figure")
400 .with_attributes([("class", "content-block mathml-block")]),
401 ))?;
402
403 Self::write_mathml_element(writer, element_str)?;
404
405 if let Some(fallback_path) = fallback_image {
406 let img_url = format!(
407 "./img/{}",
408 fallback_path.file_name().unwrap().to_string_lossy()
409 );
410
411 writer.write_event(Event::Empty(BytesStart::new("img").with_attributes([
412 ("src", img_url.as_str()),
413 ("class", "mathml-fallback"),
414 ("alt", "Mathematical formula"),
415 ])))?;
416 }
417
418 if let Some(caption) = caption {
419 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
420
421 Self::make_text(writer, caption, footnotes, start_index)?;
422
423 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
424 }
425
426 writer.write_event(Event::End(BytesEnd::new("figure")))?;
427 }
428 }
429
430 Ok(())
431 }
432
433 pub fn take_footnotes(&self) -> Vec<Footnote> {
434 match self {
435 Block::Text { footnotes, .. }
436 | Block::Quote { footnotes, .. }
437 | Block::Title { footnotes, .. }
438 | Block::Image { footnotes, .. }
439 | Block::Audio { footnotes, .. }
440 | Block::Video { footnotes, .. }
441 | Block::MathML { footnotes, .. } => footnotes.to_vec(),
442 }
443 }
444
445 fn split_content_by_index(content: &str, index_list: &[usize]) -> Vec<String> {
451 if index_list.is_empty() {
452 return vec![content.to_string()];
453 }
454
455 let mut result = Vec::with_capacity(index_list.len() + 1);
457 let mut char_iter = content.chars().enumerate();
458
459 let mut current_char_idx = 0;
460 for &target_idx in index_list {
461 let mut segment = String::new();
462
463 while current_char_idx < target_idx {
466 if let Some((_, ch)) = char_iter.next() {
467 segment.push(ch);
468 current_char_idx += 1;
469 } else {
470 break;
471 }
472 }
473
474 if !segment.is_empty() {
475 result.push(segment);
476 }
477 }
478
479 let remainder = char_iter.map(|(_, ch)| ch).collect::<String>();
480 if !remainder.is_empty() {
481 result.push(remainder);
482 }
483
484 result
485 }
486
487 fn make_text(
497 writer: &mut XmlWriter,
498 content: &str,
499 footnotes: &mut [Footnote],
500 start_index: usize,
501 ) -> Result<(), EpubError> {
502 if footnotes.is_empty() {
503 writer.write_event(Event::Text(BytesText::new(content)))?;
504 return Ok(());
505 }
506
507 footnotes.sort_unstable();
508
509 let mut position_to_count = HashMap::new();
511 for footnote in footnotes.iter() {
512 *position_to_count.entry(footnote.locate).or_insert(0usize) += 1;
513 }
514
515 let mut positions = position_to_count.keys().copied().collect::<Vec<usize>>();
516 positions.sort_unstable();
517
518 let mut current_index = start_index;
519 let content_list = Self::split_content_by_index(content, &positions);
520 for (index, segment) in content_list.iter().enumerate() {
521 writer.write_event(Event::Text(BytesText::new(segment)))?;
522
523 if let Some(&position) = positions.get(index) {
525 if let Some(&count) = position_to_count.get(&position) {
527 for _ in 0..count {
528 Self::make_footnotes(writer, current_index)?;
529 current_index += 1;
530 }
531 }
532 }
533 }
534
535 Ok(())
536 }
537
538 #[inline]
540 fn make_footnotes(writer: &mut XmlWriter, index: usize) -> Result<(), EpubError> {
541 writer.write_event(Event::Start(BytesStart::new("a").with_attributes([
542 ("href", format!("#footnote-{}", index).as_str()),
543 ("id", format!("ref-{}", index).as_str()),
544 ("class", "footnote-ref"),
545 ])))?;
546 writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index))))?;
547 writer.write_event(Event::End(BytesEnd::new("a")))?;
548
549 Ok(())
550 }
551
552 fn write_mathml_element(writer: &mut XmlWriter, element_str: &str) -> Result<(), EpubError> {
556 let mut reader = Reader::from_str(element_str);
557
558 loop {
559 match reader.read_event() {
560 Ok(Event::Eof) => break,
561
562 Ok(event) => writer.write_event(event)?,
563
564 Err(err) => {
565 return Err(
566 EpubBuilderError::InvalidMathMLFormat { error: err.to_string() }.into(),
567 );
568 }
569 }
570 }
571
572 Ok(())
573 }
574
575 fn validate_footnotes(&self) -> Result<(), EpubError> {
582 match self {
583 Block::Text { content, footnotes }
584 | Block::Quote { content, footnotes }
585 | Block::Title { content, footnotes, .. } => {
586 let max_locate = content.chars().count();
587 for footnote in footnotes.iter() {
588 if footnote.locate == 0 || footnote.locate > max_locate {
589 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
590 }
591 }
592
593 Ok(())
594 }
595
596 Block::Image { caption, footnotes, .. }
597 | Block::MathML { caption, footnotes, .. }
598 | Block::Video { caption, footnotes, .. }
599 | Block::Audio { caption, footnotes, .. } => {
600 if let Some(caption) = caption {
601 let max_locate = caption.chars().count();
602 for footnote in footnotes.iter() {
603 if footnote.locate == 0 || footnote.locate > caption.chars().count() {
604 return Err(
605 EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
606 );
607 }
608 }
609 } else if !footnotes.is_empty() {
610 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
611 }
612
613 Ok(())
614 }
615 }
616 }
617
618 fn missing_error(block_type: BlockType, missing_data: &str) -> EpubError {
619 EpubBuilderError::MissingNecessaryBlockData {
620 block_type: block_type.to_string(),
621 missing_data: format!("'{}'", missing_data),
622 }
623 .into()
624 }
625}
626
627impl TryFrom<BlockBuilder> for Block {
628 type Error = EpubError;
629
630 fn try_from(builder: BlockBuilder) -> Result<Self, Self::Error> {
631 let block = match builder.block_type {
632 BlockType::Text => {
633 let content = builder
634 .content
635 .ok_or_else(|| Self::missing_error(builder.block_type, "content"))?;
636 Block::Text { content, footnotes: builder.footnotes }
637 }
638
639 BlockType::Quote => {
640 let content = builder
641 .content
642 .ok_or_else(|| Self::missing_error(builder.block_type, "content"))?;
643 Block::Quote { content, footnotes: builder.footnotes }
644 }
645
646 BlockType::Title => {
647 let content = builder
648 .content
649 .ok_or_else(|| Self::missing_error(builder.block_type, "content"))?;
650 let level = builder
651 .level
652 .ok_or_else(|| Self::missing_error(builder.block_type, "level"))?;
653
654 Block::Title {
655 content,
656 footnotes: builder.footnotes,
657 level,
658 }
659 }
660
661 BlockType::Image => {
662 let url = builder
663 .url
664 .ok_or_else(|| Self::missing_error(builder.block_type, "url"))?;
665
666 Block::Image {
667 url,
668 alt: builder.alt,
669 caption: builder.caption,
670 footnotes: builder.footnotes,
671 }
672 }
673
674 BlockType::Audio => {
675 let url = builder
676 .url
677 .ok_or_else(|| Self::missing_error(builder.block_type, "url"))?;
678 let fallback = builder
679 .fallback
680 .ok_or_else(|| Self::missing_error(builder.block_type, "fallback"))?;
681
682 Block::Audio {
683 url,
684 fallback,
685 caption: builder.caption,
686 footnotes: builder.footnotes,
687 }
688 }
689
690 BlockType::Video => {
691 let url = builder
692 .url
693 .ok_or_else(|| Self::missing_error(builder.block_type, "url"))?;
694 let fallback = builder
695 .fallback
696 .ok_or_else(|| Self::missing_error(builder.block_type, "fallback"))?;
697
698 Block::Video {
699 url,
700 fallback,
701 caption: builder.caption,
702 footnotes: builder.footnotes,
703 }
704 }
705
706 BlockType::MathML => {
707 let element_str = builder
708 .element_str
709 .ok_or_else(|| Self::missing_error(builder.block_type, "element_str"))?;
710
711 Block::MathML {
712 element_str,
713 fallback_image: builder.fallback_image,
714 caption: builder.caption,
715 footnotes: builder.footnotes,
716 }
717 }
718 };
719
720 block.validate_footnotes()?;
721 Ok(block)
722 }
723}
724
725#[derive(Debug)]
752pub struct BlockBuilder {
753 block_type: BlockType,
755
756 content: Option<String>,
758
759 level: Option<usize>,
761
762 url: Option<PathBuf>,
764
765 alt: Option<String>,
767
768 caption: Option<String>,
770
771 fallback: Option<String>,
773
774 element_str: Option<String>,
776
777 fallback_image: Option<PathBuf>,
779
780 footnotes: Vec<Footnote>,
782}
783
784impl BlockBuilder {
785 pub fn new(block_type: BlockType) -> Self {
792 Self {
793 block_type,
794 content: None,
795 level: None,
796 url: None,
797 alt: None,
798 caption: None,
799 fallback: None,
800 element_str: None,
801 fallback_image: None,
802 footnotes: vec![],
803 }
804 }
805
806 pub fn set_content(&mut self, content: &str) -> &mut Self {
813 self.content = Some(content.to_string());
814 self
815 }
816
817 pub fn set_title_level(&mut self, level: usize) -> &mut Self {
826 if !(1..=6).contains(&level) {
827 return self;
828 }
829
830 self.level = Some(level);
831 self
832 }
833
834 pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
846 match Self::is_target_type(
847 url,
848 vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
849 ) {
850 Ok(_) => {
851 self.url = Some(url.to_path_buf());
852 Ok(self)
853 }
854 Err(err) => Err(err),
855 }
856 }
857
858 pub fn set_alt(&mut self, alt: &str) -> &mut Self {
866 self.alt = Some(alt.to_string());
867 self
868 }
869
870 pub fn set_caption(&mut self, caption: &str) -> &mut Self {
878 self.caption = Some(caption.to_string());
879 self
880 }
881
882 pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
890 self.fallback = Some(fallback.to_string());
891 self
892 }
893
894 pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
903 self.element_str = Some(element_str.to_string());
904 self
905 }
906
907 pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
920 match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
921 Ok(_) => {
922 self.fallback_image = Some(fallback_image);
923 Ok(self)
924 }
925 Err(err) => Err(err),
926 }
927 }
928
929 pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
937 self.footnotes.push(footnote);
938 self
939 }
940
941 pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
949 self.footnotes = footnotes;
950 self
951 }
952
953 #[deprecated(since = "0.2.0", note = "use `try_into()` instead")]
963 pub fn build(self) -> Result<Block, EpubError> {
964 self.try_into()
965 }
966
967 fn is_target_type(path: impl AsRef<Path>, types: Vec<MatcherType>) -> Result<(), EpubError> {
969 let path = path.as_ref();
970 if !path.is_file() {
971 return Err(EpubBuilderError::TargetIsNotFile {
972 target_path: path.to_string_lossy().to_string(),
973 }
974 .into());
975 }
976
977 let mut file = File::open(path)?;
978 let mut buf = [0; 512];
979 let read_size = file.read(&mut buf)?;
980 let header_bytes = &buf[..read_size];
981
982 match Infer::new().get(header_bytes) {
983 Some(file_type) if !types.contains(&file_type.matcher_type()) => {
984 Err(EpubBuilderError::NotExpectedFileFormat.into())
985 }
986
987 None => Err(EpubBuilderError::UnknownFileFormat {
988 file_path: path.to_string_lossy().to_string(),
989 }
990 .into()),
991
992 _ => Ok(()),
993 }
994 }
995}
996
997#[derive(Debug)]
1006pub struct ContentBuilder {
1007 pub id: String,
1013
1014 blocks: Vec<Block>,
1015 language: String,
1016 title: String,
1017 styles: StyleOptions,
1018
1019 pub(crate) temp_dir: PathBuf,
1020 pub(crate) css_files: Vec<PathBuf>,
1021}
1022
1023impl ContentBuilder {
1024 pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
1034 let temp_dir = env::temp_dir().join(local_time());
1035 fs::create_dir(&temp_dir)?;
1036
1037 Ok(Self {
1038 id: id.to_string(),
1039 blocks: vec![],
1040 language: language.to_string(),
1041 title: String::new(),
1042 styles: StyleOptions::default(),
1043 temp_dir,
1044 css_files: vec![],
1045 })
1046 }
1047
1048 pub fn set_title(&mut self, title: &str) -> &mut Self {
1050 self.title = title.to_string();
1051 self
1052 }
1053
1054 pub fn set_styles(&mut self, styles: StyleOptions) -> &mut Self {
1056 self.styles = styles;
1057 self
1058 }
1059
1060 pub fn add_css_file(&mut self, css_path: PathBuf) -> Result<&mut Self, EpubError> {
1072 if !css_path.is_file() {
1073 return Err(EpubBuilderError::TargetIsNotFile {
1074 target_path: css_path.to_string_lossy().to_string(),
1075 }
1076 .into());
1077 }
1078
1079 let file_name = css_path.file_name().unwrap().to_string_lossy().to_string();
1081 let target_dir = self.temp_dir.join("css");
1082 fs::create_dir_all(&target_dir)?;
1083
1084 let target_path = target_dir.join(&file_name);
1085 fs::copy(&css_path, &target_path)?;
1086 self.css_files.push(target_path);
1087 Ok(self)
1088 }
1089
1090 pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
1097 self.blocks.push(block);
1098
1099 match self.blocks.last() {
1100 Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
1101 self.handle_resource()?
1102 }
1103
1104 Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1105 self.handle_resource()?;
1106 }
1107
1108 _ => {}
1109 }
1110
1111 Ok(self)
1112 }
1113
1114 pub fn add_text_block(
1122 &mut self,
1123 content: &str,
1124 footnotes: Vec<Footnote>,
1125 ) -> Result<&mut Self, EpubError> {
1126 let mut builder = BlockBuilder::new(BlockType::Text);
1127 builder.set_content(content).set_footnotes(footnotes);
1128
1129 self.blocks.push(builder.try_into()?);
1130 Ok(self)
1131 }
1132
1133 pub fn add_quote_block(
1141 &mut self,
1142 content: &str,
1143 footnotes: Vec<Footnote>,
1144 ) -> Result<&mut Self, EpubError> {
1145 let mut builder = BlockBuilder::new(BlockType::Quote);
1146 builder.set_content(content).set_footnotes(footnotes);
1147
1148 self.blocks.push(builder.try_into()?);
1149 Ok(self)
1150 }
1151
1152 pub fn add_title_block(
1161 &mut self,
1162 content: &str,
1163 level: usize,
1164 footnotes: Vec<Footnote>,
1165 ) -> Result<&mut Self, EpubError> {
1166 let mut builder = BlockBuilder::new(BlockType::Title);
1167 builder
1168 .set_content(content)
1169 .set_title_level(level)
1170 .set_footnotes(footnotes);
1171
1172 self.blocks.push(builder.try_into()?);
1173 Ok(self)
1174 }
1175
1176 pub fn add_image_block(
1187 &mut self,
1188 url: PathBuf,
1189 alt: Option<String>,
1190 caption: Option<String>,
1191 footnotes: Vec<Footnote>,
1192 ) -> Result<&mut Self, EpubError> {
1193 let mut builder = BlockBuilder::new(BlockType::Image);
1194 builder.set_url(&url)?.set_footnotes(footnotes);
1195
1196 if let Some(alt) = &alt {
1197 builder.set_alt(alt);
1198 }
1199
1200 if let Some(caption) = &caption {
1201 builder.set_caption(caption);
1202 }
1203
1204 self.blocks.push(builder.try_into()?);
1205 self.handle_resource()?;
1206 Ok(self)
1207 }
1208
1209 pub fn add_audio_block(
1220 &mut self,
1221 url: PathBuf,
1222 fallback: String,
1223 caption: Option<String>,
1224 footnotes: Vec<Footnote>,
1225 ) -> Result<&mut Self, EpubError> {
1226 let mut builder = BlockBuilder::new(BlockType::Audio);
1227 builder
1228 .set_url(&url)?
1229 .set_fallback(&fallback)
1230 .set_footnotes(footnotes);
1231
1232 if let Some(caption) = &caption {
1233 builder.set_caption(caption);
1234 }
1235
1236 self.blocks.push(builder.try_into()?);
1237 self.handle_resource()?;
1238 Ok(self)
1239 }
1240
1241 pub fn add_video_block(
1252 &mut self,
1253 url: PathBuf,
1254 fallback: String,
1255 caption: Option<String>,
1256 footnotes: Vec<Footnote>,
1257 ) -> Result<&mut Self, EpubError> {
1258 let mut builder = BlockBuilder::new(BlockType::Video);
1259 builder
1260 .set_url(&url)?
1261 .set_fallback(&fallback)
1262 .set_footnotes(footnotes);
1263
1264 if let Some(caption) = &caption {
1265 builder.set_caption(caption);
1266 }
1267
1268 self.blocks.push(builder.try_into()?);
1269 self.handle_resource()?;
1270 Ok(self)
1271 }
1272
1273 pub fn add_mathml_block(
1284 &mut self,
1285 element_str: String,
1286 fallback_image: Option<PathBuf>,
1287 caption: Option<String>,
1288 footnotes: Vec<Footnote>,
1289 ) -> Result<&mut Self, EpubError> {
1290 let mut builder = BlockBuilder::new(BlockType::MathML);
1291 builder
1292 .set_mathml_element(&element_str)
1293 .set_footnotes(footnotes);
1294
1295 if let Some(fallback_image) = fallback_image {
1296 builder.set_fallback_image(fallback_image)?;
1297 }
1298
1299 if let Some(caption) = &caption {
1300 builder.set_caption(caption);
1301 }
1302
1303 self.blocks.push(builder.try_into()?);
1304 self.handle_resource()?;
1305 Ok(self)
1306 }
1307
1308 pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1338 let mut result = Vec::new();
1339
1340 let target_dir = match target.as_ref().parent() {
1342 Some(path) => {
1343 fs::create_dir_all(path)?;
1344 path.to_path_buf()
1345 }
1346 None => {
1347 return Err(EpubBuilderError::InvalidTargetPath {
1348 target_path: target.as_ref().to_string_lossy().to_string(),
1349 }
1350 .into());
1351 }
1352 };
1353
1354 self.make_content(&target)?;
1355 result.push(target.as_ref().to_path_buf());
1356
1357 for resource_type in ["img", "audio", "video", "css"] {
1359 let source = self.temp_dir.join(resource_type);
1360 if !source.is_dir() {
1361 continue;
1362 }
1363
1364 let target = target_dir.join(resource_type);
1365 fs::create_dir_all(&target)?;
1366
1367 for entry in WalkDir::new(&source)
1368 .min_depth(1)
1369 .into_iter()
1370 .filter_map(|result| result.ok())
1371 .filter(|entry| entry.file_type().is_file())
1372 {
1373 let file_name = entry.file_name();
1374 let target = target.join(file_name);
1375
1376 fs::copy(entry.path(), &target)?;
1377 result.push(target);
1378 }
1379 }
1380
1381 Ok(result)
1382 }
1383
1384 fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1391 let mut writer = Writer::new(Cursor::new(Vec::new()));
1392
1393 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1394 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1395 ("xmlns", "http://www.w3.org/1999/xhtml"),
1396 ("xml:lang", self.language.as_str()),
1397 ])))?;
1398
1399 writer.write_event(Event::Start(BytesStart::new("head")))?;
1401 writer.write_event(Event::Start(BytesStart::new("title")))?;
1402 writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1403 writer.write_event(Event::End(BytesEnd::new("title")))?;
1404
1405 if self.css_files.is_empty() {
1406 self.make_style(&mut writer)?;
1407 } else {
1408 for css_file in self.css_files.iter() {
1409 let file_name = css_file.file_name().unwrap().to_string_lossy().to_string();
1411
1412 writer.write_event(Event::Empty(BytesStart::new("link").with_attributes([
1413 ("href", format!("./css/{}", file_name).as_str()),
1414 ("rel", "stylesheet"),
1415 ("type", "text/css"),
1416 ])))?;
1417 }
1418 }
1419
1420 writer.write_event(Event::End(BytesEnd::new("head")))?;
1421
1422 writer.write_event(Event::Start(BytesStart::new("body")))?;
1424 writer.write_event(Event::Start(BytesStart::new("main")))?;
1425
1426 let mut footnote_index = 1;
1427 let mut footnotes = Vec::new();
1428 for block in self.blocks.iter_mut() {
1429 block.make(&mut writer, footnote_index)?;
1430
1431 footnotes.append(&mut block.take_footnotes());
1432 footnote_index = footnotes.len() + 1;
1433 }
1434
1435 writer.write_event(Event::End(BytesEnd::new("main")))?;
1436
1437 Self::make_footnotes(&mut writer, footnotes)?;
1438 writer.write_event(Event::End(BytesEnd::new("body")))?;
1439 writer.write_event(Event::End(BytesEnd::new("html")))?;
1440
1441 let file_path = PathBuf::from(target_path.as_ref());
1442 let file_data = writer.into_inner().into_inner();
1443 fs::write(file_path, file_data)?;
1444
1445 Ok(())
1446 }
1447
1448 fn make_style(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
1450 let style = format!(
1451 r#"
1452 * {{
1453 margin: 0;
1454 padding: 0;
1455 font-family: {font_family};
1456 text-align: {text_align};
1457 background-color: {background};
1458 color: {text};
1459 }}
1460 body, p, div, span, li, td, th {{
1461 font-size: {font_size}rem;
1462 line-height: {line_height}em;
1463 font-weight: {font_weight};
1464 font-style: {font_style};
1465 letter-spacing: {letter_spacing};
1466 }}
1467 body {{ margin: {margin}px; }}
1468 p {{ text-indent: {text_indent}em; }}
1469 a {{ color: {link_color}; text-decoration: none; }}
1470 figcaption {{ text-align: center; line-height: 1em; }}
1471 blockquote {{ padding: 1em 2em; }}
1472 blockquote > p {{ font-style: italic; }}
1473 .content-block {{ margin-bottom: {paragraph_spacing}px; }}
1474 .image-block > img,
1475 .audio-block > audio,
1476 .video-block > video {{ width: 100%; }}
1477 .footnote-ref {{ font-size: 0.5em; vertical-align: super; }}
1478 .footnote-list {{ list-style: none; padding: 0; }}
1479 .footnote-item > p {{ text-indent: 0; }}
1480 "#,
1481 font_family = self.styles.text.font_family,
1482 text_align = self.styles.layout.text_align,
1483 background = self.styles.color_scheme.background,
1484 text = self.styles.color_scheme.text,
1485 font_size = self.styles.text.font_size,
1486 line_height = self.styles.text.line_height,
1487 font_weight = self.styles.text.font_weight,
1488 font_style = self.styles.text.font_style,
1489 letter_spacing = self.styles.text.letter_spacing,
1490 margin = self.styles.layout.margin,
1491 text_indent = self.styles.text.text_indent,
1492 link_color = self.styles.color_scheme.link,
1493 paragraph_spacing = self.styles.layout.paragraph_spacing,
1494 );
1495
1496 writer.write_event(Event::Start(BytesStart::new("style")))?;
1497 writer.write_event(Event::Text(BytesText::new(&style)))?;
1498 writer.write_event(Event::End(BytesEnd::new("style")))?;
1499
1500 Ok(())
1501 }
1502
1503 fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1508 writer.write_event(Event::Start(BytesStart::new("aside")))?;
1509 writer.write_event(Event::Start(
1510 BytesStart::new("ul").with_attributes([("class", "footnote-list")]),
1511 ))?;
1512
1513 let mut index = 1;
1514 for footnote in footnotes.into_iter() {
1515 writer.write_event(Event::Start(BytesStart::new("li").with_attributes([
1516 ("id", format!("footnote-{}", index).as_str()),
1517 ("class", "footnote-item"),
1518 ])))?;
1519 writer.write_event(Event::Start(BytesStart::new("p")))?;
1520
1521 writer.write_event(Event::Start(
1522 BytesStart::new("a")
1523 .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1524 ))?;
1525 writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1526 writer.write_event(Event::End(BytesEnd::new("a")))?;
1527 writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1528
1529 writer.write_event(Event::End(BytesEnd::new("p")))?;
1530 writer.write_event(Event::End(BytesEnd::new("li")))?;
1531
1532 index += 1;
1533 }
1534
1535 writer.write_event(Event::End(BytesEnd::new("ul")))?;
1536 writer.write_event(Event::End(BytesEnd::new("aside")))?;
1537
1538 Ok(())
1539 }
1540
1541 fn handle_resource(&mut self) -> Result<(), EpubError> {
1546 match self.blocks.last() {
1547 Some(Block::Image { url, .. }) => self.copy_to_temp(url, "img")?,
1548
1549 Some(Block::Video { url, .. }) => self.copy_to_temp(url, "video")?,
1550
1551 Some(Block::Audio { url, .. }) => self.copy_to_temp(url, "audio")?,
1552
1553 Some(Block::MathML { fallback_image: Some(url), .. }) => {
1554 self.copy_to_temp(url, "img")?
1555 }
1556
1557 _ => {}
1558 }
1559
1560 Ok(())
1561 }
1562
1563 #[inline]
1564 fn copy_to_temp(&self, source: impl AsRef<Path>, resource_type: &str) -> Result<(), EpubError> {
1565 let target_dir = self.temp_dir.join(resource_type);
1566 fs::create_dir_all(&target_dir)?;
1567
1568 let source = source.as_ref();
1569 let target_path = target_dir.join(source.file_name().unwrap());
1570
1571 fs::copy(source, &target_path)?;
1572 Ok(())
1573 }
1574}
1575
1576impl Drop for ContentBuilder {
1577 fn drop(&mut self) {
1578 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1579 warn!("{}", err);
1580 };
1581 }
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586 mod block_builder_tests {
1587 use std::path::PathBuf;
1588
1589 use crate::{
1590 builder::content::{Block, BlockBuilder},
1591 error::{EpubBuilderError, EpubError},
1592 types::{BlockType, Footnote},
1593 };
1594
1595 #[test]
1596 fn test_create_text_block() {
1597 let mut builder = BlockBuilder::new(BlockType::Text);
1598 builder.set_content("Hello, World!");
1599
1600 let block = builder.try_into();
1601 assert!(block.is_ok());
1602
1603 let block = block.unwrap();
1604 match block {
1605 Block::Text { content, footnotes } => {
1606 assert_eq!(content, "Hello, World!");
1607 assert!(footnotes.is_empty());
1608 }
1609 _ => unreachable!(),
1610 }
1611 }
1612
1613 #[test]
1614 fn test_create_text_block_missing_content() {
1615 let builder = BlockBuilder::new(BlockType::Text);
1616
1617 let block: Result<Block, EpubError> = builder.try_into();
1618 assert!(block.is_err());
1619
1620 let result = block.unwrap_err();
1621 assert_eq!(
1622 result,
1623 EpubBuilderError::MissingNecessaryBlockData {
1624 block_type: "Text".to_string(),
1625 missing_data: "'content'".to_string()
1626 }
1627 .into()
1628 )
1629 }
1630
1631 #[test]
1632 fn test_create_quote_block() {
1633 let mut builder = BlockBuilder::new(BlockType::Quote);
1634 builder.set_content("To be or not to be");
1635
1636 let block: Result<Block, EpubError> = builder.try_into();
1637 assert!(block.is_ok());
1638
1639 let block = block.unwrap();
1640 match block {
1641 Block::Quote { content, footnotes } => {
1642 assert_eq!(content, "To be or not to be");
1643 assert!(footnotes.is_empty());
1644 }
1645 _ => unreachable!(),
1646 }
1647 }
1648
1649 #[test]
1650 fn test_create_title_block() {
1651 let mut builder = BlockBuilder::new(BlockType::Title);
1652 builder.set_content("Chapter 1").set_title_level(2);
1653
1654 let block: Result<Block, EpubError> = builder.try_into();
1655 assert!(block.is_ok());
1656
1657 let block = block.unwrap();
1658 match block {
1659 Block::Title { content, level, footnotes } => {
1660 assert_eq!(content, "Chapter 1");
1661 assert_eq!(level, 2);
1662 assert!(footnotes.is_empty());
1663 }
1664 _ => unreachable!(),
1665 }
1666 }
1667
1668 #[test]
1669 fn test_create_title_block_invalid_level() {
1670 let mut builder = BlockBuilder::new(BlockType::Title);
1671 builder.set_content("Chapter 1").set_title_level(10);
1672
1673 let result: Result<Block, EpubError> = builder.try_into();
1674 assert!(result.is_err());
1675
1676 let result = result.unwrap_err();
1677 assert_eq!(
1678 result,
1679 EpubBuilderError::MissingNecessaryBlockData {
1680 block_type: "Title".to_string(),
1681 missing_data: "'level'".to_string(),
1682 }
1683 .into()
1684 );
1685 }
1686
1687 #[test]
1688 fn test_create_image_block() {
1689 let img_path = PathBuf::from("./test_case/image.jpg");
1690 let mut builder = BlockBuilder::new(BlockType::Image);
1691 builder
1692 .set_url(&img_path)
1693 .unwrap()
1694 .set_alt("Test Image")
1695 .set_caption("A test image");
1696
1697 let block: Result<Block, EpubError> = builder.try_into();
1698 assert!(block.is_ok());
1699
1700 let block = block.unwrap();
1701 match block {
1702 Block::Image { url, alt, caption, footnotes } => {
1703 assert_eq!(url.file_name().unwrap(), "image.jpg");
1704 assert_eq!(alt, Some("Test Image".to_string()));
1705 assert_eq!(caption, Some("A test image".to_string()));
1706 assert!(footnotes.is_empty());
1707 }
1708 _ => unreachable!(),
1709 }
1710 }
1711
1712 #[test]
1713 fn test_create_image_block_missing_url() {
1714 let builder = BlockBuilder::new(BlockType::Image);
1715
1716 let block: Result<Block, EpubError> = builder.try_into();
1717 assert!(block.is_err());
1718
1719 let result = block.unwrap_err();
1720 assert_eq!(
1721 result,
1722 EpubBuilderError::MissingNecessaryBlockData {
1723 block_type: "Image".to_string(),
1724 missing_data: "'url'".to_string(),
1725 }
1726 .into()
1727 );
1728 }
1729
1730 #[test]
1731 fn test_create_audio_block() {
1732 let audio_path = PathBuf::from("./test_case/audio.mp3");
1733 let mut builder = BlockBuilder::new(BlockType::Audio);
1734 builder
1735 .set_url(&audio_path)
1736 .unwrap()
1737 .set_fallback("Audio not supported")
1738 .set_caption("Background music");
1739
1740 let block = builder.try_into();
1741 assert!(block.is_ok());
1742
1743 let block = block.unwrap();
1744 match block {
1745 Block::Audio { url, fallback, caption, footnotes } => {
1746 assert_eq!(url.file_name().unwrap(), "audio.mp3");
1747 assert_eq!(fallback, "Audio not supported");
1748 assert_eq!(caption, Some("Background music".to_string()));
1749 assert!(footnotes.is_empty());
1750 }
1751 _ => unreachable!(),
1752 }
1753 }
1754
1755 #[test]
1756 fn test_set_url_invalid_file_type() {
1757 let xhtml_path = PathBuf::from("./test_case/Overview.xhtml");
1758 let mut builder = BlockBuilder::new(BlockType::Image);
1759 let result = builder.set_url(&xhtml_path);
1760 assert!(result.is_err());
1761
1762 let err = result.unwrap_err();
1763 assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1764 }
1765
1766 #[test]
1767 fn test_set_url_nonexistent_file() {
1768 let nonexistent_path = PathBuf::from("./test_case/nonexistent.jpg");
1769 let mut builder = BlockBuilder::new(BlockType::Image);
1770 let result = builder.set_url(&nonexistent_path);
1771 assert!(result.is_err());
1772
1773 let err = result.unwrap_err();
1774 assert_eq!(
1775 err,
1776 EpubBuilderError::TargetIsNotFile {
1777 target_path: "./test_case/nonexistent.jpg".to_string()
1778 }
1779 .into()
1780 );
1781 }
1782
1783 #[test]
1784 fn test_set_fallback_image_invalid_type() {
1785 let audio_path = PathBuf::from("./test_case/audio.mp3");
1786 let mut builder = BlockBuilder::new(BlockType::MathML);
1787 builder.set_mathml_element("<math/>");
1788 let result = builder.set_fallback_image(audio_path);
1789 assert!(result.is_err());
1790
1791 let err = result.unwrap_err();
1792 assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1793 }
1794
1795 #[test]
1796 fn test_set_fallback_image_nonexistent() {
1797 let nonexistent_path = PathBuf::from("./test_case/nonexistent.png");
1798 let mut builder = BlockBuilder::new(BlockType::MathML);
1799 builder.set_mathml_element("<math/>");
1800 let result = builder.set_fallback_image(nonexistent_path);
1801 assert!(result.is_err());
1802
1803 let err = result.unwrap_err();
1804 assert_eq!(
1805 err,
1806 EpubBuilderError::TargetIsNotFile {
1807 target_path: "./test_case/nonexistent.png".to_string()
1808 }
1809 .into()
1810 );
1811 }
1812
1813 #[test]
1814 fn test_create_video_block() {
1815 let video_path = PathBuf::from("./test_case/video.mp4");
1816 let mut builder = BlockBuilder::new(BlockType::Video);
1817 builder
1818 .set_url(&video_path)
1819 .unwrap()
1820 .set_fallback("Video not supported")
1821 .set_caption("Demo video");
1822
1823 let block = builder.try_into();
1824 assert!(block.is_ok());
1825
1826 let block = block.unwrap();
1827 match block {
1828 Block::Video { url, fallback, caption, footnotes } => {
1829 assert_eq!(url.file_name().unwrap(), "video.mp4");
1830 assert_eq!(fallback, "Video not supported");
1831 assert_eq!(caption, Some("Demo video".to_string()));
1832 assert!(footnotes.is_empty());
1833 }
1834 _ => unreachable!(),
1835 }
1836 }
1837
1838 #[test]
1839 fn test_create_mathml_block() {
1840 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
1841 let mut builder = BlockBuilder::new(BlockType::MathML);
1842 builder
1843 .set_mathml_element(mathml_content)
1844 .set_caption("Simple equation");
1845
1846 let block = builder.try_into();
1847 assert!(block.is_ok());
1848
1849 let block = block.unwrap();
1850 match block {
1851 Block::MathML {
1852 element_str,
1853 fallback_image,
1854 caption,
1855 footnotes,
1856 } => {
1857 assert_eq!(element_str, mathml_content);
1858 assert!(fallback_image.is_none());
1859 assert_eq!(caption, Some("Simple equation".to_string()));
1860 assert!(footnotes.is_empty());
1861 }
1862 _ => unreachable!(),
1863 }
1864 }
1865
1866 #[test]
1867 fn test_create_mathml_block_with_fallback() {
1868 let img_path = PathBuf::from("./test_case/image.jpg");
1869 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
1870
1871 let mut builder = BlockBuilder::new(BlockType::MathML);
1872 builder
1873 .set_mathml_element(mathml_content)
1874 .set_fallback_image(img_path.clone())
1875 .unwrap();
1876
1877 let block = builder.try_into();
1878 assert!(block.is_ok());
1879
1880 let block = block.unwrap();
1881 match block {
1882 Block::MathML { element_str, fallback_image, .. } => {
1883 assert_eq!(element_str, mathml_content);
1884 assert!(fallback_image.is_some());
1885 }
1886 _ => unreachable!(),
1887 }
1888 }
1889
1890 #[test]
1891 fn test_footnote_management() {
1892 let mut builder = BlockBuilder::new(BlockType::Text);
1893 builder.set_content("This is a test");
1894
1895 let note1 = Footnote {
1896 locate: 5,
1897 content: "First footnote".to_string(),
1898 };
1899 let note2 = Footnote {
1900 locate: 10,
1901 content: "Second footnote".to_string(),
1902 };
1903
1904 builder.add_footnote(note1).add_footnote(note2);
1905
1906 let block = builder.try_into();
1907 assert!(block.is_ok());
1908
1909 let block = block.unwrap();
1910 match block {
1911 Block::Text { footnotes, .. } => {
1912 assert_eq!(footnotes.len(), 2);
1913 }
1914 _ => unreachable!(),
1915 }
1916 }
1917
1918 #[test]
1919 fn test_invalid_footnote_locate() {
1920 let mut builder = BlockBuilder::new(BlockType::Text);
1921 builder.set_content("Hello");
1922
1923 builder.add_footnote(Footnote {
1925 locate: 100,
1926 content: "Invalid footnote".to_string(),
1927 });
1928
1929 let result: Result<Block, EpubError> = builder.try_into();
1930 assert!(result.is_err());
1931
1932 let result = result.unwrap_err();
1933 assert_eq!(
1934 result,
1935 EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
1936 );
1937 }
1938
1939 #[test]
1940 fn test_footnote_on_media_without_caption() {
1941 let img_path = PathBuf::from("./test_case/image.jpg");
1942 let mut builder = BlockBuilder::new(BlockType::Image);
1943 builder.set_url(&img_path).unwrap();
1944
1945 builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
1946
1947 let result: Result<Block, EpubError> = builder.try_into();
1948 assert!(result.is_err());
1949
1950 let result = result.unwrap_err();
1951 assert_eq!(
1952 result,
1953 EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
1954 );
1955 }
1956 }
1957
1958 mod content_builder_tests {
1959 use std::{env, fs, path::PathBuf};
1960
1961 use crate::{
1962 builder::content::ContentBuilder,
1963 types::{ColorScheme, Footnote, PageLayout, TextAlign, TextStyle},
1964 utils::local_time,
1965 };
1966
1967 #[test]
1968 fn test_create_content_builder() {
1969 let builder = ContentBuilder::new("chapter1", "en");
1970 assert!(builder.is_ok());
1971
1972 let builder = builder.unwrap();
1973 assert_eq!(builder.id, "chapter1");
1974 }
1975
1976 #[test]
1977 fn test_set_title() {
1978 let builder = ContentBuilder::new("chapter1", "en");
1979 assert!(builder.is_ok());
1980
1981 let mut builder = builder.unwrap();
1982 builder.set_title("My Chapter").set_title("Another Title");
1983
1984 assert_eq!(builder.title, "Another Title");
1985 }
1986
1987 #[test]
1988 fn test_add_text_block() {
1989 let builder = ContentBuilder::new("chapter1", "en");
1990 assert!(builder.is_ok());
1991
1992 let mut builder = builder.unwrap();
1993 let result = builder.add_text_block("This is a paragraph", vec![]);
1994 assert!(result.is_ok());
1995 }
1996
1997 #[test]
1998 fn test_add_quote_block() {
1999 let builder = ContentBuilder::new("chapter1", "en");
2000 assert!(builder.is_ok());
2001
2002 let mut builder = builder.unwrap();
2003 let result = builder.add_quote_block("A quoted text", vec![]);
2004 assert!(result.is_ok());
2005 }
2006
2007 #[test]
2008 fn test_set_styles() {
2009 let builder = ContentBuilder::new("chapter1", "en");
2010 assert!(builder.is_ok());
2011
2012 let custom_styles = crate::types::StyleOptions {
2013 text: TextStyle {
2014 font_size: 1.5,
2015 line_height: 1.8,
2016 font_family: "Georgia, serif".to_string(),
2017 font_weight: "bold".to_string(),
2018 font_style: "italic".to_string(),
2019 letter_spacing: "0.1em".to_string(),
2020 text_indent: 1.5,
2021 },
2022 color_scheme: ColorScheme {
2023 background: "#F5F5F5".to_string(),
2024 text: "#333333".to_string(),
2025 link: "#0066CC".to_string(),
2026 },
2027 layout: PageLayout {
2028 margin: 30,
2029 text_align: TextAlign::Center,
2030 paragraph_spacing: 20,
2031 },
2032 };
2033
2034 let mut builder = builder.unwrap();
2035 builder.set_styles(custom_styles);
2036
2037 assert_eq!(builder.styles.text.font_size, 1.5);
2038 assert_eq!(builder.styles.text.font_weight, "bold");
2039 assert_eq!(builder.styles.color_scheme.background, "#F5F5F5");
2040 assert_eq!(builder.styles.layout.text_align, TextAlign::Center);
2041 }
2042
2043 #[test]
2044 fn test_add_title_block() {
2045 let builder = ContentBuilder::new("chapter1", "en");
2046 assert!(builder.is_ok());
2047
2048 let mut builder = builder.unwrap();
2049 let result = builder.add_title_block("Section Title", 2, vec![]);
2050 assert!(result.is_ok());
2051 }
2052
2053 #[test]
2054 fn test_add_image_block() {
2055 let img_path = PathBuf::from("./test_case/image.jpg");
2056 let builder = ContentBuilder::new("chapter1", "en");
2057 assert!(builder.is_ok());
2058
2059 let mut builder = builder.unwrap();
2060 let result = builder.add_image_block(
2061 img_path,
2062 Some("Alt text".to_string()),
2063 Some("Figure 1: An image".to_string()),
2064 vec![],
2065 );
2066
2067 assert!(result.is_ok());
2068 }
2069
2070 #[test]
2071 fn test_add_audio_block() {
2072 let audio_path = PathBuf::from("./test_case/audio.mp3");
2073 let builder = ContentBuilder::new("chapter1", "en");
2074 assert!(builder.is_ok());
2075
2076 let mut builder = builder.unwrap();
2077 let result = builder.add_audio_block(
2078 audio_path,
2079 "Your browser doesn't support audio".to_string(),
2080 Some("Background music".to_string()),
2081 vec![],
2082 );
2083
2084 assert!(result.is_ok());
2085 }
2086
2087 #[test]
2088 fn test_add_video_block() {
2089 let video_path = PathBuf::from("./test_case/video.mp4");
2090 let builder = ContentBuilder::new("chapter1", "en");
2091 assert!(builder.is_ok());
2092
2093 let mut builder = builder.unwrap();
2094 let result = builder.add_video_block(
2095 video_path,
2096 "Your browser doesn't support video".to_string(),
2097 Some("Tutorial video".to_string()),
2098 vec![],
2099 );
2100
2101 assert!(result.is_ok());
2102 }
2103
2104 #[test]
2105 fn test_add_mathml_block() {
2106 let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
2107 let builder = ContentBuilder::new("chapter1", "en");
2108 assert!(builder.is_ok());
2109
2110 let mut builder = builder.unwrap();
2111 let result = builder.add_mathml_block(
2112 mathml.to_string(),
2113 None,
2114 Some("Equation 1".to_string()),
2115 vec![],
2116 );
2117
2118 assert!(result.is_ok());
2119 }
2120
2121 #[test]
2122 fn test_make_content_document() {
2123 let temp_dir = env::temp_dir().join(local_time());
2124 assert!(fs::create_dir_all(&temp_dir).is_ok());
2125
2126 let output_path = temp_dir.join("chapter.xhtml");
2127
2128 let builder = ContentBuilder::new("chapter1", "en");
2129 assert!(builder.is_ok());
2130
2131 let mut builder = builder.unwrap();
2132 builder
2133 .set_title("My Chapter")
2134 .add_text_block("This is the first paragraph.", vec![])
2135 .unwrap()
2136 .add_text_block("This is the second paragraph.", vec![])
2137 .unwrap();
2138
2139 let result = builder.make(&output_path);
2140 assert!(result.is_ok());
2141 assert!(output_path.exists());
2142 assert!(fs::remove_dir_all(temp_dir).is_ok());
2143 }
2144
2145 #[test]
2146 fn test_make_content_with_media() {
2147 let temp_dir = env::temp_dir().join(local_time());
2148 assert!(fs::create_dir_all(&temp_dir).is_ok());
2149
2150 let output_path = temp_dir.join("chapter.xhtml");
2151 let img_path = PathBuf::from("./test_case/image.jpg");
2152
2153 let builder = ContentBuilder::new("chapter1", "en");
2154 assert!(builder.is_ok());
2155
2156 let mut builder = builder.unwrap();
2157 builder
2158 .set_title("Chapter with Media")
2159 .add_text_block("See image below:", vec![])
2160 .unwrap()
2161 .add_image_block(
2162 img_path,
2163 Some("Test".to_string()),
2164 Some("Figure 1".to_string()),
2165 vec![],
2166 )
2167 .unwrap();
2168
2169 let result = builder.make(&output_path);
2170 assert!(result.is_ok());
2171
2172 let img_dir = temp_dir.join("img");
2173 assert!(img_dir.exists());
2174 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2175 }
2176
2177 #[test]
2178 fn test_make_content_with_footnotes() {
2179 let temp_dir = env::temp_dir().join(local_time());
2180 assert!(fs::create_dir_all(&temp_dir).is_ok());
2181
2182 let output_path = temp_dir.join("chapter.xhtml");
2183
2184 let footnotes = vec![
2185 Footnote {
2186 locate: 10,
2187 content: "This is a footnote".to_string(),
2188 },
2189 Footnote {
2190 locate: 15,
2191 content: "Another footnote".to_string(),
2192 },
2193 ];
2194
2195 let builder = ContentBuilder::new("chapter1", "en");
2196 assert!(builder.is_ok());
2197
2198 let mut builder = builder.unwrap();
2199 builder
2200 .set_title("Chapter with Notes")
2201 .add_text_block("This is a paragraph with notes.", footnotes)
2202 .unwrap();
2203
2204 let result = builder.make(&output_path);
2205 assert!(result.is_ok());
2206 assert!(output_path.exists());
2207 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2208 }
2209
2210 #[test]
2211 fn test_add_css_file() {
2212 let builder = ContentBuilder::new("chapter1", "en");
2213 assert!(builder.is_ok());
2214
2215 let mut builder = builder.unwrap();
2216 let result = builder.add_css_file(PathBuf::from("./test_case/style.css"));
2217
2218 assert!(result.is_ok());
2219 assert_eq!(builder.css_files.len(), 1);
2220 }
2221
2222 #[test]
2223 fn test_add_css_file_nonexistent() {
2224 let builder = ContentBuilder::new("chapter1", "en");
2225 assert!(builder.is_ok());
2226
2227 let mut builder = builder.unwrap();
2228 let result = builder.add_css_file(PathBuf::from("nonexistent.css"));
2229 assert!(result.is_err());
2230 }
2231
2232 #[test]
2233 fn test_add_multiple_css_files() {
2234 let temp_dir = env::temp_dir().join(local_time());
2235 assert!(fs::create_dir_all(&temp_dir).is_ok());
2236
2237 let css_path1 = temp_dir.join("style1.css");
2238 let css_path2 = temp_dir.join("style2.css");
2239 assert!(fs::write(&css_path1, "body { color: red; }").is_ok());
2240 assert!(fs::write(&css_path2, "p { font-size: 16px; }").is_ok());
2241
2242 let builder = ContentBuilder::new("chapter1", "en");
2243 assert!(builder.is_ok());
2244
2245 let mut builder = builder.unwrap();
2246 assert!(builder.add_css_file(css_path1).is_ok());
2247 assert!(builder.add_css_file(css_path2).is_ok());
2248
2249 assert_eq!(builder.css_files.len(), 2);
2250
2251 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2252 }
2253 }
2254
2255 mod block_tests {
2256 use std::path::PathBuf;
2257
2258 use crate::{builder::content::Block, types::Footnote};
2259
2260 #[test]
2261 fn test_take_footnotes_from_text_block() {
2262 let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
2263
2264 let block = Block::Text {
2265 content: "Hello world".to_string(),
2266 footnotes: footnotes.clone(),
2267 };
2268
2269 let taken = block.take_footnotes();
2270 assert_eq!(taken.len(), 1);
2271 assert_eq!(taken[0].content, "Note");
2272 }
2273
2274 #[test]
2275 fn test_take_footnotes_from_quote_block() {
2276 let footnotes = vec![
2277 Footnote { locate: 3, content: "First".to_string() },
2278 Footnote { locate: 8, content: "Second".to_string() },
2279 ];
2280
2281 let block = Block::Quote {
2282 content: "Test quote".to_string(),
2283 footnotes: footnotes.clone(),
2284 };
2285
2286 let taken = block.take_footnotes();
2287 assert_eq!(taken.len(), 2);
2288 }
2289
2290 #[test]
2291 fn test_take_footnotes_from_image_block() {
2292 let img_path = PathBuf::from("test.png");
2293 let footnotes = vec![Footnote {
2294 locate: 2,
2295 content: "Image note".to_string(),
2296 }];
2297
2298 let block = Block::Image {
2299 url: img_path,
2300 alt: None,
2301 caption: Some("A caption".to_string()),
2302 footnotes: footnotes.clone(),
2303 };
2304
2305 let taken = block.take_footnotes();
2306 assert_eq!(taken.len(), 1);
2307 }
2308
2309 #[test]
2310 fn test_block_with_empty_footnotes() {
2311 let block = Block::Text {
2312 content: "No footnotes here".to_string(),
2313 footnotes: vec![],
2314 };
2315
2316 let taken = block.take_footnotes();
2317 assert!(taken.is_empty());
2318 }
2319 }
2320
2321 mod content_rendering_tests {
2322 use crate::builder::content::Block;
2323
2324 #[test]
2325 fn test_split_content_by_index_empty() {
2326 let result = Block::split_content_by_index("Hello", &[]);
2327 assert_eq!(result, vec!["Hello"]);
2328 }
2329
2330 #[test]
2331 fn test_split_content_by_single_index() {
2332 let result = Block::split_content_by_index("Hello World", &[5]);
2333 assert_eq!(result.len(), 2);
2334 assert_eq!(result[0], "Hello");
2335 assert_eq!(result[1], " World");
2336 }
2337
2338 #[test]
2339 fn test_split_content_by_multiple_indices() {
2340 let result = Block::split_content_by_index("One Two Three", &[3, 7]);
2341 assert_eq!(result.len(), 3);
2342 assert_eq!(result[0], "One");
2343 assert_eq!(result[1], " Two");
2344 assert_eq!(result[2], " Three");
2345 }
2346
2347 #[test]
2348 fn test_split_content_unicode() {
2349 let content = "你好世界";
2350 let result = Block::split_content_by_index(content, &[2]);
2351 assert_eq!(result.len(), 2);
2352 assert_eq!(result[0], "你好");
2353 assert_eq!(result[1], "世界");
2354 }
2355 }
2356}