diff --git a/Cargo.lock b/Cargo.lock index dca9de20..bbc3de2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -921,6 +921,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-epub" +version = "0.1.0" +dependencies = [ + "craft-xml", + "flate2", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 14116b4d..002afa47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } craft-vectordrawable = { path = "crates/vectordrawable" } craft-xml = { path = "crates/xml" } +craft-epub = { path = "crates/epub" } craft-video = { path = "crates/video" } craft-web-api = { path = "crates/web-api" } craft-window = { path = "crates/window" } diff --git a/README.md b/README.md index a5f89e4f..5b271865 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "`, `dc-metadata` | + +The crate packages content documents and finds them again. It does not turn a document model +into XHTML or XHTML into a document model: that is the application's side. + +## Writing + +```rust +use craft_epub::{Book, Metadata, Nav, NavItem, Resource}; + +let chapter = craft_epub::xhtml("en", "A Book", "", "", "

One

\n"); +let book = Book { + metadata: Metadata { + identifier: craft_epub::urn_uuid("A Book\n1760000000"), + title: "A Book".into(), + language: "en".into(), + creators: vec!["An Author".into()], + modified: "2026-10-13T09:30:00Z".into(), + ..Metadata::default() + }, + resources: vec![Resource::new("content", "content.xhtml", "application/xhtml+xml", chapter.into_bytes())], + spine: vec!["content".into()], + nav: Nav { + heading: craft_epub::contents_title("en").into(), + items: vec![NavItem { href: "content.xhtml#c1".into(), label: "One".into(), level: 1 }], + ..Nav::default() + }, + ..Book::default() +}; +let bytes = craft_epub::write(&book)?; +assert!(craft_epub::is_epub(&bytes)); +# Ok::<(), craft_epub::Error>(()) +``` + +- The archive holds `mimetype` (stored), `META-INF/container.xml`, then under `OEBPS/` the package + document `content.opf`, the navigation document `nav.xhtml`, and the resources in their order. +- `Layout::PrePaginated` writes the fixed-layout metadata; `NavKind::PageList` makes the + navigation a page list. +- Navigation entries nest by `level`. With no entries the navigation has one, to the first spine + item, because an empty list is not valid. +- Resource paths become percent-encoded hrefs. A resource marked `stored()` is not compressed + (images, fonts that are compressed already). +- Refused with `Error::Invalid`: a blank identifier or title, a modification date `w3cdtf` does + not accept, an empty spine or one naming no resource, an id that is not an XML name or is used + twice, a path that is absolute, has `.`/`..`/empty segments, a backslash or a control + character, or is used twice (compared without case), and more than `MAX_RESOURCES` resources. +- A language that is not a BCP 47 tag is written as `und`; the title is cut to `MAX_TITLE_CHARS`. + +## Reading + +```rust +# fn main() -> Result<(), craft_epub::Error> { +# let bytes: Vec = Vec::new(); +# if bytes.is_empty() { return Ok(()); } +let mut epub = craft_epub::Epub::open(&bytes)?; +let title = epub.package.title.clone(); +let spine: Vec = epub.package.spine_paths().map(str::to_string).collect(); +for path in &spine { + if let Some(xhtml) = epub.read(path)? { + // An image the chapter refers to, resolved against the chapter's own path: + let _image = epub.read_href(path, "../images/figure.png")?; + } +} +# Ok(()) } +``` + +- EPUB 2 and 3 packages. `Package` has the version, title, creators, language, identifier, + modification date, fixed-layout flag, manifest, spine (with `linear`), the EPUB 2 NCX path, and + `nav()` and `cover_image()`. The navigation document and the NCX are not parsed. +- `container.xml` and the package document are read strictly: one that is cut off or whose tags + don't match is an error, not half a book. +- A missing entry is `Ok(None)`: a spine item whose file is gone is the caller's to skip. +- `resolve` applies `.` and `..`, decodes percent escapes and drops fragments. Its result only + names an archive entry, so `../../etc/passwd` cannot leave the archive. + +### Limits + +| `Limits` field | Default | Past it | +|---|---|---| +| `max_entries` | 100,000 | `Error::TooManyEntries` | +| `max_entry_bytes` | 64 MiB | `Error::TooLarge` | +| `max_total_bytes` (all entries read from one package) | 256 MiB | `Error::TooLarge` | +| `max_manifest`, `max_spine` | 10,000 each | further items are ignored | + +Sizes are checked against what an entry really expands to, not what the archive declares. The two +XML documents are read with craft-xml's limits (depth 256, 2,000,000 nodes) and `max_entry_bytes`. + +## Where it came from + +The writer is DesignCraft's EPUB export packaging (`crates/epub/src/package.rs`: container, +package document, nested navigation, fixed layout, identifier), with the metadata, date and +language checks of WordCraft's EPUB export. The reader is WordCraft's EPUB import container layer +(container, package document, href resolution, entry budgets), moved onto craft-xml and extended +to EPUB 2 packages. diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs new file mode 100644 index 00000000..03e67e51 --- /dev/null +++ b/crates/epub/src/lib.rs @@ -0,0 +1,61 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! EPUB packages, written and read. +//! +//! An EPUB is an OCF ZIP package: an uncompressed `mimetype` entry first, `META-INF/container.xml` +//! naming the package document (OPF), which lists every resource in its manifest and the reading +//! order in its spine, and (EPUB 3) a navigation document. +//! +//! - [`write`] turns a [`Book`] (metadata, resources, spine, navigation) into an EPUB 3 package, +//! reflowable or fixed layout. The content documents are the caller's: this crate packages +//! them, it does not make XHTML out of a document model ([`xhtml`] is only the shell). +//! - [`Epub::open`] reads an EPUB 2 or 3 package: container, metadata, manifest and spine, with +//! every entry read inside byte budgets that don't trust the sizes the archive claims. +//! +//! Written from the public EPUB 3.3, OCF 3.3 and OPF 2.0.1 specifications. + +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeExamples; + +mod read; +mod text; +mod write; + +pub use read::{Epub, Item, Limits, Package, SpineItem, dir_of, parse_container, parse_package, resolve}; +pub use text::{contents_title, urn_uuid, valid_language_tag, w3cdtf}; +pub use write::{Book, Layout, MAX_RESOURCES, MAX_TITLE_CHARS, Metadata, Nav, NavItem, NavKind, Resource, write, xhtml}; + +/// The content of the first, stored `mimetype` entry. +pub const MIMETYPE: &str = "application/epub+zip"; + +/// Why a package was not read or not written. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are not a ZIP archive, or the archive lacks what makes it an EPUB. + #[error("not an EPUB package: {0}")] + NotEpub(String), + /// A required part is truncated or malformed. + #[error("EPUB part {part}: {msg}")] + Part { part: String, msg: String }, + /// A part is larger than [`Limits::max_entry_bytes`], or the parts read so far have used up + /// [`Limits::max_total_bytes`]. + #[error("EPUB part {part} is larger than the {limit}-byte limit left for it")] + TooLarge { part: String, limit: u64 }, + /// The archive has more entries than [`Limits::max_entries`]. + #[error("the EPUB package has {found} entries (at most {limit})")] + TooManyEntries { found: usize, limit: usize }, + /// The book given to [`write`] cannot be a valid package. + #[error("invalid EPUB book: {0}")] + Invalid(String), +} + +/// Does `bytes` look like an EPUB (a ZIP whose first, stored entry is the EPUB `mimetype`)? +/// +/// Packages made by careless tools put `mimetype` elsewhere or compress it; [`Epub::open`] reads +/// those too, so this is a sniff for telling formats apart, not a validity check. +pub fn is_epub(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK\x03\x04") && bytes.get(30..38) == Some(b"mimetype") && { + let extra = bytes.get(28..30).map_or(0, |e| usize::from(u16::from_le_bytes([e[0], e[1]]))); + bytes.get(38 + extra..).is_some_and(|rest| rest.starts_with(MIMETYPE.as_bytes())) + } +} diff --git a/crates/epub/src/read.rs b/crates/epub/src/read.rs new file mode 100644 index 00000000..ac93c5b0 --- /dev/null +++ b/crates/epub/src/read.rs @@ -0,0 +1,300 @@ +//! An EPUB 2 or 3 package, read: container, package document, and bounded entry reads. + +use std::collections::HashMap; +use std::io::{Cursor, Read}; + +use craft_xml::{El, Local, Options}; + +use crate::Error; + +/// What the reader refuses to exceed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Most entries the archive may hold. Default 100,000. + pub max_entries: usize, + /// Most bytes one entry may expand to. Default 64 MiB. + pub max_entry_bytes: u64, + /// Most bytes all the entries read from one package may expand to together (many entries + /// with small declared sizes can't add up past this). Default 256 MiB. + pub max_total_bytes: u64, + /// Most manifest items kept; further ones are ignored. Default 10,000. + pub max_manifest: usize, + /// Most spine items kept; further ones are ignored. Default 10,000. + pub max_spine: usize, +} + +impl Limits { + pub const DEFAULT: Limits = + Limits { max_entries: 100_000, max_entry_bytes: 64 << 20, max_total_bytes: 256 << 20, max_manifest: 10_000, max_spine: 10_000 }; +} + +impl Default for Limits { + fn default() -> Limits { + Limits::DEFAULT + } +} + +/// A manifest item. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Item { + pub id: String, + /// The `href` as written. + pub href: String, + /// The archive entry the `href` names: resolved against the package document, decoded, + /// fragment removed. + pub path: String, + pub media_type: String, + /// EPUB 3 `properties` (`nav`, `cover-image`, `mathml`, …), space separated. + pub properties: String, +} + +impl Item { + pub fn has_property(&self, property: &str) -> bool { + self.properties.split_ascii_whitespace().any(|p| p == property) + } +} + +/// A spine entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpineItem { + pub idref: String, + /// `linear="no"`: auxiliary content, outside the main reading order. + pub linear: bool, + /// The archive entry of the manifest item it refers to; `None` when no item has that id. + pub path: Option, +} + +/// The package document (OPF). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Package { + /// The `version` attribute (`2.0`, `3.0`). + pub version: String, + /// The first `dc:title`. + pub title: String, + /// Every `dc:creator`, in order. + pub creators: Vec, + /// The first `dc:language` that is not blank. + pub language: Option, + /// The `dc:identifier` the package's `unique-identifier` names, else the first. + pub identifier: Option, + /// `dcterms:modified` (EPUB 3). + pub modified: Option, + /// `rendition:layout` is `pre-paginated`: a fixed-layout book. + pub pre_paginated: bool, + pub manifest: Vec, + pub spine: Vec, + /// The archive entry of the EPUB 2 NCX the spine's `toc` attribute names. + pub ncx: Option, + /// The id of the EPUB 2 cover image (``). + cover_id: Option, +} + +impl Package { + /// The archive entries of the spine, in reading order, for the items the manifest has. + pub fn spine_paths(&self) -> impl Iterator { + self.spine.iter().filter_map(|s| s.path.as_deref()) + } + + /// The EPUB 3 navigation document. + pub fn nav(&self) -> Option<&Item> { + self.manifest.iter().find(|i| i.has_property("nav")) + } + + /// The cover image: the item with the `cover-image` property, else the one an EPUB 2 + /// `` names. + pub fn cover_image(&self) -> Option<&Item> { + let by_id = || self.cover_id.as_ref().and_then(|id| self.manifest.iter().find(|i| &i.id == id)); + self.manifest.iter().find(|i| i.has_property("cover-image")).or_else(by_id) + } +} + +/// The directory of an archive path (`a/b/c.xhtml` → `a/b`; `c.xhtml` → `""`). +pub fn dir_of(path: &str) -> &str { + path.rsplit_once('/').map_or("", |(dir, _)| dir) +} + +/// The archive entry a relative `href` names from a file in `base_dir`: `.` and `..` segments +/// applied, percent escapes decoded, fragment and query removed. +/// +/// A hostile `../../../etc/passwd` cannot leave the archive: the result only ever names an +/// archive entry, never a file system path, and `..` stops at the archive's root. +pub fn resolve(base_dir: &str, href: &str) -> String { + let href = href.split(['#', '?']).next().unwrap_or(""); + let href = percent_decode(href); + let mut parts: Vec<&str> = if href.starts_with('/') { Vec::new() } else { base_dir.split('/').filter(|s| !s.is_empty()).collect() }; + for seg in href.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +/// Decode `%XX` escapes; an invalid escape, or a result that is not UTF-8, is kept as written. +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let hex = |i: usize| b.get(i).and_then(|c| char::from(*c).to_digit(16)); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + if c == b'%' + && let (Some(h), Some(l)) = (hex(i + 1), hex(i + 2)) + { + out.push((h * 16 + l) as u8); + i += 3; + } else { + out.push(c); + i += 1; + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// Both required documents must be well formed: a lenient read would take a container or package +/// document cut off after its useful content for a complete one, and give half a book. +fn parse_xml(part: &str, xml: &[u8], limits: &Limits) -> Result { + let xml_limits = craft_xml::Limits { max_input: usize::try_from(limits.max_entry_bytes).unwrap_or(usize::MAX), ..craft_xml::Limits::DEFAULT }; + Options::local().strict().with_limits(xml_limits).parse(xml).map_err(|e| Error::Part { part: part.to_string(), msg: e.to_string() }) +} + +/// The package document's path from `META-INF/container.xml` (the first `rootfile`'s +/// `full-path`). `Ok(None)`: well formed, but it names none. +pub fn parse_container(xml: &[u8]) -> Result, Error> { + let root = parse_xml("META-INF/container.xml", xml, &Limits::DEFAULT)?; + Ok(root.find(Local("rootfile")).and_then(|r| r.attr("full-path")).filter(|p| !p.is_empty()).map(str::to_string)) +} + +/// The package document `xml`, its hrefs resolved against `opf_dir` (the directory of the +/// package document in the archive). +pub fn parse_package(xml: &[u8], opf_dir: &str, limits: &Limits) -> Result { + let root = parse_xml("package document", xml, limits)?; + let mut pkg = Package { version: root.attr("version").unwrap_or_default().to_string(), ..Package::default() }; + let unique = root.attr("unique-identifier"); + if let Some(meta) = root.child("metadata") { + // EPUB 2 packages may wrap the Dublin Core elements in `dc-metadata`. + let all = || meta.elements().flat_map(|e| if e.is("dc-metadata") { e.elements().collect::>() } else { vec![e] }); + let texts = |name: &'static str| all().filter(move |e| e.is(name)).map(|e| e.deep_text().trim().to_string()); + pkg.title = texts("title").next().unwrap_or_default(); + pkg.creators = texts("creator").filter(|c| !c.is_empty()).take(limits.max_manifest).collect(); + pkg.language = texts("language").find(|l| !l.is_empty()); + let ids = || all().filter(|e| e.is("identifier")); + let chosen = ids().find(|e| unique.is_some() && e.attr("id") == unique).or_else(|| ids().next()); + pkg.identifier = chosen.map(|e| e.deep_text().trim().to_string()).filter(|i| !i.is_empty()); + for m in all().filter(|e| e.is("meta")) { + let text = || m.deep_text().trim().to_string(); + match (m.attr("property"), m.attr("name")) { + (Some("dcterms:modified"), _) if pkg.modified.is_none() => pkg.modified = Some(text()), + (Some("rendition:layout"), _) => pkg.pre_paginated = text() == "pre-paginated", + (_, Some("cover")) if pkg.cover_id.is_none() => pkg.cover_id = m.attr("content").map(str::to_string), + _ => {} + } + } + } + let attr = |e: &El, name: &str| e.attr(name).unwrap_or_default().to_string(); + for item in root.child("manifest").into_iter().flat_map(|m| m.children("item")).take(limits.max_manifest) { + let (Some(id), Some(href)) = (item.attr("id"), item.attr("href")) else { continue }; + pkg.manifest.push(Item { + id: id.to_string(), + href: href.to_string(), + path: resolve(opf_dir, href), + media_type: attr(item, "media-type"), + properties: attr(item, "properties"), + }); + } + // The first item with an id wins, as it does when the id is looked up in the document. + let mut by_id: HashMap<&str, &str> = HashMap::new(); + for item in &pkg.manifest { + by_id.entry(&item.id).or_insert(&item.path); + } + if let Some(spine) = root.child("spine") { + pkg.ncx = spine.attr("toc").and_then(|id| by_id.get(id)).map(ToString::to_string); + for itemref in spine.children("itemref").take(limits.max_spine) { + let Some(idref) = itemref.attr("idref") else { continue }; + let path = by_id.get(idref).map(ToString::to_string); + pkg.spine.push(SpineItem { idref: idref.to_string(), linear: itemref.attr("linear") != Some("no"), path }); + } + } + Ok(pkg) +} + +/// An open EPUB package. Entries are decompressed only when asked for, within the byte budgets +/// of its [`Limits`]. +pub struct Epub<'a> { + zip: zip::ZipArchive>, + limits: Limits, + /// Bytes the entries still to be read may expand to. + left: u64, + /// The archive path of the package document. + pub opf_path: String, + pub package: Package, +} + +impl<'a> Epub<'a> { + /// Opens `bytes` with [`Limits::DEFAULT`]. + pub fn open(bytes: &'a [u8]) -> Result { + Self::open_with(bytes, Limits::DEFAULT) + } + + /// Reads the container and the package document. The `mimetype` entry is not required: + /// packages without one, or with it compressed, are common. + pub fn open_with(bytes: &'a [u8], limits: Limits) -> Result { + let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| Error::NotEpub(e.to_string()))?; + if zip.len() > limits.max_entries { + return Err(Error::TooManyEntries { found: zip.len(), limit: limits.max_entries }); + } + let mut epub = Epub { zip, limits, left: limits.max_total_bytes, opf_path: String::new(), package: Package::default() }; + let container = epub.read("META-INF/container.xml")?.ok_or_else(|| Error::NotEpub("META-INF/container.xml is missing".into()))?; + let opf_path = parse_container(&container)?.ok_or_else(|| Error::NotEpub("container.xml names no package document".into()))?; + // The container gives a path from the archive's root; resolving it also drops a leading `/`. + let opf_path = resolve("", &opf_path); + let opf = epub.read(&opf_path)?.ok_or_else(|| Error::NotEpub(format!("package document {opf_path} is missing")))?; + epub.package = parse_package(&opf, dir_of(&opf_path), &limits).map_err(|e| match e { + Error::Part { msg, .. } => Error::Part { part: opf_path.clone(), msg }, + e => e, + })?; + epub.opf_path = opf_path; + Ok(epub) + } + + /// Does the archive have an entry `path`? + pub fn contains(&self, path: &str) -> bool { + self.zip.index_for_name(path).is_some() + } + + /// The bytes of archive entry `path`, or `None` when the archive has no such entry. An entry + /// past the limits is an error, and so is one that expands to more than it declared. + pub fn read(&mut self, path: &str) -> Result>, Error> { + let Some(index) = self.zip.index_for_name(path).filter(|_| !path.ends_with('/')) else { return Ok(None) }; + let part = |msg: String| Error::Part { part: path.to_string(), msg }; + let f = self.zip.by_index(index).map_err(|e| part(e.to_string()))?; + let limit = self.limits.max_entry_bytes.min(self.left); + let too_large = || Error::TooLarge { part: path.to_string(), limit }; + if f.size() > limit { + return Err(too_large()); + } + let mut buf = Vec::new(); + f.take(limit.saturating_add(1)).read_to_end(&mut buf).map_err(|e| part(e.to_string()))?; + let n = buf.len() as u64; + if n > limit { + return Err(too_large()); + } + self.left = self.left.saturating_sub(n); + Ok(Some(buf)) + } + + /// [`Epub::read`] for what a file of the package refers to: `href` as written in the file at + /// archive path `from` (an image's `src` in a content document). + pub fn read_href(&mut self, from: &str, href: &str) -> Result>, Error> { + let path = resolve(dir_of(from), href); + if path.is_empty() { Ok(None) } else { self.read(&path) } + } + + /// Bytes the entries still to be read may expand to. + pub fn budget_left(&self) -> u64 { + self.left + } +} diff --git a/crates/epub/src/text.rs b/crates/epub/src/text.rs new file mode 100644 index 00000000..3f12d828 --- /dev/null +++ b/crates/epub/src/text.rs @@ -0,0 +1,138 @@ +//! Small values of the package document: language tags, dates, identifiers. + +/// Whether `tag` is a well-formed BCP 47 language tag, as far as its shape goes: at most 63 +/// characters, a primary subtag of 2 to 8 letters, then subtags of 1 to 8 letters or digits, with +/// at least one subtag after a single-character one. +pub fn valid_language_tag(tag: &str) -> bool { + if tag.len() > 63 { + return false; + } + let mut parts = tag.split('-'); + let Some(primary) = parts.next() else { return false }; + if !(2..=8).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) { + return false; + } + let mut parts = parts.peekable(); + while let Some(part) = parts.next() { + if part.is_empty() || part.len() > 8 || !part.bytes().all(|b| b.is_ascii_alphanumeric()) { + return false; + } + if part.len() == 1 && parts.peek().is_none() { + return false; + } + } + true +} + +/// `s` as the `CCYY-MM-DDThh:mm:ssZ` form `dcterms:modified` requires, when it starts with a +/// date and time of that shape whose fields are in range. Fractions and zone offsets after the +/// seconds are cut, not converted. +pub fn w3cdtf(s: &str) -> Option { + let b = s.as_bytes().get(..19)?; + let shape = b"dddd-dd-ddTdd:dd:dd"; + if !b.iter().zip(shape).all(|(c, p)| if *p == b'd' { c.is_ascii_digit() } else { c == p }) { + return None; + } + let number = |range: std::ops::Range| s.get(range).and_then(|v| v.parse::().ok()); + let (month, day) = (number(5..7)?, number(8..10)?); + let (hour, minute, second) = (number(11..13)?, number(14..16)?, number(17..19)?); + if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 { + return None; + } + Some(format!("{}Z", s.get(..19)?)) +} + +/// A `urn:uuid:` identifier derived from `seed`: the same seed gives the same identifier, so a +/// book keeps its identifier from one export to the next. +pub fn urn_uuid(seed: &str) -> String { + // Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID. + let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)); + let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x8422_2325_cbf2_9ce4)); + let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000; + let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000; + format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff) +} + +/// The usual heading of a table of contents in `language` (a BCP 47 tag; `_` is read as `-`), +/// English for languages without one here. +pub fn contents_title(language: &str) -> &'static str { + let lower = language.replace('_', "-").to_ascii_lowercase(); + match lower.split('-').next().unwrap_or("") { + "ar" => "المحتويات", + "bg" => "Съдържание", + "cs" => "Obsah", + "da" => "Indhold", + "de" => "Inhalt", + "es" => "Índice", + "fi" => "Sisällys", + "fr" => "Table des matières", + "he" => "תוכן העניינים", + "hr" => "Sadržaj", + "hu" => "Tartalomjegyzék", + "it" => "Indice", + "ja" => "目次", + "ko" => "목차", + "nb" | "no" => "Innhold", + "nl" => "Inhoud", + "pl" => "Spis treści", + "pt" if lower.contains("-br") => "Sumário", + "pt" => "Índice", + "ro" => "Cuprins", + "ru" => "Содержание", + "sr" if lower.contains("latn") => "Sadržaj", + "sr" => "Садржај", + "sv" => "Innehåll", + "tr" => "İçindekiler", + "uk" => "Зміст", + "vi" => "Mục lục", + "zh" if lower.contains("hant") || lower.contains("-tw") || lower.contains("-hk") => "目錄", + "zh" => "目录", + _ => "Contents", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_tags() { + for ok in ["en", "en-GB", "zh-Hant-TW", "de-CH-1996", "en-x-private", "sr-Latn"] { + assert!(valid_language_tag(ok), "{ok}"); + } + for bad in ["", "e", "en_GB", "en-", "en--GB", "en-x", "123", "abcdefghi", "en-abcdefghi", "en\">\n\n"; + +/// The package metadata. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Metadata { + /// The unique identifier (`dc:identifier`), usually a `urn:uuid:` ([`crate::urn_uuid`]). Must + /// not be blank. + pub identifier: String, + /// `dc:title`, cut to [`MAX_TITLE_CHARS`]. Must not be blank. + pub title: String, + /// A BCP 47 tag; one that is not ([`crate::valid_language_tag`]) is written as `und`. + pub language: String, + /// `dc:creator`, one element each; blank ones are left out. + pub creators: Vec, + /// `dc:subject`, one element each; blank ones are left out. + pub subjects: Vec, + /// `dc:description`, left out when blank. + pub description: Option, + /// `dcterms:modified`: must be a date and time [`crate::w3cdtf`] accepts. + pub modified: String, + /// The writing application, as ``. + pub generator: Option, +} + +/// Reflowable text, or one fixed page per spine item. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Layout { + #[default] + Reflowable, + /// `rendition:layout` `pre-paginated`; the size is the first page's, in CSS pixels. + PrePaginated { width: u32, height: u32 }, +} + +/// A file of the book: a content document, style sheet, image or font. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Resource<'a> { + /// The manifest id: an XML name, unique in the book, not `nav`. + pub id: String, + /// Where the file goes, relative to the package document: `chapter1.xhtml`, + /// `images/cover.png`. No `.` or `..` segments, no leading `/`, no backslash. + pub path: String, + pub media_type: String, + pub data: Cow<'a, [u8]>, + /// The manifest `properties` (`cover-image`, `mathml`, `svg`, `scripted`, …), space separated. + pub properties: String, + /// Store the file uncompressed (images and other data that is compressed already). + pub stored: bool, +} + +impl<'a> Resource<'a> { + pub fn new(id: impl Into, path: impl Into, media_type: impl Into, data: impl Into>) -> Self { + Resource { id: id.into(), path: path.into(), media_type: media_type.into(), data: data.into(), properties: String::new(), stored: false } + } + + pub fn with_properties(mut self, properties: &str) -> Self { + self.properties = properties.to_string(); + self + } + + pub fn stored(mut self) -> Self { + self.stored = true; + self + } +} + +/// What the navigation document's list is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NavKind { + /// A table of contents (`epub:type="toc"`). + #[default] + Toc, + /// A list of pages (`epub:type="page-list"`), for a fixed-layout book. + PageList, +} + +/// One navigation entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NavItem { + /// The target, relative to the package document, with its fragment: `chapter2.xhtml#s3`. + /// Written as given. + pub href: String, + pub label: String, + /// Nesting: an entry deeper than the one before it goes into that entry's own list; skipped + /// levels count as one. + pub level: u8, +} + +/// The navigation document (`nav.xhtml`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Nav { + pub kind: NavKind, + /// The list's heading ([`crate::contents_title`] for a table of contents). + pub heading: String, + /// Empty: one entry to the first spine item, labelled with the book's title. + pub items: Vec, +} + +/// A book to package. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Book<'a> { + pub metadata: Metadata, + pub layout: Layout, + /// The files, in the order they are listed in the manifest and stored in the archive. + pub resources: Vec>, + /// The reading order: resource ids. + pub spine: Vec, + pub nav: Nav, +} + +/// A content document's shell around `body`: XML declaration, `html` with the XHTML and `epub` +/// namespaces and the language, a `head` with the character set and title, then `head`, and a +/// `body` element with `body_attrs`. +/// +/// `lang` and `title` are escaped here. `head`, `body_attrs` (with its leading space) and `body` +/// are markup, written as given. +pub fn xhtml(lang: &str, title: &str, head: &str, body_attrs: &str, body: &str) -> String { + let lang = language(lang); + format!( + "\n\n\n{}{head}\n\n{body}\n\n", + esc(title) + ) +} + +fn language(tag: &str) -> &str { + if valid_language_tag(tag) { tag } else { "und" } +} + +fn capped_title(title: &str) -> &str { + match title.char_indices().nth(MAX_TITLE_CHARS) { + Some((at, _)) => title.get(..at).unwrap_or(title), + None => title, + } +} + +/// A resource path as a relative URL: everything but unreserved characters and `/` is +/// percent-encoded. +fn href(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for b in path.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') { + out.push(char::from(b)); + } else { + let _ = write!(out, "%{b:02X}"); + } + } + out +} + +fn invalid(msg: String) -> Result { + Err(Error::Invalid(msg)) +} + +fn check(book: &Book) -> Result<(), Error> { + if book.metadata.identifier.trim().is_empty() { + return invalid("the identifier is blank".into()); + } + if book.metadata.title.trim().is_empty() { + return invalid("the title is blank".into()); + } + if book.resources.len() > MAX_RESOURCES { + return invalid(format!("{} resources (at most {MAX_RESOURCES})", book.resources.len())); + } + let mut ids = HashSet::from(["nav"]); + // Archive names compare without case on some systems, so two paths that differ only in case + // are refused as well. + let mut paths = HashSet::from([OPF.to_string(), NAV.to_string()]); + for r in &book.resources { + if !is_name(&r.id) || r.id.contains(':') { + return invalid(format!("resource id `{}` is not an XML name", r.id)); + } + if !ids.insert(r.id.as_str()) { + return invalid(format!("resource id `{}` is used twice", r.id)); + } + let bad_path = r.path.is_empty() || r.path.contains('\\') || r.path.split('/').any(|s| matches!(s, "" | "." | "..")); + if bad_path || r.path.chars().any(char::is_control) { + return invalid(format!("resource path `{}` is not a relative path inside the package", r.path)); + } + if !paths.insert(r.path.to_lowercase()) { + return invalid(format!("resource path `{}` is used twice", r.path)); + } + if r.media_type.trim().is_empty() { + return invalid(format!("resource `{}` has no media type", r.id)); + } + } + if book.spine.is_empty() { + return invalid("the spine is empty".into()); + } + if let Some(missing) = book.spine.iter().find(|id| !ids.contains(id.as_str()) || id.as_str() == "nav") { + return invalid(format!("spine item `{missing}` is not a resource")); + } + Ok(()) +} + +fn package_document(book: &Book, modified: &str) -> String { + let m = &book.metadata; + let lang = language(&m.language); + let fixed = matches!(book.layout, Layout::PrePaginated { .. }); + let prefix = if fixed { " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"" } else { "" }; + let mut o = format!( + "\n\n\n{}\n{}\n{lang}\n", + esc(m.identifier.trim()), + esc(capped_title(&m.title)) + ); + let mut dc = |name: &str, value: &str| { + if !value.trim().is_empty() { + let _ = writeln!(o, "{}", esc(value.trim())); + } + }; + m.creators.iter().for_each(|c| dc("creator", c)); + m.subjects.iter().for_each(|s| dc("subject", s)); + dc("description", m.description.as_deref().unwrap_or("")); + let _ = writeln!(o, "{modified}"); + if let Layout::PrePaginated { width, height } = book.layout { + let _ = writeln!( + o, + "pre-paginated\nauto\n" + ); + } + if let Some(g) = m.generator.as_deref().filter(|g| !g.trim().is_empty()) { + let _ = writeln!(o, "", esc_attr(g.trim())); + } + o.push_str("\n\n\n"); + for r in &book.resources { + let _ = write!(o, "\n"); + } + o.push_str("\n\n"); + for id in &book.spine { + let _ = writeln!(o, "", esc_attr(id)); + } + o.push_str("\n\n"); + o +} + +/// The entries as nested `li`/`ol` items (the content of the navigation's outer `ol`). +fn nav_list(items: &[NavItem]) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + for NavItem { href, label, level } in items { + match open.last().copied() { + None => open.push(*level), + Some(top) if *level > top => { + out.push_str("
    "); + open.push(*level); + } + Some(_) => { + // Close the levels whose parent is as deep as this entry or deeper. + while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { + open.pop(); + out.push_str("
"); + } + out.push_str(""); + if let Some(t) = open.last_mut() { + *t = (*t).min(*level); + } + } + } + let _ = write!(out, "
  • {}", esc_attr(href), esc(label)); + } + for i in (0..open.len()).rev() { + out.push_str("
  • "); + if i > 0 { + out.push_str(""); + } + } + out +} + +fn nav_document(book: &Book) -> String { + let title = capped_title(&book.metadata.title); + let first = || { + let path = book.spine.first().and_then(|id| book.resources.iter().find(|r| &r.id == id)).map(|r| href(&r.path)).unwrap_or_default(); + vec![NavItem { href: path, label: title.to_string(), level: 1 }] + }; + let items = if book.nav.items.is_empty() { Cow::Owned(first()) } else { Cow::Borrowed(&book.nav.items) }; + let kind = match book.nav.kind { + NavKind::Toc => "toc", + NavKind::PageList => "page-list", + }; + let body = format!("", esc(&book.nav.heading), nav_list(&items)); + xhtml(&book.metadata.language, title, "", "", &body) +} + +/// `book` as an EPUB 3 package: the stored `mimetype`, `META-INF/container.xml`, then under +/// `OEBPS/` the package document, the navigation document and the resources in their order. +pub fn write(book: &Book) -> Result, Error> { + check(book)?; + let modified = + w3cdtf(&book.metadata.modified).ok_or_else(|| Error::Invalid(format!("`{}` is not a modification date", book.metadata.modified)))?; + let opf = package_document(book, &modified); + let nav = nav_document(book); + let fail = |e: &dyn std::fmt::Display| Error::Invalid(format!("the archive could not be written: {e}")); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let own = [ + ("mimetype".to_string(), MIMETYPE.as_bytes(), true), + ("META-INF/container.xml".to_string(), CONTAINER.as_bytes(), false), + (format!("{ROOT}/{OPF}"), opf.as_bytes(), false), + (format!("{ROOT}/{NAV}"), nav.as_bytes(), false), + ]; + let resources = book.resources.iter().map(|r| (format!("{ROOT}/{}", r.path), r.data.as_ref(), r.stored)); + for (name, data, store) in own.into_iter().chain(resources) { + // More than 4 GiB needs the ZIP64 form. + let options = if store { stored } else { deflated }.large_file(data.len() >= u32::MAX as usize); + z.start_file(name, options).map_err(|e| fail(&e))?; + z.write_all(data).map_err(|e| fail(&e))?; + } + z.finish().map(std::io::Cursor::into_inner).map_err(|e| fail(&e)) +} diff --git a/crates/epub/tests/read.rs b/crates/epub/tests/read.rs new file mode 100644 index 00000000..88fb27df --- /dev/null +++ b/crates/epub/tests/read.rs @@ -0,0 +1,254 @@ +//! Reading packages: the container, the package document, and the limits. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::{Cursor, Write}; + +use craft_epub::{Epub, Error, Limits, dir_of, parse_container, parse_package, resolve}; + +fn zip_of(entries: &[(&str, &[u8])]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name, data) in entries { + z.start_file(*name, o).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap().into_inner() +} + +const CONTAINER: &[u8] = br#" + + +"#; + +fn opf(spine_items: &str) -> Vec { + format!( + r#" + +Test Book +Jane Author +en + + + + + +{spine_items} +"# + ) + .into_bytes() +} + +const PNG: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + +fn sample_package() -> Vec { + zip_of(&[ + ("mimetype", b"application/epub+zip"), + ("META-INF/container.xml", CONTAINER), + ("OEBPS/content.opf", &opf(r#""#)), + ("OEBPS/text/chapter1.xhtml", b"

    Chapter One

    "), + ("OEBPS/text/chapter2.xhtml", br#""#), + ("OEBPS/images/cover.png", &PNG), + ]) +} + +#[test] +fn resolves_relative_paths() { + assert_eq!(resolve("OEBPS/text", "../images/cover.png"), "OEBPS/images/cover.png"); + assert_eq!(resolve("OEBPS", "text/chapter1.xhtml"), "OEBPS/text/chapter1.xhtml"); + assert_eq!(resolve("OEBPS/text", "chapter1.xhtml#frag"), "OEBPS/text/chapter1.xhtml"); + assert_eq!(resolve("", "cover%20art.png"), "cover art.png"); + assert_eq!(resolve("OEBPS", "/root.xhtml"), "root.xhtml"); + assert_eq!(resolve("a", "%zz%4"), "a/%zz%4", "a malformed escape stays as written"); + assert_eq!(dir_of("a/b/c.xhtml"), "a/b"); + assert_eq!(dir_of("c.xhtml"), ""); +} + +#[test] +fn path_traversal_href_cannot_escape_the_archive() { + assert_eq!(resolve("OEBPS/text", "../../../../../../etc/passwd"), "etc/passwd"); +} + +#[test] +fn reads_container_and_package() { + assert_eq!(parse_container(CONTAINER).unwrap().as_deref(), Some("OEBPS/content.opf")); + let pkg = + parse_package(&opf(r#""#), "OEBPS", &Limits::DEFAULT).unwrap(); + assert_eq!(pkg.version, "3.0"); + assert_eq!(pkg.title, "Test Book"); + assert_eq!(pkg.creators, ["Jane Author"]); + assert_eq!(pkg.language.as_deref(), Some("en")); + assert_eq!(pkg.spine_paths().collect::>(), ["OEBPS/text/chapter1.xhtml", "OEBPS/text/chapter2.xhtml"]); + assert_eq!(pkg.spine.iter().map(|s| s.linear).collect::>(), [true, false, true]); + assert_eq!(pkg.spine.last().unwrap().path, None, "an idref no item has is kept, without a path"); + assert_eq!(pkg.manifest.len(), 3); + assert_eq!(pkg.manifest[2].media_type, "image/png"); +} + +/// Text around an entity reference is one title, and a repeated element (OPF allows more than +/// one `dc:title`) does not replace what the first gave. +#[test] +fn metadata_accumulates_across_entity_references_and_keeps_only_the_first_element() { + let xml = br#" + + +Fish & Chips +A second, ignored title +Smith & Sons +isbn:1 +urn:uuid:2 +2026-01-02T03:04:05Z +pre-paginated + + + +"#; + let pkg = parse_package(xml, "OEBPS", &Limits::DEFAULT).unwrap(); + assert_eq!(pkg.title, "Fish & Chips"); + assert_eq!(pkg.creators, ["Smith & Sons"]); + assert_eq!(pkg.identifier.as_deref(), Some("urn:uuid:2")); + assert_eq!(pkg.modified.as_deref(), Some("2026-01-02T03:04:05Z")); + assert!(pkg.pre_paginated); +} + +#[test] +fn epub2_package_ncx_cover_and_dc_metadata() { + let xml = br#" +Oldx + + + + + + + +"#; + let pkg = parse_package(xml, "", &Limits::DEFAULT).unwrap(); + assert_eq!((pkg.version.as_str(), pkg.title.as_str(), pkg.identifier.as_deref()), ("2.0", "Old", Some("x"))); + assert_eq!(pkg.ncx.as_deref(), Some("toc.ncx")); + assert_eq!(pkg.cover_image().map(|i| i.path.as_str()), Some("img/c over.jpg")); + assert_eq!(pkg.nav(), None); + assert_eq!(pkg.spine_paths().collect::>(), ["c.html"], "the first item with an id wins"); +} + +#[test] +fn opens_a_package_and_reads_its_files() { + let bytes = sample_package(); + assert!(!craft_epub::is_epub(&bytes), "this mimetype entry is compressed"); + let mut epub = Epub::open(&bytes).unwrap(); + assert_eq!(epub.opf_path, "OEBPS/content.opf"); + assert_eq!(epub.package.title, "Test Book"); + let spine: Vec = epub.package.spine_paths().map(str::to_string).collect(); + assert!(epub.read(&spine[0]).unwrap().unwrap().starts_with(b"")); + assert_eq!(epub.read_href(&spine[1], "../images/cover.png").unwrap().as_deref(), Some(&PNG[..])); + assert_eq!(epub.read_href(&spine[1], "#top").unwrap(), None); + assert!(epub.contains("mimetype") && !epub.contains("nope")); +} + +#[test] +fn a_missing_spine_file_is_none_not_an_error() { + let bytes = zip_of(&[("META-INF/container.xml", CONTAINER), ("OEBPS/content.opf", &opf(r#""#))]); + let mut epub = Epub::open(&bytes).unwrap(); + assert_eq!(epub.read("OEBPS/text/chapter1.xhtml").unwrap(), None); + assert_eq!(epub.read("OEBPS/").unwrap(), None); +} + +#[test] +fn missing_container_or_package_is_an_error() { + assert!(matches!(Epub::open(&zip_of(&[("mimetype", b"application/epub+zip")])), Err(Error::NotEpub(_)))); + assert!(matches!(Epub::open(&zip_of(&[("META-INF/container.xml", CONTAINER)])), Err(Error::NotEpub(_)))); + let no_rootfile = b""; + assert!(matches!(Epub::open(&zip_of(&[("META-INF/container.xml", no_rootfile)])), Err(Error::NotEpub(_)))); +} + +#[test] +fn not_a_zip_is_an_error() { + assert!(matches!(Epub::open(b"not a zip file"), Err(Error::NotEpub(_)))); + assert!(matches!(Epub::open(b""), Err(Error::NotEpub(_)))); +} + +/// Cut off mid-element, after the rootfile it names: read leniently this is a complete +/// container, and whatever of the package is still there would open as half a book. +#[test] +fn container_truncated_after_its_useful_content_is_still_an_error() { + let truncated = br#" + +"#; + let bytes = zip_of(&[("META-INF/container.xml", &truncated[..]), ("OEBPS/content.opf", &opf(""))]); + assert!(matches!(Epub::open(&bytes), Err(Error::Part { .. }))); +} + +/// The same for the package document: cut off after its manifest and before the end of its +/// spine, it would open as a book with fewer chapters and no error. +#[test] +fn package_document_truncated_mid_spine_is_still_an_error() { + let truncated = br#" + +Test Book + +"#; + let bytes = zip_of(&[("META-INF/container.xml", CONTAINER), ("OEBPS/content.opf", &truncated[..])]); + match Epub::open(&bytes) { + Err(Error::Part { part, .. }) => assert_eq!(part, "OEBPS/content.opf"), + other => panic!("{:?}", other.map(|e| e.package)), + } +} + +#[test] +fn mismatched_closing_tag_is_an_error() { + let mismatched = br#" + + +"#; + let bytes = zip_of(&[("META-INF/container.xml", &mismatched[..]), ("OEBPS/content.opf", &opf(""))]); + assert!(matches!(Epub::open(&bytes), Err(Error::Part { .. }))); +} + +// ---------- limits ---------- + +#[test] +fn an_entry_past_the_entry_limit_is_refused() { + let bytes = sample_package(); + let limits = Limits { max_entry_bytes: 1024, ..Limits::DEFAULT }; + assert!(Epub::open_with(&bytes, limits).is_ok(), "everything here is under the limit"); + let big = vec![b' '; 2000]; + let bytes = zip_of(&[("META-INF/container.xml", CONTAINER), ("OEBPS/content.opf", &opf("")), ("OEBPS/big.bin", &big)]); + let mut epub = Epub::open_with(&bytes, limits).unwrap(); + assert_eq!(epub.read("OEBPS/big.bin"), Err(Error::TooLarge { part: "OEBPS/big.bin".into(), limit: 1024 })); +} + +#[test] +fn entries_cannot_add_up_past_the_total_budget() { + let chunk = vec![b'x'; 600]; + let bytes = zip_of(&[("META-INF/container.xml", CONTAINER), ("OEBPS/content.opf", &opf("")), ("a", &chunk), ("b", &chunk), ("c", &chunk)]); + let used = CONTAINER.len() + opf("").len(); + let limits = Limits { max_total_bytes: (used + 1500) as u64, ..Limits::DEFAULT }; + let mut epub = Epub::open_with(&bytes, limits).unwrap(); + assert_eq!(epub.budget_left(), 1500); + assert!(epub.read("a").unwrap().is_some()); + assert!(epub.read("b").unwrap().is_some()); + assert_eq!(epub.read("c"), Err(Error::TooLarge { part: "c".into(), limit: 300 })); + // The container itself is inside the budget too. + assert!(matches!(Epub::open_with(&bytes, Limits { max_total_bytes: 10, ..Limits::DEFAULT }), Err(Error::TooLarge { .. }))); +} + +#[test] +fn an_archive_with_too_many_entries_is_refused() { + let bytes = sample_package(); + assert_eq!(Epub::open_with(&bytes, Limits { max_entries: 5, ..Limits::DEFAULT }).err(), Some(Error::TooManyEntries { found: 6, limit: 5 })); + assert!(Epub::open_with(&bytes, Limits { max_entries: 6, ..Limits::DEFAULT }).is_ok()); +} + +#[test] +fn manifest_and_spine_items_past_their_limits_are_ignored() { + let xml = opf(r#""#); + let pkg = parse_package(&xml, "", &Limits { max_manifest: 2, max_spine: 2, ..Limits::DEFAULT }).unwrap(); + assert_eq!(pkg.manifest.len(), 2); + assert_eq!(pkg.spine.len(), 2); +} + +#[test] +fn a_package_document_past_the_entry_limit_is_refused() { + let xml = opf(""); + let limits = Limits { max_entry_bytes: (xml.len() - 1) as u64, ..Limits::DEFAULT }; + assert!(matches!(parse_package(&xml, "", &limits), Err(Error::Part { .. }))); +} diff --git a/crates/epub/tests/write.rs b/crates/epub/tests/write.rs new file mode 100644 index 00000000..71bc4ace --- /dev/null +++ b/crates/epub/tests/write.rs @@ -0,0 +1,231 @@ +//! 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, 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() }, + } +} + +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\">