@abfcode/spine
v0.13.0
Published
Fast, dependency-light EPUB toolkit for parsing, transforming, normalizing, and writing ebooks anywhere.
Maintainers
Readme
@abfcode/spine
A fast EPUB toolkit: parse, write, normalize, and ingest ebooks anywhere.
One parse() call turns EPUB bytes into structured content blocks, real
chapters, a normalized TOC, metadata, text chunks, and resolved anchors —
built for readers, RAG pipelines, TTS, and search indexing, and built to
survive everything from pristine EPUB3 to broken OPF soup.
npm install @abfcode/spineWhy this one
- Runs anywhere — including where DOM parsers can't. Node, browsers,
Web Workers, Electron (renderer and main), Bun, Deno, edge runtimes. Parsing
runs on an in-tree streaming tokenizer (2460/2460 html5lib conformance),
so there's no DOM anywhere. That's not a portability nicety: DOM-backed
parsing needs jsdom in Node (5–7× slower — measured against spine's
own earlier jsdom pipeline) and can't run in a Web Worker at all, because
DOMParserdoesn't exist there. - Fast at every scale. Metadata remains millisecond-scale (lazy zip — images you don't ask for are never inflated); Moby-Dick fully extracts in about 40ms on the current reference run; a 3,000-chapter / ~14MB-text web novel in about 480ms, scaling approximately linearly. Verified in-browser, not just in Node — see Performance.
- One runtime dependency (
fflate). Tokenizer, entities, zip directory, XML handling: all in-tree, purpose-built for ebooks. - Hard to break. Malformed XML recovers with warnings, missing containers/spines/TOCs fall back to archive scanning, DRM surfaces as a typed error instead of garbage, and a fuzz suite guarantees termination.
- Built for real readers. Chapters cut at TOC targets (multi-chapter
files and file-spanning chapters both come out right), footnote/
epub:typesemantics, RTL direction, pre-resolved links and image paths — and the block model is safe to render by construction: no innerHTML anywhere, so a hostile EPUB can't smuggle<script>into your UI. - Typed and verified, deeply. TypeScript-first, stable error
codes, structured warnings, and 2,800+ tests across a dozen harnesses — unit, html5lib conformance, property-based laws, generative + mutational fuzzing, a hostile-input suite (zip bombs, ReDoS, entity expansion), golden corpora, differential-vs-jsdom, generated round-trips, warning-free epubcheck, a 3,000-chapter scale harness, and a wild corpus of 64 real books parsed end-to-end with committed diffable results. Mutation-tested (StrykerJS) and perf/memory-regression-tracked. See Verification.
Usage
import { parse } from '@abfcode/spine'
const book = parse(epubBytes) // Uint8Array
book.metadata.title // 'Moby Dick; Or, The Whale'
book.toc // normalized table of contents
book.blocks(0) // structured blocks for spine item 0Writing and normalizing EPUBs
The experimental writer lives at a separate subpath, keeping parser-only bundles unchanged:
import { parse } from '@abfcode/spine'
import { writeEpub, bookToSpec } from '@abfcode/spine/write'
const bytes = writeEpub({
metadata: { title: 'My Book', authors: ['A. Writer'] },
chapters: [
{ title: 'One', content: { text: 'First paragraph.\n\nSecond paragraph.' } },
],
})
// Clean and rewrite an existing book:
const normalized = writeEpub(bookToSpec(parse(existingBytes)))Output is deterministic by default. EPUB3+NCX, nav-only EPUB3, and EPUB2 are supported; the generated conformance matrix passes epubcheck with warnings treated as failures. The writer API remains experimental until 1.0. See the API reference.
Text ingestion (.txt / .md → EPUB)
Huge amounts of webnovel content circulate as giant .txt rips. The /text
subpath turns them into shelf-ready books — for a library app's import flow,
that means raw rips go straight onto the shelf:
import { textToEpub, textToBook } from '@abfcode/spine/text'
const epub = textToEpub(txtBytes, { metadata: { title: 'My Rip' } })
const book = textToBook(txtBytes) // or parse it in one stepChapters come from an ordered heuristic ruleset (Chapter 1 / CHAPTER IV /
Ch. 3 / markdown headings / CJK 第一章 / ALL-CAPS conventions...) with
guards biased toward under-splitting — a marker-less wall stays one chapter,
and every cut is observable through onWarning. Handles legacy encodings
(gb18030, shift-jis, ...), multi-file rips, Gutenberg boilerplate, and
poetry; output passes epubcheck warning-free. Full options in
the API reference.
Chapters (RAG / TTS / export)
for (const ch of book.iterChapters()) {
ch.title // 'CHAPTER 1. Loomings.' — cut at TOC targets, so multi-chapter
ch.text // files and file-spanning chapters both come out right
ch.start // { spineIndex, blockIndex } — map text back to structure
}Chunks (fixed-size text windows)
const chunks = book.chunks({ mode: 'size', maxChars: 2000 })
// each chunk: stable id, text, spineIndex, block range, anchorsReader rendering
const blocks = book.blocks(spineIndex)
// Inline texts carry their own separator spaces: concatenate directly.
// See docs/API.md for the full Block/Inline model and whitespace contract.Off the main thread
Blocks, chunks, and chapters are plain structured-clone-able objects, so worker parsing is just:
// worker.ts
import { parse } from '@abfcode/spine'
onmessage = (e) => {
const book = parse(e.data)
postMessage({ toc: book.toc, chapters: book.chapters() })
}Error handling
import { isSpineError } from '@abfcode/spine'
try {
const book = parse(bytes)
console.warn(book.warnings) // non-fatal recoveries, if any
} catch (e) {
if (isSpineError(e, 'drm_encrypted')) {
// tell the user the book is DRM-protected
}
}Performance
Full pipeline (parse + every block + fixed-size chunks), median from a quiet WSL2 reference run on this development machine with Node 24.11.1. These are machine measurements, not cross-hardware guarantees:
| book | size | time | | --- | --- | --- | | Alice in Wonderland | 135 KB | 7.4 ms | | Frankenstein | 349 KB | 13.6 ms | | Moby-Dick | 711 KB | 40.1 ms | | Leaves of Grass (379 spine files) | 680 KB | 75.3 ms | | War and Peace | 1.8 MB | 137.2 ms | | Complete Works of Shakespeare | 2.8 MB | 407.2 ms | | Synthetic web novel (3,000 chapters, 13,971,369 text chars) | 2.8 MB | 480.5 ms |
Metadata + TOC only: 0.2–9.3 ms in that run. Scaling is approximately linear
in book size — a 6,000-chapter novel takes ~2× the 3,000-chapter one. Run
pnpm bench to reproduce. It generates the canonical scale EPUB plus a
manifest containing its size and SHA-256, then records full sample statistics
under the gitignored .bench-fixtures/ directory. pnpm verify:scale is the
regression gate: it fails if doubling chapter count approaches quadratic
growth.
The numbers hold in the primary deployment environment, not just Node:
pnpm bench:browser runs the same files in a module Worker inside real
headless Chromium and writes its own hash-linked JSON record. Across the
current Chrome 149 / Node 24 run, Worker heavy-extraction times were
~1.03–1.57× Node; the canonical scale book was ~1.10× Node.
Documentation
Development
- Prerequisites: Node 24.15 or newer and the exact pnpm version pinned in
package.json. Corepack and GitHub Actions both read that pin; runpnpm run doctorto verify Node, pnpm, the temporary directory, Java, Chromium, Bun, and Deno without installing anything. - In WSL, Windows may leak
TEMP/TMPpaths into Node subprocesses. If Vitest or tsx cannot create a temporary directory or IPC socket, run withTMPDIR=/tmp(for example,TMPDIR=/tmp pnpm test). pnpm test— the fast suite (PR-blocking, < ~30s): unit, property laws, golden corpora, html5lib conformance, generative fuzz, hostile-input suite, API-surface snapshot, differential-vs-jsdom, i18npnpm typecheck— type-check source, tests, and validation tooling; runs in CI alongside the build and fast suite.UPDATE_GOLDEN=1 pnpm test— regenerate golden snapshots after an intentional change (review the diff!)pnpm bench/pnpm bench:browser— corpus benchmarks (Node / worker)pnpm build— emitdist/(ESM +.d.ts)
Verification
The fast suite (pnpm test) plus the ReDoS audit and Node/Bun/Deno smoke run
in CI on every change. The heavier lanes below are local, on-demand audit
tools — each has a command and a committed baseline, so a maintainer runs
them when touching the relevant area and reviews the result as a git diff:
| lane | command | what it guards |
| --- | --- | --- |
| Wild corpus | pnpm verify:corpus | 64 real books parsed end-to-end; committed diffable per-book results |
| Splitter oracle | pnpm verify:splitter | text-splitter precision/recall vs the same books' EPUB chapters |
| Mutational fuzz | pnpm verify:fuzz | mutated EPUB bytes (zip surgery) must only ever throw typed errors (also a nightly CI run) |
| ReDoS audit | pnpm verify:redos | every source regex checked for catastrophic backtracking (in CI) |
| Mutation testing | pnpm verify:mutation | StrykerJS grades the tests themselves; a ratcheted baseline |
| Performance / memory | pnpm bench, pnpm bench:browser, pnpm verify:scale, pnpm verify:perf, pnpm verify:memory | shipped-dist Node/Worker timings, linear scaling, book-ratio perf, no-leak / lazy-zip / heap envelopes |
Baselines for the corpus, splitter, mutation score, and perf/memory are
committed under corpus/, so a regression shows up as a reviewable diff.
Status & stability
0.x: breaking changes land at minor versions and are called out in the
CHANGELOG. The compatibility promise itself is written down
in the stability contract — in short:
error codes, warning codes, the block model (including its JSON shape,
with unknown-kind tolerance for union growth), chapter coordinates, href
canonical form, and deterministic output are contract; message text and
cross-version parse output are not (key persisted parses to the exported
VERSION).
ESM-only, by design — every supported runtime speaks ESM natively. Type declarations ship with the package.
License
Apache-2.0
