Help us shape the future of Go! Take the 2026 Go Developer Survey

epub

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: BSD-3-Clause Imports: 11 Imported by: 0

README

epub

EPUB 2 and EPUB 3 into richdoc. Pure Go, CGO-free, including GOOS=js.

doc, err := epub.Parse(src, epub.Limits{})

An EPUB is three things in a trench coat: a ZIP, a manifest and an order. None of them is the content — the content is XHTML, which go-richdoc/html reads — so this package is the three layers above it.

META-INF/container.xml the one path in an EPUB that is fixed. It names the package document
the package document the manifest (what is in the book) and the spine (what order to read it in)
the spine the reading order, and the only thing that says it

⛔ Nothing here is found by its path

The same rule OPC taught go-ooxml, for the same reason: a reader that guessed OEBPS/content.opf would work on most books and on nothing else. The container names the package document, the package document names its content, and the spine says in which order — each step read, never assumed.

⛔ The reading order is the spine, not the ZIP

A ZIP has no order worth trusting, and an alphabetical walk of its entries puts chapter 10 before chapter 2 — the defect every naive reader of a container format has. Only the spine says what comes after what.

An item marked linear="no" is auxiliary in EPUB's terms: an answer key, a pop-up footnote, an advertisement. It is kept, in spine position, because losing a chapter is worse than carrying an appendix, and the specification leaves the choice to the reading system.

The navigation document and the NCX are not read as content. A richdoc document has headings, which is what a table of contents is made from; a second list of the same links would be a page of duplicates. A nav document the author put in the spine is content like any other and is read there.

An image becomes its bytes

Images are resolved against the chapter that referred to them and inlined as data URLs — the same decision fb2 makes, for the same reason: once the ZIP is closed, images/cover.jpg is a name nothing can resolve. The same picture referred to fifty times is inflated once.

⛔ An image that cannot be read keeps its href, and the chapter is kept; a chapter the spine names and the ZIP does not hold is an error. The two differ because a href a reader cannot resolve is still a name a person can look up, while a missing chapter is content nothing can recover — and a silently shorter book is the one failure nobody notices.

⛔ The limits are the security surface

epub.Limits{
    MaxEntries: 8192, MaxEntryBytes: 64 << 20, MaxTotalBytes: 384 << 20,
    MaxBookNodes: 1 << 20, // the whole book, not each chapter (v0.2.0)
}

Every one of these bounds an allocation decided by a number in the file. A ZIP's central directory costs about forty-six bytes an entry and declares how large each entry will be once inflated: it costs the author nothing to claim four gigabytes, and a reader that believes it allocates them.

Entries are read on demand, not all at once: a book is mostly pictures, and a conversion may want none of them. An entry is refused from its declared size before a byte is inflated, then bounded again while reading, in case the declaration was a lie the other way.

A reference that climbs out of the container is refused rather than cleaned into something harmless; a reference with a scheme is refused as a URL; a reference to nothing but a fragment is refused rather than resolved to the chapter that made it.

⛔ MaxNodes bounded each chapter, and nothing bounded the book

MaxDepth and MaxNodes are handed to the HTML reader for each content document — the doc comment always said each — so a spine of fifty chapters of a million nodes is fifty million nodes, and every one of them passed its ceiling honestly. Two ceilings that multiply, with each factor correct: the same shape as a CSV's rows × columns in go-richdoc/data.

MaxBookNodes (v0.2.0) bounds what this reader returns. ⛔ It is a different unit from MaxNodes on purpose: that one counts x/net/html's nodes inside one chapter, this one counts the blocks and inlines of the richdoc.Document the caller is handed. One name for both would be a ceiling that reads as covering a quantity it never measured.

⛔ An item and a cell are counted themselves, not only their contents — an empty one holds nothing to recurse into, so a table of a million empty <td> was one node to the first draft of the counter. It was caught by that function's own test disagreeing with the number I had worked out by hand for it, which is the argument for writing the expected count rather than asserting that two counts match.

What a byte of this format buys
per byte inflated 58.5 bytes of heap
per byte of archive 8 058.8 — and meaningless

