1use std::{
41 collections::HashMap,
42 env,
43 fs::{self, File},
44 io::{BufReader, Cursor, Read, 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::{ELEMENT_IN_DC_NAMESPACE, local_time},
64};
65
66type XmlWriter = Writer<Cursor<Vec<u8>>>;
67
68pub struct EpubVersion3;
70
71pub struct EpubBuilder<Version> {
76 epub_version: PhantomData<Version>,
78
79 temp_dir: PathBuf,
81
82 rootfiles: Vec<String>,
84
85 metadata: Vec<MetadataItem>,
87
88 manifest: HashMap<String, ManifestItem>,
90
91 spine: Vec<SpineItem>,
93
94 catalog_title: String,
95
96 catalog: Vec<NavPoint>,
98}
99
100impl EpubBuilder<EpubVersion3> {
101 pub fn new() -> Result<Self, EpubError> {
107 let temp_dir = env::temp_dir().join(local_time());
108 fs::create_dir(&temp_dir)?;
109 fs::create_dir(temp_dir.join("META-INF"))?;
110
111 let mime_file = temp_dir.join("mimetype");
112 fs::write(mime_file, "application/epub+zip")?;
113
114 Ok(EpubBuilder {
115 epub_version: PhantomData,
116 temp_dir,
117
118 rootfiles: vec![],
119 metadata: vec![],
120 manifest: HashMap::new(),
121 spine: vec![],
122
123 catalog_title: String::new(),
124 catalog: vec![],
125 })
126 }
127
128 pub fn add_rootfile(&mut self, rootfile: &str) -> &mut Self {
136 self.rootfiles.push(rootfile.to_string());
137
138 self
139 }
140
141 pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
149 self.metadata.push(item);
150 self
151 }
152
153 pub fn add_manifest(
166 &mut self,
167 manifest_source: &str,
168 manifest_item: ManifestItem,
169 ) -> Result<&mut Self, EpubError> {
170 let source = PathBuf::from(manifest_source);
171 if !source.is_file() {
172 return Err(EpubBuilderError::TargetIsNotFile {
173 target_path: manifest_source.to_string(),
174 }
175 .into());
176 }
177
178 let extension = match source.extension() {
179 Some(ext) => ext.to_string_lossy().to_lowercase(),
180 None => String::new(),
181 };
182
183 let buf = match fs::read(source) {
184 Ok(buf) => buf,
185 Err(err) => return Err(err.into()),
186 };
187
188 let real_mime = match Infer::new().get(&buf) {
189 Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
190 None => {
191 return Err(EpubBuilderError::UnknowFileFormat {
192 file_path: manifest_source.to_string(),
193 }
194 .into());
195 }
196 };
197
198 let target_path = self.temp_dir.join(&manifest_item.path);
199 if let Some(parent_dir) = target_path.parent() {
200 if !parent_dir.exists() {
201 fs::create_dir_all(parent_dir)?
202 }
203 }
204
205 match fs::write(target_path, buf) {
206 Ok(_) => {
207 self.manifest
208 .insert(manifest_item.id.clone(), manifest_item.set_mime(&real_mime));
209 Ok(self)
210 }
211 Err(err) => Err(err.into()),
212 }
213 }
214
215 pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
222 self.spine.push(item);
223 self
224 }
225
226 pub fn set_catalog_title(&mut self, title: &str) -> &mut Self {
231 self.catalog_title = title.to_string();
232 self
233 }
234
235 pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
242 self.catalog.push(item);
243 self
244 }
245
246 pub fn set_catalog(&mut self, catalog: Vec<NavPoint>) -> &mut Self {
253 self.catalog = catalog;
254 self
255 }
256
257 pub fn make<P: AsRef<Path>>(mut self, output_path: P) -> Result<(), EpubError> {
266 self.make_container_xml()?;
270 self.make_navigation_document()?;
271 self.make_opf_file()?;
272
273 if let Some(parent) = output_path.as_ref().parent() {
274 if !parent.exists() {
275 fs::create_dir_all(parent)?;
276 }
277 }
278
279 let file = File::create(output_path)?;
280 let mut zip = ZipWriter::new(file);
281 let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
282
283 for entry in WalkDir::new(&self.temp_dir) {
284 let entry = entry.map_err(|_e| EpubError::FailedParsingXml)?;
285 let path = entry.path();
286
287 let relative_path = path
288 .strip_prefix(&self.temp_dir)
289 .map_err(|_e| EpubError::FailedParsingXml)?;
290 let target_path = relative_path.to_string_lossy().replace("\\", "/");
291
292 if path.is_file() {
293 zip.start_file(target_path, options)?;
294 let mut buf = Vec::new();
295 File::open(path)?.read_to_end(&mut buf)?;
296 zip.write(&buf)?;
297 } else if path.is_dir() {
298 zip.add_directory(target_path, options)?;
299 }
300 }
301
302 zip.finish()?;
303 Ok(())
304 }
305
306 pub fn build<P: AsRef<Path>>(
317 self,
318 output_path: P,
319 ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
320 self.make(&output_path)?;
321
322 EpubDoc::new(output_path)
323 }
324
325 fn make_container_xml(&self) -> Result<(), EpubError> {
329 if self.rootfiles.is_empty() {
330 return Err(EpubBuilderError::MissingRootfile.into());
331 }
332
333 let mut writer = Writer::new(Cursor::new(Vec::new()));
334
335 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
336
337 writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
338 [
339 ("version", "1.0"),
340 ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
341 ],
342 )))?;
343 writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
344
345 for rootfile in &self.rootfiles {
346 writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
347 ("full-path", rootfile.as_str()),
348 ("media-type", "application/oebps-package+xml"),
349 ])))?;
350 }
351
352 writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
353 writer.write_event(Event::End(BytesEnd::new("container")))?;
354
355 let file_path = self.temp_dir.join("META-INF").join("container.xml");
356 let file_data = writer.into_inner().into_inner();
357 fs::write(file_path, file_data)?;
358
359 Ok(())
360 }
361
362 fn make_navigation_document(&mut self) -> Result<(), EpubError> {
366 if self.catalog.is_empty() {
367 return Err(EpubBuilderError::NavigationInfoUninitalized.into());
368 }
369
370 let mut writer = Writer::new(Cursor::new(Vec::new()));
371
372 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
373 ("xmlns", "http://www.w3.org/1999/xhtml"),
374 ("xmlns:epub", "http://www.idpf.org/2007/ops"),
375 ])))?;
376
377 writer.write_event(Event::Start(BytesStart::new("head")))?;
379 writer.write_event(Event::Start(BytesStart::new("title")))?;
380 writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
381 writer.write_event(Event::End(BytesEnd::new("title")))?;
382 writer.write_event(Event::End(BytesEnd::new("head")))?;
383
384 writer.write_event(Event::Start(BytesStart::new("body")))?;
386 writer.write_event(Event::Start(
387 BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
388 ))?;
389
390 if !self.catalog_title.is_empty() {
391 writer.write_event(Event::Start(BytesStart::new("h1")))?;
392 writer.write_event(Event::Text(BytesText::new(&self.catalog_title)))?;
393 writer.write_event(Event::End(BytesEnd::new("h1")))?;
394 }
395
396 Self::make_nav(&mut writer, &self.catalog)?;
397
398 writer.write_event(Event::End(BytesEnd::new("nav")))?;
399 writer.write_event(Event::End(BytesEnd::new("body")))?;
400
401 writer.write_event(Event::End(BytesEnd::new("html")))?;
402
403 let file_path = self.temp_dir.join("nav.xhtml");
404 let file_data = writer.into_inner().into_inner();
405 fs::write(file_path, file_data)?;
406
407 self.manifest.insert(
408 "nav".to_string(),
409 ManifestItem {
410 id: "nav".to_string(),
411 path: PathBuf::from("nav.xhtml"),
412 mime: "application/xhtml+xml".to_string(),
413 properties: Some("nav".to_string()),
414 fallback: None,
415 },
416 );
417
418 Ok(())
419 }
420
421 fn make_opf_file(&mut self) -> Result<(), EpubError> {
428 if !self.validate_metadata() {
429 return Err(EpubBuilderError::MissingNecessaryMetadata.into());
430 }
431 self.validate_manifest_fallback_chains()?;
432 self.validate_manifest_nav()?;
433
434 let mut writer = Writer::new(Cursor::new(Vec::new()));
435
436 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
437
438 writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
439 ("xmlns", "http://www.idpf.org/2007/opf"),
440 ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
441 ("unique-identifier", "pub-id"),
442 ("version", "3.0"),
443 ])))?;
444
445 self.make_opf_metadata(&mut writer)?;
446 self.make_opf_manifest(&mut writer)?;
447 self.make_opf_spine(&mut writer)?;
448
449 writer.write_event(Event::End(BytesEnd::new("package")))?;
450
451 let file_path = self.temp_dir.join(&self.rootfiles[0]);
452 let file_data = writer.into_inner().into_inner();
453 fs::write(file_path, file_data)?;
454
455 Ok(())
456 }
457
458 fn make_opf_metadata(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
459 self.metadata.push(MetadataItem {
460 id: None,
461 property: "dcterms:modified".to_string(),
462 value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
463 lang: None,
464 refined: vec![],
465 });
466
467 writer.write_event(Event::Start(BytesStart::new("metadata")))?;
468
469 for metadata in &self.metadata {
470 let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
471 format!("dc:{}", metadata.property)
472 } else {
473 "meta".to_string()
474 };
475
476 writer.write_event(Event::Start(
477 BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
478 ))?;
479 writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
480 writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
481
482 for refinement in &metadata.refined {
483 writer.write_event(Event::Start(
484 BytesStart::new("meta").with_attributes(refinement.attributes()),
485 ))?;
486 writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
487 writer.write_event(Event::End(BytesEnd::new("meta")))?;
488 }
489 }
490
491 writer.write_event(Event::End(BytesEnd::new("metadata")))?;
492
493 Ok(())
494 }
495
496 fn make_opf_manifest(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
497 writer.write_event(Event::Start(BytesStart::new("manifest")))?;
498
499 for (_, manifest) in &self.manifest {
500 writer.write_event(Event::Empty(
501 BytesStart::new("item").with_attributes(manifest.attributes()),
502 ))?;
503 }
504
505 writer.write_event(Event::End(BytesEnd::new("manifest")))?;
506
507 Ok(())
508 }
509
510 fn make_opf_spine(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
511 writer.write_event(Event::Start(BytesStart::new("spine")))?;
512
513 for spine in &self.spine {
514 writer.write_event(Event::Empty(
515 BytesStart::new("itemref").with_attributes(spine.attributes()),
516 ))?;
517 }
518
519 writer.write_event(Event::End(BytesEnd::new("spine")))?;
520
521 Ok(())
522 }
523
524 fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
525 writer.write_event(Event::Start(BytesStart::new("ol")))?;
526
527 for nav in navgations {
528 writer.write_event(Event::Start(BytesStart::new("li")))?;
529
530 if let Some(path) = &nav.content {
531 writer.write_event(Event::Start(
532 BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
533 ))?;
534 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
535 writer.write_event(Event::End(BytesEnd::new("a")))?;
536 } else {
537 writer.write_event(Event::Start(BytesStart::new("span")))?;
538 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
539 writer.write_event(Event::End(BytesEnd::new("span")))?;
540 }
541
542 if !nav.children.is_empty() {
543 Self::make_nav(writer, &nav.children)?;
544 }
545
546 writer.write_event(Event::End(BytesEnd::new("li")))?;
547 }
548
549 writer.write_event(Event::End(BytesEnd::new("ol")))?;
550
551 Ok(())
552 }
553
554 fn validate_metadata(&self) -> bool {
558 let has_title = self.metadata.iter().any(|item| item.property == "title");
559 let has_language = self.metadata.iter().any(|item| item.property == "language");
560 let has_identifier = self.metadata.iter().any(|item| {
561 item.property == "identifier" && item.id.as_ref().is_some_and(|id| id == "pub-id")
562 });
563
564 has_title && has_identifier && has_language
565 }
566
567 fn validate_manifest_fallback_chains(&self) -> Result<(), EpubError> {
568 for (id, item) in &self.manifest {
569 if item.fallback.is_none() {
570 continue;
571 }
572
573 let mut fallback_chain = Vec::new();
574 self.validate_fallback_chain(id, &mut fallback_chain)?;
575 }
576
577 Ok(())
578 }
579
580 fn validate_fallback_chain(
586 &self,
587 manifest_id: &str,
588 fallback_chain: &mut Vec<String>,
589 ) -> Result<(), EpubError> {
590 if fallback_chain.contains(&manifest_id.to_string()) {
591 fallback_chain.push(manifest_id.to_string());
592
593 return Err(EpubBuilderError::ManifestCircularReference {
594 fallback_chain: fallback_chain.join("->"),
595 }
596 .into());
597 }
598
599 let item = self.manifest.get(manifest_id).unwrap();
601
602 if let Some(fallback_id) = &item.fallback {
603 if !self.manifest.contains_key(fallback_id) {
604 return Err(EpubBuilderError::ManifestNotFound {
605 manifest_id: fallback_id.to_owned(),
606 }
607 .into());
608 }
609
610 fallback_chain.push(manifest_id.to_string());
611 self.validate_fallback_chain(fallback_id, fallback_chain)
612 } else {
613 Ok(())
615 }
616 }
617
618 fn validate_manifest_nav(&self) -> Result<(), EpubError> {
622 if self
623 .manifest
624 .values()
625 .filter(|&item| {
626 if let Some(properties) = &item.properties {
627 properties
628 .clone()
629 .split(" ")
630 .collect::<Vec<&str>>()
631 .contains(&"nav")
632 } else {
633 return false;
634 }
635 })
636 .count()
637 == 1
638 {
639 Ok(())
640 } else {
641 Err(EpubBuilderError::TooManyNavFlags.into())
642 }
643 }
644}
645
646impl<Version> Drop for EpubBuilder<Version> {
647 fn drop(&mut self) {
649 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
650 warn!("{}", err);
651 };
652 }
653}
654
655fn refine_mime_type(infer_mime: &str, extension: &str) -> String {
659 match (infer_mime, extension) {
660 ("text/xml", "xhtml")
661 | ("application/xml", "xhtml")
662 | ("text/xml", "xht")
663 | ("application/xml", "xht") => "application/xhtml+xml".to_string(),
664
665 ("text/xml", "opf") | ("application/xml", "opf") => {
666 "application/oebps-package+xml".to_string()
667 }
668
669 ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml".to_string(),
670
671 ("application/zip", "epub") => "application/epub+zip".to_string(),
672
673 ("text/plain", "css") => "text/css".to_string(),
674 ("text/plain", "js") => "application/javascript".to_string(),
675 ("text/plain", "json") => "application/json".to_string(),
676 ("text/plain", "svg") => "image/svg+xml".to_string(),
677
678 _ => infer_mime.to_string(),
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use std::{env, fs};
685
686 use crate::{
687 builder::{EpubBuilder, EpubVersion3, refine_mime_type},
688 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
689 utils::local_time,
690 };
691
692 #[test]
693 fn test_epub_builder_new() {
694 let builder = EpubBuilder::<EpubVersion3>::new();
695 assert!(builder.is_ok());
696
697 let builder = builder.unwrap();
698 assert!(builder.temp_dir.exists());
699 assert!(builder.rootfiles.is_empty());
700 assert!(builder.metadata.is_empty());
701 assert!(builder.manifest.is_empty());
702 assert!(builder.spine.is_empty());
703 assert!(builder.catalog_title.is_empty());
704 assert!(builder.catalog.is_empty());
705 }
706
707 #[test]
708 fn test_add_rootfile() {
709 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
710 builder.add_rootfile("content.opf");
711
712 assert_eq!(builder.rootfiles.len(), 1);
713 assert_eq!(builder.rootfiles[0], "content.opf");
714
715 builder
717 .add_rootfile("another.opf")
718 .add_rootfile("third.opf");
719 assert_eq!(builder.rootfiles.len(), 3);
720 assert_eq!(
721 builder.rootfiles,
722 vec!["content.opf", "another.opf", "third.opf"]
723 );
724 }
725
726 #[test]
727 fn test_add_metadata() {
728 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
729 let metadata_item = MetadataItem::new("title", "Test Book");
730
731 builder.add_metadata(metadata_item);
732
733 assert_eq!(builder.metadata.len(), 1);
734 assert_eq!(builder.metadata[0].property, "title");
735 assert_eq!(builder.metadata[0].value, "Test Book");
736 }
737
738 #[test]
739 fn test_add_manifest_success() {
740 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
741
742 let temp_dir = env::temp_dir().join(local_time());
744 fs::create_dir_all(&temp_dir).unwrap();
745 let test_file = temp_dir.join("test.xhtml");
746 fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
747
748 let manifest_item = ManifestItem::new("test", "test.xhtml").unwrap();
749 let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
750
751 assert!(result.is_ok());
752 assert_eq!(builder.manifest.len(), 1);
753 assert!(builder.manifest.contains_key("test"));
754
755 fs::remove_dir_all(temp_dir).unwrap();
756 }
757
758 #[test]
759 fn test_add_manifest_nonexistent_file() {
760 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
761
762 let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
763 let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
764
765 assert!(result.is_err());
766 if let Err(err) = result {
767 assert_eq!(
768 err.to_string(),
769 "Epub builder error: Expect a file, but 'nonexistent.xhtml' is not a file."
770 );
771 }
772 }
773
774 #[test]
775 fn test_add_spine() {
776 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
777 let spine_item = SpineItem::new("test_item");
778
779 builder.add_spine(spine_item.clone());
780
781 assert_eq!(builder.spine.len(), 1);
782 assert_eq!(builder.spine[0].idref, "test_item");
783 }
784
785 #[test]
786 fn test_set_catalog_title() {
787 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
788 let title = "Test Catalog Title";
789
790 builder.set_catalog_title(title);
791
792 assert_eq!(builder.catalog_title, title);
793 }
794
795 #[test]
796 fn test_add_catalog_item() {
797 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
798 let nav_point = NavPoint::new("Chapter 1");
799
800 builder.add_catalog_item(nav_point.clone());
801
802 assert_eq!(builder.catalog.len(), 1);
803 assert_eq!(builder.catalog[0].label, "Chapter 1");
804 }
805
806 #[test]
807 fn test_set_catalog() {
808 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
809 let nav_points = vec![NavPoint::new("Chapter 1"), NavPoint::new("Chapter 2")];
810
811 builder.set_catalog(nav_points.clone());
812
813 assert_eq!(builder.catalog.len(), 2);
814 assert_eq!(builder.catalog[0].label, "Chapter 1");
815 assert_eq!(builder.catalog[1].label, "Chapter 2");
816 }
817
818 #[test]
819 fn test_validate_metadata_success() {
820 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
821
822 builder.add_metadata(MetadataItem::new("title", "Test Book"));
823 builder.add_metadata(MetadataItem::new("language", "en"));
824 builder.add_metadata(
825 MetadataItem::new("identifier", "urn:isbn:1234567890")
826 .with_id("pub-id")
827 .build(),
828 );
829
830 assert!(builder.validate_metadata());
831 }
832
833 #[test]
834 fn test_validate_metadata_missing_required() {
835 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
836
837 builder.add_metadata(MetadataItem::new("title", "Test Book"));
838 builder.add_metadata(MetadataItem::new("language", "en"));
839
840 assert!(!builder.validate_metadata());
841 }
842
843 #[test]
844 fn test_validate_fallback_chain_valid() {
845 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
846
847 let item3 = ManifestItem::new("item3", "path3");
848 assert!(item3.is_ok());
849
850 let item3 = item3.unwrap();
851 let item2 = ManifestItem::new("item2", "path2")
852 .unwrap()
853 .with_fallback("item3")
854 .build();
855 let item1 = ManifestItem::new("item1", "path1")
856 .unwrap()
857 .with_fallback("item2")
858 .build();
859
860 builder.manifest.insert("item3".to_string(), item3);
861 builder.manifest.insert("item2".to_string(), item2);
862 builder.manifest.insert("item1".to_string(), item1);
863
864 let result = builder.validate_manifest_fallback_chains();
865 assert!(result.is_ok());
866 }
867
868 #[test]
869 fn test_validate_fallback_chain_circular_reference() {
870 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
871
872 let item2 = ManifestItem::new("item2", "path2")
873 .unwrap()
874 .with_fallback("item1")
875 .build();
876 let item1 = ManifestItem::new("item1", "path1")
877 .unwrap()
878 .with_fallback("item2")
879 .build();
880
881 builder.manifest.insert("item1".to_string(), item1);
882 builder.manifest.insert("item2".to_string(), item2);
883
884 let result = builder.validate_manifest_fallback_chains();
885 assert!(result.is_err());
886 assert!(
887 result.unwrap_err().to_string().starts_with(
888 "Epub builder error: Circular reference detected in fallback chain for"
889 ),
890 );
891 }
892
893 #[test]
894 fn test_validate_fallback_chain_not_found() {
895 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
896
897 let item1 = ManifestItem::new("item1", "path1")
898 .unwrap()
899 .with_fallback("nonexistent")
900 .build();
901
902 builder.manifest.insert("item1".to_string(), item1);
903
904 let result = builder.validate_manifest_fallback_chains();
905 assert!(result.is_err());
906 assert_eq!(
907 result.unwrap_err().to_string(),
908 "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
909 );
910 }
911
912 #[test]
913 fn test_validate_manifest_nav_single() {
914 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
915
916 let nav_item = ManifestItem::new("nav", "nav.xhtml")
917 .unwrap()
918 .append_property("nav")
919 .build();
920 builder.manifest.insert("nav".to_string(), nav_item);
921
922 let result = builder.validate_manifest_nav();
923 assert!(result.is_ok());
924 }
925
926 #[test]
927 fn test_validate_manifest_nav_multiple() {
928 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
929
930 let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
931 .unwrap()
932 .append_property("nav")
933 .build();
934 let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
935 .unwrap()
936 .append_property("nav")
937 .build();
938
939 builder.manifest.insert("nav1".to_string(), nav_item1);
940 builder.manifest.insert("nav2".to_string(), nav_item2);
941
942 let result = builder.validate_manifest_nav();
943 assert!(result.is_err());
944 assert_eq!(
945 result.unwrap_err().to_string(),
946 "Epub builder error: There are too many items with 'nav' property in the manifest."
947 );
948 }
949
950 #[test]
951 fn test_make_opf_file_success() {
952 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
953
954 builder.add_rootfile("content.opf");
955 builder.add_metadata(MetadataItem::new("title", "Test Book"));
956 builder.add_metadata(MetadataItem::new("language", "en"));
957 builder.add_metadata(
958 MetadataItem::new("identifier", "urn:isbn:1234567890")
959 .with_id("pub-id")
960 .build(),
961 );
962
963 let temp_dir = env::temp_dir().join(local_time());
964 fs::create_dir_all(&temp_dir).unwrap();
965
966 let test_file = temp_dir.join("test.xhtml");
967 fs::write(&test_file, "<html></html>").unwrap();
968
969 let manifest_result = builder.add_manifest(
970 test_file.to_str().unwrap(),
971 ManifestItem::new("test", "test.xhtml").unwrap(),
972 );
973 assert!(manifest_result.is_ok());
974
975 builder.add_catalog_item(NavPoint::new("Chapter"));
976 builder.add_spine(SpineItem::new("test"));
977
978 let result = builder.make_navigation_document();
979 assert!(result.is_ok());
980
981 let result = builder.make_opf_file();
982 assert!(result.is_ok());
983
984 let opf_path = builder.temp_dir.join("content.opf");
985 assert!(opf_path.exists());
986
987 fs::remove_dir_all(temp_dir).unwrap();
988 }
989
990 #[test]
991 fn test_make_opf_file_missing_metadata() {
992 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
993 builder.add_rootfile("content.opf");
994
995 let result = builder.make_opf_file();
996 assert!(result.is_err());
997 assert_eq!(
998 result.unwrap_err().to_string(),
999 "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1000 );
1001 }
1002
1003 #[test]
1004 fn test_refine_mime_type() {
1005 assert_eq!(
1006 refine_mime_type("text/xml", "xhtml"),
1007 "application/xhtml+xml"
1008 );
1009 assert_eq!(
1010 refine_mime_type("application/xml", "opf"),
1011 "application/oebps-package+xml"
1012 );
1013 assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
1014 assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
1015 }
1016}