1use std::{
41 cmp::Reverse,
42 env,
43 fs::{self, File},
44 io::{BufReader, Cursor, Read, Seek},
45 marker::PhantomData,
46 path::{Path, PathBuf},
47};
48
49use log::warn;
50use quick_xml::{
51 Writer,
52 events::{BytesDecl, BytesEnd, BytesStart, Event},
53};
54use walkdir::WalkDir;
55use zip::{CompressionMethod, ZipWriter, write::FileOptions};
56
57#[cfg(feature = "content-builder")]
58use crate::builder::content::ContentBuilder;
59use crate::{
60 epub::EpubDoc,
61 error::{EpubBuilderError, EpubError},
62 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
63 utils::{check_realtive_link_leakage, local_time, remove_leading_slash},
64};
65
66#[cfg(feature = "content-builder")]
67pub mod content;
68
69pub use components::CatalogBuilder;
70#[cfg(feature = "content-builder")]
71pub use components::DocumentBuilder;
72pub use components::ManifestBuilder;
73pub use components::MetadataBuilder;
74pub use components::RootfileBuilder;
75pub use components::SpineBuilder;
76
77pub(crate) mod components;
78
79type XmlWriter = Writer<Cursor<Vec<u8>>>;
80
81#[cfg_attr(test, derive(Debug))]
83pub struct EpubVersion3;
84
85#[cfg_attr(test, derive(Debug))]
144pub struct EpubBuilder<Version> {
145 epub_version: PhantomData<Version>,
147
148 pub(crate) temp_dir: PathBuf,
150
151 pub(crate) rootfiles: RootfileBuilder,
152 pub(crate) metadata: MetadataBuilder,
153 pub(crate) manifest: ManifestBuilder,
154 pub(crate) spine: SpineBuilder,
155 pub(crate) catalog: CatalogBuilder,
156
157 #[cfg(feature = "content-builder")]
158 pub(crate) content: DocumentBuilder,
159}
160
161impl EpubBuilder<EpubVersion3> {
162 pub fn new() -> Result<Self, EpubError> {
168 let temp_dir = env::temp_dir().join(local_time());
169 fs::create_dir(&temp_dir)?;
170 fs::create_dir(temp_dir.join("META-INF"))?;
171
172 let mime_file = temp_dir.join("mimetype");
173 fs::write(mime_file, "application/epub+zip")?;
174
175 Ok(EpubBuilder {
176 epub_version: PhantomData,
177 temp_dir: temp_dir.clone(),
178
179 rootfiles: RootfileBuilder::new(),
180 metadata: MetadataBuilder::new(),
181 manifest: ManifestBuilder::new(temp_dir),
182 spine: SpineBuilder::new(),
183 catalog: CatalogBuilder::new(),
184
185 #[cfg(feature = "content-builder")]
186 content: DocumentBuilder::new(),
187 })
188 }
189
190 pub fn add_rootfile(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
202 match self.rootfiles.add(rootfile) {
203 Ok(_) => Ok(self),
204 Err(err) => Err(err),
205 }
206 }
207
208 pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
216 let _ = self.metadata.add(item);
217 self
218 }
219
220 pub fn add_manifest(
237 &mut self,
238 manifest_source: impl Into<String>,
239 manifest_item: ManifestItem,
240 ) -> Result<&mut Self, EpubError> {
241 if self.rootfiles.is_empty() {
242 return Err(EpubBuilderError::MissingRootfile.into());
243 } else {
244 self.manifest
245 .set_rootfile(self.rootfiles.first().expect("Unreachable"));
246 }
247
248 match self.manifest.add(manifest_source, manifest_item) {
249 Ok(_) => Ok(self),
250 Err(err) => Err(err),
251 }
252 }
253
254 pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
261 self.spine.add(item);
262 self
263 }
264
265 pub fn set_catalog_title(&mut self, title: impl Into<String>) -> &mut Self {
270 let _ = self.catalog.set_title(title);
271 self
272 }
273
274 pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
281 let _ = self.catalog.add(item);
282 self
283 }
284
285 #[cfg(feature = "content-builder")]
294 pub fn add_content(
295 &mut self,
296 target_path: impl AsRef<str>,
297 content: ContentBuilder,
298 ) -> &mut Self {
299 self.content.add(target_path, content);
300 self
301 }
302
303 pub fn clear_all(&mut self) -> &mut Self {
312 self.rootfiles.clear();
313 self.metadata.clear();
314 self.manifest.clear();
315 self.spine.clear();
316 self.catalog.clear();
317 #[cfg(feature = "content-builder")]
318 self.content.clear();
319
320 self
321 }
322
323 pub fn rootfile(&mut self) -> &mut RootfileBuilder {
330 &mut self.rootfiles
331 }
332
333 pub fn metadata(&mut self) -> &mut MetadataBuilder {
340 &mut self.metadata
341 }
342
343 pub fn manifest(&mut self) -> &mut ManifestBuilder {
350 &mut self.manifest
351 }
352
353 pub fn spine(&mut self) -> &mut SpineBuilder {
360 &mut self.spine
361 }
362
363 pub fn catalog(&mut self) -> &mut CatalogBuilder {
370 &mut self.catalog
371 }
372
373 #[cfg(feature = "content-builder")]
380 pub fn content(&mut self) -> &mut DocumentBuilder {
381 &mut self.content
382 }
383
384 pub fn make(mut self, output_path: impl AsRef<Path>) -> Result<(), EpubError> {
393 self.make_container_xml()?;
397 self.make_navigation_document()?;
398 #[cfg(feature = "content-builder")]
399 self.make_contents()?;
400 self.make_opf_file()?;
401 self.remove_empty_dirs()?;
402
403 if let Some(parent) = output_path.as_ref().parent() {
404 if !parent.exists() {
405 fs::create_dir_all(parent)?;
406 }
407 }
408
409 let file = File::create(output_path)?;
411 let mut zip = ZipWriter::new(file);
412 let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
413
414 for entry in WalkDir::new(&self.temp_dir) {
415 let entry = entry?;
416 let path = entry.path();
417
418 let relative_path = path.strip_prefix(&self.temp_dir).unwrap();
421 let target_path = relative_path.to_string_lossy().replace("\\", "/");
422
423 if path.is_file() {
424 zip.start_file(target_path, options)?;
425
426 let mut file = File::open(path)?;
427 std::io::copy(&mut file, &mut zip)?;
428 } else if path.is_dir() {
429 zip.add_directory(target_path, options)?;
430 }
431 }
432
433 zip.finish()?;
434 Ok(())
435 }
436
437 pub fn build(
448 self,
449 output_path: impl AsRef<Path>,
450 ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
451 self.make(&output_path)?;
452
453 EpubDoc::new(output_path)
454 }
455
456 pub fn from<R: Read + Seek>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
483 let mut builder = Self::new()?;
484
485 builder.add_rootfile(doc.package_path.clone().to_string_lossy())?;
486 builder.metadata.metadata = doc.metadata.clone();
487 builder.spine.spine = doc.spine.clone();
488 builder.catalog.catalog = doc.catalog.clone();
489 builder.catalog.title = doc.catalog_title.clone();
490
491 for (_, mut manifest) in doc.manifest.clone().into_iter() {
493 if let Some(properties) = &manifest.properties {
494 if properties.contains("nav") {
495 continue;
496 }
497 }
498
499 manifest.path = PathBuf::from("/").join(manifest.path);
503
504 let (buf, _) = doc.get_manifest_item(&manifest.id)?; let target_path = normalize_manifest_path(
506 &builder.temp_dir,
507 builder.rootfiles.first().expect("Unreachable"),
508 &manifest.path,
509 &manifest.id,
510 )?;
511 if let Some(parent_dir) = target_path.parent() {
512 if !parent_dir.exists() {
513 fs::create_dir_all(parent_dir)?
514 }
515 }
516
517 fs::write(target_path, buf)?;
518 builder
519 .manifest
520 .manifest
521 .insert(manifest.id.clone(), manifest);
522 }
523
524 Ok(builder)
525 }
526
527 fn make_container_xml(&self) -> Result<(), EpubError> {
531 if self.rootfiles.is_empty() {
532 return Err(EpubBuilderError::MissingRootfile.into());
533 }
534
535 let mut writer = Writer::new(Cursor::new(Vec::new()));
536 self.rootfiles.make(&mut writer)?;
537
538 let file_path = self.temp_dir.join("META-INF").join("container.xml");
539 let file_data = writer.into_inner().into_inner();
540 fs::write(file_path, file_data)?;
541
542 Ok(())
543 }
544
545 #[cfg(feature = "content-builder")]
547 fn make_contents(&mut self) -> Result<(), EpubError> {
548 let manifest_list = self.content.make(
549 self.temp_dir.clone(),
550 self.rootfiles.first().expect("Unreachable"),
551 )?;
552
553 for item in manifest_list.into_iter() {
554 self.manifest.insert(item.id.clone(), item);
555 }
556
557 Ok(())
558 }
559
560 fn make_navigation_document(&mut self) -> Result<(), EpubError> {
564 if self.catalog.is_empty() {
565 return Err(EpubBuilderError::NavigationInfoUninitalized.into());
566 }
567
568 let mut writer = Writer::new(Cursor::new(Vec::new()));
569 self.catalog.make(&mut writer)?;
570
571 let file_path = self.temp_dir.join("nav.xhtml");
572 let file_data = writer.into_inner().into_inner();
573 fs::write(file_path, file_data)?;
574
575 self.manifest.insert(
576 "nav".to_string(),
577 ManifestItem {
578 id: "nav".to_string(),
579 path: PathBuf::from("/nav.xhtml"),
580 mime: "application/xhtml+xml".to_string(),
581 properties: Some("nav".to_string()),
582 fallback: None,
583 },
584 );
585
586 Ok(())
587 }
588
589 fn make_opf_file(&mut self) -> Result<(), EpubError> {
596 self.metadata.validate()?;
597 self.manifest.validate()?;
598 self.spine.validate(self.manifest.keys())?;
599
600 let mut writer = Writer::new(Cursor::new(Vec::new()));
601
602 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
603
604 writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
605 ("xmlns", "http://www.idpf.org/2007/opf"),
606 ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
607 ("unique-identifier", "pub-id"),
608 ("version", "3.0"),
609 ])))?;
610
611 self.metadata.make(&mut writer)?;
612 self.manifest.make(&mut writer)?;
613 self.spine.make(&mut writer)?;
614
615 writer.write_event(Event::End(BytesEnd::new("package")))?;
616
617 let file_path = self
618 .temp_dir
619 .join(self.rootfiles.first().expect("Unreachable"));
620 let file_data = writer.into_inner().into_inner();
621 fs::write(file_path, file_data)?;
622
623 Ok(())
624 }
625
626 fn remove_empty_dirs(&self) -> Result<(), EpubError> {
637 let mut dirs = WalkDir::new(self.temp_dir.as_path())
638 .min_depth(1)
639 .into_iter()
640 .filter_map(|entry| entry.ok())
641 .filter(|entry| entry.file_type().is_dir())
642 .map(|entry| entry.into_path())
643 .collect::<Vec<PathBuf>>();
644
645 dirs.sort_by_key(|p| Reverse(p.components().count()));
646
647 for dir in dirs {
648 if fs::read_dir(&dir)?.next().is_none() {
649 fs::remove_dir(dir)?;
650 }
651 }
652
653 Ok(())
654 }
655}
656
657impl<Version> Drop for EpubBuilder<Version> {
658 fn drop(&mut self) {
660 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
661 warn!("{}", err);
662 };
663 }
664}
665
666fn refine_mime_type<'a>(infer_mime: &'a str, extension: &'a str) -> &'a str {
672 match (infer_mime, extension) {
673 ("text/xml", "xhtml")
674 | ("application/xml", "xhtml")
675 | ("text/xml", "xht")
676 | ("application/xml", "xht") => "application/xhtml+xml",
677
678 ("text/xml", "opf") | ("application/xml", "opf") => "application/oebps-package+xml",
679
680 ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml",
681
682 ("application/zip", "epub") => "application/epub+zip",
683
684 ("text/plain", "css") => "text/css",
685 ("text/plain", "js") => "application/javascript",
686 ("text/plain", "json") => "application/json",
687 ("text/plain", "svg") => "image/svg+xml",
688
689 _ => infer_mime,
690 }
691}
692
693fn normalize_manifest_path<TempD: AsRef<Path>, S: AsRef<str>, P: AsRef<Path>>(
715 temp_dir: TempD,
716 rootfile: S,
717 path: P,
718 id: &str,
719) -> Result<PathBuf, EpubError> {
720 let opf_path = PathBuf::from(rootfile.as_ref());
721 let basic_path = remove_leading_slash(opf_path.parent().unwrap());
722
723 let mut target_path = if path.as_ref().starts_with("../") {
725 check_realtive_link_leakage(
726 temp_dir.as_ref().to_path_buf(),
727 basic_path.to_path_buf(),
728 &path.as_ref().to_string_lossy(),
729 )
730 .map(PathBuf::from)
731 .ok_or_else(|| EpubError::RelativeLinkLeakage {
732 path: path.as_ref().to_string_lossy().to_string(),
733 })?
734 } else if let Ok(path) = path.as_ref().strip_prefix("/") {
735 temp_dir.as_ref().join(path)
736 } else if path.as_ref().starts_with("./") {
737 Err(EpubBuilderError::IllegalManifestPath { manifest_id: id.to_string() })?
739 } else {
740 temp_dir.as_ref().join(basic_path).join(path)
741 };
742
743 #[cfg(windows)]
744 {
745 target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
746 }
747
748 Ok(target_path)
749}
750
751#[cfg(test)]
752mod tests {
753 use std::{env, fs, path::PathBuf};
754
755 use crate::{
756 builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
757 epub::EpubDoc,
758 error::{EpubBuilderError, EpubError},
759 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
760 utils::local_time,
761 };
762
763 mod test_helpers {
764 use super::*;
765
766 pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
767 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
768 builder.add_rootfile("content.opf").unwrap();
769 builder.add_metadata(MetadataItem::new("title", "Test Book"));
770 builder.add_metadata(MetadataItem::new("language", "en"));
771 builder.add_metadata(
772 MetadataItem::new("identifier", "urn:isbn:1234567890")
773 .with_id("pub-id")
774 .build(),
775 );
776 builder
777 }
778
779 pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
780 let mut builder = create_basic_builder();
781 builder.add_catalog_item(NavPoint::new("Chapter"));
782 builder.add_spine(SpineItem::new("test"));
783 builder
784 }
785 }
786
787 mod epub_builder_tests {
788 use super::*;
789
790 #[test]
791 fn test_epub_builder_new() {
792 let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
793 assert!(builder.temp_dir.exists());
794 assert!(builder.rootfiles.is_empty());
795 assert!(builder.metadata.metadata.is_empty());
796 assert!(builder.manifest.manifest.is_empty());
797 assert!(builder.spine.spine.is_empty());
798 assert!(builder.catalog.title.is_empty());
799 assert!(builder.catalog.is_empty());
800 }
801
802 #[test]
803 fn test_add_rootfile() {
804 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
805
806 builder
807 .add_rootfile("content.opf")
808 .expect("Failed to add rootfile");
809 assert_eq!(builder.rootfiles.rootfiles.len(), 1);
810 assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
811
812 builder
813 .add_rootfile("./another.opf")
814 .expect("Failed to add another rootfile");
815 assert_eq!(builder.rootfiles.rootfiles.len(), 2);
816 assert_eq!(
817 builder.rootfiles.rootfiles,
818 vec!["content.opf", "another.opf"]
819 );
820 }
821
822 #[test]
823 fn test_add_rootfile_fail() {
824 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
825
826 let result = builder.add_rootfile("/rootfile.opf");
827 assert!(result.is_err());
828 assert_eq!(
829 result.unwrap_err(),
830 EpubBuilderError::IllegalRootfilePath.into()
831 );
832
833 let result = builder.add_rootfile("../rootfile.opf");
834 assert!(result.is_err());
835 assert_eq!(
836 result.unwrap_err(),
837 EpubBuilderError::IllegalRootfilePath.into()
838 );
839 }
840
841 #[test]
842 fn test_add_metadata() {
843 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
844 let metadata_item = MetadataItem::new("title", "Test Book");
845
846 builder.add_metadata(metadata_item);
847
848 assert_eq!(builder.metadata.metadata.len(), 1);
849 assert_eq!(builder.metadata.metadata[0].property, "title");
850 assert_eq!(builder.metadata.metadata[0].value, "Test Book");
851 }
852
853 #[test]
854 fn test_add_spine() {
855 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
856 let spine_item = SpineItem::new("test_item");
857
858 builder.add_spine(spine_item);
859
860 assert_eq!(builder.spine.spine.len(), 1);
861 assert_eq!(builder.spine.spine[0].idref, "test_item");
862 }
863
864 #[test]
865 fn test_set_catalog_title() {
866 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
867 let title = "Test Catalog Title";
868
869 builder.set_catalog_title(title);
870
871 assert_eq!(builder.catalog.title, title);
872 }
873
874 #[test]
875 fn test_add_catalog_item() {
876 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
877 let nav_point = NavPoint::new("Chapter 1");
878
879 builder.add_catalog_item(nav_point);
880
881 assert_eq!(builder.catalog.catalog.len(), 1);
882 assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
883 }
884
885 #[test]
886 fn test_clear_all() {
887 let mut builder = test_helpers::create_full_builder();
888
889 assert_eq!(builder.metadata.metadata.len(), 3);
890 assert_eq!(builder.spine.spine.len(), 1);
891 assert_eq!(builder.catalog.catalog.len(), 1);
892
893 builder.clear_all();
894
895 assert!(builder.metadata.metadata.is_empty());
896 assert!(builder.spine.spine.is_empty());
897 assert!(builder.catalog.catalog.is_empty());
898 assert!(builder.catalog.title.is_empty());
899 assert!(builder.manifest.manifest.is_empty());
900
901 builder.add_metadata(MetadataItem::new("title", "New Book"));
902 builder.add_spine(SpineItem::new("new_chapter"));
903 builder.add_catalog_item(NavPoint::new("New Chapter"));
904
905 assert_eq!(builder.metadata.metadata.len(), 1);
906 assert_eq!(builder.spine.spine.len(), 1);
907 assert_eq!(builder.catalog.catalog.len(), 1);
908 }
909
910 #[test]
911 fn test_make() {
912 let mut builder = test_helpers::create_full_builder();
913
914 builder
915 .add_manifest(
916 "./test_case/Overview.xhtml",
917 ManifestItem {
918 id: "test".to_string(),
919 path: PathBuf::from("test.xhtml"),
920 mime: String::new(),
921 properties: None,
922 fallback: None,
923 },
924 )
925 .unwrap();
926
927 let file = env::temp_dir().join(format!("{}.epub", local_time()));
928 assert!(builder.make(&file).is_ok());
929 assert!(EpubDoc::new(&file).is_ok());
930 }
931
932 #[test]
933 fn test_build() {
934 let mut builder = test_helpers::create_full_builder();
935
936 builder
937 .add_manifest(
938 "./test_case/Overview.xhtml",
939 ManifestItem {
940 id: "test".to_string(),
941 path: PathBuf::from("test.xhtml"),
942 mime: String::new(),
943 properties: None,
944 fallback: None,
945 },
946 )
947 .unwrap();
948
949 let file = env::temp_dir().join(format!("{}.epub", local_time()));
950 assert!(builder.build(&file).is_ok());
951 }
952
953 #[test]
954 fn test_from() {
955 let metadata = vec![
956 MetadataItem {
957 id: None,
958 property: "title".to_string(),
959 value: "Test Book".to_string(),
960 lang: None,
961 refined: vec![],
962 },
963 MetadataItem {
964 id: None,
965 property: "language".to_string(),
966 value: "en".to_string(),
967 lang: None,
968 refined: vec![],
969 },
970 MetadataItem {
971 id: Some("pub-id".to_string()),
972 property: "identifier".to_string(),
973 value: "test-book".to_string(),
974 lang: None,
975 refined: vec![],
976 },
977 ];
978 let spine = vec![SpineItem {
979 id: None,
980 idref: "main".to_string(),
981 linear: true,
982 properties: None,
983 }];
984 let catalog = vec![
985 NavPoint {
986 label: "Nav".to_string(),
987 content: None,
988 children: vec![],
989 play_order: None,
990 },
991 NavPoint {
992 label: "Overview".to_string(),
993 content: None,
994 children: vec![],
995 play_order: None,
996 },
997 ];
998
999 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1000 builder.add_rootfile("content.opf").unwrap();
1001 builder.metadata.metadata = metadata.clone();
1002 builder.spine.spine = spine.clone();
1003 builder.catalog.catalog = catalog.clone();
1004 builder.set_catalog_title("catalog title");
1005 builder
1006 .add_manifest(
1007 "./test_case/Overview.xhtml",
1008 ManifestItem {
1009 id: "main".to_string(),
1010 path: PathBuf::from("Overview.xhtml"),
1011 mime: String::new(),
1012 properties: None,
1013 fallback: None,
1014 },
1015 )
1016 .unwrap();
1017
1018 let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1019 builder.make(&epub_file).unwrap();
1020
1021 let mut doc = EpubDoc::new(&epub_file).unwrap();
1022 let builder = EpubBuilder::from(&mut doc).unwrap();
1023
1024 assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
1025 assert_eq!(builder.manifest.manifest.len(), 1);
1026 assert_eq!(builder.spine.spine.len(), spine.len());
1027 assert_eq!(builder.catalog.catalog, catalog);
1028 assert_eq!(builder.catalog.title, "catalog title");
1029 }
1030
1031 #[test]
1032 fn test_make_container_file() {
1033 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1034
1035 let result = builder.make_container_xml();
1036 assert!(result.is_err());
1037 assert_eq!(
1038 result.unwrap_err(),
1039 EpubBuilderError::MissingRootfile.into()
1040 );
1041
1042 builder.add_rootfile("content.opf").unwrap();
1043 assert!(builder.make_container_xml().is_ok());
1044 }
1045
1046 #[test]
1047 fn test_make_navigation_document() {
1048 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1049
1050 let result = builder.make_navigation_document();
1051 assert!(result.is_err());
1052 assert_eq!(
1053 result.unwrap_err(),
1054 EpubBuilderError::NavigationInfoUninitalized.into()
1055 );
1056
1057 builder.add_catalog_item(NavPoint::new("test"));
1058 assert!(builder.make_navigation_document().is_ok());
1059 }
1060
1061 #[test]
1062 fn test_make_opf_file_success() {
1063 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1064
1065 builder.add_rootfile("content.opf").unwrap();
1066 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1067 builder.add_metadata(MetadataItem::new("language", "en"));
1068 builder.add_metadata(
1069 MetadataItem::new("identifier", "urn:isbn:1234567890")
1070 .with_id("pub-id")
1071 .build(),
1072 );
1073
1074 let test_file = builder.temp_dir.join("test.xhtml");
1075 fs::write(&test_file, "<html></html>").unwrap();
1076 builder
1077 .add_manifest(
1078 test_file.to_str().unwrap(),
1079 ManifestItem::new("test", "test.xhtml").unwrap(),
1080 )
1081 .unwrap();
1082
1083 builder.add_catalog_item(NavPoint::new("Chapter"));
1084 builder.add_spine(SpineItem::new("test"));
1085 builder.make_navigation_document().unwrap();
1086
1087 assert!(builder.make_opf_file().is_ok());
1088 assert!(builder.temp_dir.join("content.opf").exists());
1089 }
1090
1091 #[test]
1092 fn test_make_opf_file_missing_metadata() {
1093 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1094 builder.add_rootfile("content.opf").unwrap();
1095
1096 let result = builder.make_opf_file();
1097 assert!(result.is_err());
1098 assert_eq!(
1099 result.unwrap_err().to_string(),
1100 "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1101 );
1102 }
1103 }
1104
1105 mod manifest_tests {
1106 use super::*;
1107
1108 #[test]
1109 fn test_add_manifest_success() {
1110 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1111 builder.add_rootfile("content.opf").unwrap();
1112
1113 let test_file = builder.temp_dir.join("test.xhtml");
1114 fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
1115
1116 let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
1117 let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
1118
1119 assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
1120 assert_eq!(builder.manifest.manifest.len(), 1);
1121 assert!(builder.manifest.manifest.contains_key("test"));
1122 }
1123
1124 #[test]
1125 fn test_add_manifest_no_rootfile() {
1126 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1127
1128 let manifest_item = ManifestItem {
1129 id: "main".to_string(),
1130 path: PathBuf::from("/Overview.xhtml"),
1131 mime: String::new(),
1132 properties: None,
1133 fallback: None,
1134 };
1135
1136 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
1137 assert!(result.is_err());
1138 assert_eq!(
1139 result.unwrap_err(),
1140 EpubBuilderError::MissingRootfile.into()
1141 );
1142
1143 builder.add_rootfile("package.opf").unwrap();
1144 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
1145 assert!(result.is_ok());
1146 }
1147
1148 #[test]
1149 fn test_add_manifest_nonexistent_file() {
1150 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1151 builder.add_rootfile("content.opf").unwrap();
1152
1153 let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
1154 let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
1155
1156 assert!(result.is_err());
1157 assert_eq!(
1158 result.unwrap_err(),
1159 EpubBuilderError::TargetIsNotFile {
1160 target_path: "nonexistent.xhtml".to_string()
1161 }
1162 .into()
1163 );
1164 }
1165
1166 #[test]
1167 fn test_add_manifest_unknown_file_format() {
1168 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1169 builder.add_rootfile("package.opf").unwrap();
1170
1171 let result = builder.add_manifest(
1172 "./test_case/unknown_file_format.xhtml",
1173 ManifestItem {
1174 id: "file".to_string(),
1175 path: PathBuf::from("unknown_file_format.xhtml"),
1176 mime: String::new(),
1177 properties: None,
1178 fallback: None,
1179 },
1180 );
1181
1182 assert!(result.is_err());
1183 assert_eq!(
1184 result.unwrap_err(),
1185 EpubBuilderError::UnknownFileFormat {
1186 file_path: "./test_case/unknown_file_format.xhtml".to_string(),
1187 }
1188 .into()
1189 );
1190 }
1191
1192 #[test]
1193 fn test_validate_fallback_chain_valid() {
1194 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1195
1196 let item3 = ManifestItem::new("item3", "path3").unwrap();
1197 let item2 = ManifestItem::new("item2", "path2")
1198 .unwrap()
1199 .with_fallback("item3")
1200 .build();
1201 let item1 = ManifestItem::new("item1", "path1")
1202 .unwrap()
1203 .with_fallback("item2")
1204 .append_property("nav")
1205 .build();
1206
1207 builder.manifest.insert("item3".to_string(), item3);
1208 builder.manifest.insert("item2".to_string(), item2);
1209 builder.manifest.insert("item1".to_string(), item1);
1210
1211 assert!(builder.manifest.validate().is_ok());
1212 }
1213
1214 #[test]
1215 fn test_validate_fallback_chain_circular_reference() {
1216 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1217
1218 let item2 = ManifestItem::new("item2", "path2")
1219 .unwrap()
1220 .with_fallback("item1")
1221 .build();
1222 let item1 = ManifestItem::new("item1", "path1")
1223 .unwrap()
1224 .with_fallback("item2")
1225 .build();
1226
1227 builder.manifest.insert("item1".to_string(), item1);
1228 builder.manifest.insert("item2".to_string(), item2);
1229
1230 let result = builder.manifest.validate();
1231 assert!(result.is_err());
1232 assert!(result.unwrap_err().to_string().starts_with(
1233 "Epub builder error: Circular reference detected in fallback chain for"
1234 ));
1235 }
1236
1237 #[test]
1238 fn test_validate_fallback_chain_not_found() {
1239 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1240
1241 let item1 = ManifestItem::new("item1", "path1")
1242 .unwrap()
1243 .with_fallback("nonexistent")
1244 .build();
1245
1246 builder.manifest.insert("item1".to_string(), item1);
1247
1248 let result = builder.manifest.validate();
1249 assert!(result.is_err());
1250 assert_eq!(
1251 result.unwrap_err().to_string(),
1252 "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1253 );
1254 }
1255
1256 #[test]
1257 fn test_validate_manifest_nav_single() {
1258 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1259
1260 let nav_item = ManifestItem::new("nav", "nav.xhtml")
1261 .unwrap()
1262 .append_property("nav")
1263 .build();
1264 builder
1265 .manifest
1266 .manifest
1267 .insert("nav".to_string(), nav_item);
1268
1269 assert!(builder.manifest.validate().is_ok());
1270 }
1271
1272 #[test]
1273 fn test_validate_manifest_nav_multiple() {
1274 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1275
1276 let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1277 .unwrap()
1278 .append_property("nav")
1279 .build();
1280 let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1281 .unwrap()
1282 .append_property("nav")
1283 .build();
1284
1285 builder
1286 .manifest
1287 .manifest
1288 .insert("nav1".to_string(), nav_item1);
1289 builder
1290 .manifest
1291 .manifest
1292 .insert("nav2".to_string(), nav_item2);
1293
1294 let result = builder.manifest.validate();
1295 assert!(result.is_err());
1296 assert_eq!(
1297 result.unwrap_err().to_string(),
1298 "Epub builder error: There are too many items with 'nav' property in the manifest."
1299 );
1300 }
1301 }
1302
1303 mod metadata_tests {
1304 use super::*;
1305
1306 #[test]
1307 fn test_validate_metadata_success() {
1308 let builder = test_helpers::create_basic_builder();
1309 assert!(builder.metadata.validate().is_ok());
1310 }
1311
1312 #[test]
1313 fn test_validate_metadata_missing_required() {
1314 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1315 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1316 builder.add_metadata(MetadataItem::new("language", "en"));
1317 assert!(builder.metadata.validate().is_err());
1318 }
1319 }
1320
1321 mod utility_tests {
1322 use super::*;
1323
1324 #[test]
1325 fn test_normalize_manifest_path() {
1326 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1327 builder.add_rootfile("content.opf").unwrap();
1328
1329 let result = normalize_manifest_path(
1330 &builder.temp_dir,
1331 builder.rootfiles.first().unwrap(),
1332 "../../test.xhtml",
1333 "id",
1334 );
1335 assert!(result.is_err());
1336 assert_eq!(
1337 result.unwrap_err(),
1338 EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
1339 );
1340
1341 let result = normalize_manifest_path(
1342 &builder.temp_dir,
1343 builder.rootfiles.first().unwrap(),
1344 "/test.xhtml",
1345 "id",
1346 );
1347 assert!(result.is_ok());
1348 assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
1349
1350 let result = normalize_manifest_path(
1351 &builder.temp_dir,
1352 builder.rootfiles.first().unwrap(),
1353 "./test.xhtml",
1354 "manifest_id",
1355 );
1356 assert!(result.is_err());
1357 assert_eq!(
1358 result.unwrap_err(),
1359 EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
1360 .into(),
1361 );
1362 }
1363
1364 #[test]
1365 fn test_refine_mime_type() {
1366 assert_eq!(
1367 refine_mime_type("text/xml", "xhtml"),
1368 "application/xhtml+xml"
1369 );
1370 assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
1371 assert_eq!(
1372 refine_mime_type("application/xml", "opf"),
1373 "application/oebps-package+xml"
1374 );
1375 assert_eq!(
1376 refine_mime_type("text/xml", "ncx"),
1377 "application/x-dtbncx+xml"
1378 );
1379 assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1380 assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1381 }
1382 }
1383
1384 #[cfg(feature = "content-builder")]
1385 mod content_builder_tests {
1386 use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
1387
1388 #[test]
1389 fn test_make_contents_basic() {
1390 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1391 builder.add_rootfile("content.opf").unwrap();
1392
1393 let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
1394 content_builder
1395 .set_title("Test Chapter")
1396 .add_text_block("This is a test paragraph.", vec![])
1397 .unwrap();
1398
1399 builder.add_content("OEBPS/chapter1.xhtml", content_builder);
1400
1401 assert!(builder.make_contents().is_ok());
1402 assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
1403 }
1404
1405 #[test]
1406 fn test_make_contents_multiple_blocks() {
1407 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1408 builder.add_rootfile("content.opf").unwrap();
1409
1410 let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
1411 content_builder
1412 .set_title("多个区块章节")
1413 .add_text_block("第一段文本。", vec![])
1414 .unwrap()
1415 .add_quote_block("这是一个引用。", vec![])
1416 .unwrap()
1417 .add_title_block("子标题", 2, vec![])
1418 .unwrap()
1419 .add_text_block("最后的文本段落。", vec![])
1420 .unwrap();
1421
1422 builder.add_content("OEBPS/chapter2.xhtml", content_builder);
1423
1424 assert!(builder.make_contents().is_ok());
1425 assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
1426 }
1427
1428 #[test]
1429 fn test_make_contents_with_media() {
1430 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1431 builder.add_rootfile("content.opf").unwrap();
1432
1433 let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
1434 content_builder
1435 .set_title("Chapter with Media")
1436 .add_text_block("Text before image.", vec![])
1437 .unwrap()
1438 .add_image_block(
1439 std::path::PathBuf::from("./test_case/image.jpg"),
1440 Some("Test Image".to_string()),
1441 Some("Figure 1: A test image".to_string()),
1442 vec![],
1443 )
1444 .unwrap()
1445 .add_text_block("Text after image.", vec![])
1446 .unwrap();
1447
1448 builder.add_content("OEBPS/chapter3.xhtml", content_builder);
1449
1450 assert!(builder.make_contents().is_ok());
1451 assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
1452 assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
1453 }
1454
1455 #[test]
1456 fn test_make_contents_multiple_documents() {
1457 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1458 builder.add_rootfile("content.opf").unwrap();
1459
1460 for (id, title) in [
1461 ("ch1", "Chapter 1"),
1462 ("ch2", "Chapter 2"),
1463 ("ch3", "Chapter 3"),
1464 ] {
1465 let mut content = ContentBuilder::new(id, "en").unwrap();
1466 content
1467 .set_title(title)
1468 .add_text_block(&format!("Content of {}", title), vec![])
1469 .unwrap();
1470 builder.add_content(format!("OEBPS/{}.xhtml", id), content);
1471 }
1472
1473 assert!(builder.make_contents().is_ok());
1474 assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1475 assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1476 assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1477 }
1478
1479 #[test]
1480 fn test_make_contents_different_languages() {
1481 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1482 builder.add_rootfile("content.opf").unwrap();
1483
1484 let langs = [
1485 ("en_ch", "en", "English Chapter"),
1486 ("zh_ch", "zh-CN", "中文章节"),
1487 ("ja_ch", "ja", "日本語の章"),
1488 ];
1489
1490 for (id, lang, title) in langs {
1491 let mut content = ContentBuilder::new(id, lang).unwrap();
1492 content
1493 .set_title(title)
1494 .add_text_block(&format!("Text in {}", lang), vec![])
1495 .unwrap();
1496 builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
1497 }
1498
1499 assert!(builder.make_contents().is_ok());
1500 assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
1501 assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
1502 assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
1503 }
1504
1505 #[test]
1506 fn test_make_contents_unique_identifiers() {
1507 use std::path::PathBuf;
1508
1509 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1510 builder.add_rootfile("content.opf").unwrap();
1511
1512 let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
1513 content1.add_text_block("First content", vec![]).unwrap();
1514 builder.add_content("OEBPS/ch1.xhtml", content1);
1515
1516 let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
1517 content2.add_text_block("Second content", vec![]).unwrap();
1518 builder.add_content("OEBPS/ch2.xhtml", content2);
1519
1520 let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
1521 content3
1522 .add_text_block("Duplicate ID content", vec![])
1523 .unwrap();
1524 builder.add_content("OEBPS/ch3.xhtml", content3);
1525
1526 assert!(builder.make_contents().is_ok());
1527 assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1528 assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1529 assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1530
1531 let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
1532 assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
1533 }
1534
1535 #[test]
1536 fn test_make_contents_complex_structure() {
1537 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1538 builder.add_rootfile("content.opf").unwrap();
1539
1540 let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
1541 content
1542 .set_title("Complex Chapter")
1543 .add_title_block("Section 1", 2, vec![])
1544 .unwrap()
1545 .add_text_block("Introduction text.", vec![])
1546 .unwrap()
1547 .add_quote_block("A wise quote here.", vec![])
1548 .unwrap()
1549 .add_title_block("Section 2", 2, vec![])
1550 .unwrap()
1551 .add_text_block("More content with multiple paragraphs.", vec![])
1552 .unwrap()
1553 .add_text_block("Another paragraph.", vec![])
1554 .unwrap()
1555 .add_title_block("Section 3", 2, vec![])
1556 .unwrap()
1557 .add_quote_block("Another quotation.", vec![])
1558 .unwrap();
1559
1560 builder.add_content("OEBPS/complex_chapter.xhtml", content);
1561
1562 assert!(builder.make_contents().is_ok());
1563 assert!(
1564 builder
1565 .temp_dir
1566 .join("OEBPS/complex_chapter.xhtml")
1567 .exists()
1568 );
1569 }
1570
1571 #[test]
1572 fn test_make_contents_empty_document() {
1573 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1574 builder.add_rootfile("content.opf").unwrap();
1575
1576 let content = ContentBuilder::new("empty_ch", "en").unwrap();
1577 builder.add_content("OEBPS/empty.xhtml", content);
1578
1579 assert!(builder.make_contents().is_ok());
1580 assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
1581 }
1582
1583 #[test]
1584 fn test_make_contents_path_normalization() {
1585 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1586 builder.add_rootfile("OEBPS/content.opf").unwrap();
1587
1588 let mut content = ContentBuilder::new("path_test", "en").unwrap();
1589 content.add_text_block("Path test content", vec![]).unwrap();
1590
1591 builder.add_content("/OEBPS/text/chapter.xhtml", content);
1592
1593 assert!(builder.make_contents().is_ok());
1594 assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
1595 }
1596 }
1597}