Type-safe, lossless round-trip conversion between OOXML packages (
.docx,.pptx,.xlsx) and a faithful JSON model, built on Zod 4 codecs.
An OOXML file is a ZIP of parts (an OPC "package"): [Content_Types].xml, relationships, XML content, and binary parts. ooxml.js decodes the whole package to faithful JSON and encodes it back part-for-part.
graph TD
schema("document-schema.js")
ooxml("ooxml.js")
odf("odf.js")
pdfcodec("pdf-codec")
mdcodec("markdown-codec")
bytecodec("byte-codec")
documents("documents.js")
mcp("document-mcp")
cli("document-cli")
schema --> ooxml
schema --> odf
schema --> pdfcodec
schema --> mdcodec
schema --> documents
ooxml --> documents
odf --> documents
pdfcodec --> documents
mdcodec --> documents
bytecodec --> pdfcodec
bytecodec --> documents
documents --> mcp
pdfcodec --> mcp
documents --> cli
odf --> cli
pdfcodec --> cli
click schema "https://github.com/ExaDev/document-schema.js" "document-schema.js"
click ooxml "https://github.com/ExaDev/ooxml.js" "ooxml.js"
click odf "https://github.com/ExaDev/odf.js" "odf.js"
click pdfcodec "https://github.com/ExaDev/pdf-codec" "pdf-codec"
click mdcodec "https://github.com/ExaDev/markdown-codec" "markdown-codec"
click bytecodec "https://github.com/ExaDev/byte-codec" "byte-codec"
click documents "https://github.com/ExaDev/documents.js" "documents.js"
click mcp "https://github.com/ExaDev/document-mcp" "document-mcp"
click cli "https://github.com/ExaDev/document-cli" "document-cli"
style ooxml fill:#f9a825,stroke:#333,stroke-width:3px
Semantic typed models are lossy and one-directional: they cannot round-trip. True round-trip needs every part, relationship, and binary byte-for-byte at the content level. ooxml.js provides that lossless foundation, with typed reading views (Document/Presentation/Workbook) on top.
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 ooxml.js
# or
npm install ooxml.jsimport { decodePackage, encodePackage } from 'ooxml.js';
// .docx / .pptx / .xlsx bytes -> faithful JSON Package
const pkg = decodePackage(new Uint8Array(await file.arrayBuffer()));
// ...inspect or modify pkg.parts...
// Package -> bytes (content-identical to the original)
const bytes = encodePackage(pkg);The core is a Zod 4 codec, so both directions are schema-validated:
import { z } from 'zod';
import { packageCodec } from 'ooxml.js';
const pkg = z.decode(packageCodec, bytes);
const out = z.encode(packageCodec, pkg);Typed views project Package into ergonomic models (lossy; read-only). readDocx/readPptx resolve the full style/theme cascade, so order, styling, and geometry come through, not just flattened text:
import { decodePackage, readDocx } from 'ooxml.js';
const doc = readDocx(decodePackage(bytes));
// doc.sections[0].blocks holds paragraphs/tables/page-breaks in document order (including
// inside tables); each run already carries its cascade-resolved bold/italic/colour/font.readXlsx is the lossy, cell-values-only view. readXlsxContent/buildXlsxPackage are a separate ContentDocument-shaped pair — a richer reader (column widths, row heights, hidden rows/columns, merged ranges, every cell value kind, print settings) matched with this package's first writer, round-tripping a spreadsheet through the same ContentDocument shape documents.js/odf.js use:
import { buildXlsxPackage, decodePackage, readXlsxContent } from 'ooxml.js';
const content = readXlsxContent(decodePackage(bytes)); // ContentDocument, kind: 'spreadsheet'
const pkg = buildXlsxPackage(content); // a fresh Package built from scratch, not a write-back into `pkg`Every module under src/ is importable directly, by the same path it has relative to src/, without going through the barrel:
import { bytesToBase64, base64ToBytes } from 'ooxml.js/util/base64';
import { readXlsxContent } from 'ooxml.js/typed/xlsx/content';The ooxml.js format is a compact, still-plain-JSON alternative to the verbose Package (which repeats type/tag/attributes/children keys per node, with tag/namespace strings recurring thousands of times) — tuple-encoded nodes plus one interned string table, composing on packageCodec:
OOXML bytes --[packageCodec]--> Package --[compactCodec]--> CompactPackage (the ooxml.js format)
import { decodePackage, toCompact, fromCompact } from 'ooxml.js';
const pkg = decodePackage(bytes);
const compact = toCompact(pkg); // { s: string[], p: Record<path, CompactPart> }
const roundTripped = fromCompact(compact); // deep-equals pkgA word/document.xml part holding a single run of text:
<w:p><w:r><w:t>Hi</w:t></w:r></w:p>decodes to this Package (one entry in parts, each element an XmlNode):
{
"parts": {
"word/document.xml": {
"kind": "xml",
"nodes": [
{
"type": "element",
"tag": "w:p",
"attributes": [],
"children": [
{
"type": "element",
"tag": "w:r",
"attributes": [],
"children": [
{
"type": "element",
"tag": "w:t",
"attributes": [],
"children": [{ "type": "text", "value": "Hi" }]
}
]
}
]
}
]
}
}
}toCompact interns every tag and text value once (first-occurrence order) and replaces each node with a tuple (type code 0=element/1=text, then string-table indices):
{
"s": ["w:p", "w:r", "w:t", "Hi"],
"p": {
"word/document.xml": [[0, 0, [], [[0, 1, [], [[0, 2, [], [[1, 3]]]]]]]]
}
}Reading the outer tuple: [0, 0, [], [...]] is an element whose tag is s[0] ("w:p"), wrapping a child recursing down to the text leaf [1, 3] (s[3] = "Hi"). It is a JSON shape, not a compression layer: every string stays human-readable, so it stays diffable and debuggable. fromCompact(toCompact(pkg)) round-trips exactly; toCompact is deterministic.
All three format pairs have a direct codec — packageCodec (bytes ⇄ Package), compactCodec (Package ⇄ CompactPackage), and compactPackageCodec (bytes ⇄ CompactPackage directly):
import { decodeCompactPackage, encodeCompactPackage } from 'ooxml.js';
const compact = decodeCompactPackage(bytes); // OOXML bytes -> CompactPackage directly
const out = encodeCompactPackage(compact); // CompactPackage -> OOXML bytes directlypnpm build # turbo run _build (tsdown -> dist/: one ESM + CJS + .d.ts set per source module, via tsdown.config.ts)
pnpm lint # turbo run _lint (eslint . --fix --cache --max-warnings 0)
pnpm typecheck # turbo run _typecheck _typecheck:node (tsc against tsconfig.json + tsconfig.node.json, the dual-tsconfig setup)
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)
pnpm test:smoke # turbo run _test:smoke (builds dist/, then runs test/smoke.test.mjs to verify the built ESM and CJS artifacts both load and behave identically)pnpm prepublishOnly runs lint, typecheck, tsdown, publint, and attw --pack. test/smoke.test.mjs loads the built ESM/CJS barrels and checks they behave identically — a check tsc/publint/attw cannot do.
tsdown.config.ts's entry is a src/**/*.ts glob (excluding tests/.d.ts), so dist/ mirrors src/ one ESM/CJS/.d.ts/.d.cts set per module; package.json's exports adds a "./*" wildcard for deep imports.
To run a single test file: pnpm vitest run src/typed/docx.test.ts.
The package layers a lossless core outward to lossy convenience views:
src/model/— schemas:node.ts(XmlNode:text/cdata/comment/declaration/pi/element, an ordered forest matching XML mixed content) andpackage.ts(Package: path →Part;xmlparts hold parsed nodes,binaryparts hold base64 bytes — keepingPackageplain JSON).src/xml/—parse.ts/build.tsconvert XML strings ⇄XmlNode[]viafast-xml-parser(preserveOrder, entity re-encoding disabled, so order, mixed content, and entity encoding survive).src/zip.ts— thinfflatewrapper (zipSync/unzipSync).src/package-io/—read.ts/write.tsunzip, classify each entry as XML or binary (looksLikeXmlbyte sniff), and parse/serialize.src/codec.ts— public round-trip surface:packageCodec/xmlCodec(z.codec()pairs) plusdecodePackage/encodePackagewrappers.src/compact.ts— the ooxml.js format:compactCodec/compactPackageCodecplustoCompact/fromCompact/decodeCompactPackage/encodeCompactPackagewrappers.src/typed/— one-way, lossy projections.readDocxresolves the full style cascade (docDefaults→basedOn→ paragraph-mark → character styles → direct formatting) into orderedsectionsplus comments/footnotes/headers/footers/numbering;readPptxresolves placeholder → layout → master → theme inheritance intoslides(presentation order viap:sldIdLst);readXlsxcovers cell values/formulas, merged ranges, defined names.typed/shared/holds shared OOXML primitives (drawingml.tsgeometry/theme/colour,color.tsColorTransformcascade,units.ts,metadata.ts,source-path.ts). Types come fromdocument-schema.js. None encodes back to aPackage— round-trip goes throughdecodePackage/encodePackage(seesrc/typed/xlsx/for the one write-back exception).src/typed/xlsx/— aContentDocument-shaped read/write pair alongside the lossyreadXlsx(both exported; different callers).readXlsxContentreads column widths, row heights, hidden rows/columns, merged ranges, every cell value kind, print settings;buildXlsxPackagebuilds a complete xlsxPackagefrom scratch (never editing the decoded package).number-format.ts/styles.ts/serial.tsrun both ways: reading classifies style index → format code → kind (percentage/currency/date/time/dateTime); writing emits internednumFmtcodes, fed back through the classifier in tests. The classifier is not a formatter (displayTextis the typed-value spelling). Scope limits:currencywith no ISO code writes as plainnumber; non-canonical temporal values degrade to text.
- Zod-first schema/type/guard. Every model type is inferred from its Zod schema (
z.infer<typeof XSchema>), not hand-written. XmlNodeuses a recursive structural guard, notz.lazy.z.lazycollapses tounknownfor element-children in the pinned Zod version, soXmlElementSchemavalidateschildrenviaz.custom<XmlNode>(isXmlNode). Any change toXmlNode's shape must updateisXmlNodein step.CompactXmlNodeanddocument-schema.js'sContentBlockreuse this pattern.- Lossless core vs. lossy views is a hard boundary.
decodePackage/encodePackagestay byte/part faithful.src/typed/*readers are one-way; round-tripping goes through the genericPackage.readXlsxContent/buildXlsxPackageare the deliberate exception — a read/write pair aroundContentDocument, wherebuildXlsxPackagenever touches the decoded package. - XML entities stay raw in the lossless layer.
parseXmlruns withprocessEntities: false; typed readers decode the five standard entities (decodeEntitiesintyped/util.ts) only in their own lossy projection. - No type assertions.
eslint.config.tsbansasand angle-bracket casts (assertionStyle: "never",noInlineConfig: true— noeslint-disableescape hatch). Narrow with a guard or parse with Zod.
readDocx/readPptxare not a round-trip path. Numbering definitions, cell border styling/shading, andw:themeColor(withoutthemeShade/themeTint) are read; images read intoContentImageBlock(floatingwp:anchorposition not recorded);PAGE/NUMPAGESfields resolve to Word's cached text. On pptx: connector shapes (p:cxnSp) are skipped; shape rotation composes through groups; non-table graphic frames (chart/SmartArt/OLE) come through with geometry but empty content.- xlsx has no native percentage/currency/date/time cell type. Both directions are closed via the number-format engine: reading classifies style → format code → kind; writing emits interned
numFmtcodes, fed back through the classifier in tests.displayTextis the typed-value spelling, not the producer's rendered string. test:smokedepends on a fresh build. It runstsdown && vitest run --project smoke, always rebuildingdist/first. A barevitestruns both projects;smokefails loudly (Cannot find module '../dist/index.js') ifdist/is unbuilt.- Binary-vs-XML part classification is a byte sniff, not an extension check.
looksLikeXmllooks for a leading<after skipping a UTF-8 BOM and whitespace; any future binary format starting with<would misclassify. Array.isArraynarrowsunknowntoany[], notunknown[]. Indexing the result reintroducesanyand tripsno-unsafe-assignment.compact.tsandxml/parse.tseach define a localisUnknownArrayguard (value is unknown[]) — use it wherever the narrowed element is read.- TypeScript is pinned to the latest 6.x, not 7. TS 7 breaks
typescript-eslint(peer range<6.1.0) andcosmiconfig's TS loader (viatypescript.findConfigFile, which TS 7 no longer exports). Wait for ecosystem support. release-notes-generator'spresetisangular, notconventionalcommits(unlikecommit-analyzer).conventional-changelog-conventionalcommits@10.xexports its body undertemplate, but the bundledconventional-changelog-writerreads onlyoptions.mainTemplate, so the body falls back to a generic default — producing an empty changelog. Don't switch without checking upstream.
Conversion is part-content-faithful: every XML part re-serialises to equivalent XML, every binary part to identical bytes, no parts dropped or added. The re-zipped file opens correctly in Word, Excel, and PowerPoint. It is not byte-for-byte identical at the ZIP-container level — re-zipping changes archive entry layout (order, compression, metadata), not achievable deterministically across tools.
.github/workflows/ci.yml runs commitlint, lint, typecheck, unit, and smoke on every push/PR. On push to main, release.config.ts drives semantic-release: commit history decides the bump, CHANGELOG.md/package.json commit back to main, a GitHub Release is cut, and the package publishes to npmjs.org via OIDC trusted publishing (no NPM_TOKEN).
Release success is detected by diffing package.json's version before/after. Two further jobs gate on that: one republishes under @exadev/ooxml.js to GitHub Packages (via GITHUB_TOKEN), and one packs the release, generates an SPDX SBOM (pnpm sbom), and signs an SBOM and a build-provenance attestation — verifiable independently of the registry, and present even if the package is later unpublished.
Commits follow Conventional Commits (feat:, fix:, test:, chore:, …), enforced by commitlint (commitlint.config.ts) via a husky commit-msg hook and a CI job — semantic-release's version bump depends on these being well-formed. A husky pre-commit runs lint-staged (eslint --fix on staged *.ts); pre-push runs the test suite. Single main branch; no open PR workflow established.
- document-schema.js — canonical
ContentBlock/ContentSection/geometry/colour schemas andContentDocument/LayoutMetadatatypes shared withodf.jsanddocuments.js. - odf.js — sibling OpenDocument Format package, also on
document-schema.js. - documents.js — adds PDF conversion and a read-and-write docx/pptx editor on top of this package.
MIT