1#[cfg(feature = "no-indexmap")]
41use std::collections::HashMap;
42use std::{
43 cmp::Reverse,
44 env,
45 fs::{self, File},
46 io::{BufReader, Cursor, Read, Seek},
47 marker::PhantomData,
48 path::{Path, PathBuf},
49};
50
51use chrono::{SecondsFormat, Utc};
52#[cfg(not(feature = "no-indexmap"))]
53use indexmap::IndexMap;
54use infer::Infer;
55use log::warn;
56use quick_xml::{
57 Writer,
58 events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event},
59};
60use walkdir::WalkDir;
61use zip::{CompressionMethod, ZipWriter, write::FileOptions};
62
63#[cfg(feature = "content-builder")]
64use crate::builder::content::ContentBuilder;
65use crate::{
66 epub::EpubDoc,
67 error::{EpubBuilderError, EpubError},
68 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
69 utils::{
70 ELEMENT_IN_DC_NAMESPACE, check_realtive_link_leakage, local_time, remove_leading_slash,
71 },
72};
73
74#[cfg(feature = "content-builder")]
75pub mod content;
76
77type XmlWriter = Writer<Cursor<Vec<u8>>>;
78
79#[cfg_attr(test, derive(Debug))]
81pub struct EpubVersion3;
82
83#[derive(Debug)]
97pub struct RootfileBuilder {
98 pub(crate) rootfiles: Vec<String>,
100}
101
102impl RootfileBuilder {
103 pub(crate) fn new() -> Self {
105 Self { rootfiles: Vec::new() }
106 }
107
108 pub fn add(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
120 let rootfile = rootfile.as_ref();
121
122 if rootfile.starts_with("/") || rootfile.starts_with("../") {
123 return Err(EpubBuilderError::IllegalRootfilePath.into());
124 }
125
126 let rootfile = rootfile.strip_prefix("./").unwrap_or(rootfile);
127
128 self.rootfiles.push(rootfile.into());
129 Ok(self)
130 }
131
132 pub fn clear(&mut self) -> &mut Self {
136 self.rootfiles.clear();
137 self
138 }
139
140 pub(crate) fn is_empty(&self) -> bool {
142 self.rootfiles.is_empty()
143 }
144
145 pub(crate) fn first(&self) -> Option<&String> {
147 self.rootfiles.first()
148 }
149
150 pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
154 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
155
156 writer.write_event(Event::Start(BytesStart::new("container").with_attributes(
157 [
158 ("version", "1.0"),
159 ("xmlns", "urn:oasis:names:tc:opendocument:xmlns:container"),
160 ],
161 )))?;
162 writer.write_event(Event::Start(BytesStart::new("rootfiles")))?;
163
164 for rootfile in &self.rootfiles {
165 writer.write_event(Event::Empty(BytesStart::new("rootfile").with_attributes([
166 ("full-path", rootfile.as_str()),
167 ("media-type", "application/oebps-package+xml"),
168 ])))?;
169 }
170
171 writer.write_event(Event::End(BytesEnd::new("rootfiles")))?;
172 writer.write_event(Event::End(BytesEnd::new("container")))?;
173
174 Ok(())
175 }
176}
177
178#[derive(Debug)]
191pub struct MetadataBuilder {
192 pub(crate) metadata: Vec<MetadataItem>,
194}
195
196impl MetadataBuilder {
197 pub(crate) fn new() -> Self {
199 Self { metadata: Vec::new() }
200 }
201
202 pub fn add(&mut self, item: MetadataItem) -> &mut Self {
212 self.metadata.push(item);
213 self
214 }
215
216 pub fn clear(&mut self) -> &mut Self {
220 self.metadata.clear();
221 self
222 }
223
224 pub(crate) fn make(&mut self, writer: &mut XmlWriter) -> Result<(), EpubError> {
230 self.metadata.push(MetadataItem {
231 id: None,
232 property: "dcterms:modified".to_string(),
233 value: Utc::now().to_rfc3339_opts(SecondsFormat::AutoSi, true),
234 lang: None,
235 refined: vec![],
236 });
237
238 writer.write_event(Event::Start(BytesStart::new("metadata")))?;
239
240 for metadata in &self.metadata {
241 let tag_name = if ELEMENT_IN_DC_NAMESPACE.contains(&metadata.property.as_str()) {
242 format!("dc:{}", metadata.property)
243 } else {
244 "meta".to_string()
245 };
246
247 writer.write_event(Event::Start(
248 BytesStart::new(tag_name.as_str()).with_attributes(metadata.attributes()),
249 ))?;
250 writer.write_event(Event::Text(BytesText::new(metadata.value.as_str())))?;
251 writer.write_event(Event::End(BytesEnd::new(tag_name.as_str())))?;
252
253 for refinement in &metadata.refined {
254 writer.write_event(Event::Start(
255 BytesStart::new("meta").with_attributes(refinement.attributes()),
256 ))?;
257 writer.write_event(Event::Text(BytesText::new(refinement.value.as_str())))?;
258 writer.write_event(Event::End(BytesEnd::new("meta")))?;
259 }
260 }
261
262 writer.write_event(Event::End(BytesEnd::new("metadata")))?;
263
264 Ok(())
265 }
266
267 pub(crate) fn validate(&self) -> Result<(), EpubError> {
271 let mut has_title = false;
272 let mut has_language = false;
273 let mut has_identifier = false;
274
275 for item in &self.metadata {
276 match item.property.as_str() {
277 "title" => has_title = true,
278 "language" => has_language = true,
279 "identifier" => {
280 if item.id.as_ref().is_some_and(|id| id == "pub-id") {
281 has_identifier = true;
282 }
283 }
284 _ => {}
285 }
286
287 if has_title && has_language && has_identifier {
288 return Ok(());
289 }
290 }
291
292 Err(EpubBuilderError::MissingNecessaryMetadata.into())
293 }
294}
295
296#[derive(Debug)]
317pub struct ManifestBuilder {
318 temp_dir: PathBuf,
320
321 rootfile: Option<String>,
323
324 #[cfg(feature = "no-indexmap")]
326 pub(crate) manifest: HashMap<String, ManifestItem>,
327 #[cfg(not(feature = "no-indexmap"))]
328 pub(crate) manifest: IndexMap<String, ManifestItem>,
329}
330
331impl ManifestBuilder {
332 pub(crate) fn new(temp_dir: impl AsRef<Path>) -> Self {
337 Self {
338 temp_dir: temp_dir.as_ref().to_path_buf(),
339 rootfile: None,
340 #[cfg(feature = "no-indexmap")]
341 manifest: HashMap::new(),
342 #[cfg(not(feature = "no-indexmap"))]
343 manifest: IndexMap::new(),
344 }
345 }
346
347 pub(crate) fn set_rootfile(&mut self, rootfile: impl Into<String>) {
354 self.rootfile = Some(rootfile.into());
355 }
356
357 pub fn add(
371 &mut self,
372 manifest_source: impl Into<String>,
373 manifest_item: ManifestItem,
374 ) -> Result<&mut Self, EpubError> {
375 let manifest_source = manifest_source.into();
377 let source = PathBuf::from(&manifest_source);
378 if !source.is_file() {
379 return Err(EpubBuilderError::TargetIsNotFile { target_path: manifest_source }.into());
380 }
381
382 let extension = match source.extension() {
384 Some(ext) => ext.to_string_lossy().to_lowercase(),
385 None => String::new(),
386 };
387
388 let buf = fs::read(source)?;
390
391 let real_mime = match Infer::new().get(&buf) {
393 Some(infer_mime) => refine_mime_type(infer_mime.mime_type(), &extension),
394 None => {
395 return Err(
396 EpubBuilderError::UnknownFileFormat { file_path: manifest_source }.into(),
397 );
398 }
399 };
400
401 let target_path = normalize_manifest_path(
402 &self.temp_dir,
403 self.rootfile
404 .as_ref()
405 .ok_or(EpubBuilderError::MissingRootfile)?,
406 &manifest_item.path,
407 &manifest_item.id,
408 )?;
409 if let Some(parent_dir) = target_path.parent() {
410 if !parent_dir.exists() {
411 fs::create_dir_all(parent_dir)?
412 }
413 }
414
415 match fs::write(target_path, buf) {
416 Ok(_) => {
417 self.manifest
418 .insert(manifest_item.id.clone(), manifest_item.set_mime(real_mime));
419 Ok(self)
420 }
421 Err(err) => Err(err.into()),
422 }
423 }
424
425 pub fn clear(&mut self) -> &mut Self {
430 let paths = self
431 .manifest
432 .values()
433 .map(|manifest| &manifest.path)
434 .collect::<Vec<&PathBuf>>();
435
436 for path in paths {
437 let _ = fs::remove_file(path);
438 }
439
440 self.manifest.clear();
441
442 self
443 }
444
445 pub(crate) fn insert(
450 &mut self,
451 key: impl Into<String>,
452 value: ManifestItem,
453 ) -> Option<ManifestItem> {
454 self.manifest.insert(key.into(), value)
455 }
456
457 pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
461 writer.write_event(Event::Start(BytesStart::new("manifest")))?;
462
463 for manifest in self.manifest.values() {
464 writer.write_event(Event::Empty(
465 BytesStart::new("item").with_attributes(manifest.attributes()),
466 ))?;
467 }
468
469 writer.write_event(Event::End(BytesEnd::new("manifest")))?;
470
471 Ok(())
472 }
473
474 pub(crate) fn validate(&self) -> Result<(), EpubError> {
479 self.validate_fallback_chains()?;
480 self.validate_nav()?;
481
482 Ok(())
483 }
484
485 pub(crate) fn keys(&self) -> impl Iterator<Item = &String> {
492 self.manifest.keys()
493 }
494
495 fn validate_fallback_chains(&self) -> Result<(), EpubError> {
501 for (id, item) in &self.manifest {
502 if item.fallback.is_none() {
503 continue;
504 }
505
506 let mut fallback_chain = Vec::new();
507 self.validate_fallback_chain(id, &mut fallback_chain)?;
508 }
509
510 Ok(())
511 }
512
513 fn validate_fallback_chain(
519 &self,
520 manifest_id: &str,
521 fallback_chain: &mut Vec<String>,
522 ) -> Result<(), EpubError> {
523 if fallback_chain.contains(&manifest_id.to_string()) {
524 fallback_chain.push(manifest_id.to_string());
525
526 return Err(EpubBuilderError::ManifestCircularReference {
527 fallback_chain: fallback_chain.join("->"),
528 }
529 .into());
530 }
531
532 let item = self.manifest.get(manifest_id).unwrap();
534
535 if let Some(fallback_id) = &item.fallback {
536 if !self.manifest.contains_key(fallback_id) {
537 return Err(EpubBuilderError::ManifestNotFound {
538 manifest_id: fallback_id.to_owned(),
539 }
540 .into());
541 }
542
543 fallback_chain.push(manifest_id.to_string());
544 self.validate_fallback_chain(fallback_id, fallback_chain)
545 } else {
546 Ok(())
548 }
549 }
550
551 fn validate_nav(&self) -> Result<(), EpubError> {
555 if self
556 .manifest
557 .values()
558 .filter(|&item| {
559 if let Some(properties) = &item.properties {
560 properties.split(" ").any(|property| property == "nav")
561 } else {
562 false
563 }
564 })
565 .count()
566 == 1
567 {
568 Ok(())
569 } else {
570 Err(EpubBuilderError::TooManyNavFlags.into())
571 }
572 }
573}
574
575#[derive(Debug)]
584pub struct SpineBuilder {
585 pub(crate) spine: Vec<SpineItem>,
587}
588
589impl SpineBuilder {
590 pub(crate) fn new() -> Self {
592 Self { spine: Vec::new() }
593 }
594
595 pub fn add(&mut self, item: SpineItem) -> &mut Self {
606 self.spine.push(item);
607 self
608 }
609
610 pub fn clear(&mut self) -> &mut Self {
614 self.spine.clear();
615 self
616 }
617
618 pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
622 writer.write_event(Event::Start(BytesStart::new("spine")))?;
623
624 for spine in &self.spine {
625 writer.write_event(Event::Empty(
626 BytesStart::new("itemref").with_attributes(spine.attributes()),
627 ))?;
628 }
629
630 writer.write_event(Event::End(BytesEnd::new("spine")))?;
631
632 Ok(())
633 }
634
635 pub(crate) fn validate(
642 &self,
643 manifest_keys: impl Iterator<Item = impl AsRef<str>>,
644 ) -> Result<(), EpubError> {
645 let manifest_keys: Vec<String> = manifest_keys.map(|k| k.as_ref().to_string()).collect();
646 for spine in &self.spine {
647 if !manifest_keys.contains(&spine.idref) {
648 return Err(
649 EpubBuilderError::SpineManifestNotFound { idref: spine.idref.clone() }.into(),
650 );
651 }
652 }
653 Ok(())
654 }
655}
656
657#[derive(Debug)]
666pub struct CatalogBuilder {
667 pub(crate) title: String,
669
670 pub(crate) catalog: Vec<NavPoint>,
672}
673
674impl CatalogBuilder {
675 pub(crate) fn new() -> Self {
677 Self {
678 title: String::new(),
679 catalog: Vec::new(),
680 }
681 }
682
683 pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
693 self.title = title.into();
694 self
695 }
696
697 pub fn add(&mut self, item: NavPoint) -> &mut Self {
708 self.catalog.push(item);
709 self
710 }
711
712 pub fn clear(&mut self) -> &mut Self {
716 self.title.clear();
717 self.catalog.clear();
718 self
719 }
720
721 pub(crate) fn is_empty(&self) -> bool {
727 self.catalog.is_empty()
728 }
729
730 pub(crate) fn make(&self, writer: &mut XmlWriter) -> Result<(), EpubError> {
735 writer.write_event(Event::Start(BytesStart::new("html").with_attributes([
736 ("xmlns", "http://www.w3.org/1999/xhtml"),
737 ("xmlns:epub", "http://www.idpf.org/2007/ops"),
738 ])))?;
739
740 writer.write_event(Event::Start(BytesStart::new("head")))?;
742 writer.write_event(Event::Start(BytesStart::new("title")))?;
743 writer.write_event(Event::Text(BytesText::new(&self.title)))?;
744 writer.write_event(Event::End(BytesEnd::new("title")))?;
745 writer.write_event(Event::End(BytesEnd::new("head")))?;
746
747 writer.write_event(Event::Start(BytesStart::new("body")))?;
749 writer.write_event(Event::Start(
750 BytesStart::new("nav").with_attributes([("epub:type", "toc")]),
751 ))?;
752
753 if !self.title.is_empty() {
754 writer.write_event(Event::Start(BytesStart::new("h1")))?;
755 writer.write_event(Event::Text(BytesText::new(&self.title)))?;
756 writer.write_event(Event::End(BytesEnd::new("h1")))?;
757 }
758
759 Self::make_nav(writer, &self.catalog)?;
760
761 writer.write_event(Event::End(BytesEnd::new("nav")))?;
762 writer.write_event(Event::End(BytesEnd::new("body")))?;
763
764 writer.write_event(Event::End(BytesEnd::new("html")))?;
765
766 Ok(())
767 }
768
769 fn make_nav(writer: &mut XmlWriter, navgations: &Vec<NavPoint>) -> Result<(), EpubError> {
774 writer.write_event(Event::Start(BytesStart::new("ol")))?;
775
776 for nav in navgations {
777 writer.write_event(Event::Start(BytesStart::new("li")))?;
778
779 if let Some(path) = &nav.content {
780 writer.write_event(Event::Start(
781 BytesStart::new("a").with_attributes([("href", path.to_string_lossy())]),
782 ))?;
783 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
784 writer.write_event(Event::End(BytesEnd::new("a")))?;
785 } else {
786 writer.write_event(Event::Start(BytesStart::new("span")))?;
787 writer.write_event(Event::Text(BytesText::new(nav.label.as_str())))?;
788 writer.write_event(Event::End(BytesEnd::new("span")))?;
789 }
790
791 if !nav.children.is_empty() {
792 Self::make_nav(writer, &nav.children)?;
793 }
794
795 writer.write_event(Event::End(BytesEnd::new("li")))?;
796 }
797
798 writer.write_event(Event::End(BytesEnd::new("ol")))?;
799
800 Ok(())
801 }
802}
803
804#[cfg(feature = "content-builder")]
805#[derive(Debug)]
806pub struct DocumentBuilder {
807 pub(crate) documents: Vec<(PathBuf, ContentBuilder)>,
808}
809
810#[cfg(feature = "content-builder")]
811impl DocumentBuilder {
812 pub(crate) fn new() -> Self {
814 Self { documents: Vec::new() }
815 }
816
817 pub fn add(&mut self, target: impl AsRef<str>, content: ContentBuilder) -> &mut Self {
828 self.documents
829 .push((PathBuf::from(target.as_ref()), content));
830 self
831 }
832
833 pub fn clear(&mut self) -> &mut Self {
837 self.documents.clear();
838 self
839 }
840
841 pub fn make(
855 &mut self,
856 temp_dir: PathBuf,
857 rootfile: impl AsRef<str>,
858 ) -> Result<Vec<ManifestItem>, EpubError> {
859 let mut buf = vec![0; 512];
860 let contents = std::mem::take(&mut self.documents);
861
862 let mut manifest = Vec::new();
863 for (target, mut content) in contents.into_iter() {
864 let manifest_id = content.id.clone();
865
866 let absolute_target =
868 normalize_manifest_path(&temp_dir, &rootfile, &target, &manifest_id)?;
869 let mut resources = content.make(&absolute_target)?;
870
871 let to_container_path = |p: &PathBuf| -> PathBuf {
873 match p.strip_prefix(&temp_dir) {
874 Ok(rel) => PathBuf::from("/").join(rel.to_string_lossy().replace("\\", "/")),
875 Err(_) => unreachable!("path MUST under temp directory"),
876 }
877 };
878
879 let path = resources.swap_remove(0);
881 let mut file = std::fs::File::open(&path)?;
882 let _ = file.read(&mut buf)?;
883 let extension = path
884 .extension()
885 .map(|e| e.to_string_lossy().to_lowercase())
886 .unwrap_or_default();
887 let mime = match Infer::new().get(&buf) {
888 Some(infer) => refine_mime_type(infer.mime_type(), &extension),
889 None => {
890 return Err(EpubBuilderError::UnknownFileFormat {
891 file_path: path.to_string_lossy().to_string(),
892 }
893 .into());
894 }
895 }
896 .to_string();
897
898 manifest.push(ManifestItem {
899 id: manifest_id.clone(),
900 path: to_container_path(&path),
901 mime,
902 properties: None,
903 fallback: None,
904 });
905
906 for res in resources {
908 let mut file = fs::File::open(&res)?;
909 let _ = file.read(&mut buf)?;
910 let extension = res
911 .extension()
912 .map(|e| e.to_string_lossy().to_lowercase())
913 .unwrap_or_default();
914 let mime = match Infer::new().get(&buf) {
915 Some(ft) => refine_mime_type(ft.mime_type(), &extension),
916 None => {
917 return Err(EpubBuilderError::UnknownFileFormat {
918 file_path: path.to_string_lossy().to_string(),
919 }
920 .into());
921 }
922 }
923 .to_string();
924
925 let file_name = res
926 .file_name()
927 .map(|s| s.to_string_lossy().to_string())
928 .unwrap_or_default();
929 let res_id = format!("{}-{}", manifest_id, file_name);
930
931 manifest.push(ManifestItem {
932 id: res_id,
933 path: to_container_path(&res),
934 mime,
935 properties: None,
936 fallback: None,
937 });
938 }
939 }
940
941 Ok(manifest)
942 }
943}
944
945#[cfg_attr(test, derive(Debug))]
1004pub struct EpubBuilder<Version> {
1005 epub_version: PhantomData<Version>,
1007
1008 temp_dir: PathBuf,
1010
1011 rootfiles: RootfileBuilder,
1012 metadata: MetadataBuilder,
1013 manifest: ManifestBuilder,
1014 spine: SpineBuilder,
1015 catalog: CatalogBuilder,
1016
1017 #[cfg(feature = "content-builder")]
1018 content: DocumentBuilder,
1019}
1020
1021impl EpubBuilder<EpubVersion3> {
1022 pub fn new() -> Result<Self, EpubError> {
1028 let temp_dir = env::temp_dir().join(local_time());
1029 fs::create_dir(&temp_dir)?;
1030 fs::create_dir(temp_dir.join("META-INF"))?;
1031
1032 let mime_file = temp_dir.join("mimetype");
1033 fs::write(mime_file, "application/epub+zip")?;
1034
1035 Ok(EpubBuilder {
1036 epub_version: PhantomData,
1037 temp_dir: temp_dir.clone(),
1038
1039 rootfiles: RootfileBuilder::new(),
1040 metadata: MetadataBuilder::new(),
1041 manifest: ManifestBuilder::new(temp_dir),
1042 spine: SpineBuilder::new(),
1043 catalog: CatalogBuilder::new(),
1044
1045 #[cfg(feature = "content-builder")]
1046 content: DocumentBuilder::new(),
1047 })
1048 }
1049
1050 pub fn add_rootfile(&mut self, rootfile: impl AsRef<str>) -> Result<&mut Self, EpubError> {
1062 match self.rootfiles.add(rootfile) {
1063 Ok(_) => Ok(self),
1064 Err(err) => Err(err),
1065 }
1066 }
1067
1068 pub fn add_metadata(&mut self, item: MetadataItem) -> &mut Self {
1076 let _ = self.metadata.add(item);
1077 self
1078 }
1079
1080 pub fn add_manifest(
1097 &mut self,
1098 manifest_source: impl Into<String>,
1099 manifest_item: ManifestItem,
1100 ) -> Result<&mut Self, EpubError> {
1101 if self.rootfiles.is_empty() {
1102 return Err(EpubBuilderError::MissingRootfile.into());
1103 } else {
1104 self.manifest
1105 .set_rootfile(self.rootfiles.first().expect("Unreachable"));
1106 }
1107
1108 match self.manifest.add(manifest_source, manifest_item) {
1109 Ok(_) => Ok(self),
1110 Err(err) => Err(err),
1111 }
1112 }
1113
1114 pub fn add_spine(&mut self, item: SpineItem) -> &mut Self {
1121 self.spine.add(item);
1122 self
1123 }
1124
1125 pub fn set_catalog_title(&mut self, title: impl Into<String>) -> &mut Self {
1130 let _ = self.catalog.set_title(title);
1131 self
1132 }
1133
1134 pub fn add_catalog_item(&mut self, item: NavPoint) -> &mut Self {
1141 let _ = self.catalog.add(item);
1142 self
1143 }
1144
1145 #[cfg(feature = "content-builder")]
1154 pub fn add_content(
1155 &mut self,
1156 target_path: impl AsRef<str>,
1157 content: ContentBuilder,
1158 ) -> &mut Self {
1159 self.content.add(target_path, content);
1160 self
1161 }
1162
1163 pub fn clear_all(&mut self) -> &mut Self {
1172 self.rootfiles.clear();
1173 self.metadata.clear();
1174 self.manifest.clear();
1175 self.spine.clear();
1176 self.catalog.clear();
1177 #[cfg(feature = "content-builder")]
1178 self.content.clear();
1179
1180 self
1181 }
1182
1183 pub fn rootfile(&mut self) -> &mut RootfileBuilder {
1190 &mut self.rootfiles
1191 }
1192
1193 pub fn metadata(&mut self) -> &mut MetadataBuilder {
1200 &mut self.metadata
1201 }
1202
1203 pub fn manifest(&mut self) -> &mut ManifestBuilder {
1210 &mut self.manifest
1211 }
1212
1213 pub fn spine(&mut self) -> &mut SpineBuilder {
1220 &mut self.spine
1221 }
1222
1223 pub fn catalog(&mut self) -> &mut CatalogBuilder {
1230 &mut self.catalog
1231 }
1232
1233 #[cfg(feature = "content-builder")]
1240 pub fn content(&mut self) -> &mut DocumentBuilder {
1241 &mut self.content
1242 }
1243
1244 pub fn make(mut self, output_path: impl AsRef<Path>) -> Result<(), EpubError> {
1253 self.make_container_xml()?;
1257 self.make_navigation_document()?;
1258 #[cfg(feature = "content-builder")]
1259 self.make_contents()?;
1260 self.make_opf_file()?;
1261 self.remove_empty_dirs()?;
1262
1263 if let Some(parent) = output_path.as_ref().parent() {
1264 if !parent.exists() {
1265 fs::create_dir_all(parent)?;
1266 }
1267 }
1268
1269 let file = File::create(output_path)?;
1271 let mut zip = ZipWriter::new(file);
1272 let options = FileOptions::<()>::default().compression_method(CompressionMethod::Stored);
1273
1274 for entry in WalkDir::new(&self.temp_dir) {
1275 let entry = entry?;
1276 let path = entry.path();
1277
1278 let relative_path = path.strip_prefix(&self.temp_dir).unwrap();
1281 let target_path = relative_path.to_string_lossy().replace("\\", "/");
1282
1283 if path.is_file() {
1284 zip.start_file(target_path, options)?;
1285
1286 let mut file = File::open(path)?;
1287 std::io::copy(&mut file, &mut zip)?;
1288 } else if path.is_dir() {
1289 zip.add_directory(target_path, options)?;
1290 }
1291 }
1292
1293 zip.finish()?;
1294 Ok(())
1295 }
1296
1297 pub fn build(
1308 self,
1309 output_path: impl AsRef<Path>,
1310 ) -> Result<EpubDoc<BufReader<File>>, EpubError> {
1311 self.make(&output_path)?;
1312
1313 EpubDoc::new(output_path)
1314 }
1315
1316 pub fn from<R: Read + Seek>(doc: &mut EpubDoc<R>) -> Result<Self, EpubError> {
1343 let mut builder = Self::new()?;
1344
1345 builder.add_rootfile(doc.package_path.clone().to_string_lossy())?;
1346 builder.metadata.metadata = doc.metadata.clone();
1347 builder.spine.spine = doc.spine.clone();
1348 builder.catalog.catalog = doc.catalog.clone();
1349 builder.catalog.title = doc.catalog_title.clone();
1350
1351 for (_, mut manifest) in doc.manifest.clone().into_iter() {
1353 if let Some(properties) = &manifest.properties {
1354 if properties.contains("nav") {
1355 continue;
1356 }
1357 }
1358
1359 manifest.path = PathBuf::from("/").join(manifest.path);
1363
1364 let (buf, _) = doc.get_manifest_item(&manifest.id)?; let target_path = normalize_manifest_path(
1366 &builder.temp_dir,
1367 builder.rootfiles.first().expect("Unreachable"),
1368 &manifest.path,
1369 &manifest.id,
1370 )?;
1371 if let Some(parent_dir) = target_path.parent() {
1372 if !parent_dir.exists() {
1373 fs::create_dir_all(parent_dir)?
1374 }
1375 }
1376
1377 fs::write(target_path, buf)?;
1378 builder
1379 .manifest
1380 .manifest
1381 .insert(manifest.id.clone(), manifest);
1382 }
1383
1384 Ok(builder)
1385 }
1386
1387 fn make_container_xml(&self) -> Result<(), EpubError> {
1391 if self.rootfiles.is_empty() {
1392 return Err(EpubBuilderError::MissingRootfile.into());
1393 }
1394
1395 let mut writer = Writer::new(Cursor::new(Vec::new()));
1396 self.rootfiles.make(&mut writer)?;
1397
1398 let file_path = self.temp_dir.join("META-INF").join("container.xml");
1399 let file_data = writer.into_inner().into_inner();
1400 fs::write(file_path, file_data)?;
1401
1402 Ok(())
1403 }
1404
1405 #[cfg(feature = "content-builder")]
1407 fn make_contents(&mut self) -> Result<(), EpubError> {
1408 let manifest_list = self.content.make(
1409 self.temp_dir.clone(),
1410 self.rootfiles.first().expect("Unreachable"),
1411 )?;
1412
1413 for item in manifest_list.into_iter() {
1414 self.manifest.insert(item.id.clone(), item);
1415 }
1416
1417 Ok(())
1418 }
1419
1420 fn make_navigation_document(&mut self) -> Result<(), EpubError> {
1424 if self.catalog.is_empty() {
1425 return Err(EpubBuilderError::NavigationInfoUninitalized.into());
1426 }
1427
1428 let mut writer = Writer::new(Cursor::new(Vec::new()));
1429 self.catalog.make(&mut writer)?;
1430
1431 let file_path = self.temp_dir.join("nav.xhtml");
1432 let file_data = writer.into_inner().into_inner();
1433 fs::write(file_path, file_data)?;
1434
1435 self.manifest.insert(
1436 "nav".to_string(),
1437 ManifestItem {
1438 id: "nav".to_string(),
1439 path: PathBuf::from("/nav.xhtml"),
1440 mime: "application/xhtml+xml".to_string(),
1441 properties: Some("nav".to_string()),
1442 fallback: None,
1443 },
1444 );
1445
1446 Ok(())
1447 }
1448
1449 fn make_opf_file(&mut self) -> Result<(), EpubError> {
1456 self.metadata.validate()?;
1457 self.manifest.validate()?;
1458 self.spine.validate(self.manifest.keys())?;
1459
1460 let mut writer = Writer::new(Cursor::new(Vec::new()));
1461
1462 writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
1463
1464 writer.write_event(Event::Start(BytesStart::new("package").with_attributes([
1465 ("xmlns", "http://www.idpf.org/2007/opf"),
1466 ("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
1467 ("unique-identifier", "pub-id"),
1468 ("version", "3.0"),
1469 ])))?;
1470
1471 self.metadata.make(&mut writer)?;
1472 self.manifest.make(&mut writer)?;
1473 self.spine.make(&mut writer)?;
1474
1475 writer.write_event(Event::End(BytesEnd::new("package")))?;
1476
1477 let file_path = self
1478 .temp_dir
1479 .join(self.rootfiles.first().expect("Unreachable"));
1480 let file_data = writer.into_inner().into_inner();
1481 fs::write(file_path, file_data)?;
1482
1483 Ok(())
1484 }
1485
1486 fn remove_empty_dirs(&self) -> Result<(), EpubError> {
1497 let mut dirs = WalkDir::new(self.temp_dir.as_path())
1498 .min_depth(1)
1499 .into_iter()
1500 .filter_map(|entry| entry.ok())
1501 .filter(|entry| entry.file_type().is_dir())
1502 .map(|entry| entry.into_path())
1503 .collect::<Vec<PathBuf>>();
1504
1505 dirs.sort_by_key(|p| Reverse(p.components().count()));
1506
1507 for dir in dirs {
1508 if fs::read_dir(&dir)?.next().is_none() {
1509 fs::remove_dir(dir)?;
1510 }
1511 }
1512
1513 Ok(())
1514 }
1515}
1516
1517impl<Version> Drop for EpubBuilder<Version> {
1518 fn drop(&mut self) {
1520 if let Err(err) = fs::remove_dir_all(&self.temp_dir) {
1521 warn!("{}", err);
1522 };
1523 }
1524}
1525
1526fn refine_mime_type<'a>(infer_mime: &'a str, extension: &'a str) -> &'a str {
1532 match (infer_mime, extension) {
1533 ("text/xml", "xhtml")
1534 | ("application/xml", "xhtml")
1535 | ("text/xml", "xht")
1536 | ("application/xml", "xht") => "application/xhtml+xml",
1537
1538 ("text/xml", "opf") | ("application/xml", "opf") => "application/oebps-package+xml",
1539
1540 ("text/xml", "ncx") | ("application/xml", "ncx") => "application/x-dtbncx+xml",
1541
1542 ("application/zip", "epub") => "application/epub+zip",
1543
1544 ("text/plain", "css") => "text/css",
1545 ("text/plain", "js") => "application/javascript",
1546 ("text/plain", "json") => "application/json",
1547 ("text/plain", "svg") => "image/svg+xml",
1548
1549 _ => infer_mime,
1550 }
1551}
1552
1553fn normalize_manifest_path<TempD: AsRef<Path>, S: AsRef<str>, P: AsRef<Path>>(
1575 temp_dir: TempD,
1576 rootfile: S,
1577 path: P,
1578 id: &str,
1579) -> Result<PathBuf, EpubError> {
1580 let opf_path = PathBuf::from(rootfile.as_ref());
1581 let basic_path = remove_leading_slash(opf_path.parent().unwrap());
1582
1583 let mut target_path = if path.as_ref().starts_with("../") {
1585 check_realtive_link_leakage(
1586 temp_dir.as_ref().to_path_buf(),
1587 basic_path.to_path_buf(),
1588 &path.as_ref().to_string_lossy(),
1589 )
1590 .map(PathBuf::from)
1591 .ok_or_else(|| EpubError::RelativeLinkLeakage {
1592 path: path.as_ref().to_string_lossy().to_string(),
1593 })?
1594 } else if let Ok(path) = path.as_ref().strip_prefix("/") {
1595 temp_dir.as_ref().join(path)
1596 } else if path.as_ref().starts_with("./") {
1597 Err(EpubBuilderError::IllegalManifestPath { manifest_id: id.to_string() })?
1599 } else {
1600 temp_dir.as_ref().join(basic_path).join(path)
1601 };
1602
1603 #[cfg(windows)]
1604 {
1605 target_path = PathBuf::from(target_path.to_string_lossy().replace('\\', "/"));
1606 }
1607
1608 Ok(target_path)
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use std::{env, fs, path::PathBuf};
1614
1615 use crate::{
1616 builder::{EpubBuilder, EpubVersion3, normalize_manifest_path, refine_mime_type},
1617 epub::EpubDoc,
1618 error::{EpubBuilderError, EpubError},
1619 types::{ManifestItem, MetadataItem, NavPoint, SpineItem},
1620 utils::local_time,
1621 };
1622
1623 mod test_helpers {
1624 use super::*;
1625
1626 pub(super) fn create_basic_builder() -> EpubBuilder<EpubVersion3> {
1627 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1628 builder.add_rootfile("content.opf").unwrap();
1629 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1630 builder.add_metadata(MetadataItem::new("language", "en"));
1631 builder.add_metadata(
1632 MetadataItem::new("identifier", "urn:isbn:1234567890")
1633 .with_id("pub-id")
1634 .build(),
1635 );
1636 builder
1637 }
1638
1639 pub(super) fn create_full_builder() -> EpubBuilder<EpubVersion3> {
1640 let mut builder = create_basic_builder();
1641 builder.add_catalog_item(NavPoint::new("Chapter"));
1642 builder.add_spine(SpineItem::new("test"));
1643 builder
1644 }
1645 }
1646
1647 mod epub_builder_tests {
1648 use super::*;
1649
1650 #[test]
1651 fn test_epub_builder_new() {
1652 let builder = EpubBuilder::<EpubVersion3>::new().expect("Failed to create builder");
1653 assert!(builder.temp_dir.exists());
1654 assert!(builder.rootfiles.is_empty());
1655 assert!(builder.metadata.metadata.is_empty());
1656 assert!(builder.manifest.manifest.is_empty());
1657 assert!(builder.spine.spine.is_empty());
1658 assert!(builder.catalog.title.is_empty());
1659 assert!(builder.catalog.is_empty());
1660 }
1661
1662 #[test]
1663 fn test_add_rootfile() {
1664 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1665
1666 builder
1667 .add_rootfile("content.opf")
1668 .expect("Failed to add rootfile");
1669 assert_eq!(builder.rootfiles.rootfiles.len(), 1);
1670 assert_eq!(builder.rootfiles.rootfiles[0], "content.opf");
1671
1672 builder
1673 .add_rootfile("./another.opf")
1674 .expect("Failed to add another rootfile");
1675 assert_eq!(builder.rootfiles.rootfiles.len(), 2);
1676 assert_eq!(
1677 builder.rootfiles.rootfiles,
1678 vec!["content.opf", "another.opf"]
1679 );
1680 }
1681
1682 #[test]
1683 fn test_add_rootfile_fail() {
1684 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1685
1686 let result = builder.add_rootfile("/rootfile.opf");
1687 assert!(result.is_err());
1688 assert_eq!(
1689 result.unwrap_err(),
1690 EpubBuilderError::IllegalRootfilePath.into()
1691 );
1692
1693 let result = builder.add_rootfile("../rootfile.opf");
1694 assert!(result.is_err());
1695 assert_eq!(
1696 result.unwrap_err(),
1697 EpubBuilderError::IllegalRootfilePath.into()
1698 );
1699 }
1700
1701 #[test]
1702 fn test_add_metadata() {
1703 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1704 let metadata_item = MetadataItem::new("title", "Test Book");
1705
1706 builder.add_metadata(metadata_item);
1707
1708 assert_eq!(builder.metadata.metadata.len(), 1);
1709 assert_eq!(builder.metadata.metadata[0].property, "title");
1710 assert_eq!(builder.metadata.metadata[0].value, "Test Book");
1711 }
1712
1713 #[test]
1714 fn test_add_spine() {
1715 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1716 let spine_item = SpineItem::new("test_item");
1717
1718 builder.add_spine(spine_item);
1719
1720 assert_eq!(builder.spine.spine.len(), 1);
1721 assert_eq!(builder.spine.spine[0].idref, "test_item");
1722 }
1723
1724 #[test]
1725 fn test_set_catalog_title() {
1726 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1727 let title = "Test Catalog Title";
1728
1729 builder.set_catalog_title(title);
1730
1731 assert_eq!(builder.catalog.title, title);
1732 }
1733
1734 #[test]
1735 fn test_add_catalog_item() {
1736 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1737 let nav_point = NavPoint::new("Chapter 1");
1738
1739 builder.add_catalog_item(nav_point);
1740
1741 assert_eq!(builder.catalog.catalog.len(), 1);
1742 assert_eq!(builder.catalog.catalog[0].label, "Chapter 1");
1743 }
1744
1745 #[test]
1746 fn test_clear_all() {
1747 let mut builder = test_helpers::create_full_builder();
1748
1749 assert_eq!(builder.metadata.metadata.len(), 3);
1750 assert_eq!(builder.spine.spine.len(), 1);
1751 assert_eq!(builder.catalog.catalog.len(), 1);
1752
1753 builder.clear_all();
1754
1755 assert!(builder.metadata.metadata.is_empty());
1756 assert!(builder.spine.spine.is_empty());
1757 assert!(builder.catalog.catalog.is_empty());
1758 assert!(builder.catalog.title.is_empty());
1759 assert!(builder.manifest.manifest.is_empty());
1760
1761 builder.add_metadata(MetadataItem::new("title", "New Book"));
1762 builder.add_spine(SpineItem::new("new_chapter"));
1763 builder.add_catalog_item(NavPoint::new("New Chapter"));
1764
1765 assert_eq!(builder.metadata.metadata.len(), 1);
1766 assert_eq!(builder.spine.spine.len(), 1);
1767 assert_eq!(builder.catalog.catalog.len(), 1);
1768 }
1769
1770 #[test]
1771 fn test_make() {
1772 let mut builder = test_helpers::create_full_builder();
1773
1774 builder
1775 .add_manifest(
1776 "./test_case/Overview.xhtml",
1777 ManifestItem {
1778 id: "test".to_string(),
1779 path: PathBuf::from("test.xhtml"),
1780 mime: String::new(),
1781 properties: None,
1782 fallback: None,
1783 },
1784 )
1785 .unwrap();
1786
1787 let file = env::temp_dir().join(format!("{}.epub", local_time()));
1788 assert!(builder.make(&file).is_ok());
1789 assert!(EpubDoc::new(&file).is_ok());
1790 }
1791
1792 #[test]
1793 fn test_build() {
1794 let mut builder = test_helpers::create_full_builder();
1795
1796 builder
1797 .add_manifest(
1798 "./test_case/Overview.xhtml",
1799 ManifestItem {
1800 id: "test".to_string(),
1801 path: PathBuf::from("test.xhtml"),
1802 mime: String::new(),
1803 properties: None,
1804 fallback: None,
1805 },
1806 )
1807 .unwrap();
1808
1809 let file = env::temp_dir().join(format!("{}.epub", local_time()));
1810 assert!(builder.build(&file).is_ok());
1811 }
1812
1813 #[test]
1814 fn test_from() {
1815 let metadata = vec![
1816 MetadataItem {
1817 id: None,
1818 property: "title".to_string(),
1819 value: "Test Book".to_string(),
1820 lang: None,
1821 refined: vec![],
1822 },
1823 MetadataItem {
1824 id: None,
1825 property: "language".to_string(),
1826 value: "en".to_string(),
1827 lang: None,
1828 refined: vec![],
1829 },
1830 MetadataItem {
1831 id: Some("pub-id".to_string()),
1832 property: "identifier".to_string(),
1833 value: "test-book".to_string(),
1834 lang: None,
1835 refined: vec![],
1836 },
1837 ];
1838 let spine = vec![SpineItem {
1839 id: None,
1840 idref: "main".to_string(),
1841 linear: true,
1842 properties: None,
1843 }];
1844 let catalog = vec![
1845 NavPoint {
1846 label: "Nav".to_string(),
1847 content: None,
1848 children: vec![],
1849 play_order: None,
1850 },
1851 NavPoint {
1852 label: "Overview".to_string(),
1853 content: None,
1854 children: vec![],
1855 play_order: None,
1856 },
1857 ];
1858
1859 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1860 builder.add_rootfile("content.opf").unwrap();
1861 builder.metadata.metadata = metadata.clone();
1862 builder.spine.spine = spine.clone();
1863 builder.catalog.catalog = catalog.clone();
1864 builder.set_catalog_title("catalog title");
1865 builder
1866 .add_manifest(
1867 "./test_case/Overview.xhtml",
1868 ManifestItem {
1869 id: "main".to_string(),
1870 path: PathBuf::from("Overview.xhtml"),
1871 mime: String::new(),
1872 properties: None,
1873 fallback: None,
1874 },
1875 )
1876 .unwrap();
1877
1878 let epub_file = env::temp_dir().join(format!("{}.epub", local_time()));
1879 builder.make(&epub_file).unwrap();
1880
1881 let mut doc = EpubDoc::new(&epub_file).unwrap();
1882 let builder = EpubBuilder::from(&mut doc).unwrap();
1883
1884 assert_eq!(builder.metadata.metadata.len(), metadata.len() + 1);
1885 assert_eq!(builder.manifest.manifest.len(), 1);
1886 assert_eq!(builder.spine.spine.len(), spine.len());
1887 assert_eq!(builder.catalog.catalog, catalog);
1888 assert_eq!(builder.catalog.title, "catalog title");
1889 }
1890
1891 #[test]
1892 fn test_make_container_file() {
1893 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1894
1895 let result = builder.make_container_xml();
1896 assert!(result.is_err());
1897 assert_eq!(
1898 result.unwrap_err(),
1899 EpubBuilderError::MissingRootfile.into()
1900 );
1901
1902 builder.add_rootfile("content.opf").unwrap();
1903 assert!(builder.make_container_xml().is_ok());
1904 }
1905
1906 #[test]
1907 fn test_make_navigation_document() {
1908 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1909
1910 let result = builder.make_navigation_document();
1911 assert!(result.is_err());
1912 assert_eq!(
1913 result.unwrap_err(),
1914 EpubBuilderError::NavigationInfoUninitalized.into()
1915 );
1916
1917 builder.add_catalog_item(NavPoint::new("test"));
1918 assert!(builder.make_navigation_document().is_ok());
1919 }
1920
1921 #[test]
1922 fn test_make_opf_file_success() {
1923 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1924
1925 builder.add_rootfile("content.opf").unwrap();
1926 builder.add_metadata(MetadataItem::new("title", "Test Book"));
1927 builder.add_metadata(MetadataItem::new("language", "en"));
1928 builder.add_metadata(
1929 MetadataItem::new("identifier", "urn:isbn:1234567890")
1930 .with_id("pub-id")
1931 .build(),
1932 );
1933
1934 let test_file = builder.temp_dir.join("test.xhtml");
1935 fs::write(&test_file, "<html></html>").unwrap();
1936 builder
1937 .add_manifest(
1938 test_file.to_str().unwrap(),
1939 ManifestItem::new("test", "test.xhtml").unwrap(),
1940 )
1941 .unwrap();
1942
1943 builder.add_catalog_item(NavPoint::new("Chapter"));
1944 builder.add_spine(SpineItem::new("test"));
1945 builder.make_navigation_document().unwrap();
1946
1947 assert!(builder.make_opf_file().is_ok());
1948 assert!(builder.temp_dir.join("content.opf").exists());
1949 }
1950
1951 #[test]
1952 fn test_make_opf_file_missing_metadata() {
1953 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1954 builder.add_rootfile("content.opf").unwrap();
1955
1956 let result = builder.make_opf_file();
1957 assert!(result.is_err());
1958 assert_eq!(
1959 result.unwrap_err().to_string(),
1960 "Epub builder error: Requires at least one 'title', 'language', and 'identifier' with id 'pub-id'."
1961 );
1962 }
1963 }
1964
1965 mod manifest_tests {
1966 use super::*;
1967
1968 #[test]
1969 fn test_add_manifest_success() {
1970 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1971 builder.add_rootfile("content.opf").unwrap();
1972
1973 let test_file = builder.temp_dir.join("test.xhtml");
1974 fs::write(&test_file, "<html><body>Hello World</body></html>").unwrap();
1975
1976 let manifest_item = ManifestItem::new("test", "/epub/test.xhtml").unwrap();
1977 let result = builder.add_manifest(test_file.to_str().unwrap(), manifest_item);
1978
1979 assert!(result.is_ok(), "Failed to add manifest: {:?}", result.err());
1980 assert_eq!(builder.manifest.manifest.len(), 1);
1981 assert!(builder.manifest.manifest.contains_key("test"));
1982 }
1983
1984 #[test]
1985 fn test_add_manifest_no_rootfile() {
1986 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
1987
1988 let manifest_item = ManifestItem {
1989 id: "main".to_string(),
1990 path: PathBuf::from("/Overview.xhtml"),
1991 mime: String::new(),
1992 properties: None,
1993 fallback: None,
1994 };
1995
1996 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item.clone());
1997 assert!(result.is_err());
1998 assert_eq!(
1999 result.unwrap_err(),
2000 EpubBuilderError::MissingRootfile.into()
2001 );
2002
2003 builder.add_rootfile("package.opf").unwrap();
2004 let result = builder.add_manifest("./test_case/Overview.xhtml", manifest_item);
2005 assert!(result.is_ok());
2006 }
2007
2008 #[test]
2009 fn test_add_manifest_nonexistent_file() {
2010 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2011 builder.add_rootfile("content.opf").unwrap();
2012
2013 let manifest_item = ManifestItem::new("test", "nonexistent.xhtml").unwrap();
2014 let result = builder.add_manifest("nonexistent.xhtml", manifest_item);
2015
2016 assert!(result.is_err());
2017 assert_eq!(
2018 result.unwrap_err(),
2019 EpubBuilderError::TargetIsNotFile {
2020 target_path: "nonexistent.xhtml".to_string()
2021 }
2022 .into()
2023 );
2024 }
2025
2026 #[test]
2027 fn test_add_manifest_unknown_file_format() {
2028 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2029 builder.add_rootfile("package.opf").unwrap();
2030
2031 let result = builder.add_manifest(
2032 "./test_case/unknown_file_format.xhtml",
2033 ManifestItem {
2034 id: "file".to_string(),
2035 path: PathBuf::from("unknown_file_format.xhtml"),
2036 mime: String::new(),
2037 properties: None,
2038 fallback: None,
2039 },
2040 );
2041
2042 assert!(result.is_err());
2043 assert_eq!(
2044 result.unwrap_err(),
2045 EpubBuilderError::UnknownFileFormat {
2046 file_path: "./test_case/unknown_file_format.xhtml".to_string(),
2047 }
2048 .into()
2049 );
2050 }
2051
2052 #[test]
2053 fn test_validate_fallback_chain_valid() {
2054 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2055
2056 let item3 = ManifestItem::new("item3", "path3").unwrap();
2057 let item2 = ManifestItem::new("item2", "path2")
2058 .unwrap()
2059 .with_fallback("item3")
2060 .build();
2061 let item1 = ManifestItem::new("item1", "path1")
2062 .unwrap()
2063 .with_fallback("item2")
2064 .append_property("nav")
2065 .build();
2066
2067 builder.manifest.insert("item3".to_string(), item3);
2068 builder.manifest.insert("item2".to_string(), item2);
2069 builder.manifest.insert("item1".to_string(), item1);
2070
2071 assert!(builder.manifest.validate().is_ok());
2072 }
2073
2074 #[test]
2075 fn test_validate_fallback_chain_circular_reference() {
2076 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2077
2078 let item2 = ManifestItem::new("item2", "path2")
2079 .unwrap()
2080 .with_fallback("item1")
2081 .build();
2082 let item1 = ManifestItem::new("item1", "path1")
2083 .unwrap()
2084 .with_fallback("item2")
2085 .build();
2086
2087 builder.manifest.insert("item1".to_string(), item1);
2088 builder.manifest.insert("item2".to_string(), item2);
2089
2090 let result = builder.manifest.validate();
2091 assert!(result.is_err());
2092 assert!(result.unwrap_err().to_string().starts_with(
2093 "Epub builder error: Circular reference detected in fallback chain for"
2094 ));
2095 }
2096
2097 #[test]
2098 fn test_validate_fallback_chain_not_found() {
2099 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2100
2101 let item1 = ManifestItem::new("item1", "path1")
2102 .unwrap()
2103 .with_fallback("nonexistent")
2104 .build();
2105
2106 builder.manifest.insert("item1".to_string(), item1);
2107
2108 let result = builder.manifest.validate();
2109 assert!(result.is_err());
2110 assert_eq!(
2111 result.unwrap_err().to_string(),
2112 "Epub builder error: Fallback resource 'nonexistent' does not exist in manifest."
2113 );
2114 }
2115
2116 #[test]
2117 fn test_validate_manifest_nav_single() {
2118 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2119
2120 let nav_item = ManifestItem::new("nav", "nav.xhtml")
2121 .unwrap()
2122 .append_property("nav")
2123 .build();
2124 builder
2125 .manifest
2126 .manifest
2127 .insert("nav".to_string(), nav_item);
2128
2129 assert!(builder.manifest.validate().is_ok());
2130 }
2131
2132 #[test]
2133 fn test_validate_manifest_nav_multiple() {
2134 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2135
2136 let nav_item1 = ManifestItem::new("nav1", "nav1.xhtml")
2137 .unwrap()
2138 .append_property("nav")
2139 .build();
2140 let nav_item2 = ManifestItem::new("nav2", "nav2.xhtml")
2141 .unwrap()
2142 .append_property("nav")
2143 .build();
2144
2145 builder
2146 .manifest
2147 .manifest
2148 .insert("nav1".to_string(), nav_item1);
2149 builder
2150 .manifest
2151 .manifest
2152 .insert("nav2".to_string(), nav_item2);
2153
2154 let result = builder.manifest.validate();
2155 assert!(result.is_err());
2156 assert_eq!(
2157 result.unwrap_err().to_string(),
2158 "Epub builder error: There are too many items with 'nav' property in the manifest."
2159 );
2160 }
2161 }
2162
2163 mod metadata_tests {
2164 use super::*;
2165
2166 #[test]
2167 fn test_validate_metadata_success() {
2168 let builder = test_helpers::create_basic_builder();
2169 assert!(builder.metadata.validate().is_ok());
2170 }
2171
2172 #[test]
2173 fn test_validate_metadata_missing_required() {
2174 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2175 builder.add_metadata(MetadataItem::new("title", "Test Book"));
2176 builder.add_metadata(MetadataItem::new("language", "en"));
2177 assert!(builder.metadata.validate().is_err());
2178 }
2179 }
2180
2181 mod utility_tests {
2182 use super::*;
2183
2184 #[test]
2185 fn test_normalize_manifest_path() {
2186 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2187 builder.add_rootfile("content.opf").unwrap();
2188
2189 let result = normalize_manifest_path(
2190 &builder.temp_dir,
2191 builder.rootfiles.first().unwrap(),
2192 "../../test.xhtml",
2193 "id",
2194 );
2195 assert!(result.is_err());
2196 assert_eq!(
2197 result.unwrap_err(),
2198 EpubError::RelativeLinkLeakage { path: "../../test.xhtml".to_string() }
2199 );
2200
2201 let result = normalize_manifest_path(
2202 &builder.temp_dir,
2203 builder.rootfiles.first().unwrap(),
2204 "/test.xhtml",
2205 "id",
2206 );
2207 assert!(result.is_ok());
2208 assert_eq!(result.unwrap(), builder.temp_dir.join("test.xhtml"));
2209
2210 let result = normalize_manifest_path(
2211 &builder.temp_dir,
2212 builder.rootfiles.first().unwrap(),
2213 "./test.xhtml",
2214 "manifest_id",
2215 );
2216 assert!(result.is_err());
2217 assert_eq!(
2218 result.unwrap_err(),
2219 EpubBuilderError::IllegalManifestPath { manifest_id: "manifest_id".to_string() }
2220 .into(),
2221 );
2222 }
2223
2224 #[test]
2225 fn test_refine_mime_type() {
2226 assert_eq!(
2227 refine_mime_type("text/xml", "xhtml"),
2228 "application/xhtml+xml"
2229 );
2230 assert_eq!(refine_mime_type("text/xml", "xht"), "application/xhtml+xml");
2231 assert_eq!(
2232 refine_mime_type("application/xml", "opf"),
2233 "application/oebps-package+xml"
2234 );
2235 assert_eq!(
2236 refine_mime_type("text/xml", "ncx"),
2237 "application/x-dtbncx+xml"
2238 );
2239 assert_eq!(refine_mime_type("text/plain", "css"), "text/css");
2240 assert_eq!(refine_mime_type("text/plain", "unknown"), "text/plain");
2241 }
2242 }
2243
2244 #[cfg(feature = "content-builder")]
2245 mod content_builder_tests {
2246 use crate::builder::{EpubBuilder, EpubVersion3, content::ContentBuilder};
2247
2248 #[test]
2249 fn test_make_contents_basic() {
2250 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2251 builder.add_rootfile("content.opf").unwrap();
2252
2253 let mut content_builder = ContentBuilder::new("chapter1", "en").unwrap();
2254 content_builder
2255 .set_title("Test Chapter")
2256 .add_text_block("This is a test paragraph.", vec![])
2257 .unwrap();
2258
2259 builder.add_content("OEBPS/chapter1.xhtml", content_builder);
2260
2261 assert!(builder.make_contents().is_ok());
2262 assert!(builder.temp_dir.join("OEBPS/chapter1.xhtml").exists());
2263 }
2264
2265 #[test]
2266 fn test_make_contents_multiple_blocks() {
2267 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2268 builder.add_rootfile("content.opf").unwrap();
2269
2270 let mut content_builder = ContentBuilder::new("chapter2", "zh-CN").unwrap();
2271 content_builder
2272 .set_title("多个区块章节")
2273 .add_text_block("第一段文本。", vec![])
2274 .unwrap()
2275 .add_quote_block("这是一个引用。", vec![])
2276 .unwrap()
2277 .add_title_block("子标题", 2, vec![])
2278 .unwrap()
2279 .add_text_block("最后的文本段落。", vec![])
2280 .unwrap();
2281
2282 builder.add_content("OEBPS/chapter2.xhtml", content_builder);
2283
2284 assert!(builder.make_contents().is_ok());
2285 assert!(builder.temp_dir.join("OEBPS/chapter2.xhtml").exists());
2286 }
2287
2288 #[test]
2289 fn test_make_contents_with_media() {
2290 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2291 builder.add_rootfile("content.opf").unwrap();
2292
2293 let mut content_builder = ContentBuilder::new("chapter3", "en").unwrap();
2294 content_builder
2295 .set_title("Chapter with Media")
2296 .add_text_block("Text before image.", vec![])
2297 .unwrap()
2298 .add_image_block(
2299 std::path::PathBuf::from("./test_case/image.jpg"),
2300 Some("Test Image".to_string()),
2301 Some("Figure 1: A test image".to_string()),
2302 vec![],
2303 )
2304 .unwrap()
2305 .add_text_block("Text after image.", vec![])
2306 .unwrap();
2307
2308 builder.add_content("OEBPS/chapter3.xhtml", content_builder);
2309
2310 assert!(builder.make_contents().is_ok());
2311 assert!(builder.temp_dir.join("OEBPS/chapter3.xhtml").exists());
2312 assert!(builder.temp_dir.join("OEBPS/img/image.jpg").exists());
2313 }
2314
2315 #[test]
2316 fn test_make_contents_multiple_documents() {
2317 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2318 builder.add_rootfile("content.opf").unwrap();
2319
2320 for (id, title) in [
2321 ("ch1", "Chapter 1"),
2322 ("ch2", "Chapter 2"),
2323 ("ch3", "Chapter 3"),
2324 ] {
2325 let mut content = ContentBuilder::new(id, "en").unwrap();
2326 content
2327 .set_title(title)
2328 .add_text_block(&format!("Content of {}", title), vec![])
2329 .unwrap();
2330 builder.add_content(format!("OEBPS/{}.xhtml", id), content);
2331 }
2332
2333 assert!(builder.make_contents().is_ok());
2334 assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
2335 assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
2336 assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
2337 }
2338
2339 #[test]
2340 fn test_make_contents_different_languages() {
2341 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2342 builder.add_rootfile("content.opf").unwrap();
2343
2344 let langs = [
2345 ("en_ch", "en", "English Chapter"),
2346 ("zh_ch", "zh-CN", "中文章节"),
2347 ("ja_ch", "ja", "日本語の章"),
2348 ];
2349
2350 for (id, lang, title) in langs {
2351 let mut content = ContentBuilder::new(id, lang).unwrap();
2352 content
2353 .set_title(title)
2354 .add_text_block(&format!("Text in {}", lang), vec![])
2355 .unwrap();
2356 builder.add_content(format!("OEBPS/{}_chapter.xhtml", id), content);
2357 }
2358
2359 assert!(builder.make_contents().is_ok());
2360 assert!(builder.temp_dir.join("OEBPS/en_ch_chapter.xhtml").exists());
2361 assert!(builder.temp_dir.join("OEBPS/zh_ch_chapter.xhtml").exists());
2362 assert!(builder.temp_dir.join("OEBPS/ja_ch_chapter.xhtml").exists());
2363 }
2364
2365 #[test]
2366 fn test_make_contents_unique_identifiers() {
2367 use std::path::PathBuf;
2368
2369 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2370 builder.add_rootfile("content.opf").unwrap();
2371
2372 let mut content1 = ContentBuilder::new("unique_id_1", "en").unwrap();
2373 content1.add_text_block("First content", vec![]).unwrap();
2374 builder.add_content("OEBPS/ch1.xhtml", content1);
2375
2376 let mut content2 = ContentBuilder::new("unique_id_2", "en").unwrap();
2377 content2.add_text_block("Second content", vec![]).unwrap();
2378 builder.add_content("OEBPS/ch2.xhtml", content2);
2379
2380 let mut content3 = ContentBuilder::new("unique_id_1", "en").unwrap();
2381 content3
2382 .add_text_block("Duplicate ID content", vec![])
2383 .unwrap();
2384 builder.add_content("OEBPS/ch3.xhtml", content3);
2385
2386 assert!(builder.make_contents().is_ok());
2387 assert!(builder.temp_dir.join("OEBPS/ch1.xhtml").exists());
2388 assert!(builder.temp_dir.join("OEBPS/ch2.xhtml").exists());
2389 assert!(builder.temp_dir.join("OEBPS/ch3.xhtml").exists());
2390
2391 let manifest = builder.manifest.manifest.get("unique_id_1").unwrap();
2392 assert_eq!(manifest.path, PathBuf::from("/OEBPS/ch3.xhtml"));
2393 }
2394
2395 #[test]
2396 fn test_make_contents_complex_structure() {
2397 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2398 builder.add_rootfile("content.opf").unwrap();
2399
2400 let mut content = ContentBuilder::new("complex_ch", "en").unwrap();
2401 content
2402 .set_title("Complex Chapter")
2403 .add_title_block("Section 1", 2, vec![])
2404 .unwrap()
2405 .add_text_block("Introduction text.", vec![])
2406 .unwrap()
2407 .add_quote_block("A wise quote here.", vec![])
2408 .unwrap()
2409 .add_title_block("Section 2", 2, vec![])
2410 .unwrap()
2411 .add_text_block("More content with multiple paragraphs.", vec![])
2412 .unwrap()
2413 .add_text_block("Another paragraph.", vec![])
2414 .unwrap()
2415 .add_title_block("Section 3", 2, vec![])
2416 .unwrap()
2417 .add_quote_block("Another quotation.", vec![])
2418 .unwrap();
2419
2420 builder.add_content("OEBPS/complex_chapter.xhtml", content);
2421
2422 assert!(builder.make_contents().is_ok());
2423 assert!(
2424 builder
2425 .temp_dir
2426 .join("OEBPS/complex_chapter.xhtml")
2427 .exists()
2428 );
2429 }
2430
2431 #[test]
2432 fn test_make_contents_empty_document() {
2433 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2434 builder.add_rootfile("content.opf").unwrap();
2435
2436 let content = ContentBuilder::new("empty_ch", "en").unwrap();
2437 builder.add_content("OEBPS/empty.xhtml", content);
2438
2439 assert!(builder.make_contents().is_ok());
2440 assert!(builder.temp_dir.join("OEBPS/empty.xhtml").exists());
2441 }
2442
2443 #[test]
2444 fn test_make_contents_path_normalization() {
2445 let mut builder = EpubBuilder::<EpubVersion3>::new().unwrap();
2446 builder.add_rootfile("OEBPS/content.opf").unwrap();
2447
2448 let mut content = ContentBuilder::new("path_test", "en").unwrap();
2449 content.add_text_block("Path test content", vec![]).unwrap();
2450
2451 builder.add_content("/OEBPS/text/chapter.xhtml", content);
2452
2453 assert!(builder.make_contents().is_ok());
2454 assert!(builder.temp_dir.join("OEBPS/text/chapter.xhtml").exists());
2455 }
2456 }
2457}