mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 12:22:47 +00:00
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:
co-authored by
Michael
parent
ee78a9bd46
commit
5b94fc7764
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 & Chips</dc:title>
|
||||
<dc:title>A second, ignored title</dc:title>
|
||||
<dc:creator>Smith & 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 { .. })));
|
||||
}
|
||||
@@ -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 & <Chips></dc:title>",
|
||||
"<dc:language>en-GB</dc:language>",
|
||||
"<dc:creator>A & 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 "1"\"/>",
|
||||
"<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 & <Chips></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 <x></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")));
|
||||
}
|
||||
Reference in New Issue
Block a user