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 + Send>(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 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(stripped) = path.as_ref().strip_prefix("/") {
735 temp_dir.as_ref().join(stripped)
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 let target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
745
746 Ok(target_path)
747}
748
749#[cfg(test)]
750mod tests {
751 use std::{env, fs, path::PathBuf};
752
753 use crate::{
754 builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
755 epub::EpubDoc,
756 error::{EpubBuilderError, EpubError},
757 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
758 utils::local_time,
759 };
760
761 mod test_helpers {
762 use super::*;
763
764 pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
765 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
766 builder.add_rootfile("content.opf").unwrap();
767 builder.add_metadata(MetadataItem::new("title", "Test Book"));
768 builder.add_metadata(MetadataItem::new("language", "en"));
769 builder.add_metadata(
770 MetadataItem::new("identifier", "urn:isbn:1234567890")
771 .with_id("pub-id")
772 .build(),
773 );
774 builder
775 }
776
777 pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
778 let mut builder = create_basic_builder();
779 builder.add_catalog_item(NavPoint::new("Chapter"));
780 builder.add_spine(SpineItem::new("test"));
781 builder
782 }
783 }
784
785 mod epub_builder_tests {
786 use super::*;
787
788 #[test]
789 fn test_epub_builder_new() {
790 let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
791 assert!(builder.temp_dir.exists());
792 assert!(builder.rootfiles.is_empty());
793 assert!(builder.metadata.metadata.is_empty());
794 assert!(builder.manifest.manifest.is_empty());
795 assert!(builder.spine.spine.is_empty());
796 assert!(builder.catalog.title.is_empty());
797 assert!(builder.catalog.is_empty());
798 }
799
800 #[test]
801 fn test_add_rootfile() {
802 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
803
804 builder
805 .add_rootfile("content.opf")
806 .expect("Failed to add rootfile");
807 assert_eq!(builder.rootfiles.rootfiles.len(), 1);
808 assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
809
810 builder
811 .add_rootfile("./another.opf")
812 .expect("Failed to add another rootfile");
813 assert_eq!(builder.rootfiles.rootfiles.len(), 2);
814 assert_eq!(
815 builder.rootfiles.rootfiles,
816 vec!["content.opf", "another.opf"]
817 );
818 }
819
820 #[test]
821 fn test_add_rootfile_fail() {
822 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
823
824 let result = builder.add_rootfile("/rootfile.opf");
825 assert!(result.is_err());
826 assert_eq!(
827 result.unwrap_err(),
828 EpubBuilderError::IllegalRootfilePath.into()
829 );
830
831 let result = builder.add_rootfile("../rootfile.opf");
832 assert!(result.is_err());
833 assert_eq!(
834 result.unwrap_err(),
835 EpubBuilderError::IllegalRootfilePath.into()
836 );
837 }
838
839 #[test]
840 fn test_add_metadata() {
841 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
842 let metadata_item = MetadataItem::new("title", "Test Book");
843
844 builder.add_metadata(metadata_item);
845
846 assert_eq!(builder.metadata.metadata.len(), 1);
847 assert_eq!(builder.metadata.metadata[0].property, "title");
848 assert_eq!(builder.metadata.metadata[0].value, "Test Book");
849 }
850
851 #[test]
852 fn test_add_spine() {
853 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
854 let spine_item = SpineItem::new("test_item");
855
856 builder.add_spine(spine_item);
857
858 assert_eq!(builder.spine.spine.len(), 1);
859 assert_eq!(builder.spine.spine[0].idref, "test_item");
860 }
861
862 #[test]
863 fn test_set_catalog_title() {
864 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
865 let title = "Test Catalog Title";
866
867 builder.set_catalog_title(title);
868
869 assert_eq!(builder.catalog.title, title);
870 }
871
872 #[test]
873 fn test_add_catalog_item() {
874 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
875 let nav_point = NavPoint::new("Chapter 1");
876
877 builder.add_catalog_item(nav_point);
878
879 assert_eq!(builder.catalog.catalog.len(), 1);
880 assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
881 }
882
883 #[test]
884 fn test_clear_all() {
885 let mut builder = test_helpers::create_full_builder();
886
887 assert_eq!(builder.metadata.metadata.len(), 3);
888 assert_eq!(builder.spine.spine.len(), 1);
889 assert_eq!(builder.catalog.catalog.len(), 1);
890
891 builder.clear_all();
892
893 assert!(builder.metadata.metadata.is_empty());
894 assert!(builder.spine.spine.is_empty());
895 assert!(builder.catalog.catalog.is_empty());
896 assert!(builder.catalog.title.is_empty());
897 assert!(builder.manifest.manifest.is_empty());
898
899 builder.add_metadata(MetadataItem::new("title", "New Book"));
900 builder.add_spine(SpineItem::new("new_chapter"));
901 builder.add_catalog_item(NavPoint::new("New Chapter"));
902
903 assert_eq!(builder.metadata.metadata.len(), 1);
904 assert_eq!(builder.spine.spine.len(), 1);
905 assert_eq!(builder.catalog.catalog.len(), 1);
906 }
907
908 #[test]
909 fn test_make() {
910 let mut builder = test_helpers::create_full_builder();
911
912 builder
913 .add_manifest(
914 "./test_case/Overview.xhtml",
915 ManifestItem {
916 id: "test".to_string(),
917 path: PathBuf::from("test.xhtml"),
918 mime: String::new(),
919 properties: None,
920 fallback: None,
921 },
922 )
923 .unwrap();
924
925 let file = env::temp_dir().join(format!("{}.epub", local_time()));
926 assert!(builder.make(&file).is_ok());
927 assert!(EpubDoc::new(&file).is_ok());
928 }
929
930 #[test]
931 fn test_build() {
932 let mut builder = test_helpers::create_full_builder();
933
934 builder
935 .add_manifest(
936 "./test_case/Overview.xhtml",
937 ManifestItem {
938 id: "test".to_string(),
939 path: PathBuf::from("test.xhtml"),
940 mime: String::new(),
941 properties: None,
942 fallback: None,
943 },
944 )
945 .unwrap();
946
947 let file = env::temp_dir().join(format!("{}.epub", local_time()));
948 assert!(builder.build(&file).is_ok());
949 }
950
951 #[test]
952 fn test_from() {
953 let metadata = vec![
954 MetadataItem {
955 id: None,
956 property: "title".to_string(),
957 value: "Test Book".to_string(),
958 lang: None,
959 refined: vec![],
960 },
961 MetadataItem {
962 id: None,
963 property: "language".to_string(),
964 value: "en".to_string(),
965 lang: None,
966 refined: vec![],
967 },
968 MetadataItem {
969 id: Some("pub-id".to_string()),
970 property: "identifier".to_string(),
971 value: "test-book".to_string(),
972 lang: None,
973 refined: vec![],
974 },
975 ];
976 let spine = vec![SpineItem {
977 id: None,
978 idref: "main".to_string(),
979 linear: true,
980 properties: None,
981 }];
982 let catalog = vec![
983 NavPoint {
984 label: "Nav".to_string(),
985 content: None,
986 children: vec![],
987 play_order: None,
988 },
989 NavPoint {
990 label: "Overview".to_string(),
991 content: None,
992 children: vec![],
993 play_order: None,
994 },
995 ];
996
997 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
998 builder.add_rootfile("content.opf").unwrap();
999 builder.metadata.metadata = metadata.clone();
1000 builder.spine.spine = spine.clone();
1001 builder.catalog.catalog = catalog.clone();
1002 builder.set_catalog_title("catalog title");
1003 builder
1004 .add_manifest(
1005 "./test_case/Overview.xhtml",
1006 ManifestItem {
1007 id: "main".to_string(),
1008 path: PathBuf::from("Overview.xhtml"),
1009 mime: String::new(),
1010 properties: None,
1011 fallback: None,
1012 },
1013 )
1014 .unwrap();
1015
1016 let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1017 builder.make(&epub_file).unwrap();
1018
1019 let mut doc = EpubDoc::new(&epub_file).unwrap();
1020 let builder = EpubBuilder::from(&mut doc).unwrap();
1021
1022 assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
1023 assert_eq!(builder.manifest.manifest.len(), 1);
1024 assert_eq!(builder.spine.spine.len(), spine.len());
1025 assert_eq!(builder.catalog.catalog, catalog);
1026 assert_eq!(builder.catalog.title, "catalog title");
1027 }
1028
1029 #[test]
1030 fn test_make_container_file() {
1031 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1032
1033 let result = builder.make_container_xml();
1034 assert!(result.is_err());
1035 assert_eq!(
1036 result.unwrap_err(),
1037 EpubBuilderError::MissingRootfile.into()
1038 );
1039
1040 builder.add_rootfile("content.opf").unwrap();
1041 assert!(builder.make_container_xml().is_ok());
1042 }
1043
1044 #[test]
1045 fn test_make_navigation_document() {
1046 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1047
1048 let result = builder.make_navigation_document();
1049 assert!(result.is_err());
1050 assert_eq!(
1051 result.unwrap_err(),
1052 EpubBuilderError::NavigationInfoUninitalized.into()
1053 );
1054
1055 builder.add_catalog_item(NavPoint::new("test"));
1056 assert!(builder.make_navigation_document().is_ok());
1057 }
1058
1059 #[test]
1060 fn test_make_opf_file_success() {
1061 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1062
1063 builder.add_rootfile("content.opf").unwrap();
1064 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1065 builder.add_metadata(MetadataItem::new("language", "en"));
1066 builder.add_metadata(
1067 MetadataItem::new("identifier", "urn:isbn:1234567890")
1068 .with_id("pub-id")
1069 .build(),
1070 );
1071
1072 let test_file = builder.temp_dir.join("test.xhtml");
1073 fs::write(&test_file, "<html></html>").unwrap();
1074 builder
1075 .add_manifest(
1076 test_file.to_str().unwrap(),
1077 ManifestItem::new("test", "test.xhtml").unwrap(),
1078 )
1079 .unwrap();
1080
1081 builder.add_catalog_item(NavPoint::new("Chapter"));
1082 builder.add_spine(SpineItem::new("test"));
1083 builder.make_navigation_document().unwrap();
1084
1085 assert!(builder.make_opf_file().is_ok());
1086 assert!(builder.temp_dir.join("content.opf").exists());
1087 }
1088
1089 #[test]
1090 fn test_make_opf_file_missing_metadata() {
1091 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1092 builder.add_rootfile("content.opf").unwrap();
1093
1094 let result = builder.make_opf_file();
1095 assert!(result.is_err());
1096 assert_eq!(
1097 result.unwrap_err().to_string(),
1098 "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1099 );
1100 }
1101 }
1102
1103 mod manifest_tests {
1104 use super::*;
1105
1106 #[test]
1107 fn test_add_manifest_success() {
1108 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1109 builder.add_rootfile("content.opf").unwrap();
1110
1111 let test_file = builder.temp_dir.join("test.xhtml");
1112 fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
1113
1114 let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
1115 let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
1116
1117 assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
1118 assert_eq!(builder.manifest.manifest.len(), 1);
1119 assert!(builder.manifest.manifest.contains_key("test"));
1120 }
1121
1122 #[test]
1123 fn test_add_manifest_no_rootfile() {
1124 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1125
1126 let manifest_item = ManifestItem {
1127 id: "main".to_string(),
1128 path: PathBuf::from("/Overview.xhtml"),
1129 mime: String::new(),
1130 properties: None,
1131 fallback: None,
1132 };
1133
1134 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
1135 assert!(result.is_err());
1136 assert_eq!(
1137 result.unwrap_err(),
1138 EpubBuilderError::MissingRootfile.into()
1139 );
1140
1141 builder.add_rootfile("package.opf").unwrap();
1142 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
1143 assert!(result.is_ok());
1144 }
1145
1146 #[test]
1147 fn test_add_manifest_nonexistent_file() {
1148 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1149 builder.add_rootfile("content.opf").unwrap();
1150
1151 let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
1152 let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
1153
1154 assert!(result.is_err());
1155 assert_eq!(
1156 result.unwrap_err(),
1157 EpubBuilderError::TargetIsNotFile {
1158 target_path: "nonexistent.xhtml".to_string()
1159 }
1160 .into()
1161 );
1162 }
1163
1164 #[test]
1165 fn test_add_manifest_unknown_file_format() {
1166 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1167 builder.add_rootfile("package.opf").unwrap();
1168
1169 let result = builder.add_manifest(
1170 "./test_case/unknown_file_format.xhtml",
1171 ManifestItem {
1172 id: "file".to_string(),
1173 path: PathBuf::from("unknown_file_format.xhtml"),
1174 mime: String::new(),
1175 properties: None,
1176 fallback: None,
1177 },
1178 );
1179
1180 assert!(result.is_err());
1181 assert_eq!(
1182 result.unwrap_err(),
1183 EpubBuilderError::UnknownFileFormat {
1184 file_path: "./test_case/unknown_file_format.xhtml".to_string(),
1185 }
1186 .into()
1187 );
1188 }
1189
1190 #[test]
1191 fn test_validate_fallback_chain_valid() {
1192 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1193
1194 let item3 = ManifestItem::new("item3", "path3").unwrap();
1195 let item2 = ManifestItem::new("item2", "path2")
1196 .unwrap()
1197 .with_fallback("item3")
1198 .build();
1199 let item1 = ManifestItem::new("item1", "path1")
1200 .unwrap()
1201 .with_fallback("item2")
1202 .append_property("nav")
1203 .build();
1204
1205 builder.manifest.insert("item3".to_string(), item3);
1206 builder.manifest.insert("item2".to_string(), item2);
1207 builder.manifest.insert("item1".to_string(), item1);
1208
1209 assert!(builder.manifest.validate().is_ok());
1210 }
1211
1212 #[test]
1213 fn test_validate_fallback_chain_circular_reference() {
1214 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1215
1216 let item2 = ManifestItem::new("item2", "path2")
1217 .unwrap()
1218 .with_fallback("item1")
1219 .build();
1220 let item1 = ManifestItem::new("item1", "path1")
1221 .unwrap()
1222 .with_fallback("item2")
1223 .build();
1224
1225 builder.manifest.insert("item1".to_string(), item1);
1226 builder.manifest.insert("item2".to_string(), item2);
1227
1228 let result = builder.manifest.validate();
1229 assert!(result.is_err());
1230 assert!(result.unwrap_err().to_string().starts_with(
1231 "Epub builder error: Circular reference detected in fallback chain for"
1232 ));
1233 }
1234
1235 #[test]
1236 fn test_validate_fallback_chain_not_found() {
1237 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1238
1239 let item1 = ManifestItem::new("item1", "path1")
1240 .unwrap()
1241 .with_fallback("nonexistent")
1242 .build();
1243
1244 builder.manifest.insert("item1".to_string(), item1);
1245
1246 let result = builder.manifest.validate();
1247 assert!(result.is_err());
1248 assert_eq!(
1249 result.unwrap_err().to_string(),
1250 "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1251 );
1252 }
1253
1254 #[test]
1255 fn test_validate_manifest_nav_single() {
1256 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1257
1258 let nav_item = ManifestItem::new("nav", "nav.xhtml")
1259 .unwrap()
1260 .append_property("nav")
1261 .build();
1262 builder
1263 .manifest
1264 .manifest
1265 .insert("nav".to_string(), nav_item);
1266
1267 assert!(builder.manifest.validate().is_ok());
1268 }
1269
1270 #[test]
1271 fn test_validate_manifest_nav_multiple() {
1272 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1273
1274 let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1275 .unwrap()
1276 .append_property("nav")
1277 .build();
1278 let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1279 .unwrap()
1280 .append_property("nav")
1281 .build();
1282
1283 builder
1284 .manifest
1285 .manifest
1286 .insert("nav1".to_string(), nav_item1);
1287 builder
1288 .manifest
1289 .manifest
1290 .insert("nav2".to_string(), nav_item2);
1291
1292 let result = builder.manifest.validate();
1293 assert!(result.is_err());
1294 assert_eq!(
1295 result.unwrap_err().to_string(),
1296 "Epub builder error: There are too many items with 'nav' property in the manifest."
1297 );
1298 }
1299 }
1300
1301 mod metadata_tests {
1302 use super::*;
1303
1304 #[test]
1305 fn test_validate_metadata_success() {
1306 let builder = test_helpers::create_basic_builder();
1307 assert!(builder.metadata.validate().is_ok());
1308 }
1309
1310 #[test]
1311 fn test_validate_metadata_missing_required() {
1312 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1313 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1314 builder.add_metadata(MetadataItem::new("language", "en"));
1315 assert!(builder.metadata.validate().is_err());
1316 }
1317 }
1318
1319 mod utility_tests {
1320 use super::*;
1321
1322 #[test]
1323 fn test_normalize_manifest_path() {
1324 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1325 builder.add_rootfile("content.opf").unwrap();
1326
1327 let result = normalize_manifest_path(
1328 &builder.temp_dir,
1329 builder.rootfiles.first().unwrap(),
1330 "../../test.xhtml",
1331 "id",
1332 );
1333 assert!(result.is_err());
1334 assert_eq!(
1335 result.unwrap_err(),
1336 EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
1337 );
1338
1339 let result = normalize_manifest_path(
1340 &builder.temp_dir,
1341 builder.rootfiles.first().unwrap(),
1342 "/test.xhtml",
1343 "id",
1344 );
1345 assert!(result.is_ok());
1346 assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
1347
1348 let result = normalize_manifest_path(
1349 &builder.temp_dir,
1350 builder.rootfiles.first().unwrap(),
1351 "./test.xhtml",
1352 "manifest_id",
1353 );
1354 assert!(result.is_err());
1355 assert_eq!(
1356 result.unwrap_err(),
1357 EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
1358 .into(),
1359 );
1360 }
1361
1362 #[test]
1363 fn test_refine_mime_type() {
1364 assert_eq!(
1365 refine_mime_type("text/xml", "xhtml"),
1366 "application/xhtml+xml"
1367 );
1368 assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
1369 assert_eq!(
1370 refine_mime_type("application/xml", "opf"),
1371 "application/oebps-package+xml"
1372 );
1373 assert_eq!(
1374 refine_mime_type("text/xml", "ncx"),
1375 "application/x-dtbncx+xml"
1376 );
1377 assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1378 assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1379 }
1380 }
1381
1382 #[cfg(feature = "content-builder")]
1383 mod content_builder_tests {
1384 use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
1385
1386 #[test]
1387 fn test_make_contents_basic() {
1388 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1389 builder.add_rootfile("content.opf").unwrap();
1390
1391 let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
1392 content_builder
1393 .set_title("Test Chapter")
1394 .add_text_block("This is a test paragraph.", vec![])
1395 .unwrap();
1396
1397 builder.add_content("OEBPS/chapter1.xhtml", content_builder);
1398
1399 assert!(builder.make_contents().is_ok());
1400 assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
1401 }
1402
1403 #[test]
1404 fn test_make_contents_multiple_blocks() {
1405 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1406 builder.add_rootfile("content.opf").unwrap();
1407
1408 let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
1409 content_builder
1410 .set_title("多个区块章节")
1411 .add_text_block("第一段文本。", vec![])
1412 .unwrap()
1413 .add_quote_block("这是一个引用。", vec![])
1414 .unwrap()
1415 .add_title_block("子标题", 2, vec![])
1416 .unwrap()
1417 .add_text_block("最后的文本段落。", vec![])
1418 .unwrap();
1419
1420 builder.add_content("OEBPS/chapter2.xhtml", content_builder);
1421
1422 assert!(builder.make_contents().is_ok());
1423 assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
1424 }
1425
1426 #[test]
1427 fn test_make_contents_with_media() {
1428 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1429 builder.add_rootfile("content.opf").unwrap();
1430
1431 let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
1432 content_builder
1433 .set_title("Chapter with Media")
1434 .add_text_block("Text before image.", vec![])
1435 .unwrap()
1436 .add_image_block(
1437 std::path::PathBuf::from("./test_case/image.jpg"),
1438 Some("Test Image".to_string()),
1439 Some("Figure 1: A test image".to_string()),
1440 vec![],
1441 )
1442 .unwrap()
1443 .add_text_block("Text after image.", vec![])
1444 .unwrap();
1445
1446 builder.add_content("OEBPS/chapter3.xhtml", content_builder);
1447
1448 assert!(builder.make_contents().is_ok());
1449 assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
1450 assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
1451 }
1452
1453 #[test]
1454 fn test_make_contents_multiple_documents() {
1455 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1456 builder.add_rootfile("content.opf").unwrap();
1457
1458 for (id, title) in [
1459 ("ch1", "Chapter 1"),
1460 ("ch2", "Chapter 2"),
1461 ("ch3", "Chapter 3"),
1462 ] {
1463 let mut content = ContentBuilder::new(id, "en").unwrap();
1464 content
1465 .set_title(title)
1466 .add_text_block(&format!("Content of {}", title), vec![])
1467 .unwrap();
1468 builder.add_content(format!("OEBPS/{}.xhtml", id), content);
1469 }
1470
1471 assert!(builder.make_contents().is_ok());
1472 assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1473 assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1474 assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1475 }
1476
1477 #[test]
1478 fn test_make_contents_different_languages() {
1479 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1480 builder.add_rootfile("content.opf").unwrap();
1481
1482 let langs = [
1483 ("en_ch", "en", "English Chapter"),
1484 ("zh_ch", "zh-CN", "中文章节"),
1485 ("ja_ch", "ja", "日本語の章"),
1486 ];
1487
1488 for (id, lang, title) in langs {
1489 let mut content = ContentBuilder::new(id, lang).unwrap();
1490 content
1491 .set_title(title)
1492 .add_text_block(&format!("Text in {}", lang), vec![])
1493 .unwrap();
1494 builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
1495 }
1496
1497 assert!(builder.make_contents().is_ok());
1498 assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
1499 assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
1500 assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
1501 }
1502
1503 #[test]
1504 fn test_make_contents_unique_identifiers() {
1505 use std::path::PathBuf;
1506
1507 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1508 builder.add_rootfile("content.opf").unwrap();
1509
1510 let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
1511 content1.add_text_block("First content", vec![]).unwrap();
1512 builder.add_content("OEBPS/ch1.xhtml", content1);
1513
1514 let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
1515 content2.add_text_block("Second content", vec![]).unwrap();
1516 builder.add_content("OEBPS/ch2.xhtml", content2);
1517
1518 let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
1519 content3
1520 .add_text_block("Duplicate ID content", vec![])
1521 .unwrap();
1522 builder.add_content("OEBPS/ch3.xhtml", content3);
1523
1524 assert!(builder.make_contents().is_ok());
1525 assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
1526 assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
1527 assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
1528
1529 let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
1530 assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
1531 }
1532
1533 #[test]
1534 fn test_make_contents_complex_structure() {
1535 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1536 builder.add_rootfile("content.opf").unwrap();
1537
1538 let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
1539 content
1540 .set_title("Complex Chapter")
1541 .add_title_block("Section 1", 2, vec![])
1542 .unwrap()
1543 .add_text_block("Introduction text.", vec![])
1544 .unwrap()
1545 .add_quote_block("A wise quote here.", vec![])
1546 .unwrap()
1547 .add_title_block("Section 2", 2, vec![])
1548 .unwrap()
1549 .add_text_block("More content with multiple paragraphs.", vec![])
1550 .unwrap()
1551 .add_text_block("Another paragraph.", vec![])
1552 .unwrap()
1553 .add_title_block("Section 3", 2, vec![])
1554 .unwrap()
1555 .add_quote_block("Another quotation.", vec![])
1556 .unwrap();
1557
1558 builder.add_content("OEBPS/complex_chapter.xhtml", content);
1559
1560 assert!(builder.make_contents().is_ok());
1561 assert!(
1562 builder
1563 .temp_dir
1564 .join("OEBPS/complex_chapter.xhtml")
1565 .exists()
1566 );
1567 }
1568
1569 #[test]
1570 fn test_make_contents_empty_document() {
1571 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1572 builder.add_rootfile("content.opf").unwrap();
1573
1574 let content = ContentBuilder::new("empty_ch", "en").unwrap();
1575 builder.add_content("OEBPS/empty.xhtml", content);
1576
1577 assert!(builder.make_contents().is_ok());
1578 assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
1579 }
1580
1581 #[test]
1582 fn test_make_contents_path_normalization() {
1583 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1584 builder.add_rootfile("OEBPS/content.opf").unwrap();
1585
1586 let mut content = ContentBuilder::new("path_test", "en").unwrap();
1587 content.add_text_block("Path test content", vec![]).unwrap();
1588
1589 builder.add_content("/OEBPS/text/chapter.xhtml", content);
1590
1591 assert!(builder.make_contents().is_ok());
1592 assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
1593 }
1594 }
1595}