Files
craft-libs/crates/epub/README.md
Dylan M. TaylorandMichael 5b94fc7764 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>
2026-11-11 17:00:41 -05:00

4.8 KiB

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

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

# 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.