⛔ The second row is in the table to be dismissed. The author of the file picks the compression ratio, so a figure per archive byte says what an attacker chose to compress by and not what this reader does. Deflate squeezes a repeated paragraph about a hundredfold. The quantity the reader controls is what it spends per byte it has inflated, which is what MaxTotalBytes bounds — so that is the denominator, and 58.5 is essentially the HTML reader's own 56.3, because the cost is the HTML reader's.

And the control that the byte ceiling does bound the work, ported from data and html where the same control found real defects: two archives refused by the same ceiling, the second inflating to eight times what the first does, cost 100 344 and 74 744 bytes — a ratio of 0.7. Entries are bounded as they inflate, so this half of the reader was already right, and the control stays committed to keep it that way.

Measured on real books, which are not in this repository

⛔ There is no book in testdata, deliberately. The population this was measured against is the machine owner's own library — copyrighted, personal, and nobody's business in a public repository. The fixtures here are built by hand; the real corpus is read from wherever the person running the test keeps theirs:

EPUB_CORPUS=~/Books go test -run TestARealCorpus -v ./...

Without the variable the test skips and says so, because a test that passes by doing nothing is worse than one that is not there.

What twelve real books said, on 2026-10-17:

12 book(s): 11 read, 1 refused
7 480 to 377 683 words each; 0 to 127 headings; 2 to 52 images
REFUSED  Mastering Ext JS     epub: not a ZIP: zip: not a valid zip file

The refusal is the file's, not the reader's: that EPUB has no end-of-central-directory record — it is truncated — and unzip refuses it in the same breath. A reader that accepted it would hand back a partial book and call it a book.

Two of the eleven have almost no headings (0 and 2): those publishers style their chapter openers as paragraphs rather than as h1. That is the book's markup, faithfully carried.

How it is checked

  • 100% of statements, no uncovered block, race detector on, gofmt and go vet clean, ten build targets including js/wasm.
  • Every limit has a test that refuses and names which limit, including the three shapes of the budget guard: one entry too large, an entry that overruns what is left, and a budget exactly spent.
  • A corrupted archive — one byte flipped inside a chapter's compressed data, found by reading the local header rather than by guessing an offset, because the first version of that test corrupted a header field and the archive read perfectly.
  • An entry compressed with a method nothing can open, which is what a DRM'd book looks like; a manifest href that climbs out; a spine of stylesheets.
  • An image inside a table caption inside a list item: the shape that catches a tree rewrite missing a container, which would otherwise keep an href nothing can resolve.
  • A mutation sweep, which collapsed three guards into one: an empty src, a data: URL and a failed resolve all end the same way, and deleting any of the three changed nothing a test could see.
  • A fuzz target (FuzzParse, v0.1.1): no panic, a document OR an error and never neither, the same bytes always giving the same document, and — the one worth the target — the limits measured on the OUTPUT rather than trusted. Limits declares what the reader will inflate; the assertion counts what the document actually carries, which is where an accounting mistake lands. A spine of a thousand chapters each naming one image would multiply a small archive without limit if spent counted an entry once instead of on every read; it counts on every read, and now something says so. 1.8M executions found nothing.
  • ⚠ And the honest half of that number: a ZIP is checksummed, so most mutations are refused by archive/zip before any of this package runs — 1.8M executions produced 32 interesting inputs, where the same budget against a plain-text reader produces nearly five hundred. The deep paths are reached far more cheaply by fuzzing the readers this package hands work to, which is why go-richdoc/html got its own target in the same round.

⛔ The third bounded ZIP reader in this fleet

go-ooxml/opc has one for OPC packages and go-pdfkit/convert has one for comic archives. The three share a shape — a cap on entries, a cap per entry, a cap in total, and a refusal of names that climb out — and that shape is what wants extracting into a module of its own. ocf.go is written with no richdoc and no EPUB in it, so the extraction is a move rather than a rewrite.

The rest of the family

richdoc is the model · html reads the chapters · fb2 · markdown · rst · latex · data

go-pdfkit composes a richdoc document into a PDF, which is what makes .epub → PDF a two-step with no format-specific code in between.

Licence

BSD-3-Clause.

Documentation

Overview

Package epub reads an EPUB 2 or EPUB 3 publication into the richdoc document model.

An EPUB is three things in a trench coat: a ZIP, a manifest, and an order. None of them is the content — the content is XHTML, which github.com/go-richdoc/html reads — so this package is about the three layers above it:

