1use std::{
41 collections::HashMap,
42 env,
43 fs::{self, File},
44 io::{BufReader, Cursor, Read, Seek, Write},
45 marker::PhantomData,
46 path::{Path, PathBuf},
47};
48
49use chrono::{SecondsFormat, Utc};
50use infer::Infer;
51use log::warn;
52use quick_xml::{
53 Writer,
54 events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
55};
56use walkdir::WalkDir;
57use zip::{CompressionMethod, ZipWriter, write::FileOptions};
58
59use crate::{
60 epub::EpubDoc,
61 error::{EpubBuilderError, EpubError},
62 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
63 utils::{
64 ELEMENT_IN_DC_NAMESPACE, check_realtive_link_leakage, local_time, remove_leading_slash,
65 },
66};
67
68type XmlWriter = Writer<Cursor<Vec<u8>>>;
69
70pub struct EpubVersion3;
72
73pub struct EpubBuilder<Version> {
78 epub_version: PhantomData<Version>,
80
81 temp_dir: PathBuf,
83
84 rootfiles: Vec<String>,
86
87 metadata: Vec<MetadataItem>,
89
90 manifest: HashMap<String, ManifestItem>,
92
93 spine: Vec<SpineItem>,
95
96 catalog_title: String,
97
98 catalog: Vec<NavPoint>,
100}
101
102impl EpubBuilder<EpubVersion3> {
103 pub fn new() -> Result<Self, EpubError> {
109 let temp_dir = env::temp_dir().join(local_time());
110 fs::create_dir(&temp_dir)?;
111 fs::create_dir(temp_dir.join("META-INF"))?;
112
113 let mime_file = temp_dir.join("mimetype");
114 fs::write(mime_file, "application/epub+zip")?;
115
116 Ok(EpubBuilder {
117 epub_version: PhantomData,
118 temp_dir,
119
120 rootfiles: vec![],
121 metadata: vec![],
122 manifest: HashMap::new(),
123 spine: vec![],
124
125 catalog_title: String::new(),
126 catalog: vec![],
127 })
128 }
129
130 pub fn add_rootfile(&mut self, rootfile: &str) -> Result<&mut Self, EpubError> {
142 let rootfile = if rootfile.starts_with("/") || rootfile.starts_with("../") {
143 return Err(EpubBuilderError::IllegalRootfilePath.into());
144 } else if let Some(rootfile) = rootfile.strip_prefix("./") {
145 rootfile
146 } else {
147 rootfile
148 };
149
150 self.rootfiles.push(rootfile.to_string());
151
152 Ok(self)
153 }
154
155 pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
163 self.metadata.push(item);
164 self
165 }
166
167 pub fn add_manifest(
183 &mut self,
184 manifest_source: &str,
185 manifest_item: ManifestItem,
186 ) -> Result<&mut Self, EpubError> {
187 if self.rootfiles.is_empty() {
188 return Err(EpubBuilderError::MissingRootfile.into());
189 }
190
191 let source = PathBuf::from(manifest_source);
193 if !source.is_file() {
194 return Err(EpubBuilderError::TargetIsNotFile {
195 target_path: manifest_source.to_string(),
196 }
197 .into());
198 }
199
200 let extension = match source.extension() {
202 Some(ext) => ext.to_string_lossy().to_lowercase(),
203 None => String::new(),
204 };
205
206 let buf = fs::read(source)?;
208
209 let real_mime = match Infer::new().get(&buf) {
211 Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
212 None => {
213 return Err(EpubBuilderError::UnknowFileFormat {
214 file_path: manifest_source.to_string(),
215 }
216 .into());
217 }
218 };
219
220 let target_path = self.normalize_manifest_path(&manifest_item.path)?;
221 if let Some(parent_dir) = target_path.parent() {
222 if !parent_dir.exists() {
223 fs::create_dir_all(parent_dir)?
224 }
225 }
226
227 match fs::write(target_path, buf) {
228 Ok(_) => {
229 self.manifest
230 .insert(manifest_item.id.clone(), manifest_item.set_mime(&real_mime));
231 Ok(self)
232 }
233 Err(err) => Err(err.into()),
234 }
235 }
236
237 pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
244 self.spine.push(item);
245 self
246 }
247
248 pub fn set_catalog_title(&mut self, title: &str) -> &mut Self {
253 self.catalog_title = title.to_string();
254 self
255 }
256
257 pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
264 self.catalog.push(item);
265 self
266 }
267
268 pub fn set_catalog(&mut self, catalog: Vec<NavPoint>) -> &mut Self {
275 self.catalog = catalog;
276 self
277 }
278
279 pub fn make<P: AsRef<Path>>(mut self, output_path: P) -> Result<(), EpubError> {
288 self.make_container_xml()?;
292 self.make_navigation_document()?;
293 self.make_opf_file()?;
294
295 if let Some(parent) = output_path.as_ref().parent() {
296 if !parent.exists() {
297 fs::create_dir_all(parent)?;
298 }
299 }
300
301 let file = File::create(output_path)?;
303 let mut zip = ZipWriter::new(file);
304 let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
305
306 for entry in WalkDir::new(&self.temp_dir) {
307 let entry = entry.map_err(|_e| EpubError::FailedParsingXml)?;
308 let path = entry.path();
309
310 let relative_path = path
311 .strip_prefix(&self.temp_dir)
312 .map_err(|_e| EpubError::FailedParsingXml)?;
313 let target_path = relative_path.to_string_lossy().replace("\\", "/");
314
315 if path.is_file() {
316 zip.start_file(target_path, options)?;
317 let mut buf = Vec::new();
318 File::open(path)?.read_to_end(&mut buf)?;
319 zip.write_all(&buf)?;
320 } else if path.is_dir() {
321 zip.add_directory(target_path, options)?;
322 }
323 }
324
325 zip.finish()?;
326 Ok(())
327 }
328
329 pub fn build<P: AsRef<Path>>(
340 self,
341 output_path: P,
342 ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
343 self.make(&output_path)?;
344
345 EpubDoc::new(output_path)
346 }
347
348 pub fn from<R: Read + Seek>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
375 let mut builder = Self::new()?;
376
377 builder.add_rootfile(&doc.package_path.clone().to_string_lossy())?;
378 builder.metadata = doc.metadata.clone();
379 builder.spine = doc.spine.clone();
380 builder.catalog = doc.catalog.clone();
381 builder.catalog_title = doc.catalog_title.clone();
382
383 for (_, mut manifest) in doc.manifest.clone().into_iter() {
385 if let Some(properties) = &manifest.properties {
386 if properties.contains("nav") {
387 continue;
388 }
389 }
390
391 manifest.path = PathBuf::from("/").join(manifest.path);
395
396 let (buf, _) = doc.get_manifest_item(&manifest.id)?; let target_path = builder.normalize_manifest_path(&manifest.path)?;
398 if let Some(parent_dir) = target_path.parent() {
399 if !parent_dir.exists() {
400 fs::create_dir_all(parent_dir)?
401 }
402 }
403
404 fs::write(target_path, buf)?;
405 builder.manifest.insert(manifest.id.clone(), manifest);
406 }
407
408 Ok(builder)
409 }
410
411 fn make_container_xml(&self) -> Result<(), EpubError> {
415 if self.rootfiles.is_empty() {
416 return Err(EpubBuilderError::MissingRootfile.into());
417 }
418
419 let mut writer = Writer::new(Cursor::new(Vec::new()));
420
421 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
422
423 writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
424 [
425 ("version", "1.0"),
426 ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
427 ],
428 )))?;
429 writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
430
431 for rootfile in &self.rootfiles {
432 writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
433 ("full-path", rootfile.as_str()),
434 ("media-type", "application/oebps-package+xml"),
435 ])))?;
436 }
437
438 writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
439 writer.write_event(Event::End(BytesEnd::new("container")))?;
440
441 let file_path = self.temp_dir.join("META-INF").join("container.xml");
442 let file_data = writer.into_inner().into_inner();
443 fs::write(file_path, file_data)?;
444
445 Ok(())
446 }
447
448 fn make_navigation_document(&mut self) -> Result<(), EpubError> {
452 if self.catalog.is_empty() {
453 return Err(EpubBuilderError::NavigationInfoUninitalized.into());
454 }
455
456 let mut writer = Writer::new(Cursor::new(Vec::new()));
457
458 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
459 ("xmlns", "http://www.w3.org/1999/xhtml"),
460 ("xmlns:epub", "http://www.idpf.org/2007/ops"),
461 ])))?;
462
463 writer.write_event(Event::Start(BytesStart::new("head")))?;
465 writer.write_event(Event::Start(BytesStart::new("title")))?;
466 writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
467 writer.write_event(Event::End(BytesEnd::new("title")))?;
468 writer.write_event(Event::End(BytesEnd::new("head")))?;
469
470 writer.write_event(Event::Start(BytesStart::new("body")))?;
472 writer.write_event(Event::Start(
473 BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
474 ))?;
475
476 if !self.catalog_title.is_empty() {
477 writer.write_event(Event::Start(BytesStart::new("h1")))?;
478 writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
479 writer.write_event(Event::End(BytesEnd::new("h1")))?;
480 }
481
482 Self::make_nav(&mut writer, &self.catalog)?;
483
484 writer.write_event(Event::End(BytesEnd::new("nav")))?;
485 writer.write_event(Event::End(BytesEnd::new("body")))?;
486
487 writer.write_event(Event::End(BytesEnd::new("html")))?;
488
489 let file_path = self.temp_dir.join("nav.xhtml");
490 let file_data = writer.into_inner().into_inner();
491 fs::write(file_path, file_data)?;
492
493 self.manifest.insert(
494 "nav".to_string(),
495 ManifestItem {
496 id: "nav".to_string(),
497 path: PathBuf::from("/nav.xhtml"),
498 mime: "application/xhtml+xml".to_string(),
499 properties: Some("nav".to_string()),
500 fallback: None,
501 },
502 );
503
504 Ok(())
505 }
506
507 fn make_opf_file(&mut self) -> Result<(), EpubError> {
514 if !self.validate_metadata() {
515 return Err(EpubBuilderError::MissingNecessaryMetadata.into());
516 }
517 self.validate_manifest_fallback_chains()?;
518 self.validate_manifest_nav()?;
519
520 let mut writer = Writer::new(Cursor::new(Vec::new()));
521
522 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
523
524 writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
525 ("xmlns", "http://www.idpf.org/2007/opf"),
526 ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
527 ("unique-identifier", "pub-id"),
528 ("version", "3.0"),
529 ])))?;
530
531 self.make_opf_metadata(&mut writer)?;
532 self.make_opf_manifest(&mut writer)?;
533 self.make_opf_spine(&mut writer)?;
534
535 writer.write_event(Event::End(BytesEnd::new("package")))?;
536
537 let file_path = self.temp_dir.join(&self.rootfiles[0]);
538 let file_data = writer.into_inner().into_inner();
539 fs::write(file_path, file_data)?;
540
541 Ok(())
542 }
543
544 fn make_opf_metadata(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
545 self.metadata.push(MetadataItem {
546 id: None,
547 property: "dcterms:modified".to_string(),
548 value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
549 lang: None,
550 refined: vec![],
551 });
552
553 writer.write_event(Event::Start(BytesStart::new("metadata")))?;
554
555 for metadata in &self.metadata {
556 let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
557 format!("dc:{}", metadata.property)
558 } else {
559 "meta".to_string()
560 };
561
562 writer.write_event(Event::Start(
563 BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
564 ))?;
565 writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
566 writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
567
568 for refinement in &metadata.refined {
569 writer.write_event(Event::Start(
570 BytesStart::new("meta").with_attributes(refinement.attributes()),
571 ))?;
572 writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
573 writer.write_event(Event::End(BytesEnd::new("meta")))?;
574 }
575 }
576
577 writer.write_event(Event::End(BytesEnd::new("metadata")))?;
578
579 Ok(())
580 }
581
582 fn make_opf_manifest(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
583 writer.write_event(Event::Start(BytesStart::new("manifest")))?;
584
585 for manifest in self.manifest.values() {
586 writer.write_event(Event::Empty(
587 BytesStart::new("item").with_attributes(manifest.attributes()),
588 ))?;
589 }
590
591 writer.write_event(Event::End(BytesEnd::new("manifest")))?;
592
593 Ok(())
594 }
595
596 fn make_opf_spine(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
597 writer.write_event(Event::Start(BytesStart::new("spine")))?;
598
599 for spine in &self.spine {
600 writer.write_event(Event::Empty(
601 BytesStart::new("itemref").with_attributes(spine.attributes()),
602 ))?;
603 }
604
605 writer.write_event(Event::End(BytesEnd::new("spine")))?;
606
607 Ok(())
608 }
609
610 fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
611 writer.write_event(Event::Start(BytesStart::new("ol")))?;
612
613 for nav in navgations {
614 writer.write_event(Event::Start(BytesStart::new("li")))?;
615
616 if let Some(path) = &nav.content {
617 writer.write_event(Event::Start(
618 BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
619 ))?;
620 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
621 writer.write_event(Event::End(BytesEnd::new("a")))?;
622 } else {
623 writer.write_event(Event::Start(BytesStart::new("span")))?;
624 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
625 writer.write_event(Event::End(BytesEnd::new("span")))?;
626 }
627
628 if !nav.children.is_empty() {
629 Self::make_nav(writer, &nav.children)?;
630 }
631
632 writer.write_event(Event::End(BytesEnd::new("li")))?;
633 }
634
635 writer.write_event(Event::End(BytesEnd::new("ol")))?;
636
637 Ok(())
638 }
639
640 fn validate_metadata(&self) -> bool {
644 let has_title = self.metadata.iter().any(|item| item.property == "title");
645 let has_language = self.metadata.iter().any(|item| item.property == "language");
646 let has_identifier = self.metadata.iter().any(|item| {
647 item.property == "identifier" && item.id.as_ref().is_some_and(|id| id == "pub-id")
648 });
649
650 has_title && has_identifier && has_language
651 }
652
653 fn validate_manifest_fallback_chains(&self) -> Result<(), EpubError> {
654 for (id, item) in &self.manifest {
655 if item.fallback.is_none() {
656 continue;
657 }
658
659 let mut fallback_chain = Vec::new();
660 self.validate_fallback_chain(id, &mut fallback_chain)?;
661 }
662
663 Ok(())
664 }
665
666 fn validate_fallback_chain(
672 &self,
673 manifest_id: &str,
674 fallback_chain: &mut Vec<String>,
675 ) -> Result<(), EpubError> {
676 if fallback_chain.contains(&manifest_id.to_string()) {
677 fallback_chain.push(manifest_id.to_string());
678
679 return Err(EpubBuilderError::ManifestCircularReference {
680 fallback_chain: fallback_chain.join("->"),
681 }
682 .into());
683 }
684
685 let item = self.manifest.get(manifest_id).unwrap();
687
688 if let Some(fallback_id) = &item.fallback {
689 if !self.manifest.contains_key(fallback_id) {
690 return Err(EpubBuilderError::ManifestNotFound {
691 manifest_id: fallback_id.to_owned(),
692 }
693 .into());
694 }
695
696 fallback_chain.push(manifest_id.to_string());
697 self.validate_fallback_chain(fallback_id, fallback_chain)
698 } else {
699 Ok(())
701 }
702 }
703
704 fn validate_manifest_nav(&self) -> Result<(), EpubError> {
708 if self
709 .manifest
710 .values()
711 .filter(|&item| {
712 if let Some(properties) = &item.properties {
713 properties
714 .clone()
715 .split(" ")
716 .collect::<Vec<&str>>()
717 .contains(&"nav")
718 } else {
719 false
720 }
721 })
722 .count()
723 == 1
724 {
725 Ok(())
726 } else {
727 Err(EpubBuilderError::TooManyNavFlags.into())
728 }
729 }
730
731 fn normalize_manifest_path<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf, EpubError> {
750 let opf_path = PathBuf::from(&self.rootfiles[0]);
751 let basic_path = remove_leading_slash(opf_path.parent().unwrap());
752
753 let mut target_path = if path.as_ref().starts_with("../") {
755 check_realtive_link_leakage(
756 self.temp_dir.clone(),
757 basic_path.to_path_buf(),
758 &path.as_ref().to_string_lossy(),
759 )
760 .map(PathBuf::from)
761 .ok_or_else(|| EpubError::RealtiveLinkLeakage {
762 path: path.as_ref().to_string_lossy().to_string(),
763 })?
764 } else if let Ok(path) = path.as_ref().strip_prefix("/") {
765 self.temp_dir.join(path)
766 } else if path.as_ref().starts_with("./") {
767 Err(EpubBuilderError::IllegalManifestPath {
769 manifest_id: path.as_ref().to_string_lossy().to_string(),
770 })?
771 } else {
772 self.temp_dir.join(basic_path).join(path)
773 };
774
775 #[cfg(windows)]
776 {
777 target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
778 }
779
780 Ok(target_path)
781 }
782}
783
784impl<Version> Drop for EpubBuilder<Version> {
785 fn drop(&mut self) {
787 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
788 warn!("{}", err);
789 };
790 }
791}
792
793fn refine_mime_type(infer_mime: &str, extension: &str) -> String {
797 match (infer_mime, extension) {
798 ("text/xml", "xhtml")
799 | ("application/xml", "xhtml")
800 | ("text/xml", "xht")
801 | ("application/xml", "xht") => "application/xhtml+xml".to_string(),
802
803 ("text/xml", "opf") | ("application/xml", "opf") => {
804 "application/oebps-package+xml".to_string()
805 }
806
807 ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml".to_string(),
808
809 ("application/zip", "epub") => "application/epub+zip".to_string(),
810
811 ("text/plain", "css") => "text/css".to_string(),
812 ("text/plain", "js") => "application/javascript".to_string(),
813 ("text/plain", "json") => "application/json".to_string(),
814 ("text/plain", "svg") => "image/svg+xml".to_string(),
815
816 _ => infer_mime.to_string(),
817 }
818}
819
820#[cfg(test)]
821mod tests {
822 use std::{env, fs, path::PathBuf};
823
824 use crate::{
825 builder::{EpubBuilder, EpubVersion3, refine_mime_type},
826 epub::EpubDoc,
827 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
828 utils::local_time,
829 };
830
831 #[test]
832 fn test_from() {
833 let builder = EpubBuilder::<EpubVersion3>::new();
834 assert!(builder.is_ok());
835
836 let metadata = vec![
837 MetadataItem {
838 id: None,
839 property: "title".to_string(),
840 value: "Test Book".to_string(),
841 lang: None,
842 refined: vec![],
843 },
844 MetadataItem {
845 id: None,
846 property: "language".to_string(),
847 value: "en".to_string(),
848 lang: None,
849 refined: vec![],
850 },
851 MetadataItem {
852 id: Some("pub-id".to_string()),
853 property: "identifier".to_string(),
854 value: "test-book".to_string(),
855 lang: None,
856 refined: vec![],
857 },
858 ];
859 let spine = vec![SpineItem {
860 id: None,
861 idref: "main".to_string(),
862 linear: true,
863 properties: None,
864 }];
865 let catalog = vec![
866 NavPoint {
867 label: "Nav".to_string(),
868 content: None,
869 children: vec![],
870 play_order: None,
871 },
872 NavPoint {
873 label: "Overview".to_string(),
874 content: None,
875 children: vec![],
876 play_order: None,
877 },
878 ];
879
880 let mut builder = builder.unwrap();
881 assert!(builder.add_rootfile("content.opf").is_ok());
882 builder.metadata = metadata.clone();
883 builder.spine = spine.clone();
884 builder.catalog = catalog.clone();
885 builder.set_catalog_title("catalog title");
886 let result = builder.add_manifest(
887 "./test_case/Overview.xhtml",
888 ManifestItem {
889 id: "main".to_string(),
890 path: PathBuf::from("Overview.xhtml"),
891 mime: String::new(),
892 properties: None,
893 fallback: None,
894 },
895 );
896 assert!(result.is_ok());
897
898 let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
899 let result = builder.make(&epub_file);
900 assert!(result.is_ok());
901
902 let doc = EpubDoc::new(&epub_file);
903 assert!(doc.is_ok());
904
905 let mut doc = doc.unwrap();
906 let builder = EpubBuilder::from(&mut doc);
907 assert!(builder.is_ok());
908 let builder = builder.unwrap();
909
910 assert_eq!(builder.metadata.len(), metadata.len() + 1);
911 assert_eq!(builder.manifest.len(), 1); assert_eq!(builder.spine.len(), spine.len());
913 assert_eq!(builder.catalog, catalog);
914 assert_eq!(builder.catalog_title, "catalog title");
915
916 fs::remove_file(epub_file).unwrap();
917 }
918
919 #[test]
920 fn test_epub_builder_new() {
921 let builder = EpubBuilder::<EpubVersion3>::new();
922 assert!(builder.is_ok());
923
924 let builder = builder.unwrap();
925 assert!(builder.temp_dir.exists());
926 assert!(builder.rootfiles.is_empty());
927 assert!(builder.metadata.is_empty());
928 assert!(builder.manifest.is_empty());
929 assert!(builder.spine.is_empty());
930 assert!(builder.catalog_title.is_empty());
931 assert!(builder.catalog.is_empty());
932 }
933
934 #[test]
935 fn test_add_rootfile() {
936 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
937 assert!(builder.add_rootfile("content.opf").is_ok());
938
939 assert_eq!(builder.rootfiles.len(), 1);
940 assert_eq!(builder.rootfiles[0], "content.opf");
941
942 assert!(builder.add_rootfile("another.opf").is_ok());
943 assert_eq!(builder.rootfiles.len(), 2);
944 assert_eq!(builder.rootfiles, vec!["content.opf", "another.opf"]);
945 }
946
947 #[test]
948 fn test_add_metadata() {
949 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
950 let metadata_item = MetadataItem::new("title", "Test Book");
951
952 builder.add_metadata(metadata_item);
953
954 assert_eq!(builder.metadata.len(), 1);
955 assert_eq!(builder.metadata[0].property, "title");
956 assert_eq!(builder.metadata[0].value, "Test Book");
957 }
958
959 #[test]
960 fn test_add_manifest_success() {
961 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
962 assert!(builder.add_rootfile("content.opf").is_ok());
963
964 let temp_dir = env::temp_dir().join(local_time());
966 fs::create_dir_all(&temp_dir).unwrap();
967 let test_file = temp_dir.join("test.xhtml");
968 fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
969
970 let manifest_item = ManifestItem::new("test", "test.xhtml").unwrap();
971 let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
972
973 assert!(result.is_ok());
974 assert_eq!(builder.manifest.len(), 1);
975 assert!(builder.manifest.contains_key("test"));
976
977 fs::remove_dir_all(temp_dir).unwrap();
978 }
979
980 #[test]
981 fn test_add_manifest_nonexistent_file() {
982 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
983 assert!(builder.add_rootfile("content.opf").is_ok());
984
985 let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
986 let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
987
988 assert!(result.is_err());
989 if let Err(err) = result {
990 assert_eq!(
991 err.to_string(),
992 "Epub builder error: Expect a file, but 'nonexistent.xhtml' is not a file."
993 );
994 }
995 }
996
997 #[test]
998 fn test_add_spine() {
999 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1000 let spine_item = SpineItem::new("test_item");
1001
1002 builder.add_spine(spine_item.clone());
1003
1004 assert_eq!(builder.spine.len(), 1);
1005 assert_eq!(builder.spine[0].idref, "test_item");
1006 }
1007
1008 #[test]
1009 fn test_set_catalog_title() {
1010 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1011 let title = "Test Catalog Title";
1012
1013 builder.set_catalog_title(title);
1014
1015 assert_eq!(builder.catalog_title, title);
1016 }
1017
1018 #[test]
1019 fn test_add_catalog_item() {
1020 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1021 let nav_point = NavPoint::new("Chapter 1");
1022
1023 builder.add_catalog_item(nav_point.clone());
1024
1025 assert_eq!(builder.catalog.len(), 1);
1026 assert_eq!(builder.catalog[0].label, "Chapter 1");
1027 }
1028
1029 #[test]
1030 fn test_set_catalog() {
1031 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1032 let nav_points = vec![NavPoint::new("Chapter 1"), NavPoint::new("Chapter 2")];
1033
1034 builder.set_catalog(nav_points.clone());
1035
1036 assert_eq!(builder.catalog.len(), 2);
1037 assert_eq!(builder.catalog[0].label, "Chapter 1");
1038 assert_eq!(builder.catalog[1].label, "Chapter 2");
1039 }
1040
1041 #[test]
1042 fn test_validate_metadata_success() {
1043 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1044
1045 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1046 builder.add_metadata(MetadataItem::new("language", "en"));
1047 builder.add_metadata(
1048 MetadataItem::new("identifier", "urn:isbn:1234567890")
1049 .with_id("pub-id")
1050 .build(),
1051 );
1052
1053 assert!(builder.validate_metadata());
1054 }
1055
1056 #[test]
1057 fn test_validate_metadata_missing_required() {
1058 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1059
1060 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1061 builder.add_metadata(MetadataItem::new("language", "en"));
1062
1063 assert!(!builder.validate_metadata());
1064 }
1065
1066 #[test]
1067 fn test_validate_fallback_chain_valid() {
1068 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1069
1070 let item3 = ManifestItem::new("item3", "path3");
1071 assert!(item3.is_ok());
1072
1073 let item3 = item3.unwrap();
1074 let item2 = ManifestItem::new("item2", "path2")
1075 .unwrap()
1076 .with_fallback("item3")
1077 .build();
1078 let item1 = ManifestItem::new("item1", "path1")
1079 .unwrap()
1080 .with_fallback("item2")
1081 .build();
1082
1083 builder.manifest.insert("item3".to_string(), item3);
1084 builder.manifest.insert("item2".to_string(), item2);
1085 builder.manifest.insert("item1".to_string(), item1);
1086
1087 let result = builder.validate_manifest_fallback_chains();
1088 assert!(result.is_ok());
1089 }
1090
1091 #[test]
1092 fn test_validate_fallback_chain_circular_reference() {
1093 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1094
1095 let item2 = ManifestItem::new("item2", "path2")
1096 .unwrap()
1097 .with_fallback("item1")
1098 .build();
1099 let item1 = ManifestItem::new("item1", "path1")
1100 .unwrap()
1101 .with_fallback("item2")
1102 .build();
1103
1104 builder.manifest.insert("item1".to_string(), item1);
1105 builder.manifest.insert("item2".to_string(), item2);
1106
1107 let result = builder.validate_manifest_fallback_chains();
1108 assert!(result.is_err());
1109 assert!(
1110 result.unwrap_err().to_string().starts_with(
1111 "Epub builder error: Circular reference detected in fallback chain for"
1112 ),
1113 );
1114 }
1115
1116 #[test]
1117 fn test_validate_fallback_chain_not_found() {
1118 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1119
1120 let item1 = ManifestItem::new("item1", "path1")
1121 .unwrap()
1122 .with_fallback("nonexistent")
1123 .build();
1124
1125 builder.manifest.insert("item1".to_string(), item1);
1126
1127 let result = builder.validate_manifest_fallback_chains();
1128 assert!(result.is_err());
1129 assert_eq!(
1130 result.unwrap_err().to_string(),
1131 "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
1132 );
1133 }
1134
1135 #[test]
1136 fn test_validate_manifest_nav_single() {
1137 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1138
1139 let nav_item = ManifestItem::new("nav", "nav.xhtml")
1140 .unwrap()
1141 .append_property("nav")
1142 .build();
1143 builder.manifest.insert("nav".to_string(), nav_item);
1144
1145 let result = builder.validate_manifest_nav();
1146 assert!(result.is_ok());
1147 }
1148
1149 #[test]
1150 fn test_validate_manifest_nav_multiple() {
1151 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1152
1153 let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
1154 .unwrap()
1155 .append_property("nav")
1156 .build();
1157 let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
1158 .unwrap()
1159 .append_property("nav")
1160 .build();
1161
1162 builder.manifest.insert("nav1".to_string(), nav_item1);
1163 builder.manifest.insert("nav2".to_string(), nav_item2);
1164
1165 let result = builder.validate_manifest_nav();
1166 assert!(result.is_err());
1167 assert_eq!(
1168 result.unwrap_err().to_string(),
1169 "Epub builder error: There are too many items with 'nav' property in the manifest."
1170 );
1171 }
1172
1173 #[test]
1174 fn test_make_opf_file_success() {
1175 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1176
1177 assert!(builder.add_rootfile("content.opf").is_ok());
1178 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1179 builder.add_metadata(MetadataItem::new("language", "en"));
1180 builder.add_metadata(
1181 MetadataItem::new("identifier", "urn:isbn:1234567890")
1182 .with_id("pub-id")
1183 .build(),
1184 );
1185
1186 let temp_dir = env::temp_dir().join(local_time());
1187 fs::create_dir_all(&temp_dir).unwrap();
1188
1189 let test_file = temp_dir.join("test.xhtml");
1190 fs::write(&test_file, "<html></html>").unwrap();
1191
1192 let manifest_result = builder.add_manifest(
1193 test_file.to_str().unwrap(),
1194 ManifestItem::new("test", "test.xhtml").unwrap(),
1195 );
1196 assert!(manifest_result.is_ok());
1197
1198 builder.add_catalog_item(NavPoint::new("Chapter"));
1199 builder.add_spine(SpineItem::new("test"));
1200
1201 let result = builder.make_navigation_document();
1202 assert!(result.is_ok());
1203
1204 let result = builder.make_opf_file();
1205 assert!(result.is_ok());
1206
1207 let opf_path = builder.temp_dir.join("content.opf");
1208 assert!(opf_path.exists());
1209
1210 fs::remove_dir_all(temp_dir).unwrap();
1211 }
1212
1213 #[test]
1214 fn test_make_opf_file_missing_metadata() {
1215 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1216 assert!(builder.add_rootfile("content.opf").is_ok());
1217
1218 let result = builder.make_opf_file();
1219 assert!(result.is_err());
1220 assert_eq!(
1221 result.unwrap_err().to_string(),
1222 "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1223 );
1224 }
1225
1226 #[test]
1227 fn test_refine_mime_type() {
1228 assert_eq!(
1229 refine_mime_type("text/xml", "xhtml"),
1230 "application/xhtml+xml"
1231 );
1232 assert_eq!(
1233 refine_mime_type("application/xml", "opf"),
1234 "application/oebps-package+xml"
1235 );
1236 assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1237 assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1238 }
1239}