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},
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
489pub struct BlockBuilder {
514 block_type: BlockType,
516
517 content: Option<String>,
519
520 level: Option<usize>,
522
523 url: Option<PathBuf>,
525
526 alt: Option<String>,
528
529 caption: Option<String>,
531
532 fallback: Option<String>,
534
535 element_str: Option<String>,
537
538 fallback_image: Option<PathBuf>,
540
541 footnotes: Vec<Footnote>,
543}
544
545impl BlockBuilder {
546 pub fn new(block_type: BlockType) -> Self {
553 Self {
554 block_type,
555 content: None,
556 level: None,
557 url: None,
558 alt: None,
559 caption: None,
560 fallback: None,
561 element_str: None,
562 fallback_image: None,
563 footnotes: vec![],
564 }
565 }
566
567 pub fn set_content(&mut self, content: &str) -> &mut Self {
574 self.content = Some(content.to_string());
575 self
576 }
577
578 pub fn set_title_level(&mut self, level: usize) -> &mut Self {
587 if !(1..=6).contains(&level) {
588 return self;
589 }
590
591 self.level = Some(level);
592 self
593 }
594
595 pub fn set_url(&mut self, url: &PathBuf) -> Result<&mut Self, EpubError> {
607 match Self::is_target_type(
608 url,
609 vec![MatcherType::Image, MatcherType::Audio, MatcherType::Video],
610 ) {
611 Ok(_) => {
612 self.url = Some(url.to_path_buf());
613 Ok(self)
614 }
615 Err(err) => Err(err),
616 }
617 }
618
619 pub fn set_alt(&mut self, alt: &str) -> &mut Self {
627 self.alt = Some(alt.to_string());
628 self
629 }
630
631 pub fn set_caption(&mut self, caption: &str) -> &mut Self {
639 self.caption = Some(caption.to_string());
640 self
641 }
642
643 pub fn set_fallback(&mut self, fallback: &str) -> &mut Self {
651 self.fallback = Some(fallback.to_string());
652 self
653 }
654
655 pub fn set_mathml_element(&mut self, element_str: &str) -> &mut Self {
664 self.element_str = Some(element_str.to_string());
665 self
666 }
667
668 pub fn set_fallback_image(&mut self, fallback_image: PathBuf) -> Result<&mut Self, EpubError> {
681 match Self::is_target_type(&fallback_image, vec![MatcherType::Image]) {
682 Ok(_) => {
683 self.fallback_image = Some(fallback_image);
684 Ok(self)
685 }
686 Err(err) => Err(err),
687 }
688 }
689
690 pub fn add_footnote(&mut self, footnote: Footnote) -> &mut Self {
698 self.footnotes.push(footnote);
699 self
700 }
701
702 pub fn set_footnotes(&mut self, footnotes: Vec<Footnote>) -> &mut Self {
710 self.footnotes = footnotes;
711 self
712 }
713
714 pub fn remove_last_footnote(&mut self) -> &mut Self {
719 self.footnotes.pop();
720 self
721 }
722
723 pub fn clear_footnotes(&mut self) -> &mut Self {
727 self.footnotes.clear();
728 self
729 }
730
731 pub fn build(self) -> Result<Block, EpubError> {
741 let block = match self.block_type {
742 BlockType::Text => {
743 if let Some(content) = self.content {
744 Block::Text { content, footnotes: self.footnotes }
745 } else {
746 return Err(EpubBuilderError::MissingNecessaryBlockData {
747 block_type: "Text".to_string(),
748 missing_data: "'content'".to_string(),
749 }
750 .into());
751 }
752 }
753
754 BlockType::Quote => {
755 if let Some(content) = self.content {
756 Block::Quote { content, footnotes: self.footnotes }
757 } else {
758 return Err(EpubBuilderError::MissingNecessaryBlockData {
759 block_type: "Quote".to_string(),
760 missing_data: "'content'".to_string(),
761 }
762 .into());
763 }
764 }
765
766 BlockType::Title => match (self.content, self.level) {
767 (Some(content), Some(level)) => Block::Title {
768 content,
769 level,
770 footnotes: self.footnotes,
771 },
772 _ => {
773 return Err(EpubBuilderError::MissingNecessaryBlockData {
774 block_type: "Title".to_string(),
775 missing_data: "'content' or 'level'".to_string(),
776 }
777 .into());
778 }
779 },
780
781 BlockType::Image => {
782 if let Some(url) = self.url {
783 Block::Image {
784 url,
785 alt: self.alt,
786 caption: self.caption,
787 footnotes: self.footnotes,
788 }
789 } else {
790 return Err(EpubBuilderError::MissingNecessaryBlockData {
791 block_type: "Image".to_string(),
792 missing_data: "'url'".to_string(),
793 }
794 .into());
795 }
796 }
797
798 BlockType::Audio => match (self.url, self.fallback) {
799 (Some(url), Some(fallback)) => Block::Audio {
800 url,
801 fallback,
802 caption: self.caption,
803 footnotes: self.footnotes,
804 },
805 _ => {
806 return Err(EpubBuilderError::MissingNecessaryBlockData {
807 block_type: "Audio".to_string(),
808 missing_data: "'url' or 'fallback'".to_string(),
809 }
810 .into());
811 }
812 },
813
814 BlockType::Video => match (self.url, self.fallback) {
815 (Some(url), Some(fallback)) => Block::Video {
816 url,
817 fallback,
818 caption: self.caption,
819 footnotes: self.footnotes,
820 },
821 _ => {
822 return Err(EpubBuilderError::MissingNecessaryBlockData {
823 block_type: "Video".to_string(),
824 missing_data: "'url' or 'fallback'".to_string(),
825 }
826 .into());
827 }
828 },
829
830 BlockType::MathML => {
831 if let Some(element_str) = self.element_str {
832 Block::MathML {
833 element_str,
834 fallback_image: self.fallback_image,
835 caption: self.caption,
836 footnotes: self.footnotes,
837 }
838 } else {
839 return Err(EpubBuilderError::MissingNecessaryBlockData {
840 block_type: "MathML".to_string(),
841 missing_data: "'element_str'".to_string(),
842 }
843 .into());
844 }
845 }
846 };
847
848 Self::validate_footnotes(&block)?;
849 Ok(block)
850 }
851
852 fn is_target_type(path: &PathBuf, types: Vec<MatcherType>) -> Result<(), EpubError> {
862 if !path.is_file() {
863 return Err(EpubBuilderError::TargetIsNotFile {
864 target_path: path.to_string_lossy().to_string(),
865 }
866 .into());
867 }
868
869 let mut file = File::open(path)?;
870 let mut buf = [0; 512];
871 let read_size = file.read(&mut buf)?;
872 let header_bytes = &buf[..read_size];
873
874 match Infer::new().get(header_bytes) {
875 Some(file_type) if !types.contains(&file_type.matcher_type()) => {
876 Err(EpubBuilderError::NotExpectedFileFormat.into())
877 }
878
879 None => Err(EpubBuilderError::UnknownFileFormat {
880 file_path: path.to_string_lossy().to_string(),
881 }
882 .into()),
883
884 _ => Ok(()),
885 }
886 }
887
888 fn validate_footnotes(block: &Block) -> Result<(), EpubError> {
895 match block {
896 Block::Text { content, footnotes }
897 | Block::Quote { content, footnotes }
898 | Block::Title { content, footnotes, .. } => {
899 let max_locate = content.chars().count();
900 for footnote in footnotes.iter() {
901 if footnote.locate == 0 || footnote.locate > content.chars().count() {
902 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate }.into());
903 }
904 }
905
906 Ok(())
907 }
908
909 Block::Image { caption, footnotes, .. }
910 | Block::MathML { caption, footnotes, .. }
911 | Block::Video { caption, footnotes, .. }
912 | Block::Audio { caption, footnotes, .. } => {
913 if let Some(caption) = caption {
914 let max_locate = caption.chars().count();
915 for footnote in footnotes.iter() {
916 if footnote.locate == 0 || footnote.locate > caption.chars().count() {
917 return Err(
918 EpubBuilderError::InvalidFootnoteLocate { max_locate }.into()
919 );
920 }
921 }
922 } else if !footnotes.is_empty() {
923 return Err(EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into());
924 }
925
926 Ok(())
927 }
928 }
929 }
930}
931
932#[derive(Debug)]
938pub struct ContentBuilder {
939 pub id: String,
945
946 blocks: Vec<Block>,
947 language: String,
948 title: String,
949
950 pub(crate) temp_dir: PathBuf,
951}
952
953impl ContentBuilder {
954 pub fn new(id: &str, language: &str) -> Result<Self, EpubError> {
962 let temp_dir = env::temp_dir().join(local_time());
963 fs::create_dir(&temp_dir)?;
964
965 Ok(Self {
966 id: id.to_string(),
967 blocks: vec![],
968 language: language.to_string(),
969 title: String::new(),
970 temp_dir,
971 })
972 }
973
974 pub fn set_title(&mut self, title: &str) -> &mut Self {
981 self.title = title.to_string();
982 self
983 }
984
985 pub fn add_block(&mut self, block: Block) -> Result<&mut Self, EpubError> {
992 self.blocks.push(block);
993
994 match self.blocks.last() {
995 Some(Block::Image { .. }) | Some(Block::Audio { .. }) | Some(Block::Video { .. }) => {
996 self.handle_resource()?
997 }
998
999 Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1000 self.handle_resource()?;
1001 }
1002
1003 _ => {}
1004 }
1005
1006 Ok(self)
1007 }
1008
1009 pub fn add_text_block(
1017 &mut self,
1018 content: &str,
1019 footnotes: Vec<Footnote>,
1020 ) -> Result<&mut Self, EpubError> {
1021 let mut builder = BlockBuilder::new(BlockType::Text);
1022 builder.set_content(content).set_footnotes(footnotes);
1023
1024 self.blocks.push(builder.build()?);
1025 Ok(self)
1026 }
1027
1028 pub fn add_quote_block(
1036 &mut self,
1037 content: &str,
1038 footnotes: Vec<Footnote>,
1039 ) -> Result<&mut Self, EpubError> {
1040 let mut builder = BlockBuilder::new(BlockType::Quote);
1041 builder.set_content(content).set_footnotes(footnotes);
1042
1043 self.blocks.push(builder.build()?);
1044 Ok(self)
1045 }
1046
1047 pub fn add_title_block(
1056 &mut self,
1057 content: &str,
1058 level: usize,
1059 footnotes: Vec<Footnote>,
1060 ) -> Result<&mut Self, EpubError> {
1061 let mut builder = BlockBuilder::new(BlockType::Title);
1062 builder
1063 .set_content(content)
1064 .set_title_level(level)
1065 .set_footnotes(footnotes);
1066
1067 self.blocks.push(builder.build()?);
1068 Ok(self)
1069 }
1070
1071 pub fn add_image_block(
1082 &mut self,
1083 url: PathBuf,
1084 alt: Option<String>,
1085 caption: Option<String>,
1086 footnotes: Vec<Footnote>,
1087 ) -> Result<&mut Self, EpubError> {
1088 let mut builder = BlockBuilder::new(BlockType::Image);
1089 builder.set_url(&url)?.set_footnotes(footnotes);
1090
1091 if let Some(alt) = &alt {
1092 builder.set_alt(alt);
1093 }
1094
1095 if let Some(caption) = &caption {
1096 builder.set_caption(caption);
1097 }
1098
1099 self.blocks.push(builder.build()?);
1100 self.handle_resource()?;
1101 Ok(self)
1102 }
1103
1104 pub fn add_audio_block(
1115 &mut self,
1116 url: PathBuf,
1117 fallback: String,
1118 caption: Option<String>,
1119 footnotes: Vec<Footnote>,
1120 ) -> Result<&mut Self, EpubError> {
1121 let mut builder = BlockBuilder::new(BlockType::Audio);
1122 builder
1123 .set_url(&url)?
1124 .set_fallback(&fallback)
1125 .set_footnotes(footnotes);
1126
1127 if let Some(caption) = &caption {
1128 builder.set_caption(caption);
1129 }
1130
1131 self.blocks.push(builder.build()?);
1132 self.handle_resource()?;
1133 Ok(self)
1134 }
1135
1136 pub fn add_video_block(
1147 &mut self,
1148 url: PathBuf,
1149 fallback: String,
1150 caption: Option<String>,
1151 footnotes: Vec<Footnote>,
1152 ) -> Result<&mut Self, EpubError> {
1153 let mut builder = BlockBuilder::new(BlockType::Video);
1154 builder
1155 .set_url(&url)?
1156 .set_fallback(&fallback)
1157 .set_footnotes(footnotes);
1158
1159 if let Some(caption) = &caption {
1160 builder.set_caption(caption);
1161 }
1162
1163 self.blocks.push(builder.build()?);
1164 self.handle_resource()?;
1165 Ok(self)
1166 }
1167
1168 pub fn add_mathml_block(
1179 &mut self,
1180 element_str: String,
1181 fallback_image: Option<PathBuf>,
1182 caption: Option<String>,
1183 footnotes: Vec<Footnote>,
1184 ) -> Result<&mut Self, EpubError> {
1185 let mut builder = BlockBuilder::new(BlockType::MathML);
1186 builder
1187 .set_mathml_element(&element_str)
1188 .set_footnotes(footnotes);
1189
1190 if let Some(fallback_image) = fallback_image {
1191 builder.set_fallback_image(fallback_image)?;
1192 }
1193
1194 if let Some(caption) = &caption {
1195 builder.set_caption(caption);
1196 }
1197
1198 self.blocks.push(builder.build()?);
1199 self.handle_resource()?;
1200 Ok(self)
1201 }
1202
1203 pub fn remove_last_block(&mut self) -> &mut Self {
1207 self.blocks.pop();
1208 self
1209 }
1210
1211 pub fn take_last_block(&mut self) -> Option<Block> {
1220 self.blocks.pop()
1221 }
1222
1223 pub fn clear_blocks(&mut self) -> &mut Self {
1227 self.blocks.clear();
1228 self
1229 }
1230
1231 pub fn make<P: AsRef<Path>>(&mut self, target: P) -> Result<Vec<PathBuf>, EpubError> {
1240 let mut result = Vec::new();
1241
1242 let target_dir = match target.as_ref().parent() {
1244 Some(path) => {
1245 fs::create_dir_all(path)?;
1246 path.to_path_buf()
1247 }
1248 None => {
1249 return Err(EpubBuilderError::InvalidTargetPath {
1250 target_path: target.as_ref().to_string_lossy().to_string(),
1251 }
1252 .into());
1253 }
1254 };
1255
1256 self.make_content(&target)?;
1257 result.push(target.as_ref().to_path_buf());
1258
1259 for resource_type in ["img", "audio", "video"] {
1261 let source = self.temp_dir.join(resource_type);
1262 if source.exists() && source.is_dir() {
1263 let target = target_dir.join(resource_type);
1264 fs::create_dir_all(&target)?;
1265
1266 for entry in fs::read_dir(&source)? {
1267 let entry = entry?;
1268 if entry.file_type()?.is_file() {
1269 let file_name = entry.file_name();
1270 let target = target.join(&file_name);
1271
1272 fs::copy(source.join(&file_name), &target)?;
1273 result.push(target);
1274 }
1275 }
1276 }
1277 }
1278
1279 Ok(result)
1280 }
1281
1282 fn make_content<P: AsRef<Path>>(&mut self, target_path: P) -> Result<(), EpubError> {
1289 let mut writer = Writer::new(Cursor::new(Vec::new()));
1290
1291 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1292 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
1293 ("xmlns", "http://www.w3.org/1999/xhtml"),
1294 ("xml:lang", self.language.as_str()),
1295 ])))?;
1296
1297 writer.write_event(Event::Start(BytesStart::new("head")))?;
1299 writer.write_event(Event::Start(BytesStart::new("title")))?;
1300 writer.write_event(Event::Text(BytesText::new(&self.title)))?;
1301 writer.write_event(Event::End(BytesEnd::new("title")))?;
1302 writer.write_event(Event::End(BytesEnd::new("head")))?;
1303
1304 writer.write_event(Event::Start(BytesStart::new("body")))?;
1306
1307 let mut footnote_index = 1;
1308 let mut footnotes = Vec::new();
1309 for block in self.blocks.iter_mut() {
1310 block.make(&mut writer, footnote_index)?;
1311
1312 footnotes.append(&mut block.take_footnotes());
1313 footnote_index = footnotes.len() + 1;
1314 }
1315
1316 Self::make_footnotes(&mut writer, footnotes)?;
1317 writer.write_event(Event::End(BytesEnd::new("body")))?;
1318 writer.write_event(Event::End(BytesEnd::new("html")))?;
1319
1320 let file_path = PathBuf::from(target_path.as_ref());
1321 let file_data = writer.into_inner().into_inner();
1322 fs::write(file_path, file_data)?;
1323
1324 Ok(())
1325 }
1326
1327 fn make_footnotes(writer: &mut XmlWriter, footnotes: Vec<Footnote>) -> Result<(), EpubError> {
1332 writer.write_event(Event::Start(BytesStart::new("aside")))?;
1333 writer.write_event(Event::Start(BytesStart::new("ul")))?;
1334
1335 let mut index = 1;
1336 for footnote in footnotes.into_iter() {
1337 writer.write_event(Event::Start(
1338 BytesStart::new("li")
1339 .with_attributes([("id", format!("footnote-{}", index).as_str())]),
1340 ))?;
1341 writer.write_event(Event::Start(BytesStart::new("p")))?;
1342
1343 writer.write_event(Event::Start(
1344 BytesStart::new("a")
1345 .with_attributes([("href", format!("#ref-{}", index).as_str())]),
1346 ))?;
1347 writer.write_event(Event::Text(BytesText::new(&format!("[{}]", index,))))?;
1348 writer.write_event(Event::End(BytesEnd::new("a")))?;
1349 writer.write_event(Event::Text(BytesText::new(&footnote.content)))?;
1350
1351 writer.write_event(Event::End(BytesEnd::new("p")))?;
1352 writer.write_event(Event::End(BytesEnd::new("li")))?;
1353
1354 index += 1;
1355 }
1356
1357 writer.write_event(Event::End(BytesEnd::new("ul")))?;
1358 writer.write_event(Event::End(BytesEnd::new("aside")))?;
1359
1360 Ok(())
1361 }
1362
1363 fn handle_resource(&mut self) -> Result<(), EpubError> {
1365 match self.blocks.last() {
1366 Some(Block::Image { url, .. }) => {
1367 let target_dir = self.temp_dir.join("img");
1368 fs::create_dir_all(&target_dir)?;
1369
1370 let target_path = target_dir.join(url.file_name().unwrap());
1371 fs::copy(url, &target_path)?;
1372 }
1373
1374 Some(Block::Video { url, .. }) => {
1375 let target_dir = self.temp_dir.join("video");
1376 fs::create_dir_all(&target_dir)?;
1377
1378 let target_path = target_dir.join(url.file_name().unwrap());
1379 fs::copy(url, &target_path)?;
1380 }
1381
1382 Some(Block::Audio { url, .. }) => {
1383 let target_dir = self.temp_dir.join("audio");
1384 fs::create_dir_all(&target_dir)?;
1385
1386 let target_path = target_dir.join(url.file_name().unwrap());
1387 fs::copy(url, &target_path)?;
1388 }
1389
1390 Some(Block::MathML { fallback_image, .. }) if fallback_image.is_some() => {
1391 let target_dir = self.temp_dir.join("img");
1392 fs::create_dir_all(&target_dir)?;
1393
1394 let target_path =
1395 target_dir.join(fallback_image.as_ref().unwrap().file_name().unwrap());
1396
1397 fs::copy(fallback_image.as_ref().unwrap(), &target_path)?;
1398 }
1399
1400 Some(_) => {}
1401 None => {}
1402 }
1403
1404 Ok(())
1405 }
1406}
1407
1408impl Drop for ContentBuilder {
1409 fn drop(&mut self) {
1410 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1411 warn!("{}", err);
1412 };
1413 }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 mod block_builder_tests {
1419 use std::path::PathBuf;
1420
1421 use crate::{
1422 builder::content::{Block, BlockBuilder},
1423 error::EpubBuilderError,
1424 types::{BlockType, Footnote},
1425 };
1426
1427 #[test]
1428 fn test_create_text_block() {
1429 let mut builder = BlockBuilder::new(BlockType::Text);
1430 builder.set_content("Hello, World!");
1431
1432 let block = builder.build();
1433 assert!(block.is_ok());
1434
1435 let block = block.unwrap();
1436 match block {
1437 Block::Text { content, footnotes } => {
1438 assert_eq!(content, "Hello, World!");
1439 assert!(footnotes.is_empty());
1440 }
1441 _ => unreachable!(),
1442 }
1443 }
1444
1445 #[test]
1446 fn test_create_text_block_missing_content() {
1447 let builder = BlockBuilder::new(BlockType::Text);
1448
1449 let block = builder.build();
1450 assert!(block.is_err());
1451
1452 let result = block.unwrap_err();
1453 assert_eq!(
1454 result,
1455 EpubBuilderError::MissingNecessaryBlockData {
1456 block_type: "Text".to_string(),
1457 missing_data: "'content'".to_string()
1458 }
1459 .into()
1460 )
1461 }
1462
1463 #[test]
1464 fn test_create_quote_block() {
1465 let mut builder = BlockBuilder::new(BlockType::Quote);
1466 builder.set_content("To be or not to be");
1467
1468 let block = builder.build();
1469 assert!(block.is_ok());
1470
1471 let block = block.unwrap();
1472 match block {
1473 Block::Quote { content, footnotes } => {
1474 assert_eq!(content, "To be or not to be");
1475 assert!(footnotes.is_empty());
1476 }
1477 _ => unreachable!(),
1478 }
1479 }
1480
1481 #[test]
1482 fn test_create_title_block() {
1483 let mut builder = BlockBuilder::new(BlockType::Title);
1484 builder.set_content("Chapter 1").set_title_level(2);
1485
1486 let block = builder.build();
1487 assert!(block.is_ok());
1488
1489 let block = block.unwrap();
1490 match block {
1491 Block::Title { content, level, footnotes } => {
1492 assert_eq!(content, "Chapter 1");
1493 assert_eq!(level, 2);
1494 assert!(footnotes.is_empty());
1495 }
1496 _ => unreachable!(),
1497 }
1498 }
1499
1500 #[test]
1501 fn test_create_title_block_invalid_level() {
1502 let mut builder = BlockBuilder::new(BlockType::Title);
1503 builder.set_content("Chapter 1").set_title_level(10);
1504
1505 let result = builder.build();
1506 assert!(result.is_err());
1507
1508 let result = result.unwrap_err();
1509 assert_eq!(
1510 result,
1511 EpubBuilderError::MissingNecessaryBlockData {
1512 block_type: "Title".to_string(),
1513 missing_data: "'content' or 'level'".to_string(),
1514 }
1515 .into()
1516 );
1517 }
1518
1519 #[test]
1520 fn test_create_image_block() {
1521 let img_path = PathBuf::from("./test_case/image.jpg");
1522 let mut builder = BlockBuilder::new(BlockType::Image);
1523 builder
1524 .set_url(&img_path)
1525 .unwrap()
1526 .set_alt("Test Image")
1527 .set_caption("A test image");
1528
1529 let block = builder.build();
1530 assert!(block.is_ok());
1531
1532 let block = block.unwrap();
1533 match block {
1534 Block::Image { url, alt, caption, footnotes } => {
1535 assert_eq!(url.file_name().unwrap(), "image.jpg");
1536 assert_eq!(alt, Some("Test Image".to_string()));
1537 assert_eq!(caption, Some("A test image".to_string()));
1538 assert!(footnotes.is_empty());
1539 }
1540 _ => unreachable!(),
1541 }
1542 }
1543
1544 #[test]
1545 fn test_create_image_block_missing_url() {
1546 let builder = BlockBuilder::new(BlockType::Image);
1547
1548 let block = builder.build();
1549 assert!(block.is_err());
1550
1551 let result = block.unwrap_err();
1552 assert_eq!(
1553 result,
1554 EpubBuilderError::MissingNecessaryBlockData {
1555 block_type: "Image".to_string(),
1556 missing_data: "'url'".to_string(),
1557 }
1558 .into()
1559 );
1560 }
1561
1562 #[test]
1563 fn test_create_audio_block() {
1564 let audio_path = PathBuf::from("./test_case/audio.mp3");
1565 let mut builder = BlockBuilder::new(BlockType::Audio);
1566 builder
1567 .set_url(&audio_path)
1568 .unwrap()
1569 .set_fallback("Audio not supported")
1570 .set_caption("Background music");
1571
1572 let block = builder.build();
1573 assert!(block.is_ok());
1574
1575 let block = block.unwrap();
1576 match block {
1577 Block::Audio { url, fallback, caption, footnotes } => {
1578 assert_eq!(url.file_name().unwrap(), "audio.mp3");
1579 assert_eq!(fallback, "Audio not supported");
1580 assert_eq!(caption, Some("Background music".to_string()));
1581 assert!(footnotes.is_empty());
1582 }
1583 _ => unreachable!(),
1584 }
1585 }
1586
1587 #[test]
1588 fn test_create_video_block() {
1589 let video_path = PathBuf::from("./test_case/video.mp4");
1590 let mut builder = BlockBuilder::new(BlockType::Video);
1591 builder
1592 .set_url(&video_path)
1593 .unwrap()
1594 .set_fallback("Video not supported")
1595 .set_caption("Demo video");
1596
1597 let block = builder.build();
1598 assert!(block.is_ok());
1599
1600 let block = block.unwrap();
1601 match block {
1602 Block::Video { url, fallback, caption, footnotes } => {
1603 assert_eq!(url.file_name().unwrap(), "video.mp4");
1604 assert_eq!(fallback, "Video not supported");
1605 assert_eq!(caption, Some("Demo video".to_string()));
1606 assert!(footnotes.is_empty());
1607 }
1608 _ => unreachable!(),
1609 }
1610 }
1611
1612 #[test]
1613 fn test_create_mathml_block() {
1614 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>1</mn></mrow></math>"#;
1615 let mut builder = BlockBuilder::new(BlockType::MathML);
1616 builder
1617 .set_mathml_element(mathml_content)
1618 .set_caption("Simple equation");
1619
1620 let block = builder.build();
1621 assert!(block.is_ok());
1622
1623 let block = block.unwrap();
1624 match block {
1625 Block::MathML {
1626 element_str,
1627 fallback_image,
1628 caption,
1629 footnotes,
1630 } => {
1631 assert_eq!(element_str, mathml_content);
1632 assert!(fallback_image.is_none());
1633 assert_eq!(caption, Some("Simple equation".to_string()));
1634 assert!(footnotes.is_empty());
1635 }
1636 _ => unreachable!(),
1637 }
1638 }
1639
1640 #[test]
1641 fn test_create_mathml_block_with_fallback() {
1642 let img_path = PathBuf::from("./test_case/image.jpg");
1643 let mathml_content = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
1644
1645 let mut builder = BlockBuilder::new(BlockType::MathML);
1646 builder
1647 .set_mathml_element(mathml_content)
1648 .set_fallback_image(img_path.clone())
1649 .unwrap();
1650
1651 let block = builder.build();
1652 assert!(block.is_ok());
1653
1654 let block = block.unwrap();
1655 match block {
1656 Block::MathML { element_str, fallback_image, .. } => {
1657 assert_eq!(element_str, mathml_content);
1658 assert!(fallback_image.is_some());
1659 }
1660 _ => unreachable!(),
1661 }
1662 }
1663
1664 #[test]
1665 fn test_footnote_management() {
1666 let mut builder = BlockBuilder::new(BlockType::Text);
1667 builder.set_content("This is a test");
1668
1669 let note1 = Footnote {
1670 locate: 5,
1671 content: "First footnote".to_string(),
1672 };
1673 let note2 = Footnote {
1674 locate: 10,
1675 content: "Second footnote".to_string(),
1676 };
1677
1678 builder.add_footnote(note1).add_footnote(note2);
1679
1680 let block = builder.build();
1681 assert!(block.is_ok());
1682
1683 let block = block.unwrap();
1684 match block {
1685 Block::Text { footnotes, .. } => {
1686 assert_eq!(footnotes.len(), 2);
1687 }
1688 _ => unreachable!(),
1689 }
1690 }
1691
1692 #[test]
1693 fn test_remove_last_footnote() {
1694 let mut builder = BlockBuilder::new(BlockType::Text);
1695 builder.set_content("This is a test");
1696
1697 builder.add_footnote(Footnote { locate: 5, content: "Note 1".to_string() });
1698 builder.add_footnote(Footnote {
1699 locate: 10,
1700 content: "Note 2".to_string(),
1701 });
1702 builder.remove_last_footnote();
1703
1704 let block = builder.build();
1705 assert!(block.is_ok());
1706
1707 let block = block.unwrap();
1708 match block {
1709 Block::Text { footnotes, .. } => {
1710 assert_eq!(footnotes.len(), 1);
1711 assert!(footnotes[0].content == "Note 1");
1712 }
1713 _ => unreachable!(),
1714 }
1715 }
1716
1717 #[test]
1718 fn test_clear_footnotes() {
1719 let mut builder = BlockBuilder::new(BlockType::Text);
1720 builder.set_content("This is a test");
1721
1722 builder.add_footnote(Footnote { locate: 5, content: "Note".to_string() });
1723
1724 builder.clear_footnotes();
1725
1726 let block = builder.build();
1727 assert!(block.is_ok());
1728
1729 let block = block.unwrap();
1730 match block {
1731 Block::Text { footnotes, .. } => {
1732 assert!(footnotes.is_empty());
1733 }
1734 _ => unreachable!(),
1735 }
1736 }
1737
1738 #[test]
1739 fn test_invalid_footnote_locate() {
1740 let mut builder = BlockBuilder::new(BlockType::Text);
1741 builder.set_content("Hello");
1742
1743 builder.add_footnote(Footnote {
1745 locate: 100,
1746 content: "Invalid footnote".to_string(),
1747 });
1748
1749 let result = builder.build();
1750 assert!(result.is_err());
1751
1752 let result = result.unwrap_err();
1753 assert_eq!(
1754 result,
1755 EpubBuilderError::InvalidFootnoteLocate { max_locate: 5 }.into()
1756 );
1757 }
1758
1759 #[test]
1760 fn test_footnote_on_media_without_caption() {
1761 let img_path = PathBuf::from("./test_case/image.jpg");
1762 let mut builder = BlockBuilder::new(BlockType::Image);
1763 builder.set_url(&img_path).unwrap();
1764
1765 builder.add_footnote(Footnote { locate: 1, content: "Note".to_string() });
1766
1767 let result = builder.build();
1768 assert!(result.is_err());
1769
1770 let result = result.unwrap_err();
1771 assert_eq!(
1772 result,
1773 EpubBuilderError::InvalidFootnoteLocate { max_locate: 0 }.into()
1774 );
1775 }
1776 }
1777
1778 mod content_builder_tests {
1779 use std::{env, fs, path::PathBuf};
1780
1781 use crate::{
1782 builder::content::{Block, ContentBuilder},
1783 types::Footnote,
1784 utils::local_time,
1785 };
1786
1787 #[test]
1788 fn test_create_content_builder() {
1789 let builder = ContentBuilder::new("chapter1", "en");
1790 assert!(builder.is_ok());
1791
1792 let builder = builder.unwrap();
1793 assert_eq!(builder.id, "chapter1");
1794 }
1795
1796 #[test]
1797 fn test_set_title() {
1798 let builder = ContentBuilder::new("chapter1", "en");
1799 assert!(builder.is_ok());
1800
1801 let mut builder = builder.unwrap();
1802 builder.set_title("My Chapter").set_title("Another Title");
1803
1804 assert_eq!(builder.title, "Another Title");
1805 }
1806
1807 #[test]
1808 fn test_add_text_block() {
1809 let builder = ContentBuilder::new("chapter1", "en");
1810 assert!(builder.is_ok());
1811
1812 let mut builder = builder.unwrap();
1813 let result = builder.add_text_block("This is a paragraph", vec![]);
1814 assert!(result.is_ok());
1815 }
1816
1817 #[test]
1818 fn test_add_quote_block() {
1819 let builder = ContentBuilder::new("chapter1", "en");
1820 assert!(builder.is_ok());
1821
1822 let mut builder = builder.unwrap();
1823 let result = builder.add_quote_block("A quoted text", vec![]);
1824 assert!(result.is_ok());
1825 }
1826
1827 #[test]
1828 fn test_add_title_block() {
1829 let builder = ContentBuilder::new("chapter1", "en");
1830 assert!(builder.is_ok());
1831
1832 let mut builder = builder.unwrap();
1833 let result = builder.add_title_block("Section Title", 2, vec![]);
1834 assert!(result.is_ok());
1835 }
1836
1837 #[test]
1838 fn test_add_image_block() {
1839 let img_path = PathBuf::from("./test_case/image.jpg");
1840 let builder = ContentBuilder::new("chapter1", "en");
1841 assert!(builder.is_ok());
1842
1843 let mut builder = builder.unwrap();
1844 let result = builder.add_image_block(
1845 img_path,
1846 Some("Alt text".to_string()),
1847 Some("Figure 1: An image".to_string()),
1848 vec![],
1849 );
1850
1851 assert!(result.is_ok());
1852 }
1853
1854 #[test]
1855 fn test_add_audio_block() {
1856 let audio_path = PathBuf::from("./test_case/audio.mp3");
1857 let builder = ContentBuilder::new("chapter1", "en");
1858 assert!(builder.is_ok());
1859
1860 let mut builder = builder.unwrap();
1861 let result = builder.add_audio_block(
1862 audio_path,
1863 "Your browser doesn't support audio".to_string(),
1864 Some("Background music".to_string()),
1865 vec![],
1866 );
1867
1868 assert!(result.is_ok());
1869 }
1870
1871 #[test]
1872 fn test_add_video_block() {
1873 let video_path = PathBuf::from("./test_case/video.mp4");
1874 let builder = ContentBuilder::new("chapter1", "en");
1875 assert!(builder.is_ok());
1876
1877 let mut builder = builder.unwrap();
1878 let result = builder.add_video_block(
1879 video_path,
1880 "Your browser doesn't support video".to_string(),
1881 Some("Tutorial video".to_string()),
1882 vec![],
1883 );
1884
1885 assert!(result.is_ok());
1886 }
1887
1888 #[test]
1889 fn test_add_mathml_block() {
1890 let mathml = r#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi></mrow></math>"#;
1891 let builder = ContentBuilder::new("chapter1", "en");
1892 assert!(builder.is_ok());
1893
1894 let mut builder = builder.unwrap();
1895 let result = builder.add_mathml_block(
1896 mathml.to_string(),
1897 None,
1898 Some("Equation 1".to_string()),
1899 vec![],
1900 );
1901
1902 assert!(result.is_ok());
1903 }
1904
1905 #[test]
1906 fn test_remove_last_block() {
1907 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
1908
1909 builder.add_text_block("First block", vec![]).unwrap();
1910 builder.add_text_block("Second block", vec![]).unwrap();
1911 assert_eq!(builder.blocks.len(), 2);
1912
1913 builder.remove_last_block();
1914 assert_eq!(builder.blocks.len(), 1);
1915 }
1916
1917 #[test]
1918 fn test_take_last_block() {
1919 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
1920
1921 builder.add_text_block("Block content", vec![]).unwrap();
1922
1923 let block = builder.take_last_block();
1924 assert!(block.is_some());
1925
1926 let block = block.unwrap();
1927 match block {
1928 Block::Text { content, .. } => {
1929 assert_eq!(content, "Block content");
1930 }
1931 _ => unreachable!(),
1932 }
1933
1934 let block2 = builder.take_last_block();
1935 assert!(block2.is_none());
1936 }
1937
1938 #[test]
1939 fn test_clear_blocks() {
1940 let mut builder = ContentBuilder::new("chapter1", "en").unwrap();
1941
1942 builder.add_text_block("Block 1", vec![]).unwrap();
1943 builder.add_text_block("Block 2", vec![]).unwrap();
1944 assert_eq!(builder.blocks.len(), 2);
1945
1946 builder.clear_blocks();
1947
1948 let block = builder.take_last_block();
1949 assert!(block.is_none());
1950 }
1951
1952 #[test]
1953 fn test_make_content_document() {
1954 let temp_dir = env::temp_dir().join(local_time());
1955 assert!(fs::create_dir_all(&temp_dir).is_ok());
1956
1957 let output_path = temp_dir.join("chapter.xhtml");
1958
1959 let builder = ContentBuilder::new("chapter1", "en");
1960 assert!(builder.is_ok());
1961
1962 let mut builder = builder.unwrap();
1963 builder
1964 .set_title("My Chapter")
1965 .add_text_block("This is the first paragraph.", vec![])
1966 .unwrap()
1967 .add_text_block("This is the second paragraph.", vec![])
1968 .unwrap();
1969
1970 let result = builder.make(&output_path);
1971 assert!(result.is_ok());
1972 assert!(output_path.exists());
1973 assert!(fs::remove_dir_all(temp_dir).is_ok());
1974 }
1975
1976 #[test]
1977 fn test_make_content_with_media() {
1978 let temp_dir = env::temp_dir().join(local_time());
1979 assert!(fs::create_dir_all(&temp_dir).is_ok());
1980
1981 let output_path = temp_dir.join("chapter.xhtml");
1982 let img_path = PathBuf::from("./test_case/image.jpg");
1983
1984 let builder = ContentBuilder::new("chapter1", "en");
1985 assert!(builder.is_ok());
1986
1987 let mut builder = builder.unwrap();
1988 builder
1989 .set_title("Chapter with Media")
1990 .add_text_block("See image below:", vec![])
1991 .unwrap()
1992 .add_image_block(
1993 img_path,
1994 Some("Test".to_string()),
1995 Some("Figure 1".to_string()),
1996 vec![],
1997 )
1998 .unwrap();
1999
2000 let result = builder.make(&output_path);
2001 assert!(result.is_ok());
2002
2003 let img_dir = temp_dir.join("img");
2004 assert!(img_dir.exists());
2005 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2006 }
2007
2008 #[test]
2009 fn test_make_content_with_footnotes() {
2010 let temp_dir = env::temp_dir().join(local_time());
2011 assert!(fs::create_dir_all(&temp_dir).is_ok());
2012
2013 let output_path = temp_dir.join("chapter.xhtml");
2014
2015 let footnotes = vec![
2016 Footnote {
2017 locate: 10,
2018 content: "This is a footnote".to_string(),
2019 },
2020 Footnote {
2021 locate: 15,
2022 content: "Another footnote".to_string(),
2023 },
2024 ];
2025
2026 let builder = ContentBuilder::new("chapter1", "en");
2027 assert!(builder.is_ok());
2028
2029 let mut builder = builder.unwrap();
2030 builder
2031 .set_title("Chapter with Notes")
2032 .add_text_block("This is a paragraph with notes.", footnotes)
2033 .unwrap();
2034
2035 let result = builder.make(&output_path);
2036 assert!(result.is_ok());
2037 assert!(output_path.exists());
2038 assert!(fs::remove_dir_all(&temp_dir).is_ok());
2039 }
2040 }
2041
2042 mod block_tests {
2043 use std::path::PathBuf;
2044
2045 use crate::{builder::content::Block, types::Footnote};
2046
2047 #[test]
2048 fn test_take_footnotes_from_text_block() {
2049 let footnotes = vec![Footnote { locate: 5, content: "Note".to_string() }];
2050
2051 let block = Block::Text {
2052 content: "Hello world".to_string(),
2053 footnotes: footnotes.clone(),
2054 };
2055
2056 let taken = block.take_footnotes();
2057 assert_eq!(taken.len(), 1);
2058 assert_eq!(taken[0].content, "Note");
2059 }
2060
2061 #[test]
2062 fn test_take_footnotes_from_quote_block() {
2063 let footnotes = vec![
2064 Footnote { locate: 3, content: "First".to_string() },
2065 Footnote { locate: 8, content: "Second".to_string() },
2066 ];
2067
2068 let block = Block::Quote {
2069 content: "Test quote".to_string(),
2070 footnotes: footnotes.clone(),
2071 };
2072
2073 let taken = block.take_footnotes();
2074 assert_eq!(taken.len(), 2);
2075 }
2076
2077 #[test]
2078 fn test_take_footnotes_from_image_block() {
2079 let img_path = PathBuf::from("test.png");
2080 let footnotes = vec![Footnote {
2081 locate: 2,
2082 content: "Image note".to_string(),
2083 }];
2084
2085 let block = Block::Image {
2086 url: img_path,
2087 alt: None,
2088 caption: Some("A caption".to_string()),
2089 footnotes: footnotes.clone(),
2090 };
2091
2092 let taken = block.take_footnotes();
2093 assert_eq!(taken.len(), 1);
2094 }
2095
2096 #[test]
2097 fn test_block_with_empty_footnotes() {
2098 let block = Block::Text {
2099 content: "No footnotes here".to_string(),
2100 footnotes: vec![],
2101 };
2102
2103 let taken = block.take_footnotes();
2104 assert!(taken.is_empty());
2105 }
2106 }
2107
2108 mod content_rendering_tests {
2109 use crate::builder::content::Block;
2110
2111 #[test]
2112 fn test_split_content_by_index_empty() {
2113 let result = Block::split_content_by_index("Hello", &[]);
2114 assert_eq!(result, vec!["Hello"]);
2115 }
2116
2117 #[test]
2118 fn test_split_content_by_single_index() {
2119 let result = Block::split_content_by_index("Hello World", &[5]);
2120 assert_eq!(result.len(), 2);
2121 assert_eq!(result[0], "Hello");
2122 assert_eq!(result[1], " World");
2123 }
2124
2125 #[test]
2126 fn test_split_content_by_multiple_indices() {
2127 let result = Block::split_content_by_index("One Two Three", &[3, 7]);
2128 assert_eq!(result.len(), 3);
2129 assert_eq!(result[0], "One");
2130 assert_eq!(result[1], " Two");
2131 assert_eq!(result[2], " Three");
2132 }
2133
2134 #[test]
2135 fn test_split_content_unicode() {
2136 let content = "你好世界";
2137 let result = Block::split_content_by_index(content, &[2]);
2138 assert_eq!(result.len(), 2);
2139 assert_eq!(result[0], "你好");
2140 assert_eq!(result[1], "世界");
2141 }
2142 }
2143}