npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

epub-codec

v1.3.5

Published

EPUB 2/3 reading and deterministic EPUB 3 writing against the shared document-schema.js content pivot.

Readme

epub-codec

GitHub npm npm version CI

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:3px

epub-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 install

Install as a dependency in another project:

pnpm add epub-codec
# or
npm install epub-codec

Usage

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 3

The 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 content

Unlike 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-entries zipPackage/unzipPackage over fflate, hand-duplicated from ooxml.js's and odf.js's own identical wrappers rather than depending on archive-codec — see Dependency choices.
  • src/xml/ — the lossless XML layer every other module in this package builds on: parse.ts/build.ts wrap fast-xml-parser with the identical preserveOrder configuration ooxml.js's and odf.js's own XML modules use (order and mixed content survive; entity encoding stays raw until entities.ts decodes it in the one place text actually becomes content), query.ts the same handful of tree-walking helpers (rootElement, findChildElement, childrenWithTag, elementsWithTag, attrValue, plus findElement and textContent, needed here for footnote-target and nav-toc resolution respectively), node.ts the ordered XmlNode forest itself — also the shape src/model/package.ts's own XmlPart carries.
  • src/model/package.ts/src/package-io/ — the lossless byte-level Package model (ExaDev/documents.js#963), mirroring ooxml.js's and odf.js's own model/package.ts/package-io/ exactly: every zip entry as a Part (an XmlNode[] forest, or raw base64 bytes), read.ts's parsePackage/packageFromEntries classifying entries into parts, write.ts's serializePackage/packageToEntries the structural inverse (including the OCF mimetype-first-stored hoist). src/codec.ts wraps the pair as decodePackage/encodePackage; src/read.ts/src/write.ts cross this same boundary internally rather than talking to src/zip.ts directly.
  • src/ocf/container.ts resolves the OPF rootfile from META-INF/container.xml (EPUB 3.3 §6.7.2); write.ts is its structural inverse.
  • src/opf/parse.ts/write.ts read and write the OPF package document (§5.4): Dublin Core metadata (metadata.ts), the manifest, and the spine.
  • src/nav/nav3.ts/ncx.ts reduce the EPUB 3 <nav epub:type="toc"> document and the EPUB 2 NCX to a flat, fragment-stripped href sequence each; reconcile.ts compares that sequence against the spine's own reading order (the issue's own explicit "the spine wins" decision); write.ts builds 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> to ContentBlock[]), write.ts (writeXhtmlBody, its structural inverse, built on document-schema.js's own decomposeSection rather than re-deriving heading/list/construct nesting by hand), inline.ts (run-level formatting, footnote reference extents, and internal-link extents), footnote.ts (EPUB 3 epub:type="noteref"/"footnote" and the EPUB 2 linked-anchor idiom, both mapped onto the identical anchor construct), link-target.ts (resolveHrefTarget, the same-/cross-document href-to-element resolver src/read.ts's own whole-spine internal-link registry and inline.ts's reference-side lookup both build on — ExaDev/documents.js#963), list-id.ts (list marker type packed into the opaque numId, mirroring markdown-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 from byte-codec — see Dependency choices.
  • src/util/base64.ts — isomorphic base64 codec, a third hand-written copy of the identical helper odf.js's and pdf-codec's own src/util/base64.ts already 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 into readEpub(Content)/writeEpub(Content), decodePackage/encodePackage first/last.
  • src/codec.tsepubCodec/epubContentCodec (the ContentDocument/DocumentTree round trip) and packageCodec/decodePackage/encodePackage (the lossless Package round trip), each a z.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-codec for the ZIP layer. archive-codec's own zip/container.ts is structurally identical to what this package needs (zipPackage/unzipPackage, ordered entries, a stored flag) — but it does not pin a fixed entry mtime, unlike ooxml.js's and odf.js's own zip.ts, both of which do exactly to keep output byte-deterministic across two builds of identical content. Nothing in this family writes through archive-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 reasoning odf.js's own README states for not depending on ooxml.js — so this package's own src/zip.ts is a third hand-written copy of the identical ~30-line wrapper, not a fourth pattern. archive-codec remains 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-codec for image dimensions. byte-codec's image/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: ContentImageBlock carries 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 on byte-codec for 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 — so src/image/dimensions.ts mirrors markdown-codec's own identical src/image/image.ts instead: 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 by eslint.config.ts's own no-restricted-imports patterns.
  • Worker-isomorphic: runtime src/ must not import node:*, a bare Node builtin, or use the Buffer global — enforced statically by the shared no-restricted-imports/no-restricted-globals guard and dynamically by pnpm test:workers (the whole read/write pipeline exercised inside a real Cloudflare Workers isolate via @cloudflare/vitest-pool-workers). writeEpubContent's generated identifier uses crypto.randomUUID() (the Web Crypto API, globally available in Node 20+/Workers/browsers), never node:crypto.
  • A three-tier read/write failure policy, matching markdown-codec's/pdf-codec's own: throw a typed EpubParseError/EpubWriteError subclass for input this package cannot meaningfully process at all (an invalid mimetype entry, a missing/unparsable container.xml/OPF, an empty spine, unbalanced construct markers, a non-'wordprocessing' document); report an EpubDiagnostic through 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.ts asserts every EpubDiagnosticCodes entry 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), matching markdown-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's ContentRun carries 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 no w:vertAlign handling 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-target link construct (a run-level extent, target: { kind: "internal", anchor }) rather than riding ContentRun.hyperlink as a plain string; the target element itself is wrapped in its own anchor construct (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-only remains 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 rides ContentRun.hyperlink verbatim, 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 (a class="footnote"/"noteref" convention with no epub:type vocabulary at all, recognised by src/xhtml/footnote.ts's isFootnoteReference) map onto the identical anchor construct — a run-level point extent at the reference site, a constructStart/blocks/constructEnd triple 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 (each ContentSection is its own block list), but this was never that — the reference's own point extent and the body's own constructStart/constructEnd pair are two independently well-formed markers in two different sections, connected only by sharing one name. 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, and src/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 as SourceResidue on the owning ContentSection, never interpreted: CSS is residue, not content. writeEpubContent re-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 empty ContentParagraph.
  • List marker type (bullet vs. ordered, and an <ol>'s own non-default start) is packed into the opaque numId (epub{N}:{bullet|ordered}[@{start}], src/xhtml/list-id.ts), since ContentListMembership carries no field of its own for it — the identical mechanism markdown-codec's own src/shared/list-id.ts uses 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 by readContainerChildren: 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 through readContainerChildren<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 single buildInlineRuns call rather than readContainerChildren) — is instead reached by src/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 an epub/image-inline-unsupported diagnostic rather than silently vanishing. A <caption>, <dt>/<dd>, <figcaption>, and a table cell all route through readContainerChildren too (ExaDev/documents.js#1023 — see the dedicated bullet below), so a direct-child <img> in any of them now becomes a real ContentImageBlock the 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, since ContentTable has 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's constructs field 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 more dt/dd pairs 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 via epub/list-content-outside-item, through the same readContainerChildren dispatch 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 real ContentImageBlock — 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 constraint epub/image-inline-unsupported already 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's readPreRuns/readPreText have 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 an epub/image-pre-unsupported diagnostic 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 constructs field wherever such a paragraph is built — a heading and readContainerChildren's own segment flush both share constructsField, the one helper src/xhtml/read.ts funnels 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 through readContainerChildren (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 through buildInlineRuns (a <pre>'s content model needs its text preserved verbatim, which buildInlineRuns'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 like src/xhtml/inline.ts's appendAnchor does, 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-run readPreText path 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, readPre also stamps every paragraph it produces with document-schema.js's own ContentParagraph.preformatted flag, 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, isPreBlockParagraph checks preformatted first (falling back to codeLanguage or 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 through writePreRunsToNodes — 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/readPreText map 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 same readContainerChildren-plus-diagnostic pattern epub/list-content-outside-item already established: a <dl> (or one of its <div> wrappers) carrying anything other than dt/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 via epub/definition-list-content-outside-entry (a non-conformant wrapper's own dt/dd children 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 only tr and 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 via epub/table-content-unrecognized; a <tr> carrying content outside any <td>/<th> is recovered as its own cell in the row's own column sequence via epub/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, via epub/table-duplicate-caption, rather than findChildElement'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 own epub/noscript-content-skipped diagnostic (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 bare buildInlineRuns call each; a block element reached that way has no case in buildInlineRuns'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, preformatted flag, 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 through readContainerChildren instead, 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 own DEFINITION_BODY_INDENT_PT offset is threaded through a BuildState.extraIndentPt field (additive with blockquote nesting's own quoteDepth, mirrored via the identical withQuote/withExtraIndent pattern) 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 as readContainerChildren's own baseStyle parameter, 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 pathxml/node.ts's isTextLikeNode is 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 in read.ts, and xml/query.ts's own decodedTextContent), 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's decodeTextLikeNode decodes 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 through decodeEntities would corrupt exactly the unescaped content CDATA exists to carry. xml/query.ts's own plain textContent is deliberately narrower and CDATA-blind: it is this package's long-standing published export, and a caller's own decodeEntities(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 named decodedTextContent instead, used by opf/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 division construct — 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 (still indentLeftPt/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 decodeddocument-schema.js's ContentImageBlockSchema widened its format field 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 an epub/image-format-unsupported diagnostic until that decode work lands.
  • A table cell's own construct-boundary marker (if a foreign producer's ContentDocument carries one) has no XHTML representation on write and is dropped with a diagnostic — document-schema.js's own decompose never 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 anchorType is bookmark, endnote, or comment (only footnote is recognised) has no representable EPUB spelling this package's own reader understands yet and is reported through epub/construct-unrepresented on write rather than silently dropped — genuinely reachable via a cross-format bridge, since ooxml.js's own docx reader emits exactly this shape for a bookmark or w:commentRangeStart/End pair 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:rights have no LayoutMetadata field to land in and are reported as epub/metadata-field-unmapped rather 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 surface

To 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/DocumentTree schema and the decompose/flattenTree/assembleTree transform this package's writer is built on.
  • markdown-codec — the closest architectural relative: hand-written, AST-to-ContentDocument lowering, 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 Package model (decodePackage/encodePackage, ExaDev/documents.js#963) conventions this package's own src/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