mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* craft-zip: ZIP preflight, bounded entry read and Budget (from craft-archive, #129) The ZIP half of the craft-archive proposal (#126, #129): an allocation-free check of the end records and central directory that runs before a ZIP reader indexes the archive, a bounded entry read that distrusts the declared size, and the named Budget both charge. The XML half of #129 is not carried: craft-xml bounds XML now. * craft-zip: Archive (entry-count guard, duplicate names, budgeted reads) and Writer check_entry_count is craft-opc's guard: the declared entry count, ZIP64 included, checked on the raw bytes before zip parses the central directory. Archive reads entries within a per-entry limit and a total Budget through read_entry. Duplicate names are found by counting the central directory headers against the entries zip kept. Writer is craft-opc's ZipOut: stored or deflated entries, ZIP64 for large ones, one fixed timestamp. * craft-idml, craft-epub: read and write through craft-zip Both crates drop their own bounded ZIP reads and zip writers. They gain the entry-count check before the archive is indexed (ZIP64 included) and archives whose bytes depend only on their content (one fixed timestamp). * craft-epub: table of contents (navigation document, NCX), EPUB 2 NCX writing, encryption.xml Epub::toc reads the EPUB 3 navigation document's toc, else the EPUB 2 NCX, as entries with a label, target and level, walked without recursion and capped. Book::ncx also writes toc.ncx for older reading systems. Epub::encryption lists what META-INF/encryption.xml says is encrypted and tells font obfuscation from DRM. --------- Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
114 lines
5.4 KiB
Markdown
114 lines
5.4 KiB
Markdown
# 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.
|
|
- `Book::ncx` also writes `toc.ncx`, the EPUB 2 table of contents, for older reading systems.
|
|
- 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()`.
|
|
- `Epub::toc()` gives the table of contents as entries with a label, the archive entry and
|
|
fragment they point at, and a level: from the EPUB 3 navigation document, else the EPUB 2 NCX
|
|
(`parse_nav`, `parse_ncx`). At most `MAX_TOC_ENTRIES` (100,000) entries.
|
|
- `Epub::encryption()` lists what `META-INF/encryption.xml` says is encrypted, and tells font
|
|
obfuscation from DRM. This crate does not decrypt or de-obfuscate.
|
|
- `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 |
|
|
|
|
The ZIP layer is craft-zip: the declared entry count is checked before the archive is indexed, and
|
|
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.
|