META-INF/container.xml   names the package document, which is NOT at a
                         fixed path: content.opf, OEBPS/content.opf and
                         EPUB/package.opf are all real
the package document     the manifest (what is in the book) and the spine
                         (what order to read it in)
the spine                the reading order, and the only thing that says it

⛔ Nothing here is found by its path

The same rule OPC taught go-ooxml, and for the same reason: a reader that guessed "OEBPS/content.opf" would work on most books and on nothing else. The container names the package document; the package document names its content; the spine says in which order. Each step is read, never assumed.

⛔ The reading order is the spine, not the ZIP

A ZIP has no order worth trusting and an alphabetical walk of its entries gives chapter 10 before chapter 2 — the defect every naive reader of a container format has. Only the spine says what comes after what.

An item marked linear="no" is auxiliary in EPUB's terms: an answer key, a pop-up footnote, an advertisement. It is KEPT, in spine position, because losing a chapter is worse than carrying an appendix a reader did not ask for, and the specification leaves the choice to the reading system.

An image becomes its bytes

Images are resolved against the chapter that referred to them and inlined as data URLs, the same decision github.com/go-richdoc/fb2 makes and for the same reason: once the ZIP is closed, "images/cover.jpg" is a name nothing can resolve. An image past Limits.MaxEntryBytes keeps its href rather than being truncated — half a JPEG is not a smaller picture.

What it is not

It does not read the navigation document or the NCX as a table of contents. A richdoc document has headings, which is what a table of contents is made from; a second list of the same links would be a page of duplicates. A nav document the author put IN the spine is content like any other and is read there.

Nothing here is CGO, and it builds for GOOS=js.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Parse

func Parse(src []byte, lim Limits) (*richdoc.Document, error)

Parse reads an EPUB publication into a document.

The whole of the format is three reads and a loop: the container names the package document, the package document's manifest says what is in the book, its spine says in what order, and each content document goes through the HTML reader.

Types

type Limits

type Limits struct {
	// MaxEntries is how many files the ZIP may hold. It bounds an allocation
	// made before a byte of content is read.
	MaxEntries int

	// MaxEntryBytes is how large any one entry may be once inflated, and
	// MaxTotalBytes how much this reader will inflate in all. One entry under
	// the ceiling says nothing about a thousand of them.
	MaxEntryBytes int64
	MaxTotalBytes int64

	// MaxDepth and MaxNodes are handed to the HTML reader for each content
	// document. They are named here so a caller sets one Limits rather than
	// two.
	//
	// ⛔ EACH, and that is the whole of what they bound. Every chapter gets a
	// fresh MaxNodes, so a spine of fifty chapters of a million nodes is fifty
	// million nodes and each one of them passed its ceiling honestly. The
	// comment above has always said "each"; what it did not say is that nothing
	// bounded the BOOK. See MaxBookNodes.
	MaxDepth int
	MaxNodes int

	// MaxBookNodes is how many nodes the document THIS READER RETURNS may hold,
	// counted across every chapter.
	//
	// ⛔ IT IS A DIFFERENT UNIT FROM MaxNodes, deliberately. MaxNodes counts
	// golang.org/x/net/html's nodes inside one chapter; this counts the blocks
	// and inlines of the [richdoc.Document] that comes out, which is the thing
	// the caller is handed and holds. Making one name mean both would be the
	// mistake this package keeps finding elsewhere: a ceiling that reads as
	// covering a quantity it never measured.
	//
	// ⛔ It is checked AFTER each chapter, so a book can overshoot by at most
	// one chapter — itself bounded by MaxNodes. Capping the last chapter at the
	// remaining budget instead would refuse chapters that fit: richdoc nodes
	// are fewer than the html nodes they came from, so a budget of ten
	// richdoc nodes is not a budget of ten html nodes, and a reader that
	// confused the two would refuse a legitimate book.
	MaxBookNodes int
}

Limits bound what a publication may ask for. A zero field means the default, so Limits{} is the default everywhere.

⛔ Every one of these bounds an allocation decided by a number IN THE FILE. A ZIP's central directory costs about forty-six bytes an entry and says how large each entry will be once inflated: it costs the author of that file nothing to claim four gigabytes, and a reader that believes it allocates them.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL