Add craft-epub: EPUB packages, written (EPUB 3) and read (EPUB 2 and 3) (#893)

* craft-epub: read EPUB 2 and 3 packages (container, package document, bounded entry reads)

The container layer of WordCraft's EPUB import (storytold/wordcraft#3119): container.xml,
the package document's metadata, manifest and spine, href resolution, and entry reads inside
per-entry and total byte budgets. Moved onto craft-xml, read strictly, and extended to EPUB 2
packages (NCX path, cover meta, dc-metadata).

* craft-epub: write EPUB 3 packages (reflowable and fixed layout)

The packaging of DesignCraft's EPUB export (crates/epub/src/package.rs at 513f77b8): the stored
mimetype, container, package document, nested navigation, fixed-layout metadata and the
derived identifier, behind a neutral Book of metadata, resources, spine and navigation. Adds
the dc:subject and dc:description metadata, the W3CDTF check and the stricter language tag
check of WordCraft's EPUB export (storytold/wordcraft#2815).

---------

Co-authored-by: Michael <monashmichaelmu@gmail.com>
This commit is contained in:
Dylan M. Taylor
2026-11-11 17:00:41 -05:00
committed by GitHub
co-authored by Michael
parent ee78a9bd46
commit 5b94fc7764
11 changed files with 1459 additions and 0 deletions
Generated
+10
View File
@@ -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"
+1
View File
@@ -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" }
+1
View File
@@ -70,6 +70,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-ecproj-format`](crates/ecproj-format/README.md): the envelope of EffectCraft `.ecproj`
project files: `savedBy`, the schema version and its check, and the file object with the
project and its saved view as type parameters. The project model stays in EffectCraft.
- [`craft-epub`](crates/epub/README.md): EPUB packages: the OCF ZIP container, the package document (metadata, manifest, spine) and the navigation document, written as EPUB 3 (reflowable and fixed layout) and read (EPUB 2 and 3) with bounded entry sizes. Content documents are the caller's.
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
lookup by family, style, localized, legacy, full and PostScript names, family loads shared
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "craft-epub"
description = "EPUB packages: the OCF ZIP container, the package document (manifest, spine, metadata) and the navigation document, written (EPUB 3, reflowable and fixed layout) and read (EPUB 2 and 3) with bounded reads."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-xml = { workspace = true }
thiserror = { workspace = true }
# `deflate-flate2` with flate2's default miniz_oxide backend, as craft-update pins it (zip's own
# `deflate` feature would switch a whole app's flate2 to zlib-rs).
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
# The backend of zip's deflate: `deflate-flate2` leaves the choice to the build.
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
+106
View File
@@ -0,0 +1,106 @@
# craft-epub
EPUB packages, written and read. No `unsafe`.
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.
| Document | Used for |
|---|---|
| EPUB 3.3 (W3C Recommendation) | package document, navigation document, fixed-layout metadata |
| EPUB OCF 3.3 | the ZIP container, `mimetype`, `container.xml` |
| OPF 2.0.1 | EPUB 2 packages: `spine toc`, `<meta name="cover">`, `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", "", "", "<h1 id=\"c1\">One</h1>\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<u8> = 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<String> = 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.
+61
View File
@@ -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()))
}
}
+300
View File
@@ -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<String>,
}
/// 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<String>,
/// The first `dc:language` that is not blank.
pub language: Option<String>,
/// The `dc:identifier` the package's `unique-identifier` names, else the first.
pub identifier: Option<String>,
/// `dcterms:modified` (EPUB 3).
pub modified: Option<String>,
/// `rendition:layout` is `pre-paginated`: a fixed-layout book.
pub pre_paginated: bool,
pub manifest: Vec<Item>,
pub spine: Vec<SpineItem>,
/// The archive entry of the EPUB 2 NCX the spine's `toc` attribute names.
pub ncx: Option<String>,
/// The id of the EPUB 2 cover image (`<meta name="cover">`).
cover_id: Option<String>,
}
impl Package {
/// The archive entries of the spine, in reading order, for the items the manifest has.
pub fn spine_paths(&self) -> impl Iterator<Item = &str> {
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
/// `<meta name="cover">` 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<El, Error> {
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<Option<String>, 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<Package, Error> {
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::<Vec<_>>() } 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<Cursor<&'a [u8]>>,
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, Error> {
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<Self, Error> {
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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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
}
}
+138
View File
@@ -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<String> {
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<usize>| s.get(range).and_then(|v| v.parse::<u32>().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\"><x", &"en-".repeat(30)] {
assert!(!valid_language_tag(bad), "{bad}");
}
}
#[test]
fn dates() {
assert_eq!(w3cdtf("2026-10-13T09:30:00Z").as_deref(), Some("2026-10-13T09:30:00Z"));
assert_eq!(w3cdtf("2026-10-13T09:30:00.123+02:00").as_deref(), Some("2026-10-13T09:30:00Z"));
for bad in
["", "2026-10-13", "2026-13-13T09:30:00Z", "2026-10-32T09:30:00Z", "2026-10-13T24:30:00Z", "2026-10-13 09:30:00", "12026-10-13T09:30:00Z"]
{
assert_eq!(w3cdtf(bad), None, "{bad}");
}
}
#[test]
fn identifiers_are_stable_and_distinct() {
let a = urn_uuid("Book\n1");
assert_eq!(a, urn_uuid("Book\n1"));
assert_ne!(a, urn_uuid("Book\n2"));
assert_eq!(a.len(), "urn:uuid:".len() + 36);
assert_eq!(a.as_bytes().get(23), Some(&b'5'), "{a}");
}
#[test]
fn contents_titles() {
assert_eq!(contents_title("nl-NL"), "Inhoud");
assert_eq!(contents_title("pt_BR"), "Sumário");
assert_eq!(contents_title("pt"), "Índice");
assert_eq!(contents_title("zh-Hant"), "目錄");
assert_eq!(contents_title("zh"), "目录");
assert_eq!(contents_title("sr-Latn-RS"), "Sadržaj");
assert_eq!(contents_title("xx"), "Contents");
}
}
+337
View File
@@ -0,0 +1,337 @@
//! A [`Book`] as an EPUB 3 package.
use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::io::Write as _;
use craft_xml::{esc, esc_attr, is_name};
use crate::text::{valid_language_tag, w3cdtf};
use crate::{Error, MIMETYPE};
/// Characters of the title the package keeps. A title is usually repeated in every content
/// document, so a huge one would multiply by the number of chapters or pages.
pub const MAX_TITLE_CHARS: usize = 1024;
/// Most resources one book may hold.
pub const MAX_RESOURCES: usize = 100_000;
/// Where the package keeps its own files, and every resource under it.
const ROOT: &str = "OEBPS";
const OPF: &str = "content.opf";
const NAV: &str = "nav.xhtml";
const CONTAINER: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\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<String>,
/// `dc:subject`, one element each; blank ones are left out.
pub subjects: Vec<String>,
/// `dc:description`, left out when blank.
pub description: Option<String>,
/// `dcterms:modified`: must be a date and time [`crate::w3cdtf`] accepts.
pub modified: String,
/// The writing application, as `<meta name="generator">`.
pub generator: Option<String>,
}
/// 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<String>, path: impl Into<String>, media_type: impl Into<String>, data: impl Into<Cow<'a, [u8]>>) -> 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<NavItem>,
}
/// 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<Resource<'a>>,
/// The reading order: resource ids.
pub spine: Vec<String>,
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title>{head}</head>\n<body{body_attrs}>\n{body}</body>\n</html>\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<T>(msg: String) -> Result<T, Error> {
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\"{prefix}>\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>\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, "<dc:{name}>{}</dc:{name}>", 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, "<meta property=\"dcterms:modified\">{modified}</meta>");
if let Layout::PrePaginated { width, height } = book.layout {
let _ = writeln!(
o,
"<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{width}x{height}\"/>"
);
}
if let Some(g) = m.generator.as_deref().filter(|g| !g.trim().is_empty()) {
let _ = writeln!(o, "<meta name=\"generator\" content=\"{}\"/>", esc_attr(g.trim()));
}
o.push_str("</metadata>\n<manifest>\n<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n");
for r in &book.resources {
let _ = write!(o, "<item id=\"{}\" href=\"{}\" media-type=\"{}\"", esc_attr(&r.id), href(&r.path), esc_attr(r.media_type.trim()));
if !r.properties.trim().is_empty() {
let _ = write!(o, " properties=\"{}\"", esc_attr(r.properties.trim()));
}
o.push_str("/>\n");
}
o.push_str("</manifest>\n<spine>\n");
for id in &book.spine {
let _ = writeln!(o, "<itemref idref=\"{}\"/>", esc_attr(id));
}
o.push_str("</spine>\n</package>\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<u8> = 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("<ol>");
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("</li></ol>");
}
out.push_str("</li>");
if let Some(t) = open.last_mut() {
*t = (*t).min(*level);
}
}
}
let _ = write!(out, "<li><a href=\"{}\">{}</a>", esc_attr(href), esc(label));
}
for i in (0..open.len()).rev() {
out.push_str("</li>");
if i > 0 {
out.push_str("</ol>");
}
}
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!("<nav epub:type=\"{kind}\" id=\"toc\"><h1>{}</h1><ol>{}</ol></nav>", 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<Vec<u8>, 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))
}
+254
View File
@@ -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<u8> {
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#"<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>"#;
fn opf(spine_items: &str) -> Vec<u8> {
format!(
r#"<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata><dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Test Book</dc:title>
<dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Jane Author</dc:creator>
<dc:language xmlns:dc="http://purl.org/dc/elements/1.1/">en</dc:language></metadata>
<manifest>
<item id="c1" href="text/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="c2" href="text/chapter2.xhtml" media-type="application/xhtml+xml"/>
<item id="img1" href="images/cover.png" media-type="image/png"/>
</manifest>
<spine>{spine_items}</spine>
</package>"#
)
.into_bytes()
}
const PNG: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
fn sample_package() -> Vec<u8> {
zip_of(&[
("mimetype", b"application/epub+zip"),
("META-INF/container.xml", CONTAINER),
("OEBPS/content.opf", &opf(r#"<itemref idref="c1"/><itemref idref="c2"/>"#)),
("OEBPS/text/chapter1.xhtml", b"<html><body><h1>Chapter One</h1></body></html>"),
("OEBPS/text/chapter2.xhtml", br#"<html><body><img src="../images/cover.png"/></body></html>"#),
("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#"<itemref idref="c1"/><itemref idref="c2" linear="no"/><itemref idref="gone"/>"#), "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::<Vec<_>>(), ["OEBPS/text/chapter1.xhtml", "OEBPS/text/chapter2.xhtml"]);
assert_eq!(pkg.spine.iter().map(|s| s.linear).collect::<Vec<_>>(), [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#"<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Fish &amp; Chips</dc:title>
<dc:title>A second, ignored title</dc:title>
<dc:creator>Smith &amp; Sons</dc:creator>
<dc:identifier id="other">isbn:1</dc:identifier>
<dc:identifier id="id">urn:uuid:2</dc:identifier>
<meta property="dcterms:modified">2026-01-02T03:04:05Z</meta>
<meta property="rendition:layout">pre-paginated</meta>
</metadata>
<manifest/>
<spine/>
</package>"#;
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#"<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="BookId">
<metadata><dc-metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Old</dc:title><dc:identifier id="BookId">x</dc:identifier></dc-metadata>
<meta name="cover" content="cov"/></metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="cov" href="img/c%20over.jpg" media-type="image/jpeg"/>
<item id="c" href="c.html" media-type="application/xhtml+xml"/>
<item id="c" href="dup.html" media-type="application/xhtml+xml"/>
</manifest>
<spine toc="ncx"><itemref idref="c"/></spine></package>"#;
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::<Vec<_>>(), ["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<String> = epub.package.spine_paths().map(str::to_string).collect();
assert!(epub.read(&spine[0]).unwrap().unwrap().starts_with(b"<html>"));
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#"<itemref idref="c1"/>"#))]);
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"<container><rootfiles/></container>";
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#"<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>"#;
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#"<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata><dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Test Book</dc:title></metadata>
<manifest><item id="c1" href="chapter1.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="c1"/>"#;
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#"<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</wrong>"#;
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#"<itemref idref="c1"/><itemref idref="c2"/><itemref idref="c1"/>"#);
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 { .. })));
}
+231
View File
@@ -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 & <Chips>".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", "<link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/>", "", &format!("<p id=\"p{n}\">{n}</p>\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<u8>)> {
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 [
"<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"en-GB\">",
"<dc:identifier id=\"bookid\">urn:uuid:1</dc:identifier>",
"<dc:title>Fish &amp; &lt;Chips&gt;</dc:title>",
"<dc:language>en-GB</dc:language>",
"<dc:creator>A &amp; B</dc:creator>\n<dc:creator>C</dc:creator>",
"<dc:subject>food</dc:subject>",
"<dc:description>About \"fish\"</dc:description>",
"<meta property=\"dcterms:modified\">2026-10-13T09:30:00Z</meta>",
"<meta name=\"generator\" content=\"Test &quot;1&quot;\"/>",
"<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>",
"<item id=\"content2\" href=\"content2.xhtml\" media-type=\"application/xhtml+xml\" properties=\"mathml\"/>",
"<item id=\"img0\" href=\"images/a%20b%231.png\" media-type=\"image/png\" properties=\"cover-image\"/>",
"<spine>\n<itemref idref=\"content\"/>\n<itemref idref=\"content2\"/>\n</spine>",
] {
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 & <Chips>", 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::<Vec<_>>(), ["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 epub:type=\"toc\" id=\"toc\"><h1>Contents</h1><ol><li><a href=\"content.xhtml\">Fish &amp; &lt;Chips&gt;</a></li></ol></nav>"
),
"{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 <x>", 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 = "<ol><li><a href=\"content.xhtml#a\">One</a><ol><li><a href=\"content.xhtml#b\">One.one</a><ol><li><a href=\"content.xhtml#c\">One.one.one &lt;x&gt;</a></li></ol></li></ol></li><li><a href=\"content2.xhtml#d\">Two</a><ol><li><a href=\"content2.xhtml#e\">Two.deep</a></li><li><a href=\"content2.xhtml#f\">Two.next</a></li></ol></li></ol>";
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/#\">",
"<meta property=\"rendition:layout\">pre-paginated</meta>",
"<meta property=\"rendition:spread\">auto</meta>",
"<meta name=\"original-resolution\" content=\"612x792\"/>",
] {
assert!(opf.contains(want), "missing {want}\n{opf}");
}
assert!(
text(&bytes, "OEBPS/nav.xhtml")
.contains("<nav epub:type=\"page-list\" id=\"toc\"><h1>Pages</h1><ol><li><a href=\"content.xhtml\">1</a></li></ol></nav>")
);
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\"><script>".into();
let bytes = write(&b).unwrap();
assert!(text(&bytes, "OEBPS/content.opf").contains("xml:lang=\"und\""));
assert!(text(&bytes, "OEBPS/nav.xhtml").contains("xml:lang=\"und\" lang=\"und\""));
assert!(
xhtml("x y", "T", "", " epub:type=\"cover\"", "")
.contains("lang=\"und\">\n<head><meta charset=\"utf-8\"/><title>T</title></head>\n<body epub:type=\"cover\">\n</body>")
);
}
#[test]
fn books_that_cannot_be_packages_are_refused() {
let refused = |change: &dyn Fn(&mut Book<'static>), why: &str| {
let mut b = book();
change(&mut b);
match write(&b) {
Err(Error::Invalid(msg)) => assert!(msg.contains(why), "{msg} (wanted {why})"),
other => panic!("{why}: {:?}", other.map(|b| b.len())),
}
};
refused(&|b| b.metadata.identifier = " ".into(), "identifier");
refused(&|b| b.metadata.title = String::new(), "title");
refused(&|b| b.metadata.modified = "yesterday".into(), "modification date");
refused(&|b| b.spine.clear(), "spine is empty");
refused(&|b| b.spine.push("missing".into()), "`missing`");
refused(&|b| b.spine.push("nav".into()), "`nav`");
refused(&|b| b.resources[0].id = "nav".into(), "used twice");
refused(&|b| b.resources[0].id = "content".into(), "used twice");
refused(&|b| b.resources[0].id = "a b".into(), "not an XML name");
refused(&|b| b.resources[0].id = "\"><x".into(), "not an XML name");
refused(&|b| b.resources[0].path = "CONTENT.xhtml".into(), "used twice");
refused(&|b| b.resources[0].path = "nav.xhtml".into(), "used twice");
refused(&|b| b.resources[0].path = "Content.OPF".into(), "used twice");
for path in ["", "/abs.css", "../mimetype", "a/../b.css", "a//b.css", "a\\b.css", "./a.css", "a/", "a\nb"] {
refused(&|b| b.resources[0].path = path.into(), "not a relative path");
}
refused(&|b| b.resources[0].media_type = " ".into(), "media type");
}
#[test]
fn more_resources_than_the_limit_are_refused() {
let mut b = book();
b.resources = (0..=craft_epub::MAX_RESOURCES).map(|i| Resource::new(format!("r{i}"), format!("r{i}"), "text/plain", Vec::new())).collect();
assert!(matches!(write(&b), Err(Error::Invalid(msg)) if msg.contains("at most")));
}