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]
68#[derive(Debug)]
69pub enum Block {
70 #[non_exhaustive]
74 Text {
75 content: String,
76 footnotes: Vec<Footnote>,
77 },
78
79 #[non_exhaustive]
83 Quote {
84 content: String,
85 footnotes: Vec<Footnote>,
86 },
87
88 #[non_exhaustive]
90 Title {
91 content: String,
92 footnotes: Vec<Footnote>,
93
94 level: usize,
98 },
99
100 #[non_exhaustive]
102 Image {
103 url: PathBuf,
105
106 alt: Option<String>,
108
109 caption: Option<String>,
111
112 footnotes: Vec<Footnote>,
113 },
114
115 #[non_exhaustive]
117 Audio {
118 url: PathBuf,
120
121 fallback: String,
125
126 caption: Option<String>,
128
129 footnotes: Vec<Footnote>,
130 },
131
132 #[non_exhaustive]
134 Video {
135 url: PathBuf,
137
138 fallback: String,
142
143 caption: Option<String>,
145
146 footnotes: Vec<Footnote>,
147 },
148
149 #[non_exhaustive]
151 MathML {
152 element_str: String,
157
158 fallback_image: Option<PathBuf>,
163
164 caption: Option<String>,
166
167 footnotes: Vec<Footnote>,
168 },
169}
170
171impl Block {
172 pub(crate) fn make(
176 &mut self,
177 writer: &mut XmlWriter,
178 start_index: usize,
179 ) -> Result<(), EpubError> {
180 match self {
181 Block::Text { content, footnotes } => {
182 writer.write_event(Event::Start(
183 BytesStart::new("p").with_attributes([("class", "content-block")]),
184 ))?;
185
186 Self::make_text(writer, content, footnotes, start_index)?;
187
188 writer.write_event(Event::End(BytesEnd::new("p")))?;
189 }
190
191 Block::Quote { content, footnotes } => {
192 writer.write_event(Event::Start(BytesStart::new("blockquote").with_attributes(
193 [
194 ("class", "content-block"),
195 ("cite", "SOME ATTR NEED TO BE SET"),
196 ],
197 )))?;
198 writer.write_event(Event::Start(BytesStart::new("p")))?;
199
200 Self::make_text(writer, content, footnotes, start_index)?;
201
202 writer.write_event(Event::End(BytesEnd::new("p")))?;
203 writer.write_event(Event::End(BytesEnd::new("blockquote")))?;
204 }
205
206 Block::Title { content, footnotes, level } => {
207 let tag_name = format!("h{}", level);
208 writer.write_event(Event::Start(
209 BytesStart::new(tag_name.as_str())
210 .with_attributes([("class", "content-block")]),
211 ))?;
212
213 Self::make_text(writer, content, footnotes, start_index)?;
214
215 writer.write_event(Event::End(BytesEnd::new(tag_name)))?;
216 }
217
218 Block::Image { url, alt, caption, footnotes } => {
219 let url = format!("./img/{}", url.file_name().unwrap().to_string_lossy());
220
221 let mut attr = Vec::new();
222 attr.push(("src", url.as_str()));
223 attr.push(("class", "image-block"));
224 if let Some(alt) = alt {
225 attr.push(("alt", alt.as_str()));
226 }
227
228 writer.write_event(Event::Start(
229 BytesStart::new("figure").with_attributes([("class", "content-block")]),
230 ))?;
231 writer.write_event(Event::Empty(BytesStart::new("img").with_attributes(attr)))?;
232
233 if let Some(caption) = caption {
234 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
235
236 Self::make_text(writer, caption, footnotes, start_index)?;
237
238 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
239 }
240
241 writer.write_event(Event::End(BytesEnd::new("figure")))?;
242 }
243
244 Block::Audio { url, fallback, caption, footnotes } => {
245 let url = format!("./audio/{}", url.file_name().unwrap().to_string_lossy());
246
247 let attr = vec![
248 ("src", url.as_str()),
249 ("class", "audio-block"),
250 ("controls", "controls"), ];
252
253 writer.write_event(Event::Start(
254 BytesStart::new("figure").with_attributes([("class", "content-block")]),
255 ))?;
256 writer.write_event(Event::Start(BytesStart::new("audio").with_attributes(attr)))?;
257
258 writer.write_event(Event::Start(BytesStart::new("p")))?;
259 writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
260 writer.write_event(Event::End(BytesEnd::new("p")))?;
261
262 writer.write_event(Event::End(BytesEnd::new("audio")))?;
263
264 if let Some(caption) = caption {
265 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
266
267 Self::make_text(writer, caption, footnotes, start_index)?;
268
269 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
270 }
271
272 writer.write_event(Event::End(BytesEnd::new("figure")))?;
273 }
274
275 Block::Video { url, fallback, caption, footnotes } => {
276 let url = format!("./video/{}", url.file_name().unwrap().to_string_lossy());
277
278 let attr = vec![
279 ("src", url.as_str()),
280 ("class", "video-block"),
281 ("controls", "controls"), ];
283
284 writer.write_event(Event::Start(
285 BytesStart::new("figure").with_attributes([("class", "content-block")]),
286 ))?;
287 writer.write_event(Event::Start(BytesStart::new("video").with_attributes(attr)))?;
288
289 writer.write_event(Event::Start(BytesStart::new("p")))?;
290 writer.write_event(Event::Text(BytesText::new(fallback.as_str())))?;
291 writer.write_event(Event::End(BytesEnd::new("p")))?;
292
293 writer.write_event(Event::End(BytesEnd::new("video")))?;
294
295 if let Some(caption) = caption {
296 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
297
298 Self::make_text(writer, caption, footnotes, start_index)?;
299
300 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
301 }
302
303 writer.write_event(Event::End(BytesEnd::new("figure")))?;
304 }
305
306 Block::MathML {
307 element_str,
308 fallback_image,
309 caption,
310 footnotes,
311 } => {
312 writer.write_event(Event::Start(
313 BytesStart::new("figure").with_attributes([("class", "content-block")]),
314 ))?;
315
316 Self::write_mathml_element(writer, element_str)?;
317
318 if let Some(fallback_path) = fallback_image {
319 let img_url = format!(
320 "./img/{}",
321 fallback_path.file_name().unwrap().to_string_lossy()
322 );
323
324 writer.write_event(Event::Empty(BytesStart::new("img").with_attributes([
325 ("src", img_url.as_str()),
326 ("class", "mathml-fallback"),
327 ("alt", "Mathematical formula"),
328 ])))?;
329 }
330
331 if let Some(caption) = caption {
332 writer.write_event(Event::Start(BytesStart::new("figcaption")))?;
333
334 Self::make_text(writer, caption, footnotes, start_index)?;
335
336 writer.write_event(Event::End(BytesEnd::new("figcaption")))?;
337 }
338
339 writer.write_event(Event::End(BytesEnd::new("figure")))?;
340 }
341 }
342
343 Ok(())
344 }
345
346 pub fn take_footnotes(&self) -> Vec<Footnote> {
347 match self {
348 Block::Text { footnotes, .. } => footnotes.to_vec(),
349 Block::Quote { footnotes, .. } => footnotes.to_vec(),
350 Block::Title { footnotes, .. } => footnotes.to_vec(),
351 Block::Image { footnotes, .. } => footnotes.to_vec(),
352 Block::Audio { footnotes, .. } => footnotes.to_vec(),
353 Block::Video { footnotes, .. } => footnotes.to_vec(),
354 Block::MathML { footnotes, .. } => footnotes.to_vec(),
355 }
356 }
357
358 fn split_content_by_index(content: &str, index_list: &[usize]) -> Vec<String> {
364 if index_list.is_empty() {
365 return vec![content.to_string()];
366 }
367
368 let mut result = Vec::with_capacity(index_list.len() + 1);
370 let mut char_iter = content.chars().enumerate();
371
372 let mut current_char_idx = 0;
373 for &target_idx in index_list {
374 let mut segment = String::new();
375
376 while current_char_idx < target_idx {
379 if let Some((_, ch)) = char_iter.next() {
380 segment.push(ch);
381 current_char_idx += 1;
382 } else {
383 break;
384 }
385 }
386
387 if !segment.is_empty() {
388 result.push(segment);
389 }
390 }
391
392 let remainder = char_iter.map(|(_, ch)| ch).collect::<String>();
393 if !remainder.is_empty() {
394 result.push(remainder);
395 }
396
397 result
398 }
399
400 fn make_text(
410 writer: &mut XmlWriter,
411 content: &str,
412 footnotes: &mut [Footnote],
413 start_index: usize,
414 ) -> Result<(), EpubError> {
415 if footnotes.is_empty() {
416 writer.write_event(Event::Text(BytesText::new(content)))?;
417 return Ok(());
418 }
419
420 footnotes.sort_unstable();
421
422 let mut position_to_count = HashMap::new();
424 for footnote in footnotes.iter() {
425 *position_to_count.entry(footnote.locate).or_insert(0usize) += 1;
426 }
427
428 let mut positions = position_to_count.keys().copied().collect::<Vec<usize>>();
429 positions.sort_unstable();
430
431 let mut current_index = start_index;
432 let content_list = Self::split_content_by_index(content, &positions);
433 for (index, segment) in content_list.iter().enumerate() {
434 writer.write_event(Event::Text(BytesText::new(segment)))?;
435
436 if let Some(&position) = positions.get(index) {
438 if let Some(&count) = position_to_count.get(&position) {
440 for _ in 0..count {
441 Self::make_footnotes(writer, current_index)?;
442 current_index += 1;
443 }
444 }
445 }
446 }
447
448 Ok(())
449 }
450
451 #[inline]
453 fn make_footnotes(writer: &mut XmlWriter, index: usize) -> Result<(), EpubError> {
454 writer.write_event(Event::Start(BytesStart::new("a").with_attributes([
455 ("href", format!("#footnote-{}", index).as_str()),
456 ("id", format!("ref-{}", index).as_str()),
457 ("class", "footnote-ref"),
458 ])))?;
459 writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index))))?;
460 writer.write_event(Event::End(BytesEnd::new("a")))?;
461
462 Ok(())
463 }
464
465 fn write_mathml_element(writer: &mut XmlWriter, element_str: &str) -> Result<(), EpubError> {
469 let mut reader = Reader::from_str(element_str);
470
471 loop {
472 match reader.read_event() {
473 Ok(Event::Eof) => break,
474
475 Ok(event) => writer.write_event(event)?,
476
477 Err(err) => {
478 return Err(
479 EpubBuilderError::InvalidMathMLFormat { error: err.to_string() }.into(),
480 );
481 }
482 }
483 }
484
485 Ok(())
486 }
487}
488
489#[derive(Debug)]
514pub struct BlockBuilder {
515 block_type: BlockType,
517
518 content: Option<String>,
520
521 level: Option<usize>,
523
524 url: Option<PathBuf>,
526
527 alt: Option<String>,
529
530 caption: Option<String>,
532
533 fallback: Option<String>,
535
536 element_str: Option<String>,
538
539 fallback_image: Option<PathBuf>,
541
542 footnotes: Vec<Footnote>,
544}
545
546impl BlockBuilder {
547 pub fn new(block_type: BlockType) -> Self {
554 Self {
555 block_type,
556 content: None,
557 level: None,
558 url: None,
559 alt: None,
560 caption: None,
561 fallback: None,
562 element_str: None,
563 fallback_image: None,
564 footnotes: vec![],
565 }
566 }
567
568 pub fn set_content(&mut self, content: &str) -> &mut Self {
575 self.content = Some(content.to_string());
576 self
577 }
578
579 pub fn set_title_level(&mut self, level: usize) -> &mut Self {
588 if !(1..=6).contains(&level) {
589 return self;
590 }
591
592 self.level = Some(level);
593 self
594 }
595
596 pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
608 match Self::is_target_type(
609 url,
610 vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
611 ) {
612 Ok(_) => {
613 self.url = Some(url.to_path_buf());
614 Ok(self)
615 }
616 Err(err) => Err(err),
617 }
618 }
619
620 pub fn set_alt(&mut self, alt: &str) -> &mut Self {
628 self.alt = Some(alt.to_string());
629 self
630 }
631
632 pub fn set_caption(&mut self, caption: &str) -> &mut Self {
640 self.caption = Some(caption.to_string());
641 self
642 }
643
644 pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
652 self.fallback = Some(fallback.to_string());
653 self
654 }
655
656 pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
665 self.element_str = Some(element_str.to_string());
666 self
667 }
668
669 pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
682 match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
683 Ok(_) => {
684 self.fallback_image = Some(fallback_image);
685 Ok(self)
686 }
687 Err(err) => Err(err),
688 }
689 }
690
691 pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
699 self.footnotes.push(footnote);
700 self
701 }
702
703 pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
711 self.footnotes = footnotes;
712 self
713 }
714
715 pub fn remove_last_footnote(&mut self) -> &mut Self {
720 self.footnotes.pop();
721 self
722 }
723
724 pub fn clear_footnotes(&mut self) -> &mut Self {
728 self.footnotes.clear();
729 self
730 }
731
732 pub fn build(self) -> Result<Block, EpubError> {
742 let block = match self.block_type {
743 BlockType::Text => {
744 if let Some(content) = self.content {
745 Block::Text { content, footnotes: self.footnotes }
746 } else {
747 return Err(EpubBuilderError::MissingNecessaryBlockData {
748 block_type: "Text".to_string(),
749 missing_data: "'content'".to_string(),
750 }
751 .into());
752 }
753 }
754
755 BlockType::Quote => {
756 if let Some(content) = self.content {
757 Block::Quote { content, footnotes: self.footnotes }
758 } else {
759 return Err(EpubBuilderError::MissingNecessaryBlockData {
760 block_type: "Quote".to_string(),
761 missing_data: "'content'".to_string(),
762 }
763 .into());
764 }
765 }
766
767 BlockType::Title => match (self.content, self.level) {
768 (Some(content), Some(level)) => Block::Title {
769 content,
770 level,
771 footnotes: self.footnotes,
772 },
773 _ => {
774 return Err(EpubBuilderError::MissingNecessaryBlockData {
775 block_type: "Title".to_string(),
776 missing_data: "'content' or 'level'".to_string(),
777 }
778 .into());
779 }
780 },
781
782 BlockType::Image => {
783 if let Some(url) = self.url {
784 Block::Image {
785 url,
786 alt: self.alt,
787 caption: self.caption,
788 footnotes: self.footnotes,
789 }
790 } else {
791 return Err(EpubBuilderError::MissingNecessaryBlockData {
792 block_type: "Image".to_string(),
793 missing_data: "'url'".to_string(),
794 }
795 .into());
796 }
797 }
798
799 BlockType::Audio => match (self.url, self.fallback) {
800 (Some(url), Some(fallback)) => Block::Audio {
801 url,
802 fallback,
803 caption: self.caption,
804 footnotes: self.footnotes,
805 },
806 _ => {
807 return Err(EpubBuilderError::MissingNecessaryBlockData {
808 block_type: "Audio".to_string(),
809 missing_data: "'url' or 'fallback'".to_string(),
810 }
811 .into());
812 }
813 },
814
815 BlockType::Video => match (self.url, self.fallback) {
816 (Some(url), Some(fallback)) => Block::Video {
817 url,
818 fallback,
819 caption: self.caption,
820 footnotes: self.footnotes,
821 },
822 _ => {
823 return Err(EpubBuilderError::MissingNecessaryBlockData {
824 block_type: "Video".to_string(),
825 missing_data: "'url' or 'fallback'".to_string(),
826 }
827 .into());
828 }
829 },
830
831 BlockType::MathML => {
832 if let Some(element_str) = self.element_str {
833 Block::MathML {
834 element_str,
835 fallback_image: self.fallback_image,
836 caption: self.caption,
837 footnotes: self.footnotes,
838 }
839 } else {
840 return Err(EpubBuilderError::MissingNecessaryBlockData {
841 block_type: "MathML".to_string(),
842 missing_data: "'element_str'".to_string(),
843 }
844 .into());
845 }
846 }
847 };
848
849 Self::validate_footnotes(&block)?;
850 Ok(block)
851 }
852
853 fn is_target_type(path: &PathBuf, types: Vec<MatcherType>) -> Result<(), EpubError> {
863 if !path.is_file() {
864 return Err(EpubBuilderError::TargetIsNotFile {
865 target_path: path.to_string_lossy().to_string(),
866 }
867 .into());
868 }
869
870 let mut file = File::open(path)?;
871 let mut buf = [0; 512];
872 let read_size = file.read(&mut buf)?;
873 let header_bytes = &buf[..read_size];
874
875 match Infer::new().get(header_bytes) {
876 Some(file_type) if !types.contains(&file_type.matcher_type()) => {
877 Err(EpubBuilderError::NotExpectedFileFormat.into())
878 }
879
880 None => Err(EpubBuilderError::UnknownFileFormat {
881 file_path: path.to_string_lossy().to_string(),
882 }
883 .into()),
884
885 _ => Ok(()),
886 }
887 }
888
889 fn validate_footnotes(block: &Block) -> Result<(), EpubError> {
896 match block {
897 Block::Text { content, footnotes }
898 | Block::Quote { content, footnotes }
899 | Block::Title { content, footnotes, .. } => {
900 let max_locate = content.chars().count();
901 for footnote in footnotes.iter() {
902 if footnote.locate == 0 || footnote.locate > content.chars().count() {
903 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
904 }
905 }
906
907 Ok(())
908 }
909
910 Block::Image { caption, footnotes, .. }
911 | Block::MathML { caption, footnotes, .. }
912 | Block::Video { caption, footnotes, .. }
913 | Block::Audio { caption, footnotes, .. } => {
914 if let Some(caption) = caption {
915 let max_locate = caption.chars().count();
916 for footnote in footnotes.iter() {
917 if footnote.locate == 0 || footnote.locate > caption.chars().count() {
918 return Err(
919 EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
920 );
921 }
922 }
923 } else if !footnotes.is_empty() {
924 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
925 }
926
927 Ok(())
928 }
929 }
930 }
931}
932
933#[derive(Debug)]
939pub struct ContentBuilder {
940 pub id: String,
946
947 blocks: Vec<Block>,
948 language: String,
949 title: String,
950 styles: StyleOptions,
951
952 pub(crate) temp_dir: PathBuf,
953}
954
955impl ContentBuilder {
956 pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
964 let temp_dir = env::temp_dir().join(local_time());
965 fs::create_dir(&temp_dir)?;
966
967 Ok(Self {
968 id: id.to_string(),
969 blocks: vec![],
970 language: language.to_string(),
971 title: String::new(),
972 styles: StyleOptions::default(),
973 temp_dir,
974 })
975 }
976
977 pub fn set_title(&mut self, title: &str) -> &mut Self {
984 self.title = title.to_string();
985 self
986 }
987
988 pub fn set_styles(&mut self, styles: StyleOptions) -> &mut Self {
993 self.styles = styles;
994 self
995 }
996
997 pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
1004 self.blocks.push(block);
1005
1006 match self.blocks.last() {
1007 Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
1008 self.handle_resource()?
1009 }
1010
1011 Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1012 self.handle_resource()?;
1013 }
1014
1015 _ => {}
1016 }
1017
1018 Ok(self)
1019 }
1020
1021 pub fn add_text_block(
1029 &mut self,
1030 content: &str,
1031 footnotes: Vec<Footnote>,
1032 ) -> Result<&mut Self, EpubError> {
1033 let mut builder = BlockBuilder::new(BlockType::Text);
1034 builder.set_content(content).set_footnotes(footnotes);
1035
1036 self.blocks.push(builder.build()?);
1037 Ok(self)
1038 }
1039
1040 pub fn add_quote_block(
1048 &mut self,
1049 content: &str,
1050 footnotes: Vec<Footnote>,
1051 ) -> Result<&mut Self, EpubError> {
1052 let mut builder = BlockBuilder::new(BlockType::Quote);
1053 builder.set_content(content).set_footnotes(footnotes);
1054
1055 self.blocks.push(builder.build()?);
1056 Ok(self)
1057 }
1058
1059 pub fn add_title_block(
1068 &mut self,
1069 content: &str,
1070 level: usize,
1071 footnotes: Vec<Footnote>,
1072 ) -> Result<&mut Self, EpubError> {
1073 let mut builder = BlockBuilder::new(BlockType::Title);
1074 builder
1075 .set_content(content)
1076 .set_title_level(level)
1077 .set_footnotes(footnotes);
1078
1079 self.blocks.push(builder.build()?);
1080 Ok(self)
1081 }
1082
1083 pub fn add_image_block(
1094 &mut self,
1095 url: PathBuf,
1096 alt: Option<String>,
1097 caption: Option<String>,
1098 footnotes: Vec<Footnote>,
1099 ) -> Result<&mut Self, EpubError> {
1100 let mut builder = BlockBuilder::new(BlockType::Image);
1101 builder.set_url(&url)?.set_footnotes(footnotes);
1102
1103 if let Some(alt) = &alt {
1104 builder.set_alt(alt);
1105 }
1106
1107 if let Some(caption) = &caption {
1108 builder.set_caption(caption);
1109 }
1110
1111 self.blocks.push(builder.build()?);
1112 self.handle_resource()?;
1113 Ok(self)
1114 }
1115
1116 pub fn add_audio_block(
1127 &mut self,
1128 url: PathBuf,
1129 fallback: String,
1130 caption: Option<String>,
1131 footnotes: Vec<Footnote>,
1132 ) -> Result<&mut Self, EpubError> {
1133 let mut builder = BlockBuilder::new(BlockType::Audio);
1134 builder
1135 .set_url(&url)?
1136 .set_fallback(&fallback)
1137 .set_footnotes(footnotes);
1138
1139 if let Some(caption) = &caption {
1140 builder.set_caption(caption);
1141 }
1142
1143 self.blocks.push(builder.build()?);
1144 self.handle_resource()?;
1145 Ok(self)
1146 }
1147
1148 pub fn add_video_block(
1159 &mut self,
1160 url: PathBuf,
1161 fallback: String,
1162 caption: Option<String>,
1163 footnotes: Vec<Footnote>,
1164 ) -> Result<&mut Self, EpubError> {
1165 let mut builder = BlockBuilder::new(BlockType::Video);
1166 builder
1167 .set_url(&url)?
1168 .set_fallback(&fallback)
1169 .set_footnotes(footnotes);
1170
1171 if let Some(caption) = &caption {
1172 builder.set_caption(caption);
1173 }
1174
1175 self.blocks.push(builder.build()?);
1176 self.handle_resource()?;
1177 Ok(self)
1178 }
1179
1180 pub fn add_mathml_block(
1191 &mut self,
1192 element_str: String,
1193 fallback_image: Option<PathBuf>,
1194 caption: Option<String>,
1195 footnotes: Vec<Footnote>,
1196 ) -> Result<&mut Self, EpubError> {
1197 let mut builder = BlockBuilder::new(BlockType::MathML);
1198 builder
1199 .set_mathml_element(&element_str)
1200 .set_footnotes(footnotes);
1201
1202 if let Some(fallback_image) = fallback_image {
1203 builder.set_fallback_image(fallback_image)?;
1204 }
1205
1206 if let Some(caption) = &caption {
1207 builder.set_caption(caption);
1208 }
1209
1210 self.blocks.push(builder.build()?);
1211 self.handle_resource()?;
1212 Ok(self)
1213 }
1214
1215 pub fn remove_last_block(&mut self) -> &mut Self {
1219 self.blocks.pop();
1220 self
1221 }
1222
1223 pub fn take_last_block(&mut self) -> Option<Block> {
1232 self.blocks.pop()
1233 }
1234
1235 pub fn clear_blocks(&mut self) -> &mut Self {
1239 self.blocks.clear();
1240 self
1241 }
1242
1243 pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1252 let mut result = Vec::new();
1253
1254 let target_dir = match target.as_ref().parent() {
1256 Some(path) => {
1257 fs::create_dir_all(path)?;
1258 path.to_path_buf()
1259 }
1260 None => {
1261 return Err(EpubBuilderError::InvalidTargetPath {
1262 target_path: target.as_ref().to_string_lossy().to_string(),
1263 }
1264 .into());
1265 }
1266 };
1267
1268 self.make_content(&target)?;
1269 result.push(target.as_ref().to_path_buf());
1270
1271 for resource_type in ["img", "audio", "video"] {
1273 let source = self.temp_dir.join(resource_type);
1274 if source.exists() && source.is_dir() {
1275 let target = target_dir.join(resource_type);
1276 fs::create_dir_all(&target)?;
1277
1278 for entry in fs::read_dir(&source)? {
1279 let entry = entry?;
1280 if entry.file_type()?.is_file() {
1281 let file_name = entry.file_name();
1282 let target = target.join(&file_name);
1283
1284 fs::copy(source.join(&file_name), &target)?;
1285 result.push(target);
1286 }
1287 }
1288 }
1289 }
1290
1291 Ok(result)
1292 }
1293
1294 fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1301 let mut writer = Writer::new(Cursor::new(Vec::new()));
1302
1303 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1304 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1305 ("xmlns", "http://www.w3.org/1999/xhtml"),
1306 ("xml:lang", self.language.as_str()),
1307 ])))?;
1308
1309 writer.write_event(Event::Start(BytesStart::new("head")))?;
1311 writer.write_event(Event::Start(BytesStart::new("title")))?;
1312 writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1313 writer.write_event(Event::End(BytesEnd::new("title")))?;
1314
1315 self.make_style(&mut writer)?;
1316
1317 writer.write_event(Event::End(BytesEnd::new("head")))?;
1318
1319 writer.write_event(Event::Start(BytesStart::new("body")))?;
1321 writer.write_event(Event::Start(BytesStart::new("main")))?;
1322
1323 let mut footnote_index = 1;
1324 let mut footnotes = Vec::new();
1325 for block in self.blocks.iter_mut() {
1326 block.make(&mut writer, footnote_index)?;
1327
1328 footnotes.append(&mut block.take_footnotes());
1329 footnote_index = footnotes.len() + 1;
1330 }
1331
1332 writer.write_event(Event::End(BytesEnd::new("main")))?;
1333
1334 Self::make_footnotes(&mut writer, footnotes)?;
1335 writer.write_event(Event::End(BytesEnd::new("body")))?;
1336 writer.write_event(Event::End(BytesEnd::new("html")))?;
1337
1338 let file_path = PathBuf::from(target_path.as_ref());
1339 let file_data = writer.into_inner().into_inner();
1340 fs::write(file_path, file_data)?;
1341
1342 Ok(())
1343 }
1344
1345 fn make_style(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
1347 let style = format!(
1348 r#"
1349 * {{
1350 margin: 0;
1351 padding: 0;
1352 font-family: {font_family};
1353 text-align: {text_align};
1354 background-color: {background};
1355 color: {text};
1356 }}
1357 body, p, div, span, li, td, th {{
1358 font-size: {font_size}rem;
1359 line-height: {line_height}em;
1360 font-weight: {font_weight};
1361 font-style: {font_style};
1362 letter-spacing: {letter_spacing};
1363 }}
1364 body {{ margin: {margin}px; }}
1365 p {{ text-indent: {text_indent}em; }}
1366 a {{ color: {link_color}; text-decoration: none; }}
1367 figcaption {{ text-align: center; line-height: 1em; }}
1368 blockquote {{ padding: 1em 2em; }}
1369 blockquote > p {{ font-style: italic; }}
1370 .content-block {{ margin-bottom: {paragraph_spacing}px; }}
1371 .image-block, .audio-block, .video-block {{ width: 100%; }}
1372 .footnote-ref {{ font-size: 0.5em; vertical-align: super; }}
1373 .footnote-list {{ list-style: none; padding: 0; }}
1374 .footnote-item > p {{ text-indent: 0; }}
1375 "#,
1376 font_family = self.styles.text.font_family,
1377 text_align = self.styles.layout.text_align,
1378 background = self.styles.color_scheme.background,
1379 text = self.styles.color_scheme.text,
1380 font_size = self.styles.text.font_size,
1381 line_height = self.styles.text.line_height,
1382 font_weight = self.styles.text.font_weight,
1383 font_style = self.styles.text.font_style,
1384 letter_spacing = self.styles.text.letter_spacing,
1385 margin = self.styles.layout.margin,
1386 text_indent = self.styles.text.text_indent,
1387 link_color = self.styles.color_scheme.link,
1388 paragraph_spacing = self.styles.layout.paragraph_spacing,
1389 );
1390
1391 writer.write_event(Event::Start(BytesStart::new("style")))?;
1392 writer.write_event(Event::Text(BytesText::new(&style)))?;
1393 writer.write_event(Event::End(BytesEnd::new("style")))?;
1394
1395 Ok(())
1396 }
1397
1398 fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1403 writer.write_event(Event::Start(BytesStart::new("aside")))?;
1404 writer.write_event(Event::Start(
1405 BytesStart::new("ul").with_attributes([("class", "footnote-list")]),
1406 ))?;
1407
1408 let mut index = 1;
1409 for footnote in footnotes.into_iter() {
1410 writer.write_event(Event::Start(BytesStart::new("li").with_attributes([
1411 ("id", format!("footnote-{}", index).as_str()),
1412 ("class", "footnote-item"),
1413 ])))?;
1414 writer.write_event(Event::Start(BytesStart::new("p")))?;
1415
1416 writer.write_event(Event::Start(
1417 BytesStart::new("a")
1418 .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1419 ))?;
1420 writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1421 writer.write_event(Event::End(BytesEnd::new("a")))?;
1422 writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1423
1424 writer.write_event(Event::End(BytesEnd::new("p")))?;
1425 writer.write_event(Event::End(BytesEnd::new("li")))?;
1426
1427 index += 1;
1428 }
1429
1430 writer.write_event(Event::End(BytesEnd::new("ul")))?;
1431 writer.write_event(Event::End(BytesEnd::new("aside")))?;
1432
1433 Ok(())
1434 }
1435
1436 fn handle_resource(&mut self) -> Result<(), EpubError> {
1441 match self.blocks.last() {
1442 Some(Block::Image { url, .. }) => {
1443 let target_dir = self.temp_dir.join("img");
1444 fs::create_dir_all(&target_dir)?;
1445
1446 let target_path = target_dir.join(url.file_name().unwrap());
1447 fs::copy(url, &target_path)?;
1448 }
1449
1450 Some(Block::Video { url, .. }) => {
1451 let target_dir = self.temp_dir.join("video");
1452 fs::create_dir_all(&target_dir)?;
1453
1454 let target_path = target_dir.join(url.file_name().unwrap());
1455 fs::copy(url, &target_path)?;
1456 }
1457
1458 Some(Block::Audio { url, .. }) => {
1459 let target_dir = self.temp_dir.join("audio");
1460 fs::create_dir_all(&target_dir)?;
1461
1462 let target_path = target_dir.join(url.file_name().unwrap());
1463 fs::copy(url, &target_path)?;
1464 }
1465
1466 Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1467 let target_dir = self.temp_dir.join("img");
1468 fs::create_dir_all(&target_dir)?;
1469
1470 let target_path =
1471 target_dir.join(fallback_image.as_ref().unwrap().file_name().unwrap());
1472
1473 fs::copy(fallback_image.as_ref().unwrap(), &target_path)?;
1474 }
1475
1476 Some(_) => {}
1477 None => {}
1478 }
1479
1480 Ok(())
1481 }
1482}
1483
1484impl Drop for ContentBuilder {
1485 fn drop(&mut self) {
1486 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1487 warn!("{}", err);
1488 };
1489 }
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 mod block_builder_tests {
1610 use std::path::PathBuf;
1611
1612 use crate::{
1613 builder::content::{Block, BlockBuilder},
1614 error::EpubBuilderError,
1615 types::{BlockType, Footnote},
1616 };
1617
1618 #[test]
1619 fn test_create_text_block() {
1620 let mut builder = BlockBuilder::new(BlockType::Text);
1621 builder.set_content("Hello, World!");
1622
1623 let block = builder.build();
1624 assert!(block.is_ok());
1625
1626 let block = block.unwrap();
1627 match block {
1628 Block::Text { content, footnotes } => {
1629 assert_eq!(content, "Hello, World!");
1630 assert!(footnotes.is_empty());
1631 }
1632 _ => unreachable!(),
1633 }
1634 }
1635
1636 #[test]
1637 fn test_create_text_block_missing_content() {
1638 let builder = BlockBuilder::new(BlockType::Text);
1639
1640 let block = builder.build();
1641 assert!(block.is_err());
1642
1643 let result = block.unwrap_err();
1644 assert_eq!(
1645 result,
1646 EpubBuilderError::MissingNecessaryBlockData {
1647 block_type: "Text".to_string(),
1648 missing_data: "'content'".to_string()
1649 }
1650 .into()
1651 )
1652 }
1653
1654 #[test]
1655 fn test_create_quote_block() {
1656 let mut builder = BlockBuilder::new(BlockType::Quote);
1657 builder.set_content("To be or not to be");
1658
1659 let block = builder.build();
1660 assert!(block.is_ok());
1661
1662 let block = block.unwrap();
1663 match block {
1664 Block::Quote { content, footnotes } => {
1665 assert_eq!(content, "To be or not to be");
1666 assert!(footnotes.is_empty());
1667 }
1668 _ => unreachable!(),
1669 }
1670 }
1671
1672 #[test]
1673 fn test_create_title_block() {
1674 let mut builder = BlockBuilder::new(BlockType::Title);
1675 builder.set_content("Chapter 1").set_title_level(2);
1676
1677 let block = builder.build();
1678 assert!(block.is_ok());
1679
1680 let block = block.unwrap();
1681 match block {
1682 Block::Title { content, level, footnotes } => {
1683 assert_eq!(content, "Chapter 1");
1684 assert_eq!(level, 2);
1685 assert!(footnotes.is_empty());
1686 }
1687 _ => unreachable!(),
1688 }
1689 }
1690
1691 #[test]
1692 fn test_create_title_block_invalid_level() {
1693 let mut builder = BlockBuilder::new(BlockType::Title);
1694 builder.set_content("Chapter 1").set_title_level(10);
1695
1696 let result = builder.build();
1697 assert!(result.is_err());
1698
1699 let result = result.unwrap_err();
1700 assert_eq!(
1701 result,
1702 EpubBuilderError::MissingNecessaryBlockData {
1703 block_type: "Title".to_string(),
1704 missing_data: "'content' or 'level'".to_string(),
1705 }
1706 .into()
1707 );
1708 }
1709
1710 #[test]
1711 fn test_create_image_block() {
1712 let img_path = PathBuf::from("./test_case/image.jpg");
1713 let mut builder = BlockBuilder::new(BlockType::Image);
1714 builder
1715 .set_url(&img_path)
1716 .unwrap()
1717 .set_alt("Test Image")
1718 .set_caption("A test image");
1719
1720 let block = builder.build();
1721 assert!(block.is_ok());
1722
1723 let block = block.unwrap();
1724 match block {
1725 Block::Image { url, alt, caption, footnotes } => {
1726 assert_eq!(url.file_name().unwrap(), "image.jpg");
1727 assert_eq!(alt, Some("Test Image".to_string()));
1728 assert_eq!(caption, Some("A test image".to_string()));
1729 assert!(footnotes.is_empty());
1730 }
1731 _ => unreachable!(),
1732 }
1733 }
1734
1735 #[test]
1736 fn test_create_image_block_missing_url() {
1737 let builder = BlockBuilder::new(BlockType::Image);
1738
1739 let block = builder.build();
1740 assert!(block.is_err());
1741
1742 let result = block.unwrap_err();
1743 assert_eq!(
1744 result,
1745 EpubBuilderError::MissingNecessaryBlockData {
1746 block_type: "Image".to_string(),
1747 missing_data: "'url'".to_string(),
1748 }
1749 .into()
1750 );
1751 }
1752
1753 #[test]
1754 fn test_create_audio_block() {
1755 let audio_path = PathBuf::from("./test_case/audio.mp3");
1756 let mut builder = BlockBuilder::new(BlockType::Audio);
1757 builder
1758 .set_url(&audio_path)
1759 .unwrap()
1760 .set_fallback("Audio not supported")
1761 .set_caption("Background music");
1762
1763 let block = builder.build();
1764 assert!(block.is_ok());
1765
1766 let block = block.unwrap();
1767 match block {
1768 Block::Audio { url, fallback, caption, footnotes } => {
1769 assert_eq!(url.file_name().unwrap(), "audio.mp3");
1770 assert_eq!(fallback, "Audio not supported");
1771 assert_eq!(caption, Some("Background music".to_string()));
1772 assert!(footnotes.is_empty());
1773 }
1774 _ => unreachable!(),
1775 }
1776 }
1777
1778 #[test]
1779 fn test_set_url_invalid_file_type() {
1780 let xhtml_path = PathBuf::from("./test_case/Overview.xhtml");
1781 let mut builder = BlockBuilder::new(BlockType::Image);
1782 let result = builder.set_url(&xhtml_path);
1783 assert!(result.is_err());
1784
1785 let err = result.unwrap_err();
1786 assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1787 }
1788
1789 #[test]
1790 fn test_set_url_nonexistent_file() {
1791 let nonexistent_path = PathBuf::from("./test_case/nonexistent.jpg");
1792 let mut builder = BlockBuilder::new(BlockType::Image);
1793 let result = builder.set_url(&nonexistent_path);
1794 assert!(result.is_err());
1795
1796 let err = result.unwrap_err();
1797 assert_eq!(
1798 err,
1799 EpubBuilderError::TargetIsNotFile {
1800 target_path: "./test_case/nonexistent.jpg".to_string()
1801 }
1802 .into()
1803 );
1804 }
1805
1806 #[test]
1807 fn test_set_fallback_image_invalid_type() {
1808 let audio_path = PathBuf::from("./test_case/audio.mp3");
1809 let mut builder = BlockBuilder::new(BlockType::MathML);
1810 builder.set_mathml_element("<math/>");
1811 let result = builder.set_fallback_image(audio_path);
1812 assert!(result.is_err());
1813
1814 let err = result.unwrap_err();
1815 assert_eq!(err, EpubBuilderError::NotExpectedFileFormat.into());
1816 }
1817
1818 #[test]
1819 fn test_set_fallback_image_nonexistent() {
1820 let nonexistent_path = PathBuf::from("./test_case/nonexistent.png");
1821 let mut builder = BlockBuilder::new(BlockType::MathML);
1822 builder.set_mathml_element("<math/>");
1823 let result = builder.set_fallback_image(nonexistent_path);
1824 assert!(result.is_err());
1825
1826 let err = result.unwrap_err();
1827 assert_eq!(
1828 err,
1829 EpubBuilderError::TargetIsNotFile {
1830 target_path: "./test_case/nonexistent.png".to_string()
1831 }
1832 .into()
1833 );
1834 }
1835
1836 #[test]
1837 fn test_create_video_block() {
1838 let video_path = PathBuf::from("./test_case/video.mp4");
1839 let mut builder = BlockBuilder::new(BlockType::Video);
1840 builder
1841 .set_url(&video_path)
1842 .unwrap()
1843 .set_fallback("Video not supported")
1844 .set_caption("Demo video");
1845
1846 let block = builder.build();
1847 assert!(block.is_ok());
1848
1849 let block = block.unwrap();
1850 match block {
1851 Block::Video { url, fallback, caption, footnotes } => {
1852 assert_eq!(url.file_name().unwrap(), "video.mp4");
1853 assert_eq!(fallback, "Video not supported");
1854 assert_eq!(caption, Some("Demo video".to_string()));
1855 assert!(footnotes.is_empty());
1856 }
1857 _ => unreachable!(),
1858 }
1859 }
1860
1861 #[test]
1862 fn test_create_mathml_block() {
1863 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
1864 let mut builder = BlockBuilder::new(BlockType::MathML);
1865 builder
1866 .set_mathml_element(mathml_content)
1867 .set_caption("Simple equation");
1868
1869 let block = builder.build();
1870 assert!(block.is_ok());
1871
1872 let block = block.unwrap();
1873 match block {
1874 Block::MathML {
1875 element_str,
1876 fallback_image,
1877 caption,
1878 footnotes,
1879 } => {
1880 assert_eq!(element_str, mathml_content);
1881 assert!(fallback_image.is_none());
1882 assert_eq!(caption, Some("Simple equation".to_string()));
1883 assert!(footnotes.is_empty());
1884 }
1885 _ => unreachable!(),
1886 }
1887 }
1888
1889 #[test]
1890 fn test_create_mathml_block_with_fallback() {
1891 let img_path = PathBuf::from("./test_case/image.jpg");
1892 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
1893
1894 let mut builder = BlockBuilder::new(BlockType::MathML);
1895 builder
1896 .set_mathml_element(mathml_content)
1897 .set_fallback_image(img_path.clone())
1898 .unwrap();
1899
1900 let block = builder.build();
1901 assert!(block.is_ok());
1902
1903 let block = block.unwrap();
1904 match block {
1905 Block::MathML { element_str, fallback_image, .. } => {
1906 assert_eq!(element_str, mathml_content);
1907 assert!(fallback_image.is_some());
1908 }
1909 _ => unreachable!(),
1910 }
1911 }
1912
1913 #[test]
1914 fn test_footnote_management() {
1915 let mut builder = BlockBuilder::new(BlockType::Text);
1916 builder.set_content("This is a test");
1917
1918 let note1 = Footnote {
1919 locate: 5,
1920 content: "First footnote".to_string(),
1921 };
1922 let note2 = Footnote {
1923 locate: 10,
1924 content: "Second footnote".to_string(),
1925 };
1926
1927 builder.add_footnote(note1).add_footnote(note2);
1928
1929 let block = builder.build();
1930 assert!(block.is_ok());
1931
1932 let block = block.unwrap();
1933 match block {
1934 Block::Text { footnotes, .. } => {
1935 assert_eq!(footnotes.len(), 2);
1936 }
1937 _ => unreachable!(),
1938 }
1939 }
1940
1941 #[test]
1942 fn test_remove_last_footnote() {
1943 let mut builder = BlockBuilder::new(BlockType::Text);
1944 builder.set_content("This is a test");
1945
1946 builder.add_footnote(Footnote { locate: 5, content: "Note 1".to_string() });
1947 builder.add_footnote(Footnote {
1948 locate: 10,
1949 content: "Note 2".to_string(),
1950 });
1951 builder.remove_last_footnote();
1952
1953 let block = builder.build();
1954 assert!(block.is_ok());
1955
1956 let block = block.unwrap();
1957 match block {
1958 Block::Text { footnotes, .. } => {
1959 assert_eq!(footnotes.len(), 1);
1960 assert!(footnotes[0].content == "Note 1");
1961 }
1962 _ => unreachable!(),
1963 }
1964 }
1965
1966 #[test]
1967 fn test_clear_footnotes() {
1968 let mut builder = BlockBuilder::new(BlockType::Text);
1969 builder.set_content("This is a test");
1970
1971 builder.add_footnote(Footnote { locate: 5, content: "Note".to_string() });
1972
1973 builder.clear_footnotes();
1974
1975 let block = builder.build();
1976 assert!(block.is_ok());
1977
1978 let block = block.unwrap();
1979 match block {
1980 Block::Text { footnotes, .. } => {
1981 assert!(footnotes.is_empty());
1982 }
1983 _ => unreachable!(),
1984 }
1985 }
1986
1987 #[test]
1988 fn test_invalid_footnote_locate() {
1989 let mut builder = BlockBuilder::new(BlockType::Text);
1990 builder.set_content("Hello");
1991
1992 builder.add_footnote(Footnote {
1994 locate: 100,
1995 content: "Invalid footnote".to_string(),
1996 });
1997
1998 let result = builder.build();
1999 assert!(result.is_err());
2000
2001 let result = result.unwrap_err();
2002 assert_eq!(
2003 result,
2004 EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
2005 );
2006 }
2007
2008 #[test]
2009 fn test_footnote_on_media_without_caption() {
2010 let img_path = PathBuf::from("./test_case/image.jpg");
2011 let mut builder = BlockBuilder::new(BlockType::Image);
2012 builder.set_url(&img_path).unwrap();
2013
2014 builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
2015
2016 let result = builder.build();
2017 assert!(result.is_err());
2018
2019 let result = result.unwrap_err();
2020 assert_eq!(
2021 result,
2022 EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
2023 );
2024 }
2025 }
2026
2027 mod content_builder_tests {
2028 use std::{env, fs, path::PathBuf};
2029
2030 use crate::{
2031 builder::content::{Block, ContentBuilder},
2032 types::{ColorScheme, Footnote, PageLayout, TextAlign, TextStyle},
2033 utils::local_time,
2034 };
2035
2036 #[test]
2037 fn test_create_content_builder() {
2038 let builder = ContentBuilder::new("chapter1", "en");
2039 assert!(builder.is_ok());
2040
2041 let builder = builder.unwrap();
2042 assert_eq!(builder.id, "chapter1");
2043 }
2044
2045 #[test]
2046 fn test_set_title() {
2047 let builder = ContentBuilder::new("chapter1", "en");
2048 assert!(builder.is_ok());
2049
2050 let mut builder = builder.unwrap();
2051 builder.set_title("My Chapter").set_title("Another Title");
2052
2053 assert_eq!(builder.title, "Another Title");
2054 }
2055
2056 #[test]
2057 fn test_add_text_block() {
2058 let builder = ContentBuilder::new("chapter1", "en");
2059 assert!(builder.is_ok());
2060
2061 let mut builder = builder.unwrap();
2062 let result = builder.add_text_block("This is a paragraph", vec![]);
2063 assert!(result.is_ok());
2064 }
2065
2066 #[test]
2067 fn test_add_quote_block() {
2068 let builder = ContentBuilder::new("chapter1", "en");
2069 assert!(builder.is_ok());
2070
2071 let mut builder = builder.unwrap();
2072 let result = builder.add_quote_block("A quoted text", vec![]);
2073 assert!(result.is_ok());
2074 }
2075
2076 #[test]
2077 fn test_set_styles() {
2078 let builder = ContentBuilder::new("chapter1", "en");
2079 assert!(builder.is_ok());
2080
2081 let custom_styles = crate::types::StyleOptions {
2082 text: TextStyle {
2083 font_size: 1.5,
2084 line_height: 1.8,
2085 font_family: "Georgia, serif".to_string(),
2086 font_weight: "bold".to_string(),
2087 font_style: "italic".to_string(),
2088 letter_spacing: "0.1em".to_string(),
2089 text_indent: 1.5,
2090 },
2091 color_scheme: ColorScheme {
2092 background: "#F5F5F5".to_string(),
2093 text: "#333333".to_string(),
2094 link: "#0066CC".to_string(),
2095 },
2096 layout: PageLayout {
2097 margin: 30,
2098 text_align: TextAlign::Center,
2099 paragraph_spacing: 20,
2100 },
2101 };
2102
2103 let mut builder = builder.unwrap();
2104 builder.set_styles(custom_styles);
2105
2106 assert_eq!(builder.styles.text.font_size, 1.5);
2107 assert_eq!(builder.styles.text.font_weight, "bold");
2108 assert_eq!(builder.styles.color_scheme.background, "#F5F5F5");
2109 assert_eq!(builder.styles.layout.text_align, TextAlign::Center);
2110 }
2111
2112 #[test]
2113 fn test_add_title_block() {
2114 let builder = ContentBuilder::new("chapter1", "en");
2115 assert!(builder.is_ok());
2116
2117 let mut builder = builder.unwrap();
2118 let result = builder.add_title_block("Section Title", 2, vec![]);
2119 assert!(result.is_ok());
2120 }
2121
2122 #[test]
2123 fn test_add_image_block() {
2124 let img_path = PathBuf::from("./test_case/image.jpg");
2125 let builder = ContentBuilder::new("chapter1", "en");
2126 assert!(builder.is_ok());
2127
2128 let mut builder = builder.unwrap();
2129 let result = builder.add_image_block(
2130 img_path,
2131 Some("Alt text".to_string()),
2132 Some("Figure 1: An image".to_string()),
2133 vec![],
2134 );
2135
2136 assert!(result.is_ok());
2137 }
2138
2139 #[test]
2140 fn test_add_audio_block() {
2141 let audio_path = PathBuf::from("./test_case/audio.mp3");
2142 let builder = ContentBuilder::new("chapter1", "en");
2143 assert!(builder.is_ok());
2144
2145 let mut builder = builder.unwrap();
2146 let result = builder.add_audio_block(
2147 audio_path,
2148 "Your browser doesn't support audio".to_string(),
2149 Some("Background music".to_string()),
2150 vec![],
2151 );
2152
2153 assert!(result.is_ok());
2154 }
2155
2156 #[test]
2157 fn test_add_video_block() {
2158 let video_path = PathBuf::from("./test_case/video.mp4");
2159 let builder = ContentBuilder::new("chapter1", "en");
2160 assert!(builder.is_ok());
2161
2162 let mut builder = builder.unwrap();
2163 let result = builder.add_video_block(
2164 video_path,
2165 "Your browser doesn't support video".to_string(),
2166 Some("Tutorial video".to_string()),
2167 vec![],
2168 );
2169
2170 assert!(result.is_ok());
2171 }
2172
2173 #[test]
2174 fn test_add_mathml_block() {
2175 let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
2176 let builder = ContentBuilder::new("chapter1", "en");
2177 assert!(builder.is_ok());
2178
2179 let mut builder = builder.unwrap();
2180 let result = builder.add_mathml_block(
2181 mathml.to_string(),
2182 None,
2183 Some("Equation 1".to_string()),
2184 vec![],
2185 );
2186
2187 assert!(result.is_ok());
2188 }
2189
2190 #[test]
2191 fn test_remove_last_block() {
2192 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2193
2194 builder.add_text_block("First block", vec![]).unwrap();
2195 builder.add_text_block("Second block", vec![]).unwrap();
2196 assert_eq!(builder.blocks.len(), 2);
2197
2198 builder.remove_last_block();
2199 assert_eq!(builder.blocks.len(), 1);
2200 }
2201
2202 #[test]
2203 fn test_take_last_block() {
2204 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2205
2206 builder.add_text_block("Block content", vec![]).unwrap();
2207
2208 let block = builder.take_last_block();
2209 assert!(block.is_some());
2210
2211 let block = block.unwrap();
2212 match block {
2213 Block::Text { content, .. } => {
2214 assert_eq!(content, "Block content");
2215 }
2216 _ => unreachable!(),
2217 }
2218
2219 let block2 = builder.take_last_block();
2220 assert!(block2.is_none());
2221 }
2222
2223 #[test]
2224 fn test_clear_blocks() {
2225 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
2226
2227 builder.add_text_block("Block 1", vec![]).unwrap();
2228 builder.add_text_block("Block 2", vec![]).unwrap();
2229 assert_eq!(builder.blocks.len(), 2);
2230
2231 builder.clear_blocks();
2232
2233 let block = builder.take_last_block();
2234 assert!(block.is_none());
2235 }
2236
2237 #[test]
2238 fn test_make_content_document() {
2239 let temp_dir = env::temp_dir().join(local_time());
2240 assert!(fs::create_dir_all(&temp_dir).is_ok());
2241
2242 let output_path = temp_dir.join("chapter.xhtml");
2243
2244 let builder = ContentBuilder::new("chapter1", "en");
2245 assert!(builder.is_ok());
2246
2247 let mut builder = builder.unwrap();
2248 builder
2249 .set_title("My Chapter")
2250 .add_text_block("This is the first paragraph.", vec![])
2251 .unwrap()
2252 .add_text_block("This is the second paragraph.", vec![])
2253 .unwrap();
2254
2255 let result = builder.make(&output_path);
2256 assert!(result.is_ok());
2257 assert!(output_path.exists());
2258 assert!(fs::remove_dir_all(temp_dir).is_ok());
2259 }
2260
2261 #[test]
2262 fn test_make_content_with_media() {
2263 let temp_dir = env::temp_dir().join(local_time());
2264 assert!(fs::create_dir_all(&temp_dir).is_ok());
2265
2266 let output_path = temp_dir.join("chapter.xhtml");
2267 let img_path = PathBuf::from("./test_case/image.jpg");
2268
2269 let builder = ContentBuilder::new("chapter1", "en");
2270 assert!(builder.is_ok());
2271
2272 let mut builder = builder.unwrap();
2273 builder
2274 .set_title("Chapter with Media")
2275 .add_text_block("See image below:", vec![])
2276 .unwrap()
2277 .add_image_block(
2278 img_path,
2279 Some("Test".to_string()),
2280 Some("Figure 1".to_string()),
2281 vec![],
2282 )
2283 .unwrap();
2284
2285 let result = builder.make(&output_path);
2286 assert!(result.is_ok());
2287
2288 let img_dir = temp_dir.join("img");
2289 assert!(img_dir.exists());
2290 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2291 }
2292
2293 #[test]
2294 fn test_make_content_with_footnotes() {
2295 let temp_dir = env::temp_dir().join(local_time());
2296 assert!(fs::create_dir_all(&temp_dir).is_ok());
2297
2298 let output_path = temp_dir.join("chapter.xhtml");
2299
2300 let footnotes = vec![
2301 Footnote {
2302 locate: 10,
2303 content: "This is a footnote".to_string(),
2304 },
2305 Footnote {
2306 locate: 15,
2307 content: "Another footnote".to_string(),
2308 },
2309 ];
2310
2311 let builder = ContentBuilder::new("chapter1", "en");
2312 assert!(builder.is_ok());
2313
2314 let mut builder = builder.unwrap();
2315 builder
2316 .set_title("Chapter with Notes")
2317 .add_text_block("This is a paragraph with notes.", footnotes)
2318 .unwrap();
2319
2320 let result = builder.make(&output_path);
2321 assert!(result.is_ok());
2322 assert!(output_path.exists());
2323 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2324 }
2325 }
2326
2327 mod block_tests {
2328 use std::path::PathBuf;
2329
2330 use crate::{builder::content::Block, types::Footnote};
2331
2332 #[test]
2333 fn test_take_footnotes_from_text_block() {
2334 let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
2335
2336 let block = Block::Text {
2337 content: "Hello world".to_string(),
2338 footnotes: footnotes.clone(),
2339 };
2340
2341 let taken = block.take_footnotes();
2342 assert_eq!(taken.len(), 1);
2343 assert_eq!(taken[0].content, "Note");
2344 }
2345
2346 #[test]
2347 fn test_take_footnotes_from_quote_block() {
2348 let footnotes = vec![
2349 Footnote { locate: 3, content: "First".to_string() },
2350 Footnote { locate: 8, content: "Second".to_string() },
2351 ];
2352
2353 let block = Block::Quote {
2354 content: "Test quote".to_string(),
2355 footnotes: footnotes.clone(),
2356 };
2357
2358 let taken = block.take_footnotes();
2359 assert_eq!(taken.len(), 2);
2360 }
2361
2362 #[test]
2363 fn test_take_footnotes_from_image_block() {
2364 let img_path = PathBuf::from("test.png");
2365 let footnotes = vec![Footnote {
2366 locate: 2,
2367 content: "Image note".to_string(),
2368 }];
2369
2370 let block = Block::Image {
2371 url: img_path,
2372 alt: None,
2373 caption: Some("A caption".to_string()),
2374 footnotes: footnotes.clone(),
2375 };
2376
2377 let taken = block.take_footnotes();
2378 assert_eq!(taken.len(), 1);
2379 }
2380
2381 #[test]
2382 fn test_block_with_empty_footnotes() {
2383 let block = Block::Text {
2384 content: "No footnotes here".to_string(),
2385 footnotes: vec![],
2386 };
2387
2388 let taken = block.take_footnotes();
2389 assert!(taken.is_empty());
2390 }
2391 }
2392
2393 mod content_rendering_tests {
2394 use crate::builder::content::Block;
2395
2396 #[test]
2397 fn test_split_content_by_index_empty() {
2398 let result = Block::split_content_by_index("Hello", &[]);
2399 assert_eq!(result, vec!["Hello"]);
2400 }
2401
2402 #[test]
2403 fn test_split_content_by_single_index() {
2404 let result = Block::split_content_by_index("Hello World", &[5]);
2405 assert_eq!(result.len(), 2);
2406 assert_eq!(result[0], "Hello");
2407 assert_eq!(result[1], " World");
2408 }
2409
2410 #[test]
2411 fn test_split_content_by_multiple_indices() {
2412 let result = Block::split_content_by_index("One Two Three", &[3, 7]);
2413 assert_eq!(result.len(), 3);
2414 assert_eq!(result[0], "One");
2415 assert_eq!(result[1], " Two");
2416 assert_eq!(result[2], " Three");
2417 }
2418
2419 #[test]
2420 fn test_split_content_unicode() {
2421 let content = "你好世界";
2422 let result = Block::split_content_by_index(content, &[2]);
2423 assert_eq!(result.len(), 2);
2424 assert_eq!(result[0], "你好");
2425 assert_eq!(result[1], "世界");
2426 }
2427 }
2428}