epub-codec
v1.3.5
Published
EPUB 2/3 reading and deterministic EPUB 3 writing against the shared document-schema.js content pivot.
Maintainers
Readme
epub-codec
A hand-written, dependency-minimal EPUB 2/3 codec: reads flowable EPUB 2 and EPUB 3 packages into the shared document-schema.js content pivot, and writes deterministic, minimal EPUB 3. Built on fast-xml-parser, fflate, and Zod 4.
An EPUB decomposes into two things this family already does well: an OCF ZIP container with a manifest and a fixed first entry — structurally odf.js/ooxml.js territory — whose payload is flowable block content in well-formed XHTML — semantically markdown-codec territory, since EPUB 3.3 requires its content documents to be real XML, not tag-soup HTML. This package sits at exactly that intersection: its OCF/ZIP layer and XML parse/build wrapper mirror the conventions those siblings already established (hand-duplicated, not imported — see Architecture for why), and its XHTML-to-ContentDocument mapping is the closest relative markdown-codec's own AST-to-ContentDocument lowering has in this family.
graph TD
schema("document-schema.js")
epubcodec("epub-codec")
schema --> epubcodec
click schema "https://github.com/ExaDev/documents.js/tree/main/packages/document-schema.js" "document-schema.js"
click epubcodec "https://github.com/ExaDev/documents.js/tree/main/packages/epub-codec" "epub-codec"
style epubcodec fill:#f9a825,stroke:#333,stroke-width:3pxepub-codec depends on nothing else in this family beyond document-schema.js — see Dependency choices for why it does not depend on archive-codec or byte-codec, both real candidates the issue that created this package asked to be checked. Wiring this package into documents.js's conversion engine, document-cli, document-mcp, or the web UI is explicitly out of scope here — see ExaDev/documents.js#802, a separate, already-filed follow-up blocked on this package existing at all.
Scope
Flowable EPUB only. Fixed-layout EPUB (FXL, rendition:layout-pre-paginated) is fixed-page geometry — closer to pdf-codec's own private layout representation than to flowable content — and is not specially detected or rejected; an FXL package's XHTML still reads as ordinary flowable content, just without the positioning its properties="rendition:layout-pre-paginated" metadata was asking a reading system to honour.
Read EPUB 2 and EPUB 3. Write EPUB 3 only. readEpub/readEpubContent accept either; writeEpub/writeEpubContent always produce a minimal, spec-valid EPUB 3 package, matching how this family already treats legacy format quirks elsewhere (read, never re-emitted).
Getting started
Requires Node.js >=20 and pnpm 11.6.0 (pinned via packageManager in package.json).
pnpm installInstall as a dependency in another project:
pnpm add epub-codec
# or
npm install epub-codecUsage
Reading and writing EPUB bytes, at the tree level (the primary API — document-schema.js's DocumentTree, matching markdown-codec's identical dual-level convention):
import { readEpub, writeEpub } from "epub-codec";
const tree = readEpub(epubBytes); // -> DocumentTree, kind: 'wordprocessing'
// tree.children is one section group per spine itemref, in spine order -- headings, lists, and
// footnote/blockquote constructs already promoted into their own groups by document-schema.js's assembleTree.
const bytes = writeEpub(tree); // a fresh, minimal, spec-valid EPUB 3The flat pair one level down (ContentDocument, the codec-exchange shape every reader/writer in this family actually reads and writes):
import { readEpubContent, writeEpubContent } from "epub-codec";
const { sections, metadata } = readEpubContent(epubBytes); // ContentDocument, kind: 'wordprocessing'
const bytes = writeEpubContent({ kind: "wordprocessing", metadata, sections });Both accept an optional sink (EpubDiagnosticSink, called once per recoverable read issue or unrepresentable construct — see Conventions for the three-tier policy this shares with markdown-codec/pdf-codec):
import { readEpubContent } from "epub-codec";
const document = readEpubContent(epubBytes, {
sink: (diagnostic) => console.warn(diagnostic.code, diagnostic.message),
});The same round trip as a schema-validated z.codec() pair, mirroring markdown-codec's markdownCodec/markdownContentCodec:
import { z } from "zod";
import { epubCodec, epubContentCodec } from "epub-codec";
const tree = z.decode(epubCodec, epubBytes); // throws a ZodError if epubBytes has no zip header
const bytes2 = z.encode(epubCodec, tree);This is the no-extra-options form only — readEpub(Content)/writeEpub(Content) remain the entry points for a diagnostic sink.
The lossless byte-level Package (ExaDev/documents.js#963), for a caller that wants a genuine EPUB-to-EPUB round trip with no ContentDocument/DocumentTree projection in between — readEpub(Content)/writeEpubContent already cross this exact boundary internally, so reaching for it directly is for a caller inspecting or editing raw parts, not a prerequisite for ordinary reading/writing:
import { decodePackage, encodePackage } from "epub-codec";
const pkg = decodePackage(epubBytes); // -> { parts: Record<string, Part> }, every zip entry as XML nodes or base64 bytes
const bytes2 = encodePackage(pkg); // decode -> encode is a fixed point: bytes2 carries the identical part contentUnlike ooxml.js/odf.js, neither of which offers a one-shot bytes-in convenience at all (their own typed readers take an already-decoded Package, e.g. readDocx(decodePackage(bytes))), readEpub(Content)/writeEpub(Content) above stay this package's own established bytes-in/bytes-out API — decodePackage/encodePackage are additive, not a replacement for it.
One spine itemref becomes one ContentSection, in spine order — every itemref, including one marked linear="no" (an EPUB 2 idiom for supplementary content, most often a footnote/endnote page): this package reads it as an ordinary section rather than silently skipping real content a reading system happens to route around. EPUB has no page concept of its own, so every section is given the same invented A4 + 1in default geometry (ReadEpubOptions/WriteEpubOptions carry no override for this, unlike markdown-codec's pageSize/margins options — nothing in this package's own scope needs one yet).
Architecture
Layered from the lossless OCF/XML primitives outward to the XHTML-to-ContentDocument mapping itself:
src/zip.ts— the OCF ZIP container: fixed-mtime, ordered-entrieszipPackage/unzipPackageoverfflate, hand-duplicated fromooxml.js's andodf.js's own identical wrappers rather than depending onarchive-codec— see Dependency choices.src/xml/— the lossless XML layer every other module in this package builds on:parse.ts/build.tswrapfast-xml-parserwith the identicalpreserveOrderconfigurationooxml.js's andodf.js's own XML modules use (order and mixed content survive; entity encoding stays raw untilentities.tsdecodes it in the one place text actually becomes content),query.tsthe same handful of tree-walking helpers (rootElement,findChildElement,childrenWithTag,elementsWithTag,attrValue, plusfindElementandtextContent, needed here for footnote-target and nav-toc resolution respectively),node.tsthe orderedXmlNodeforest itself — also the shapesrc/model/package.ts's ownXmlPartcarries.src/model/package.ts/src/package-io/— the lossless byte-levelPackagemodel (ExaDev/documents.js#963), mirroringooxml.js's andodf.js's ownmodel/package.ts/package-io/exactly: every zip entry as aPart(anXmlNode[]forest, or raw base64 bytes),read.ts'sparsePackage/packageFromEntriesclassifying entries into parts,write.ts'sserializePackage/packageToEntriesthe structural inverse (including the OCF mimetype-first-stored hoist).src/codec.tswraps the pair asdecodePackage/encodePackage;src/read.ts/src/write.tscross this same boundary internally rather than talking tosrc/zip.tsdirectly.src/ocf/—container.tsresolves the OPF rootfile fromMETA-INF/container.xml(EPUB 3.3 §6.7.2);write.tsis its structural inverse.src/opf/—parse.ts/write.tsread and write the OPF package document (§5.4): Dublin Core metadata (metadata.ts), the manifest, and the spine.src/nav/—nav3.ts/ncx.tsreduce the EPUB 3<nav epub:type="toc">document and the EPUB 2 NCX to a flat, fragment-stripped href sequence each;reconcile.tscompares that sequence against the spine's own reading order (the issue's own explicit "the spine wins" decision);write.tsbuilds a minimal EPUB 3 nav document, one entry per section, titled from each section's own first heading.src/xhtml/— the mapping this package exists for:read.ts(readXhtmlBody, one XHTML content document's<body>toContentBlock[]),write.ts(writeXhtmlBody, its structural inverse, built ondocument-schema.js's owndecomposeSectionrather than re-deriving heading/list/construct nesting by hand),inline.ts(run-level formatting, footnote reference extents, and internal-link extents),footnote.ts(EPUB 3epub:type="noteref"/"footnote"and the EPUB 2 linked-anchor idiom, both mapped onto the identicalanchorconstruct),link-target.ts(resolveHrefTarget, the same-/cross-document href-to-element resolversrc/read.ts's own whole-spine internal-link registry andinline.ts's reference-side lookup both build on — ExaDev/documents.js#963),list-id.ts(list marker type packed into the opaquenumId, mirroringmarkdown-codec's own mechanism for the identical schema gap),context.ts/style-constants.ts(shared read-side plumbing and round-trip-only styleId/font constants).src/image/dimensions.ts— PNG/JPEG format detection and pixel dimensions, hand-written rather than reused frombyte-codec— see Dependency choices.src/util/base64.ts— isomorphic base64 codec, a third hand-written copy of the identical helperodf.js's andpdf-codec's ownsrc/util/base64.tsalready carry.src/path.ts— package-relative path resolution (manifest hrefs against the OPF's own directory,<img src>against its own XHTML document's directory), honouring../segments.src/diagnostics.ts— the three-tier read/write failure policy; see Conventions.src/read.ts/src/write.ts— the public entry points, composing every layer above intoreadEpub(Content)/writeEpub(Content),decodePackage/encodePackagefirst/last.src/codec.ts—epubCodec/epubContentCodec(theContentDocument/DocumentTreeround trip) andpackageCodec/decodePackage/encodePackage(the losslessPackageround trip), each az.codec()pair.
Dependency choices
The issue that created this package (ExaDev/documents.js#801) named archive-codec and byte-codec as real candidates for this package's own OCF/ZIP and image-dimension layers, and asked for the choice to be investigated and justified rather than assumed either way. Both were investigated; both were declined, for reasons specific to what each actually offers today rather than as a blanket "hand-write everything" reflex:
- Not
archive-codecfor the ZIP layer.archive-codec's ownzip/container.tsis structurally identical to what this package needs (zipPackage/unzipPackage, ordered entries, astoredflag) — but it does not pin a fixed entry mtime, unlikeooxml.js's andodf.js's ownzip.ts, both of which do exactly to keep output byte-deterministic across two builds of identical content. Nothing in this family writes througharchive-codec's own ZIP writer today, so the gap has never mattered before; it would matter here, since byte-deterministic output is this package's own explicit requirement. Every existing format codec in this family (ooxml.js,odf.js) already hand-duplicates this exact wrapper rather than sharing it — precisely to keep each codec's release cadence decoupled from a package it would otherwise need to bump in lockstep with, the same reasoningodf.js's own README states for not depending onooxml.js— so this package's ownsrc/zip.tsis a third hand-written copy of the identical ~30-line wrapper, not a fourth pattern.archive-codecremains genuinely useful elsewhere in this family (ZIP-in-ZIP walking, CFB reading) — neither of which a flat OCF container needs, since a flowable EPUB has no nested-archive or compound-file embedding to recurse into. - Not
byte-codecfor image dimensions.byte-codec'simage/jpeg-info.ts(header-only JPEG dimensions, no sample decoding) would have been a clean reuse for the JPEG half — but its PNG half (image/png-decode.ts) is a full pixel decode, normalising every PNG colour type to 8-bit gray/RGB planes for a PDF Image XObject's own needs. This package never needs a single decoded pixel:ContentImageBlockcarries the image's own raw bytes as base64 and only needs its dimensions, so a full PNG decode would pay real CPU per manifest image to read four IHDR bytes, and risks rejecting a real PNG variant an IHDR-only read would tolerate. Depending onbyte-codecfor the JPEG half alone while hand-writing the PNG half would gain nothing over hand-writing both — JPEG marker scanning and PNG IHDR reading are each roughly the same amount of code — sosrc/image/dimensions.tsmirrorsmarkdown-codec's own identicalsrc/image/image.tsinstead: the one other codec in this family with the identical problem (dimensions from arbitrary image bytes, no format-native explicit sizing to read instead — every OOXML/ODF image anchor already carries its own explicit display size).
Conventions
- Hand-written, dependency-minimal, matching every sibling codec's own stated bet: no
epubjs/epub-gen/epub2/node-epub/general-purpose ZIP library dependency (adm-zip,jszip,yazl,yauzl), enforced byeslint.config.ts's ownno-restricted-importspatterns. - Worker-isomorphic: runtime
src/must not importnode:*, a bare Node builtin, or use theBufferglobal — enforced statically by the sharedno-restricted-imports/no-restricted-globalsguard and dynamically bypnpm test:workers(the whole read/write pipeline exercised inside a real Cloudflare Workers isolate via@cloudflare/vitest-pool-workers).writeEpubContent's generated identifier usescrypto.randomUUID()(the Web Crypto API, globally available in Node 20+/Workers/browsers), nevernode:crypto. - A three-tier read/write failure policy, matching
markdown-codec's/pdf-codec's own: throw a typedEpubParseError/EpubWriteErrorsubclass for input this package cannot meaningfully process at all (an invalid mimetype entry, a missing/unparsablecontainer.xml/OPF, an empty spine, unbalanced construct markers, a non-'wordprocessing'document); report anEpubDiagnosticthrough the caller's own sink for a recoverable producer mistake or an individual construct this package's own mapping cannot represent, while the rest of the document still reads.src/diagnostics-coverage.test.tsasserts everyEpubDiagnosticCodesentry is reachable from real input — a code that exists only in a comment is dead documentation, worse than none. z.codec()for the round trip (epubCodec,epubContentCodec), matchingmarkdown-codec's pair exactly: each wraps the independently-tested read/write pair at its own level with automatic two-way schema validation (no-options form only).- No type assertions anywhere. Every loosely-typed value is narrowed through a type guard or Zod parse at the boundary.
- Conventional commits, enforced via commitlint + husky.
Gotchas and quirks
Every construct this package's XHTML mapping cannot represent losslessly is a documented EpubDiagnosticCodes entry — see src/diagnostics.ts for the full table, and each module's own top-of-file comment for which side (read/write) it belongs to:
epub/element-unmapped—<sub>/<sup>degrade to plain text:document-schema.js'sContentRuncarries no subscript/superscript field at all, a genuine family-wide schema gap (no sibling codec has ever needed one;ooxml.js's own docx reader has now:vertAlignhandling either), not something specific to this package.- Internal-link semantics (ExaDev/documents.js#963) — a same-document fragment href, or a cross-document href naming another spine document's own id, that resolves to a real, block-level element anywhere in the spine builds
document-schema.js's own internal-targetlinkconstruct (a run-level extent,target: { kind: "internal", anchor }) rather than ridingContentRun.hyperlinkas a plain string; the target element itself is wrapped in its ownanchorconstruct (anchorType: "bookmark") so the two ends of the link are both addressable. A cross-document target's own name is qualified with its owning document's own path ("OEBPS/chapter2.xhtml#sec2", never a bare"sec2") precisely because two different target documents can otherwise legitimately reuse the identical id; a same-document target keeps the bare fragment.src/write.ts's own whole-document anchor-section index resolves a reference back to either a same-file"#name"href or a cross-file"sectionN.xhtml#name"one on write, so this round-trips through a same-format write.epub/link-target-external-onlyremains the diagnostic for what's left: an href genuinely external (a URI scheme), or an internal-looking href this package cannot resolve to a real element at all (a nonexistent fragment, or one with no fragment naming only a whole document) — that still ridesContentRun.hyperlinkverbatim, restoring byte-for-byte either way. - Footnotes recognise a cross-document body (ExaDev/documents.js#963). Both the EPUB 3 structured idiom (
epub:type="noteref"/"footnote") and the EPUB 2 linked-anchor idiom (aclass="footnote"/"noteref"convention with noepub:typevocabulary at all, recognised bysrc/xhtml/footnote.ts'sisFootnoteReference) map onto the identicalanchorconstruct — a run-level point extent at the reference site, aconstructStart/blocks/constructEndtriple around the body. A reference and its own body need not live in the same spine document:document-schema.js's own construct-marker contract still forbids a single bracket pair from straddling a block-list boundary (eachContentSectionis its own block list), but this was never that — the reference's own point extent and the body's ownconstructStart/constructEndpair are two independently well-formed markers in two different sections, connected only by sharing onename.src/read.ts's own whole-spine pass resolves every href (same- and cross-document) before any section is read, so a cross-document footnote (a separate "notes.xhtml", the more common real-world EPUB 2 shape) is recognised and linked exactly like a same-document one; a cross-document name is qualified with its owning document's own path ("OEBPS/notes.xhtml#note1") to keep it distinct from a same-named footnote in a different document, andsrc/write.ts's own whole-document anchor-section index resolves the reference back to a working same-file or cross-file href on every write. epub/style-residue— a document's own<head>style declarations (<link rel="stylesheet">,<style>) are quarantined verbatim asSourceResidueon the owningContentSection, never interpreted: CSS is residue, not content.writeEpubContentre-emits it into the written<head>on a same-format write (this family's standard restorable-fidelity re-emission contract).- An empty or whitespace-only paragraph (
<p></p>,<p> </p>, or bare whitespace text between two block-level siblings — the common case in any pretty-printed real EPUB) is dropped entirely on read, matching the same "anonymous block box" rule a browser's own HTML block-formatting context already applies to inter-block whitespace, rather than becoming a bogus emptyContentParagraph. - List marker type (bullet vs. ordered, and an
<ol>'s own non-defaultstart) is packed into the opaquenumId(epub{N}:{bullet|ordered}[@{start}],src/xhtml/list-id.ts), sinceContentListMembershipcarries no field of its own for it — the identical mechanismmarkdown-codec's ownsrc/shared/list-id.tsuses for its GFM bullet/ordered distinction, hand-mirrored rather than shared. A numId outside this grammar (odf.js's bare"list1", markdown-codec's own"md1:bullet") is read back as an ordinary bullet list with no declared start. - A container's own direct-child
<img>(some producers/editors wrap every floating image in a paragraph tag rather than a<figure>) is split at the image byreadContainerChildren: the phrasing content before and after becomes its own paragraph (dropped entirely when empty), the image its own block, in source order. That split fires for every container this package reads transparently throughreadContainerChildren—<body>itself,<p>,<li>,<blockquote>,<figure>,<div>,<section>,<article>,<aside>, and<nav>— never only a named handful of them; an<img>reached anywhere else — nested inside a<span>/<a>at any depth, or a direct child of a heading (the one container this package still reads via a singlebuildInlineRunscall rather thanreadContainerChildren) — is instead reached bysrc/xhtml/inline.ts's own run-building recursion, which by that point has committed to producing a flat run sequence with no block list left to insert a sibling image block into, and degrades to its alt text (or nothing, when it carries none) with anepub/image-inline-unsupporteddiagnostic rather than silently vanishing. A<caption>,<dt>/<dd>,<figcaption>, and a table cell all route throughreadContainerChildrentoo (ExaDev/documents.js#1023 — see the dedicated bullet below), so a direct-child<img>in any of them now becomes a realContentImageBlockthe same way, rather than degrading. ExaDev/documents.js#994 closed the four remaining silent gaps this guarantee did not originally cover: a non-empty<caption>, a legal direct child of<table>, is now read as one or more ordinary blocks immediately before the table (epub/table-caption-unsupported, fired once per<caption>element regardless of how many blocks it decomposes into, sinceContentTablehas no field of its own for a caption's distinct tag; a run-level construct the caption's own inline content carries — a footnote reference, most commonly — rides its own paragraph'sconstructsfield exactly like any other paragraph's), while an empty or whitespace-only<caption>is dropped entirely instead, with no diagnostic, matching this package's own empty-paragraph-drop rule documented above; a<dl>wrapping one or moredt/ddpairs in a<div>(legal HTML5, used for a per-entry styling hook) is now recognised by recursing into the<div>, with no diagnostic at all, since the wrapper carries no properties of its own to lose (identical to every other<div>this package already reads transparently); and any content — a nested<ul>/<ol>, a bare<img>, stray text — sitting directly inside a<ul>/<ol>rather than inside an<li>(not valid HTML5, but a shape real-world converters do emit) is now recovered viaepub/list-content-outside-item, through the samereadContainerChildrendispatch an<li>'s real children already use: content sitting between or after real<li>siblings attaches to the preceding item's own nesting — a stray list shares its numId and increments its level, exactly as if it had been nested correctly — while content sitting before the very first<li>has no preceding item to attach to and is instead recovered inheriting whatever list membership its own enclosing context already carries (none, unless the<ul>/<ol>it sits directly inside is itself nested inside another list's<li>), landing in the read result immediately before the list's own real items — matching a browser's rendering order for this shape, though not necessarily its nesting depth when the enclosing list is itself nested. One part of the original finding remains a genuine, permanent structural limit rather than a recovered gap: an<img>inside a<pre>/<code>block still cannot become a realContentImageBlock— this package always reads a<pre>as a single text-content paragraph, so there is no block list to insert an image block into, the same constraintepub/image-inline-unsupportedalready names elsewhere (the HTML Standard's own content model for<pre>is phrasing content, not plain text, which is exactly why<a>/<code>/<span>/<img>/<br>are legal, real markup inside one and this package'sreadPreRuns/readPreTexthave to handle each explicitly) — but it no longer vanishes silently either; its alt text (or nothing, when it carries none) is spliced into the extracted text in its place, with anepub/image-pre-unsupporteddiagnostic firing unconditionally — every image inside a<pre>, not only one with no alt text. - A run-level construct extent (most commonly a footnote reference) carried by inline content built directly into a paragraph is preserved on that paragraph's own
constructsfield wherever such a paragraph is built — a heading andreadContainerChildren's own segment flush both shareconstructsField, the one helpersrc/xhtml/read.tsfunnels every such paragraph through, so a fix applied to one cannot silently miss the other. A table caption, a table cell, a<dt>/<dd>, and a<figcaption>get this for free by routing throughreadContainerChildren(ExaDev/documents.js#1023) rather than needing their own direct call to the helper. A<pre>/<code>block's own paragraph shares it too, and round-trips it in both directions, despite never routing throughbuildInlineRuns(a<pre>'s content model needs its text preserved verbatim, whichbuildInlineRuns's own whitespace normalisation would break): on read,readPreRuns, a dedicated run-splitting walk reached only once a cheap subtree pre-check confirms the<pre>actually carries a recognised footnote reference somewhere inside it, brackets that reference's own text as its own run range exactly likesrc/xhtml/inline.ts'sappendAnchordoes, rebasing any nested call's own construct indices onto the outer run sequence at the point of the merge; the common case (no footnote reference anywhere in the block) still takes the cheap, single-runreadPreTextpath unchanged. Because a construct nested inside a<pre>can split its content into more than one run with no bearing on whether the block is itself preformatted,readPrealso stamps every paragraph it produces withdocument-schema.js's ownContentParagraph.preformattedflag, unconditionally and regardless of run count — the one signal the writer can trust, since inferring "this was a<pre>" from run shape (a lone monospace run, say) silently misclassifies a multi-run one as an ordinary paragraph the moment it carries a construct. On write,isPreBlockParagraphcheckspreformattedfirst (falling back tocodeLanguageor the legacy single-monospace-run-with-a-newline heuristic for a foreign producer's document that sets neither), and a recognised<pre>paragraph's own runs and constructs are written throughwritePreRunsToNodes— the<pre>twin of the ordinary paragraph writer, sharing its extent-finding walk but writing a run's embedded newline as a literal"\n"character rather than splitting it into a<br/>the way the ordinary path does;readPreRuns/readPreTextmap a<br>read back from a<pre>straight to the same literal"\n", so either spelling round-trips to identical text. A stray element sitting where only a narrower set of tags is expected is likewise recovered rather than dropped, following the samereadContainerChildren-plus-diagnostic patternepub/list-content-outside-itemalready established: a<dl>(or one of its<div>wrappers) carrying anything other thandt/dd/<div>— a stray<p>, stray text, a stray<img>, or a non-conformant wrapper like<section>used in<div>'s own place — is recovered viaepub/definition-list-content-outside-entry(a non-conformant wrapper's owndt/ddchildren lose their distinct term/definition treatment once routed this way, degrading to plain concatenated text — a real fidelity cost, but a text-preserving one). The same treatment applies to a<table>: content sitting outside any row, caption, or<colgroup>— whether directly inside the<table>itself, inside one of its<thead>/<tbody>/<tfoot>row groups (which admit onlytrand script-supporting children per the HTML Standard), or inside a<colgroup>itself (which admits only<col>and<template>children per the HTML Standard — narrower than the script-supporting category, since a<colgroup>does not admit a bare<script>) — is recovered immediately before the table viaepub/table-content-unrecognized; a<tr>carrying content outside any<td>/<th>is recovered as its own cell in the row's own column sequence viaepub/table-row-content-outside-cell; and a<table>carrying more than one<caption>(HTML5 permits at most one) has every caption beyond the first read as its own paragraph too, viaepub/table-duplicate-caption, rather thanfindChildElement's own first-match-only resolution silently discarding it as it previously did.<script>,<template>,<style>, and<noscript>appearing anywhere in<body>content are all now skipped by one shared guard (isInertElement,src/xhtml/context.ts) rather than leaking their own raw content as document prose — none of the four were inert outside<head>-level residue quarantine before this fix.<script>'s raw JS and<template>'s inert DOM subtree are never real content regardless of where they are found, and a body-level<style>is CSS, exactly like the<head>-level style residue this package already quarantines rather than interprets — none of the three fire a diagnostic, since none of them ever carry anything document-schema.js's vocabulary could represent.<noscript>is treated the same conservative way for a different reason: its own children ARE ordinary markup a scripting-disabled reading system would genuinely render, but this package cannot tell that case apart from a producer's own "please enable JavaScript" placeholder from the markup alone — so, unlike the other three, dropping a<noscript>'s subtree fires its ownepub/noscript-content-skippeddiagnostic (reportInertElementSkip,src/xhtml/context.ts) at every site that discards one, naming the potential loss rather than staying silent about it. - A
<caption>,<dt>,<dd>,<figcaption>,<td>, or<th>now recognises real block-level content rather than flattening it (ExaDev/documents.js#1023) — all six are Flow content per the HTML Standard, so a<pre>, a nested list, or more than one paragraph inside one is real, conformant markup. These six used to build their content with one barebuildInlineRunscall each; a block element reached that way has no case inbuildInlineRuns's own dispatch, so it fell into the same treatment as a<span>, recursing into its children as ordinary phrasing content — losing a<pre>'s own block shape,preformattedflag,codeLanguage, and verbatim whitespace, and, separately, concatenating more than one paragraph-shaped child into one undelimited run with no break (or even a word boundary) between them:<td><p>Alpha</p><p>Beta</p></td>used to read as the single run"AlphaBeta". All six now route throughreadContainerChildreninstead, the same general block-container reader<p>/<li>/<blockquote>/<figure>/<div>/<section>/<article>/<aside>/<nav>already use, so a<pre>stays preformatted, a nested list stays real list structure, and sibling paragraphs stay distinct.<dd>'s ownDEFINITION_BODY_INDENT_PToffset is threaded through aBuildState.extraIndentPtfield (additive with blockquote nesting's ownquoteDepth, mirrored via the identicalwithQuote/withExtraIndentpattern) so every paragraph the descent produces picks up the indent, not only a single top-level one; a<th>'s own implied bold is threaded through asreadContainerChildren's ownbaseStyleparameter, reaching every direct phrasing segment but not content nested inside a further block-level child (a<table>-in-a-<th>, say) — a narrow, documented degrade for a genuinely rare shape, not a silent one. - A CDATA section (
<![CDATA[...]]>) is read exactly like an ordinary text node everywhere in the XHTML reading path —xml/node.ts'sisTextLikeNodeis the one shared predicate every text-bearing walk in this package now dispatches on (buildInlineRuns,readContainerChildren's own phrasing/block split,readPre's three text-extraction paths, every stray-content collector inread.ts, andxml/query.ts's owndecodedTextContent), rather than each walk separately checking for"text"and quietly excluding"cdata". A CDATA section is simply the alternate XML spelling a producer reaches for when its own literal text would otherwise need escaping (a code sample, or any other content containing a raw</&) — exactly the kind of content a DocBook-to-EPUB pipeline or similar tool emits inside a<pre>— never a distinct kind of content, so treating the two differently was silently dropping real, well-formed input rather than a corpus-tolerance gap. The one place CDATA is genuinely not interchangeable with a text node is entity decoding:xml/entities.ts'sdecodeTextLikeNodedecodes a text node's raw value exactly as before but returns a CDATA node's value untouched, since CDATA content is never subject to XML entity resolution in the first place — running it back throughdecodeEntitieswould corrupt exactly the unescaped content CDATA exists to carry.xml/query.ts's own plaintextContentis deliberately narrower and CDATA-blind: it is this package's long-standing published export, and a caller's owndecodeEntities(textContent(x))idiom stays correct only because that walk never mixes in undecoded-by-design CDATA content for the wrap to misapply entity resolution to — the CDATA-aware, decode-internally behaviour lands as the distinctly nameddecodedTextContentinstead, used byopf/metadata.ts's own four Dublin Core readers, so upgrading to CDATA support never silently double-decodes an existing caller's text nodes. - A blockquote containing a heading cannot carry its
divisionconstruct — a marker extent may not open or close a heading scope, and the last heading inside an extent always leaves one standing (document-schema.js's own constraint) — so it degrades to indent-only structure (stillindentLeftPt/styleId: "Quote") while the heading keeps its own heading fidelity. - Image dimensions are derived from pixel size via the CSS reference-pixel ratio (1px = 1/96in), not read from any
<img width>/<img height>attribute or CSS: an EPUB's own XHTML/CSS carries no reliable point-based sizing of its own, so the image's natural pixel size is the one dimension every manifest image reliably has. - A GIF or SVG manifest image is not yet decoded —
document-schema.js'sContentImageBlockSchemawidened itsformatfield to admit"svg"/"gif"alongside"png"/"jpeg"specifically for this package's own manifest image kinds, but this package's reader does not yet decode either one; it degrades to alt text with anepub/image-format-unsupporteddiagnostic until that decode work lands. - A table cell's own construct-boundary marker (if a foreign producer's
ContentDocumentcarries one) has no XHTML representation on write and is dropped with a diagnostic —document-schema.js's owndecomposenever descends into a table cell, so a cell's blocks are never grouped the way a section's are, and this package's own reader never emits one there either. - A run-level anchor extent whose
anchorTypeisbookmark,endnote, orcomment(onlyfootnoteis recognised) has no representable EPUB spelling this package's own reader understands yet and is reported throughepub/construct-unrepresentedon write rather than silently dropped — genuinely reachable via a cross-format bridge, sinceooxml.js's own docx reader emits exactly this shape for a bookmark orw:commentRangeStart/Endpair whose two halves sit inside one paragraph. The run text the extent wraps is unaffected either way; only its own marker (and, for a comment, the definitions-table link a same-format write would need) goes unwritten. dc:publisher/dc:contributor/dc:rightshave noLayoutMetadatafield to land in and are reported asepub/metadata-field-unmappedrather than silently dropped.
Fidelity
Semantic fidelity — headings, paragraphs, lists (nested, bullet/ordered), definition lists, tables, images, hyperlinks (external and internal, same- or cross-document — ExaDev/documents.js#963), text styling (bold/italic/underline/strike/monospace), blockquotes, pre/code blocks, horizontal rules, figure/figcaption, and footnotes (both EPUB 2 and EPUB 3 idioms, same- or cross-document — ExaDev/documents.js#963) all survive as first-class document-schema.js nodes or constructs — see ExaDev/documents.js#801 for the full acceptance list this package was built against, and src/roundtrip.test.ts for the real end-to-end proof (a hand-built ContentDocument covering nearly all of it, written to a genuine EPUB 3 zip and read back unchanged).
Restorable fidelity — a same-format (EPUB-to-EPUB) round trip re-emits quarantined CSS residue verbatim, and re-resolves an internal link/bookmark or footnote reference/body pair to a working same-file or cross-file href on every write (ExaDev/documents.js#963); the one remaining documented gap above (sub/sup styling) is a permanent, structural limit rather than a restorable one, since the source construct itself has nowhere in the schema to ride.
Byte fidelity, at the Package level (ExaDev/documents.js#963). This package now has the same lossless byte-level Package model ooxml.js/odf.js do — a decodePackage/encodePackage pair, mirroring theirs exactly, that reads every OCF zip entry into a generic Part (an XML entry as an ordered XmlNode[] forest, anything else as raw base64 bytes) and writes it straight back, with no EPUB-specific interpretation in between. decodePackage(bytes) |> encodePackage is a genuine fixed point: src/package-round-trip.test.ts pins decode -> encode -> decode idempotence, per-part content preservation (an XML part's own entities/structure, a binary part's own bytes verbatim), and the one deliberate departure from a generic zip-of-XML writer this format shares with ODF's identical requirement — the mimetype part hoisted first and stored uncompressed (EPUB 3.3 §6.3), regardless of the input zip's own entry order, and never fabricated if the input never carried one. readEpub(Content)/writeEpubContent cross this identical boundary internally (decodePackage first, encodePackage last) rather than talking to the zip layer directly, so the two are the same lossless core, not two independent implementations that happen to agree.
Not byte fidelity at the ContentDocument level. The ContentDocument/DocumentTree mapping stays a lossy projection on top of that lossless core — the same "lossless core vs. lossy views" boundary ooxml.js's own README states for its identical decodePackage/readDocx split: writeEpubContent always builds a fresh EpubPackage from the content it's given rather than touching one readEpubContent might have decoded, so a read-then-write round trip through the content level never reproduces the original bytes (a fresh dc:identifier is minted on every write, per this package's own explicit write scope, among other differences a lossy projection can't avoid). What is deterministic at this level: two writes of the same ContentDocument produce byte-identical zip layout (mimetype-first, stored, ordered entries, fixed mtimes) even though the OPF entry's own compressed bytes differ (the fresh identifier). src/roundtrip.test.ts pins the layout invariant directly rather than claiming full byte determinism at this level.
Build, test, and lint
pnpm build # turbo run _build -> tsdown (dist/: ESM + CJS + .d.ts, one file set per src module)
pnpm typecheck # turbo run _typecheck _typecheck:attw -> tsc -p tsconfig.json && tsc -p tsconfig.node.json, plus attw --pack
pnpm lint # turbo run _lint -> eslint . --fix --cache --max-warnings 0
pnpm test # turbo run _test -> vitest run --project unit
pnpm test:watch # vitest --project unit
pnpm test:workers # turbo run _test:workers -> vitest run --config vitest.workers.config.ts, inside a real Cloudflare Workers (workerd) isolate
pnpm test:smoke # turbo run _test:smoke -> rebuilds dist/, then verifies the built ESM/CJS output loads and exposes the public surfaceTo run a single test file: pnpm vitest run src/path/to/file.test.ts.
Release and publishing
Release, CI, and commit-message conventions are all workspace-wide, not package-local — see the monorepo root README for the mechanism (topological per-package semantic-release via @exadev/semantic-release-workspace, OIDC trusted npm publishing, automatic sibling dependency-range rewriting) and its post-release republishing and attestation note on the restored GitHub Packages mirrors, npm aliases, and SBOM/provenance signing. Publishing this package for the first time needs the one-time npm trusted-publisher registration the root README's own Releases section describes — organization ExaDev, repository documents.js, workflow ci.yml.
Contributing
Conventional Commits, enforced workspace-wide by commitlint through a root commit-msg hook. Work inside packages/epub-codec/; see CONTRIBUTING.md for the shared git hooks and history conventions.
References
- document-schema.js — the canonical
ContentDocument/DocumentTreeschema and thedecompose/flattenTree/assembleTreetransform this package's writer is built on. - markdown-codec — the closest architectural relative: hand-written, AST-to-
ContentDocumentlowering, the identical dual-level (DocumentTree/ContentDocument) API, the identical three-tier diagnostic policy, and the identical list-numId-packing mechanism for the identical schema gap. - ooxml.js / odf.js — the OCF/ZIP, lossless-XML-layer, and byte-level
Packagemodel (decodePackage/encodePackage, ExaDev/documents.js#963) conventions this package's ownsrc/zip.ts/src/xml//src/model/package.ts/src/package-io/mirror;odf.js's in particular, since ODF shares EPUB's identical OCF mimetype-first-stored requirement. - archive-codec — the ZIP-in-ZIP/CFB utility package this package deliberately does not depend on; see Dependency choices.
- byte-codec — the byte/image utility package this package deliberately does not depend on for image dimensions; see Dependency choices.
- EPUB 3.3 — the current W3C Recommendation this package's own EPUB 3 reading and writing targets.
- OCF 1.0 / OPF 2.0.1 / OPS 2.0.1 — the legacy IDPF specifications this package's EPUB 2 reading targets (container.xml, the OPF package document, and the NCX navigation format are all unchanged in substance between the two generations).
License
MIT
