1use std::{
38 collections::HashMap,
39 env,
40 fs::{self, File},
41 io::{BufReader, Cursor, Read, Seek, Write},
42 marker::PhantomData,
43 path::{Path, PathBuf},
44};
45
46use chrono::{SecondsFormat, Utc};
47use infer::Infer;
48use log::warn;
49use quick_xml::{
50 Writer,
51 events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
52};
53use walkdir::WalkDir;
54use zip::{CompressionMethod, ZipWriter, write::FileOptions};
55
56use crate::{
57 epub::EpubDoc,
58 error::{EpubBuilderError, EpubError},
59 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
60 utils::{
61 ELEMENT_IN_DC_NAMESPACE, check_realtive_link_leakage, local_time, remove_leading_slash,
62 },
63};
64
65type XmlWriter = Writer<Cursor<Vec<u8>>>;
66
67#[cfg_attr(test, derive(Debug))]
69pub struct EpubVersion3;
70
71#[cfg_attr(test, derive(Debug))]
116pub struct EpubBuilder<Version> {
117 epub_version: PhantomData<Version>,
119
120 temp_dir: PathBuf,
122
123 rootfiles: Vec<String>,
125
126 metadata: Vec<MetadataItem>,
128
129 manifest: HashMap<String, ManifestItem>,
131
132 spine: Vec<SpineItem>,
134
135 catalog_title: String,
136
137 catalog: Vec<NavPoint>,
139}
140
141impl EpubBuilder<EpubVersion3> {
142 pub fn new() -> Result<Self, EpubError> {
148 let temp_dir = env::temp_dir().join(local_time());
149 fs::create_dir(&temp_dir)?;
150 fs::create_dir(temp_dir.join("META-INF"))?;
151
152 let mime_file = temp_dir.join("mimetype");
153 fs::write(mime_file, "application/epub+zip")?;
154
155 Ok(EpubBuilder {
156 epub_version: PhantomData,
157 temp_dir,
158
159 rootfiles: vec![],
160 metadata: vec![],
161 manifest: HashMap::new(),
162 spine: vec![],
163
164 catalog_title: String::new(),
165 catalog: vec![],
166 })
167 }
168
169 pub fn add_rootfile(&mut self, rootfile: &str) -> Result<&mut Self, EpubError> {
181 let rootfile = if rootfile.starts_with("/") || rootfile.starts_with("../") {
182 return Err(EpubBuilderError::IllegalRootfilePath.into());
183 } else if let Some(rootfile) = rootfile.strip_prefix("./") {
184 rootfile
185 } else {
186 rootfile
187 };
188
189 self.rootfiles.push(rootfile.to_string());
190
191 Ok(self)
192 }
193
194 pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
202 self.metadata.push(item);
203 self
204 }
205
206 pub fn add_manifest(
222 &mut self,
223 manifest_source: &str,
224 manifest_item: ManifestItem,
225 ) -> Result<&mut Self, EpubError> {
226 if self.rootfiles.is_empty() {
227 return Err(EpubBuilderError::MissingRootfile.into());
228 }
229
230 let source = PathBuf::from(manifest_source);
232 if !source.is_file() {
233 return Err(EpubBuilderError::TargetIsNotFile {
234 target_path: manifest_source.to_string(),
235 }
236 .into());
237 }
238
239 let extension = match source.extension() {
241 Some(ext) => ext.to_string_lossy().to_lowercase(),
242 None => String::new(),
243 };
244
245 let buf = fs::read(source)?;
247
248 let real_mime = match Infer::new().get(&buf) {
250 Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
251 None => {
252 return Err(EpubBuilderError::UnknownFileFormat {
253 file_path: manifest_source.to_string(),
254 }
255 .into());
256 }
257 };
258
259 let target_path = self.normalize_manifest_path(&manifest_item.path, &manifest_item.id)?;
260 if let Some(parent_dir) = target_path.parent() {
261 if !parent_dir.exists() {
262 fs::create_dir_all(parent_dir)?
263 }
264 }
265
266 match fs::write(target_path, buf) {
267 Ok(_) => {
268 self.manifest
269 .insert(manifest_item.id.clone(), manifest_item.set_mime(&real_mime));
270 Ok(self)
271 }
272 Err(err) => Err(err.into()),
273 }
274 }
275
276 pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
283 self.spine.push(item);
284 self
285 }
286
287 pub fn set_catalog_title(&mut self, title: &str) -> &mut Self {
292 self.catalog_title = title.to_string();
293 self
294 }
295
296 pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
303 self.catalog.push(item);
304 self
305 }
306
307 pub fn set_catalog(&mut self, catalog: Vec<NavPoint>) -> &mut Self {
314 self.catalog = catalog;
315 self
316 }
317
318 pub fn make<P: AsRef<Path>>(mut self, output_path: P) -> Result<(), EpubError> {
327 self.make_container_xml()?;
331 self.make_navigation_document()?;
332 self.make_opf_file()?;
333
334 if let Some(parent) = output_path.as_ref().parent() {
335 if !parent.exists() {
336 fs::create_dir_all(parent)?;
337 }
338 }
339
340 let file = File::create(output_path)?;
342 let mut zip = ZipWriter::new(file);
343 let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
344
345 for entry in WalkDir::new(&self.temp_dir) {
346 let entry = entry?;
347 let path = entry.path();
348
349 let relative_path = path.strip_prefix(&self.temp_dir).unwrap();
352 let target_path = relative_path.to_string_lossy().replace("\\", "/");
353
354 if path.is_file() {
355 zip.start_file(target_path, options)?;
356 let mut buf = Vec::new();
357 File::open(path)?.read_to_end(&mut buf)?;
358 zip.write_all(&buf)?;
359 } else if path.is_dir() {
360 zip.add_directory(target_path, options)?;
361 }
362 }
363
364 zip.finish()?;
365 Ok(())
366 }
367
368 pub fn build<P: AsRef<Path>>(
379 self,
380 output_path: P,
381 ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
382 self.make(&output_path)?;
383
384 EpubDoc::new(output_path)
385 }
386
387 pub fn from<R: Read + Seek>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
414 let mut builder = Self::new()?;
415
416 builder.add_rootfile(&doc.package_path.clone().to_string_lossy())?;
417 builder.metadata = doc.metadata.clone();
418 builder.spine = doc.spine.clone();
419 builder.catalog = doc.catalog.clone();
420 builder.catalog_title = doc.catalog_title.clone();
421
422 for (_, mut manifest) in doc.manifest.clone().into_iter() {
424 if let Some(properties) = &manifest.properties {
425 if properties.contains("nav") {
426 continue;
427 }
428 }
429
430 manifest.path = PathBuf::from("/").join(manifest.path);
434
435 let (buf, _) = doc.get_manifest_item(&manifest.id)?; let target_path = builder.normalize_manifest_path(&manifest.path, &manifest.id)?;
437 if let Some(parent_dir) = target_path.parent() {
438 if !parent_dir.exists() {
439 fs::create_dir_all(parent_dir)?
440 }
441 }
442
443 fs::write(target_path, buf)?;
444 builder.manifest.insert(manifest.id.clone(), manifest);
445 }
446
447 Ok(builder)
448 }
449
450 fn make_container_xml(&self) -> Result<(), EpubError> {
454 if self.rootfiles.is_empty() {
455 return Err(EpubBuilderError::MissingRootfile.into());
456 }
457
458 let mut writer = Writer::new(Cursor::new(Vec::new()));
459
460 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
461
462 writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
463 [
464 ("version", "1.0"),
465 ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
466 ],
467 )))?;
468 writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
469
470 for rootfile in &self.rootfiles {
471 writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
472 ("full-path", rootfile.as_str()),
473 ("media-type", "application/oebps-package+xml"),
474 ])))?;
475 }
476
477 writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
478 writer.write_event(Event::End(BytesEnd::new("container")))?;
479
480 let file_path = self.temp_dir.join("META-INF").join("container.xml");
481 let file_data = writer.into_inner().into_inner();
482 fs::write(file_path, file_data)?;
483
484 Ok(())
485 }
486
487 fn make_navigation_document(&mut self) -> Result<(), EpubError> {
491 if self.catalog.is_empty() {
492 return Err(EpubBuilderError::NavigationInfoUninitalized.into());
493 }
494
495 let mut writer = Writer::new(Cursor::new(Vec::new()));
496
497 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
498 ("xmlns", "http://www.w3.org/1999/xhtml"),
499 ("xmlns:epub", "http://www.idpf.org/2007/ops"),
500 ])))?;
501
502 writer.write_event(Event::Start(BytesStart::new("head")))?;
504 writer.write_event(Event::Start(BytesStart::new("title")))?;
505 writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
506 writer.write_event(Event::End(BytesEnd::new("title")))?;
507 writer.write_event(Event::End(BytesEnd::new("head")))?;
508
509 writer.write_event(Event::Start(BytesStart::new("body")))?;
511 writer.write_event(Event::Start(
512 BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
513 ))?;
514
515 if !self.catalog_title.is_empty() {
516 writer.write_event(Event::Start(BytesStart::new("h1")))?;
517 writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
518 writer.write_event(Event::End(BytesEnd::new("h1")))?;
519 }
520
521 Self::make_nav(&mut writer, &self.catalog)?;
522
523 writer.write_event(Event::End(BytesEnd::new("nav")))?;
524 writer.write_event(Event::End(BytesEnd::new("body")))?;
525
526 writer.write_event(Event::End(BytesEnd::new("html")))?;
527
528 let file_path = self.temp_dir.join("nav.xhtml");
529 let file_data = writer.into_inner().into_inner();
530 fs::write(file_path, file_data)?;
531
532 self.manifest.insert(
533 "nav".to_string(),
534 ManifestItem {
535 id: "nav".to_string(),
536 path: PathBuf::from("/nav.xhtml"),
537 mime: "application/xhtml+xml".to_string(),
538 properties: Some("nav".to_string()),
539 fallback: None,
540 },
541 );
542
543 Ok(())
544 }
545
546 fn make_opf_file(&mut self) -> Result<(), EpubError> {
553 if !self.validate_metadata() {
554 return Err(EpubBuilderError::MissingNecessaryMetadata.into());
555 }
556 self.validate_manifest_fallback_chains()?;
557 self.validate_manifest_nav()?;
558
559 let mut writer = Writer::new(Cursor::new(Vec::new()));
560
561 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
562
563 writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
564 ("xmlns", "http://www.idpf.org/2007/opf"),
565 ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
566 ("unique-identifier", "pub-id"),
567 ("version", "3.0"),
568 ])))?;
569
570 self.make_opf_metadata(&mut writer)?;
571 self.make_opf_manifest(&mut writer)?;
572 self.make_opf_spine(&mut writer)?;
573
574 writer.write_event(Event::End(BytesEnd::new("package")))?;
575
576 let file_path = self.temp_dir.join(&self.rootfiles[0]);
577 let file_data = writer.into_inner().into_inner();
578 fs::write(file_path, file_data)?;
579
580 Ok(())
581 }
582
583 fn make_opf_metadata(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
584 self.metadata.push(MetadataItem {
585 id: None,
586 property: "dcterms:modified".to_string(),
587 value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
588 lang: None,
589 refined: vec![],
590 });
591
592 writer.write_event(Event::Start(BytesStart::new("metadata")))?;
593
594 for metadata in &self.metadata {
595 let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
596 format!("dc:{}", metadata.property)
597 } else {
598 "meta".to_string()
599 };
600
601 writer.write_event(Event::Start(
602 BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
603 ))?;
604 writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
605 writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
606
607 for refinement in &metadata.refined {
608 writer.write_event(Event::Start(
609 BytesStart::new("meta").with_attributes(refinement.attributes()),
610 ))?;
611 writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
612 writer.write_event(Event::End(BytesEnd::new("meta")))?;
613 }
614 }
615
616 writer.write_event(Event::End(BytesEnd::new("metadata")))?;
617
618 Ok(())
619 }
620
621 fn make_opf_manifest(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
622 writer.write_event(Event::Start(BytesStart::new("manifest")))?;
623
624 for manifest in self.manifest.values() {
625 writer.write_event(Event::Empty(
626 BytesStart::new("item").with_attributes(manifest.attributes()),
627 ))?;
628 }
629
630 writer.write_event(Event::End(BytesEnd::new("manifest")))?;
631
632 Ok(())
633 }
634
635 fn make_opf_spine(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
636 writer.write_event(Event::Start(BytesStart::new("spine")))?;
637
638 for spine in &self.spine {
639 writer.write_event(Event::Empty(
640 BytesStart::new("itemref").with_attributes(spine.attributes()),
641 ))?;
642 }
643
644 writer.write_event(Event::End(BytesEnd::new("spine")))?;
645
646 Ok(())
647 }
648
649 fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
650 writer.write_event(Event::Start(BytesStart::new("ol")))?;
651
652 for nav in navgations {
653 writer.write_event(Event::Start(BytesStart::new("li")))?;
654
655 if let Some(path) = &nav.content {
656 writer.write_event(Event::Start(
657 BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
658 ))?;
659 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
660 writer.write_event(Event::End(BytesEnd::new("a")))?;
661 } else {
662 writer.write_event(Event::Start(BytesStart::new("span")))?;
663 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
664 writer.write_event(Event::End(BytesEnd::new("span")))?;
665 }
666
667 if !nav.children.is_empty() {
668 Self::make_nav(writer, &nav.children)?;
669 }
670
671 writer.write_event(Event::End(BytesEnd::new("li")))?;
672 }
673
674 writer.write_event(Event::End(BytesEnd::new("ol")))?;
675
676 Ok(())
677 }
678
679 fn validate_metadata(&self) -> bool {
683 let has_title = self.metadata.iter().any(|item| item.property == "title");
684 let has_language = self.metadata.iter().any(|item| item.property == "language");
685 let has_identifier = self.metadata.iter().any(|item| {
686 item.property == "identifier" && item.id.as_ref().is_some_and(|id| id == "pub-id")
687 });
688
689 has_title && has_identifier && has_language
690 }
691
692 fn validate_manifest_fallback_chains(&self) -> Result<(), EpubError> {
693 for (id, item) in &self.manifest {
694 if item.fallback.is_none() {
695 continue;
696 }
697
698 let mut fallback_chain = Vec::new();
699 self.validate_fallback_chain(id, &mut fallback_chain)?;
700 }
701
702 Ok(())
703 }
704
705 fn validate_fallback_chain(
711 &self,
712 manifest_id: &str,
713 fallback_chain: &mut Vec<String>,
714 ) -> Result<(), EpubError> {
715 if fallback_chain.contains(&manifest_id.to_string()) {
716 fallback_chain.push(manifest_id.to_string());
717
718 return Err(EpubBuilderError::ManifestCircularReference {
719 fallback_chain: fallback_chain.join("->"),
720 }
721 .into());
722 }
723
724 let item = self.manifest.get(manifest_id).unwrap();
726
727 if let Some(fallback_id) = &item.fallback {
728 if !self.manifest.contains_key(fallback_id) {
729 return Err(EpubBuilderError::ManifestNotFound {
730 manifest_id: fallback_id.to_owned(),
731 }
732 .into());
733 }
734
735 fallback_chain.push(manifest_id.to_string());
736 self.validate_fallback_chain(fallback_id, fallback_chain)
737 } else {
738 Ok(())
740 }
741 }
742
743 fn validate_manifest_nav(&self) -> Result<(), EpubError> {
747 if self
748 .manifest
749 .values()
750 .filter(|&item| {
751 if let Some(properties) = &item.properties {
752 properties
753 .clone()
754 .split(" ")
755 .collect::<Vec<&str>>()
756 .contains(&"nav")
757 } else {
758 false
759 }
760 })
761 .count()
762 == 1
763 {
764 Ok(())
765 } else {
766 Err(EpubBuilderError::TooManyNavFlags.into())
767 }
768 }
769
770 fn normalize_manifest_path<P: AsRef<Path>>(
790 &self,
791 path: P,
792 id: &str,
793 ) -> Result<PathBuf, EpubError> {
794 let opf_path = PathBuf::from(&self.rootfiles[0]);
795 let basic_path = remove_leading_slash(opf_path.parent().unwrap());
796
797 let mut target_path = if path.as_ref().starts_with("../") {
799 check_realtive_link_leakage(
800 self.temp_dir.clone(),
801 basic_path.to_path_buf(),
802 &path.as_ref().to_string_lossy(),
803 )
804 .map(PathBuf::from)
805 .ok_or_else(|| EpubError::RealtiveLinkLeakage {
806 path: path.as_ref().to_string_lossy().to_string(),
807 })?
808 } else if let Ok(path) = path.as_ref().strip_prefix("/") {
809 self.temp_dir.join(path)
810 } else if path.as_ref().starts_with("./") {
811 Err(EpubBuilderError::IllegalManifestPath {
813 manifest_id: id.to_string(),
814 })?
815 } else {
816 self.temp_dir.join(basic_path).join(path)
817 };
818
819 #[cfg(windows)]
820 {
821 target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
822 }
823
824 Ok(target_path)
825 }
826}
827
828impl<Version> Drop for EpubBuilder<Version> {
829 fn drop(&mut self) {
831 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
832 warn!("{}", err);
833 };
834 }
835}
836
837fn refine_mime_type(infer_mime: &str, extension: &str) -> String {
841 match (infer_mime, extension) {
842 ("text/xml", "xhtml")
843 | ("application/xml", "xhtml")
844 | ("text/xml", "xht")
845 | ("application/xml", "xht") => "application/xhtml+xml".to_string(),
846
847 ("text/xml", "opf") | ("application/xml", "opf") => {
848 "application/oebps-package+xml".to_string()
849 }
850
851 ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml".to_string(),
852
853 ("application/zip", "epub") => "application/epub+zip".to_string(),
854
855 ("text/plain", "css") => "text/css".to_string(),
856 ("text/plain", "js") => "application/javascript".to_string(),
857 ("text/plain", "json") => "application/json".to_string(),
858 ("text/plain", "svg") => "image/svg+xml".to_string(),
859
860 _ => infer_mime.to_string(),
861 }
862}
863
864#[cfg(test)]
865mod tests {
866 use std::{env, fs, path::PathBuf};
867
868 use crate::{
869 builder::{EpubBuilder, EpubVersion3, refine_mime_type},
870 epub::EpubDoc,
871 error::{EpubBuilderError, EpubError},
872 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
873 utils::local_time,
874 };
875
876 #[test]
877 fn test_epub_builder_new() {
878 let builder = EpubBuilder::<EpubVersion3>::new();
879 assert!(builder.is_ok());
880
881 let builder = builder.unwrap();
882 assert!(builder.temp_dir.exists());
883 assert!(builder.rootfiles.is_empty());
884 assert!(builder.metadata.is_empty());
885 assert!(builder.manifest.is_empty());
886 assert!(builder.spine.is_empty());
887 assert!(builder.catalog_title.is_empty());
888 assert!(builder.catalog.is_empty());
889 }
890
891 #[test]
892 fn test_add_rootfile() {
893 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
894 assert!(builder.add_rootfile("content.opf").is_ok());
895
896 assert_eq!(builder.rootfiles.len(), 1);
897 assert_eq!(builder.rootfiles[0], "content.opf");
898
899 assert!(builder.add_rootfile("./another.opf").is_ok());
900 assert_eq!(builder.rootfiles.len(), 2);
901 assert_eq!(builder.rootfiles, vec!["content.opf", "another.opf"]);
902 }
903
904 #[test]
905 fn test_add_rootfile_fail() {
906 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
907
908 let result = builder.add_rootfile("/rootfile.opf");
909 assert!(result.is_err());
910 assert_eq!(
911 result.unwrap_err(),
912 EpubBuilderError::IllegalRootfilePath.into()
913 );
914
915 let result = builder.add_rootfile("../rootfile.opf");
916 assert!(result.is_err());
917 assert_eq!(
918 result.unwrap_err(),
919 EpubBuilderError::IllegalRootfilePath.into()
920 );
921 }
922
923 #[test]
924 fn test_add_metadata() {
925 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
926 let metadata_item = MetadataItem::new("title", "Test Book");
927
928 builder.add_metadata(metadata_item);
929
930 assert_eq!(builder.metadata.len(), 1);
931 assert_eq!(builder.metadata[0].property, "title");
932 assert_eq!(builder.metadata[0].value, "Test Book");
933 }
934
935 #[test]
936 fn test_add_manifest_success() {
937 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
938 assert!(builder.add_rootfile("content.opf").is_ok());
939
940 let temp_dir = env::temp_dir().join(local_time());
942 fs::create_dir_all(&temp_dir).unwrap();
943 let test_file = temp_dir.join("test.xhtml");
944 fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
945
946 let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
947 let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
948
949 assert!(result.is_ok());
950 assert_eq!(builder.manifest.len(), 1);
951 assert!(builder.manifest.contains_key("test"));
952
953 fs::remove_dir_all(temp_dir).unwrap();
954 }
955
956 #[test]
957 fn test_add_manifest_no_rootfile() {
958 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
959
960 let manifest_item = ManifestItem {
961 id: "main".to_string(),
962 path: PathBuf::from("/Overview.xhtml"),
963 mime: String::new(),
964 properties: None,
965 fallback: None,
966 };
967
968 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
969 assert!(result.is_err());
970 assert_eq!(
971 result.unwrap_err(),
972 EpubBuilderError::MissingRootfile.into()
973 );
974
975 let result = builder.add_rootfile("package.opf");
976 assert!(result.is_ok());
977
978 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
979 assert!(result.is_ok());
980 }
981
982 #[test]
983 fn test_add_manifest_nonexistent_file() {
984 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
985 assert!(builder.add_rootfile("content.opf").is_ok());
986
987 let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
988 let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
989
990 assert!(result.is_err());
991 assert_eq!(
992 result.unwrap_err(),
993 EpubBuilderError::TargetIsNotFile {
994 target_path: "nonexistent.xhtml".to_string()
995 }
996 .into()
997 );
998 }
999
1000 #[test]
1001 fn test_add_manifest_unknow_file_format() {
1002 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1003 let result = builder.add_rootfile("package.opf");
1004 assert!(result.is_ok());
1005
1006 let result = builder.add_manifest(
1007 "./test_case/unknown_file_format.xhtml",
1008 ManifestItem {
1009 id: "file".to_string(),
1010 path: PathBuf::from("unknown_file_format.xhtml"),
1011 mime: String::new(),
1012 properties: None,
1013 fallback: None,
1014 },
1015 );
1016
1017 assert!(result.is_err());
1018 assert_eq!(
1019 result.unwrap_err(),
1020 EpubBuilderError::UnknownFileFormat {
1021 file_path: "./test_case/unknown_file_format.xhtml".to_string(),
1022 }
1023 .into()
1024 )
1025 }
1026
1027 #[test]
1028 fn test_add_spine() {
1029 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1030 let spine_item = SpineItem::new("test_item");
1031
1032 builder.add_spine(spine_item.clone());
1033
1034 assert_eq!(builder.spine.len(), 1);
1035 assert_eq!(builder.spine[0].idref, "test_item");
1036 }
1037
1038 #[test]
1039 fn test_set_catalog_title() {
1040 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1041 let title = "Test Catalog Title";
1042
1043 builder.set_catalog_title(title);
1044
1045 assert_eq!(builder.catalog_title, title);
1046 }
1047
1048 #[test]
1049 fn test_add_catalog_item() {
1050 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1051 let nav_point = NavPoint::new("Chapter 1");
1052
1053 builder.add_catalog_item(nav_point.clone());
1054
1055 assert_eq!(builder.catalog.len(), 1);
1056 assert_eq!(builder.catalog[0].label, "Chapter 1");
1057 }
1058
1059 #[test]
1060 fn test_set_catalog() {
1061 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1062 let nav_points = vec![NavPoint::new("Chapter 1"), NavPoint::new("Chapter 2")];
1063
1064 builder.set_catalog(nav_points.clone());
1065
1066 assert_eq!(builder.catalog.len(), 2);
1067 assert_eq!(builder.catalog[0].label, "Chapter 1");
1068 assert_eq!(builder.catalog[1].label, "Chapter 2");
1069 }
1070
1071 #[test]
1072 fn test_make_container_file() {
1073 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1074
1075 let result = builder.make_container_xml();
1076 assert!(result.is_err());
1077 assert_eq!(
1078 result.unwrap_err(),
1079 EpubBuilderError::MissingRootfile.into()
1080 );
1081
1082 assert!(builder.add_rootfile("content.opf").is_ok());
1083 let result = builder.make_container_xml();
1084 assert!(result.is_ok());
1085 }
1086
1087 #[test]
1088 fn test_make_navigation_document() {
1089 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1090
1091 let result = builder.make_navigation_document();
1092 assert!(result.is_err());
1093 assert_eq!(
1094 result.unwrap_err(),
1095 EpubBuilderError::NavigationInfoUninitalized.into()
1096 );
1097
1098 builder.set_catalog(vec![NavPoint::new("test")]);
1099 assert!(builder.make_navigation_document().is_ok());
1100 }
1101
1102 #[test]
1103 fn test_validate_metadata_success() {
1104 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1105
1106 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1107 builder.add_metadata(MetadataItem::new("language", "en"));
1108 builder.add_metadata(
1109 MetadataItem::new("identifier", "urn:isbn:1234567890")
1110 .with_id("pub-id")
1111 .build(),
1112 );
1113
1114 assert!(builder.validate_metadata());
1115 }
1116
1117 #[test]
1118 fn test_validate_metadata_missing_required() {
1119 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1120
1121 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1122 builder.add_metadata(MetadataItem::new("language", "en"));
1123
1124 assert!(!builder.validate_metadata());
1125 }
1126
1127 #[test]
1128 fn test_validate_fallback_chain_valid() {
1129 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1130
1131 let item3 = ManifestItem::new("item3", "path3");
1132 assert!(item3.is_ok());
1133
1134 let item3 = item3.unwrap();
1135 let item2 = ManifestItem::new("item2", "path2")
1136 .unwrap()
1137 .with_fallback("item3")
1138 .build();
1139 let item1 = ManifestItem::new("item1", "path1")
1140 .unwrap()
1141 .with_fallback("item2")
1142 .build();
1143
1144 builder.manifest.insert("item3".to_string(), item3);
1145 builder.manifest.insert("item2".to_string(), item2);
1146 builder.manifest.insert("item1".to_string(), item1);
1147
1148 let result = builder.validate_manifest_fallback_chains();
1149 assert!(result.is_ok());
1150 }
1151
1152 #[test]
1153 fn test_validate_fallback_chain_circular_reference() {
1154 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1155
1156 let item2 = ManifestItem::new("item2", "path2")
1157 .unwrap()
1158 .with_fallback("item1")
1159 .build();
1160 let item1 = ManifestItem::new("item1", "path1")
1161 .unwrap()
1162 .with_fallback("item2")
1163 .build();
1164
1165 builder.manifest.insert("item1".to_string(), item1);
1166 builder.manifest.insert("item2".to_string(), item2);
1167
1168 let result = builder.validate_manifest_fallback_chains();
1169 assert!(result.is_err());
1170 assert!(
1171 result.unwrap_err().to_string().starts_with(
1172 "Epub builder error: Circular reference detected in fallback chain for"
1173 ),
1174 );
1175 }
1176
1177 #[test]
1178 fn test_validate_fallback_chain_not_found() {
1179 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1180
1181 let item1 = ManifestItem::new("item1", "path1")
1182 .unwrap()
1183 .with_fallback("nonexistent")
1184 .build();
1185
1186 builder.manifest.insert("item1".to_string(), item1);
1187
1188 let result = builder.validate_manifest_fallback_chains();
1189 assert!(result.is_err());
1190 assert_eq!(
1191 result.unwrap_err().to_string(),
1192 "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1193 );
1194 }
1195
1196 #[test]
1197 fn test_validate_manifest_nav_single() {
1198 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1199
1200 let nav_item = ManifestItem::new("nav", "nav.xhtml")
1201 .unwrap()
1202 .append_property("nav")
1203 .build();
1204 builder.manifest.insert("nav".to_string(), nav_item);
1205
1206 let result = builder.validate_manifest_nav();
1207 assert!(result.is_ok());
1208 }
1209
1210 #[test]
1211 fn test_validate_manifest_nav_multiple() {
1212 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1213
1214 let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1215 .unwrap()
1216 .append_property("nav")
1217 .build();
1218 let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1219 .unwrap()
1220 .append_property("nav")
1221 .build();
1222
1223 builder.manifest.insert("nav1".to_string(), nav_item1);
1224 builder.manifest.insert("nav2".to_string(), nav_item2);
1225
1226 let result = builder.validate_manifest_nav();
1227 assert!(result.is_err());
1228 assert_eq!(
1229 result.unwrap_err().to_string(),
1230 "Epub builder error: There are too many items with 'nav' property in the manifest."
1231 );
1232 }
1233
1234 #[test]
1235 fn test_make_opf_file_success() {
1236 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1237
1238 assert!(builder.add_rootfile("content.opf").is_ok());
1239 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1240 builder.add_metadata(MetadataItem::new("language", "en"));
1241 builder.add_metadata(
1242 MetadataItem::new("identifier", "urn:isbn:1234567890")
1243 .with_id("pub-id")
1244 .build(),
1245 );
1246
1247 let temp_dir = env::temp_dir().join(local_time());
1248 fs::create_dir_all(&temp_dir).unwrap();
1249
1250 let test_file = temp_dir.join("test.xhtml");
1251 fs::write(&test_file, "<html></html>").unwrap();
1252
1253 let manifest_result = builder.add_manifest(
1254 test_file.to_str().unwrap(),
1255 ManifestItem::new("test", "test.xhtml").unwrap(),
1256 );
1257 assert!(manifest_result.is_ok());
1258
1259 builder.add_catalog_item(NavPoint::new("Chapter"));
1260 builder.add_spine(SpineItem::new("test"));
1261
1262 let result = builder.make_navigation_document();
1263 assert!(result.is_ok());
1264
1265 let result = builder.make_opf_file();
1266 assert!(result.is_ok());
1267
1268 let opf_path = builder.temp_dir.join("content.opf");
1269 assert!(opf_path.exists());
1270
1271 fs::remove_dir_all(temp_dir).unwrap();
1272 }
1273
1274 #[test]
1275 fn test_make_opf_file_missing_metadata() {
1276 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1277 assert!(builder.add_rootfile("content.opf").is_ok());
1278
1279 let result = builder.make_opf_file();
1280 assert!(result.is_err());
1281 assert_eq!(
1282 result.unwrap_err().to_string(),
1283 "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1284 );
1285 }
1286
1287 #[test]
1288 fn test_make() {
1289 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1290
1291 assert!(builder.add_rootfile("content.opf").is_ok());
1292 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1293 builder.add_metadata(MetadataItem::new("language", "en"));
1294 builder.add_metadata(
1295 MetadataItem::new("identifier", "test_identifier")
1296 .with_id("pub-id")
1297 .build(),
1298 );
1299
1300 assert!(
1301 builder
1302 .add_manifest(
1303 "./test_case/Overview.xhtml",
1304 ManifestItem {
1305 id: "test".to_string(),
1306 path: PathBuf::from("test.xhtml"),
1307 mime: String::new(),
1308 properties: None,
1309 fallback: None,
1310 },
1311 )
1312 .is_ok()
1313 );
1314
1315 builder.add_catalog_item(NavPoint::new("Chapter"));
1316 builder.add_spine(SpineItem::new("test"));
1317
1318 let file = env::temp_dir()
1319 .join("temp_dir")
1320 .join(format!("{}.epub", local_time()));
1321 assert!(builder.make(&file).is_ok());
1322 assert!(EpubDoc::new(&file).is_ok());
1323 }
1324
1325 #[test]
1326 fn test_build() {
1327 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1328
1329 assert!(builder.add_rootfile("content.opf").is_ok());
1330 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1331 builder.add_metadata(MetadataItem::new("language", "en"));
1332 builder.add_metadata(
1333 MetadataItem::new("identifier", "test_identifier")
1334 .with_id("pub-id")
1335 .build(),
1336 );
1337
1338 assert!(
1339 builder
1340 .add_manifest(
1341 "./test_case/Overview.xhtml",
1342 ManifestItem {
1343 id: "test".to_string(),
1344 path: PathBuf::from("test.xhtml"),
1345 mime: String::new(),
1346 properties: None,
1347 fallback: None,
1348 },
1349 )
1350 .is_ok()
1351 );
1352
1353 builder.add_catalog_item(NavPoint::new("Chapter"));
1354 builder.add_spine(SpineItem::new("test"));
1355
1356 let file = env::temp_dir().join(format!("{}.epub", local_time()));
1357 assert!(builder.build(&file).is_ok());
1358 }
1359
1360 #[test]
1361 fn test_from() {
1362 let builder = EpubBuilder::<EpubVersion3>::new();
1363 assert!(builder.is_ok());
1364
1365 let metadata = vec![
1366 MetadataItem {
1367 id: None,
1368 property: "title".to_string(),
1369 value: "Test Book".to_string(),
1370 lang: None,
1371 refined: vec![],
1372 },
1373 MetadataItem {
1374 id: None,
1375 property: "language".to_string(),
1376 value: "en".to_string(),
1377 lang: None,
1378 refined: vec![],
1379 },
1380 MetadataItem {
1381 id: Some("pub-id".to_string()),
1382 property: "identifier".to_string(),
1383 value: "test-book".to_string(),
1384 lang: None,
1385 refined: vec![],
1386 },
1387 ];
1388 let spine = vec![SpineItem {
1389 id: None,
1390 idref: "main".to_string(),
1391 linear: true,
1392 properties: None,
1393 }];
1394 let catalog = vec![
1395 NavPoint {
1396 label: "Nav".to_string(),
1397 content: None,
1398 children: vec![],
1399 play_order: None,
1400 },
1401 NavPoint {
1402 label: "Overview".to_string(),
1403 content: None,
1404 children: vec![],
1405 play_order: None,
1406 },
1407 ];
1408
1409 let mut builder = builder.unwrap();
1410 assert!(builder.add_rootfile("content.opf").is_ok());
1411 builder.metadata = metadata.clone();
1412 builder.spine = spine.clone();
1413 builder.catalog = catalog.clone();
1414 builder.set_catalog_title("catalog title");
1415 let result = builder.add_manifest(
1416 "./test_case/Overview.xhtml",
1417 ManifestItem {
1418 id: "main".to_string(),
1419 path: PathBuf::from("Overview.xhtml"),
1420 mime: String::new(),
1421 properties: None,
1422 fallback: None,
1423 },
1424 );
1425 assert!(result.is_ok());
1426
1427 let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1428 let result = builder.make(&epub_file);
1429 assert!(result.is_ok());
1430
1431 let doc = EpubDoc::new(&epub_file);
1432 assert!(doc.is_ok());
1433
1434 let mut doc = doc.unwrap();
1435 let builder = EpubBuilder::from(&mut doc);
1436 assert!(builder.is_ok());
1437 let builder = builder.unwrap();
1438
1439 assert_eq!(builder.metadata.len(), metadata.len() + 1);
1440 assert_eq!(builder.manifest.len(), 1); assert_eq!(builder.spine.len(), spine.len());
1442 assert_eq!(builder.catalog, catalog);
1443 assert_eq!(builder.catalog_title, "catalog title");
1444
1445 fs::remove_file(epub_file).unwrap();
1446 }
1447
1448 #[test]
1449 fn test_normalize_manifest_path() {
1450 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1451
1452 assert!(builder.add_rootfile("content.opf").is_ok());
1453
1454 let result = builder.normalize_manifest_path("../../test.xhtml", "id");
1455 assert!(result.is_err());
1456 assert_eq!(
1457 result.unwrap_err(),
1458 EpubError::RealtiveLinkLeakage {
1459 path: "../../test.xhtml".to_string()
1460 }
1461 );
1462
1463 let result = builder.normalize_manifest_path("/test.xhtml", "id");
1464 assert!(result.is_ok());
1465 assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
1466
1467 let result = builder.normalize_manifest_path("./test.xhtml", "manifest_id");
1468 assert!(result.is_err());
1469 assert_eq!(
1470 result.unwrap_err(),
1471 EpubBuilderError::IllegalManifestPath {
1472 manifest_id: "manifest_id".to_string()
1473 }
1474 .into(),
1475 );
1476 }
1477
1478 #[test]
1479 fn test_refine_mime_type() {
1480 assert_eq!(
1481 refine_mime_type("text/xml", "xhtml"),
1482 "application/xhtml+xml"
1483 );
1484 assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
1485 assert_eq!(
1486 refine_mime_type("application/xml", "opf"),
1487 "application/oebps-package+xml"
1488 );
1489 assert_eq!(
1490 refine_mime_type("text/xml", "ncx"),
1491 "application/x-dtbncx+xml"
1492 );
1493 assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1494 assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1495 }
1496}