//! Writing packages, and reading them back. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::Read; use craft_epub::{Book, Epub, Error, Layout, MAX_TITLE_CHARS, Metadata, Nav, NavItem, NavKind, Resource, TocEntry, is_epub, write, xhtml}; fn metadata() -> Metadata { Metadata { identifier: "urn:uuid:1".into(), title: "Fish & ".into(), language: "en-GB".into(), creators: vec!["A & B".into(), " ".into(), "C".into()], subjects: vec!["food".into()], description: Some("About \"fish\"".into()), modified: "2026-10-13T09:30:00Z".into(), generator: Some("Test \"1\"".into()), } } fn book() -> Book<'static> { let chapter = |n: u32| xhtml("en-GB", "Fish", "", "", &format!("

{n}

\n")); Book { metadata: metadata(), layout: Layout::Reflowable, resources: vec![ Resource::new("css", "style.css", "text/css", b"p{margin:0}".to_vec()), Resource::new("content", "content.xhtml", "application/xhtml+xml", chapter(1).into_bytes()), Resource::new("content2", "content2.xhtml", "application/xhtml+xml", chapter(2).into_bytes()).with_properties("mathml"), Resource::new("img0", "images/a b#1.png", "image/png", vec![0x89, b'P', b'N', b'G']).with_properties("cover-image").stored(), ], spine: vec!["content".into(), "content2".into()], nav: Nav { kind: NavKind::Toc, heading: "Contents".into(), items: Vec::new() }, ncx: false, } } fn entries(bytes: &[u8]) -> Vec<(String, zip::CompressionMethod, Vec)> { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); (0..z.len()) .map(|i| { let name = z.name_for_index(i).unwrap().unwrap().into_owned(); let mut f = z.by_index(i).unwrap(); let mut data = Vec::new(); f.read_to_end(&mut data).unwrap(); (name, f.compression(), data) }) .collect() } fn text(bytes: &[u8], name: &str) -> String { String::from_utf8(entries(bytes).into_iter().find(|e| e.0 == name).unwrap().2).unwrap() } #[test] fn the_package_starts_with_a_stored_mimetype_and_keeps_the_resource_order() { let bytes = write(&book()).unwrap(); assert!(is_epub(&bytes)); let e = entries(&bytes); let names: Vec<&str> = e.iter().map(|e| e.0.as_str()).collect(); assert_eq!( names, [ "mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/nav.xhtml", "OEBPS/style.css", "OEBPS/content.xhtml", "OEBPS/content2.xhtml", "OEBPS/images/a b#1.png" ] ); assert_eq!((e[0].1, e[0].2.as_slice()), (zip::CompressionMethod::Stored, &b"application/epub+zip"[..])); assert_eq!(e[2].1, zip::CompressionMethod::Deflated); assert_eq!(e[7].1, zip::CompressionMethod::Stored, "a resource marked stored is not compressed"); } #[test] fn the_package_document_carries_the_metadata_manifest_and_spine() { let bytes = write(&book()).unwrap(); let opf = text(&bytes, "OEBPS/content.opf"); for want in [ "", "urn:uuid:1", "Fish & <Chips>", "en-GB", "A & B\nC", "food", "About \"fish\"", "2026-10-13T09:30:00Z", "", "", "", "", "\n\n\n", ] { assert!(opf.contains(want), "missing {want}\n{opf}"); } assert!(!opf.contains("rendition:"), "a reflowable book says nothing about rendition"); craft_xml::Options::default().strict().parse(opf.as_bytes()).expect("well formed"); } #[test] fn what_is_written_reads_back() { let bytes = write(&book()).unwrap(); let mut epub = Epub::open(&bytes).unwrap(); let p = epub.package.clone(); assert_eq!((p.version.as_str(), p.title.as_str(), p.language.as_deref()), ("3.0", "Fish & ", Some("en-GB"))); assert_eq!(p.creators, ["A & B", "C"]); assert_eq!(p.identifier.as_deref(), Some("urn:uuid:1")); assert_eq!(p.modified.as_deref(), Some("2026-10-13T09:30:00Z")); assert!(!p.pre_paginated); assert_eq!(p.spine_paths().collect::>(), ["OEBPS/content.xhtml", "OEBPS/content2.xhtml"]); assert_eq!(p.nav().map(|i| i.path.as_str()), Some("OEBPS/nav.xhtml")); let cover = p.cover_image().unwrap(); assert_eq!(cover.path, "OEBPS/images/a b#1.png", "the percent-encoded href names the entry"); assert_eq!(epub.read(&cover.path).unwrap().unwrap(), [0x89, b'P', b'N', b'G']); } #[test] fn an_empty_navigation_points_at_the_first_spine_item() { let nav = text(&write(&book()).unwrap(), "OEBPS/nav.xhtml"); assert!( nav.contains( "" ), "{nav}" ); craft_xml::Options::default().strict().parse(nav.as_bytes()).expect("well formed"); } #[test] fn navigation_nests_by_level() { let item = |href: &str, label: &str, level| NavItem { href: href.into(), label: label.into(), level }; let mut b = book(); b.nav.items = vec![ item("content.xhtml#a", "One", 1), item("content.xhtml#b", "One.one", 2), item("content.xhtml#c", "One.one.one ", 3), item("content2.xhtml#d", "Two", 1), // A skipped level counts as one. item("content2.xhtml#e", "Two.deep", 3), item("content2.xhtml#f", "Two.next", 2), ]; let nav = text(&write(&b).unwrap(), "OEBPS/nav.xhtml"); let want = "
  1. One
    1. One.one
      1. One.one.one <x>
  2. Two
    1. Two.deep
    2. Two.next
"; assert!(nav.contains(want), "{nav}"); craft_xml::Options::default().strict().parse(nav.as_bytes()).expect("well formed"); } #[test] fn a_fixed_layout_book_declares_its_rendition_and_lists_pages() { let mut b = book(); b.layout = Layout::PrePaginated { width: 612, height: 792 }; b.nav = Nav { kind: NavKind::PageList, heading: "Pages".into(), items: vec![NavItem { href: "content.xhtml".into(), label: "1".into(), level: 1 }] }; let bytes = write(&b).unwrap(); let opf = text(&bytes, "OEBPS/content.opf"); for want in [ " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">", "pre-paginated", "auto", "", ] { assert!(opf.contains(want), "missing {want}\n{opf}"); } assert!( text(&bytes, "OEBPS/nav.xhtml") .contains("") ); assert!(Epub::open(&bytes).unwrap().package.pre_paginated); } #[test] fn a_huge_title_is_capped() { let mut b = book(); b.metadata.title = "é".repeat(MAX_TITLE_CHARS + 500); let bytes = write(&b).unwrap(); assert_eq!(Epub::open(&bytes).unwrap().package.title.chars().count(), MAX_TITLE_CHARS); assert!(!text(&bytes, "OEBPS/nav.xhtml").contains(&"é".repeat(MAX_TITLE_CHARS + 1))); } #[test] fn a_language_that_is_not_a_tag_is_written_as_undetermined() { let mut b = book(); b.metadata.language = "en\">