ooxml.js
v2.1.1
Published
Type-safe, lossless round-trip conversion between OOXML packages (docx, pptx, xlsx) and JSON, built on Zod 4 codecs.
Downloads
987
Maintainers
Readme
ooxml.js
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 archive of parts (an OPC "package"): [Content_Types].xml, relationships (*.rels), XML content parts, and binary parts (images, embedded objects). ooxml.js decodes the whole package into a faithful JSON model and encodes it back — part for part — so encode(decode(file)) reproduces the original content.
Why
Semantic, typed document models are lossy and one-directional: they cannot round-trip. True round-trip requires capturing every part, relationship, and binary byte-for-byte at the content level. ooxml.js provides that lossless generic foundation, with ergonomic typed reading views (Document, Presentation, Workbook) layered on top for convenient access.
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 ooxml.js
# or
npm install ooxml.jsUsage
import { 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 reading views project the generic Package into ergonomic models (lossy; for reading, not round-trip). readDocx/readPptx resolve the full style/theme cascade, so document order, run/paragraph styling, and geometry all 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.The ooxml.js format
The verbose Package JSON is faithful but repetitive: every node repeats its type/tag/attributes/children keys, and tag and namespace strings recur thousands of times across a real document. The ooxml.js format is a compact, still-plain-JSON alternative — tuple-encoded nodes plus a single interned string table — that composes on top of packageCodec without changing what it guarantees:
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 pkgFor example, a 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, in first-occurrence order, and replaces each node with a tuple (a leading type code — 0 for element, 1 for text — followed by 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 (0) whose tag is s[0] ("w:p"), with no attributes ([]), wrapping one child — the same shape recursively for w:r and w:t, down to the text leaf [1, 3] (type 1 = text, value s[3] = "Hi").
It is a JSON shape, not a compression layer: every string is still human-readable text, so it stays diffable and debuggable, just without the repeated structural keys and duplicate strings of the verbose Package model. fromCompact(toCompact(pkg)) round-trips exactly, and toCompact is deterministic for a given Package value (the same input always produces the same string table).
Every pair of the three formats — OOXML bytes, Package, and CompactPackage — has a direct codec, so you never have to hand-compose two calls: packageCodec (bytes ⇄ Package), compactCodec (Package ⇄ CompactPackage), and compactPackageCodec (bytes ⇄ CompactPackage directly, via decodeCompactPackage/encodeCompactPackage):
import { decodeCompactPackage, encodeCompactPackage } from 'ooxml.js';
const compact = decodeCompactPackage(bytes); // OOXML bytes -> CompactPackage directly
const out = encodeCompactPackage(compact); // CompactPackage -> OOXML bytes directlyBuild, test, and lint
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts, via tsdown.config.ts)
pnpm lint # eslint . --max-warnings 0
pnpm typecheck # tsc --noEmit
pnpm test # vitest run
pnpm test:watch # vitest
pnpm test:smoke # builds dist/, then runs test/smoke.test.mjs to verify the built ESM and CJS artifacts both load and behave identicallypnpm prepublishOnly runs lint, typecheck, tsdown, publint, and @arethetypeswrong/cli (attw --pack) — the full publish-readiness check — before a release.
test/smoke.test.mjs loads the actual built dist/index.js (ESM) and dist/index.cjs (CJS) artifacts and checks they load and behave identically — a check none of vitest's normal run, tsc, publint, or attw can do, since those either run against source or statically analyse package metadata without executing the compiled output. vitest.config.ts defines it as its own smoke project (vitest's test.projects), separate from the unit project (src/**/*.test.ts); pnpm test/test:watch pass --project unit and pnpm test:smoke passes --project smoke after tsdown rebuilds dist/, so neither run touches the other project's files.
To run a single test file: pnpm vitest run src/typed/docx.test.ts.
Architecture
The package is layered from a lossless core outward to lossy convenience views:
src/model/— the schemas.node.tsdefinesXmlNode(text/cdata/comment/declaration/pi/element) as an ordered forest, matching XML's mixed-content model exactly.package.tsdefinesPackageas a record of zip-entry path toPart(xmlparts hold a parsed node forest;binaryparts hold base64 bytes, keeping the wholePackagea plain JSON value).src/xml/—parse.tsandbuild.tsconvert between an XML string and theXmlNode[]forest, viafast-xml-parserinpreserveOrdermode with entity re-encoding disabled, so element order, mixed content, and original entity encoding survive unchanged.src/zip.ts— thin wrapper overfflate's synchronouszipSync/unzipSync, isomorphic and dependency-free.src/package-io/—read.tsandwrite.tssit between the zip and XML layers: unzip a package into path -> bytes, classify each entry as XML or binary (looksLikeXmlsniffs the leading non-whitespace byte for<), and parse/serialize accordingly.src/codec.ts— the public round-trip surface:packageCodec/xmlCodecarez.codec()pairs, anddecodePackage/encodePackageare the ergonomic wrappers around them.src/compact.ts— the ooxml.js format:compactCodec(z.codec(PackageSchema, CompactPackageSchema, …)) mapsPackage ⇄ CompactPackage, withtoCompact/fromCompactas the ergonomic wrappers.compactPackageCodeccomposespackageCodecandcompactCodecinto a direct bytes ⇄CompactPackagecodec (decodeCompactPackage/encodeCompactPackage), so all three format pairs — bytes/Package,Package/CompactPackage, bytes/CompactPackage— have a named codec rather than requiring callers to chain two.src/typed/— one-way, lossy projections that read the genericPackageinto ergonomic document/presentation/workbook models.docx/andpptx/share one block content model —ContentParagraph/ContentTable/ContentImageBlock/ContentPageBreak, discriminated asContentBlock, imported from the siblingdocument-content-modelpackage rather than defined here (see below) — instead of each keeping its own, disjoint shape:readDocxresolves the full WordprocessingML style cascade (docx/styles.ts:docDefaults→ named-stylebasedOnchains → paragraph-mark run properties → character styles → direct formatting) into orderedsectionsof paragraphs/tables/page-breaks (document order preserved, including inside tables), pluscomments,footnotes, andheaders/footers;readPptxresolves the placeholder → layout → master → theme inheritance cascade (pptx/inherit.ts) intoslidesof positioned, styledshapes(geometry, run/paragraph formatting, embedded images, tables, speaker notes) in presentation order (p:sldIdLst, never slide filename order);readXlsxcovers cell values and formulas, merged ranges and defined names.typed/shared/holds the OOXML-specific primitives bothdocx/andpptx/build on:drawingml.ts(DrawingMLa:xfrmgeometry, theme/colour resolution, group-transform composition — includingColorTransform/applyColorTransforms, the shade/tint/lumMod/lumOff cascade maths, which stays here rather than indocument-content-modelsince it's OOXML-cascade-resolution logic, not a content-model shape),units.ts(OOXML unit conversions — EMU/twip/half-point),metadata.ts(docProps/core.xml+docProps/app.xml→DocumentMetadata, shared verbatim across docx/pptx/xlsx), andsource-path.ts(stamps a deterministic, document-order path likesections[0].blocks[2].runs[1]onto everyContentRun/ContentBlock/ContentShape, so a downstream consumer can trace a rendered item back to where it came from — seedocument-content-model's ownsourcePathfield). Geometry (Box/PageSize/Margins), colour (Color/ColorSchema), and alignment (Alignment) types are imported fromdocument-content-model, not defined locally.src/image/sniff.ts(magic-byte PNG/JPEG detection) supportsreadPptx's picture-shape reading. None of this can be encoded back to aPackage— round-tripping always goes throughdecodePackage/encodePackage, never through a typed view.typed/util.tsholds the shared XML-walking helpers (walk,elementsWithTag,childrenWithTag,attr,rootElement,textContent, entity decoding,resolveRelationships) every typed reader builds on.
Conventions
- Zod-first schema/type/guard. Every model type is inferred from its Zod schema (
z.infer<typeof XSchema>), not hand-written — schema, type, and validator stay in lockstep. XmlNodeuses a recursive structural guard, notz.lazy.z.lazycollapses tounknownfor the element-children case in the Zod version this project pins, soXmlElementSchemavalidateschildrenviaz.custom<XmlNode>(isXmlNode), a hand-written recursive type guard inmodel/node.ts. Any change toXmlNode's shape must updateisXmlNodein step.src/compact.ts'sCompactXmlNode(isCompactXmlNode+z.custom) reuses the same pattern for the same reason;document-content-model's ownContentBlock(isContentBlock+z.custom, since a table cell's blocks can themselves contain a table) does too, one level up the dependency graph.- Lossless core vs. lossy views is a hard boundary.
decodePackage/encodePackage(and the underlying codecs) must stay byte/part faithful — every part round-trips unchanged.src/typed/*readers are explicitly one-way and are allowed to drop information (documented per-reader, e.g.readDocxresolves cached field-result text rather than re-evaluating livePAGE/NUMPAGESfields, docx's ownw:themeColorreferences aren't resolved, andreadXlsxdrops cell styles, formats and charts). Don't blur this line by adding write-back support to a typed reader; a full round-trip always goes through the genericPackage. - XML entities stay raw in the lossless layer.
parseXmlruns withprocessEntities: falseso encoded entities (e.g.&) are preserved verbatim for round-trip fidelity; typed readers decode the five standard entities (decodeEntitiesintyped/util.ts) only in their own lossy projection, never in the core model. - No type assertions.
eslint.config.tsruns@typescript-eslint/consistent-type-assertionswithassertionStyle: "never", banningasand angle-bracket casts outright, withlinterOptions.noInlineConfig: trueso there is noeslint-disableescape hatch either — narrow with a guard or parse with Zod. An exception would have to be scoped structurally, as afiles-matched override block ineslint.config.ts, not an inline comment.
Gotchas and quirks
readDocx/readPptxare richer than a flat text/shape dump, but still not a round-trip path — here is what's still not captured. Not modelled: numbering definitions themselves (glyph, format, restart-at-level, fromword/numbering.xml) — only each paragraph's ownnumId/levelmembership is captured, so a consumer can group paragraphs into a list but can't render the list's own markers without separately readingword/numbering.xml; table cell border styling (w:tcBorders,a:tcPrline properties — only cell shading/fill is read); docx's ownw:themeColorrun-colour references (real-world runs overwhelmingly use directw:valhex instead); livePAGE/NUMPAGESfield re-evaluation (fields resolve to Word's own cached result text, correct unless a different pagination would change the value); and docx inline/floating images (w:drawing) —readPptx's picture-shape reading (p:pic) has no docx-side equivalent yet. On the pptx side specifically: connector shapes (p:cxnSp) are skipped entirely (decorative, no text); a shape's rotation is passed through from its own locala:xfrm/@rotrather than composed through a rotated or flipped parent group (ECMA-376's real composition rule there is one of DrawingML's more arcane corners); and non-table graphic frames (chart/SmartArt/OLE) come through with correct geometry but empty content.test:smokedepends on a fresh build. It runstsdown && vitest run --project smoke, so it always rebuildsdist/first — don't run it expecting to test a stale build.--projectmatters fortest/smoke.test.mjs.vitest.config.tsdefinesunitandsmokeas separate projects;pnpm test/test:watch/test:smokealways pass the right--projectflag. A barevitest/vitest runwith no--projectfilter runs both projects, andsmokefails loudly (Cannot find module '../dist/index.js') ifdist/hasn't been built yet — a clear failure pointing at the cause, not a silent false pass, but still worth knowing if you invokevitestdirectly instead of through the npm scripts.- Binary-vs-XML part classification is a byte sniff, not an extension check.
package-io/read.ts'slooksLikeXmllooks for a leading<after skipping a UTF-8 BOM and whitespace; this is deliberate (no standard OOXML binary part starts with<) but means any future binary format starting with<would misclassify. Array.isArraynarrowsunknowntoany[], notunknown[].lib.es5.d.tstypes its parameter asany, so TypeScript can't do better even after the check succeeds — indexing straight into the result (e.g.value[0]) silently reintroducesanyand trips@typescript-eslint/no-unsafe-assignment.compact.tsandxml/parse.tseach define a localisUnknownArrayguard (value is unknown[]) for exactly this reason; reach for it instead ofArray.isArraywherever the narrowed element is going to be read.- TypeScript is pinned to the latest 6.x, not 7. TypeScript 7 restructured its JS-facing API surface heavily enough that both
typescript-eslint(peer range<6.1.0) andcosmiconfig's TypeScript loader (used bysemantic-releaseto readrelease.config.ts, viatypescript.findConfigFile, which TS 7 no longer exports) break under it. Upgrading past 6.x has to wait for that ecosystem tooling to add TS 7 support. release-notes-generator'spresetisangular, notconventionalcommits, unlikecommit-analyzer's.[email protected]exports its changelog body under the keytemplate, but theconventional-changelog-writerversion@semantic-release/[email protected]bundles only readsoptions.mainTemplate— so the body silently falls back to the writer's own generic default, whose commit partial doesn't match conventionalcommits' function-based partial signature either. The result is a changelog with a version header and nothing under it, confirmed even with zero custom configuration (preset: 'conventionalcommits', nopresetConfigat all) — not something introduced by this project's own config.commit-analyzeris unaffected because it only readswhatBumpdata from the same preset, no template rendering involved. Don't "fix the inconsistency" by switchingrelease-notes-generatortoconventionalcommitstoo without first checking whether this upstream mismatch has been resolved.
Fidelity
Conversion is part-content-faithful: every XML part re-serialises to equivalent XML, every binary part to identical bytes, and no parts are dropped or added. The re-zipped file is content-identical and opens correctly in Word, Excel, and PowerPoint.
It is not guaranteed to be byte-for-byte identical at the ZIP-container level — re-zipping changes archive entry layout (entry order, compression, metadata), and that is not achievable deterministically across the tools that produce OOXML files.
Release and publishing
.github/workflows/ci.yml runs commitlint, lint, typecheck, the unit suite, and the smoke test on every push and pull request. On a push to main where those all pass, release.config.ts drives semantic-release: commit history since the last tag decides the version bump, CHANGELOG.md and package.json are committed back to main, a GitHub Release is cut, and the package publishes to npmjs.org — via npm's OIDC trusted publishing, so no NPM_TOKEN exists anywhere in the pipeline.
Whether that release actually published a new version is detected by diffing package.json's version before and after the release step, not by trusting a third-party action's own detection. Two further jobs gate on that: one republishes the same build under the scoped @exadev/ooxml.js alias to GitHub Packages (which has no OIDC exchange of its own, so it authenticates with GITHUB_TOKEN instead), and one packs the release into its own directory, generates an SPDX SBOM (pnpm sbom), and signs both an SBOM and a build-provenance attestation against that exact tarball — verifiable independently of the registry, and still present if the package is later unpublished.
Contributing
Commits follow Conventional Commits (feat:, fix:, test:, chore:, …), enforced by commitlint (commitlint.config.ts) via a husky commit-msg hook and a CI commitlint job — semantic-release's version bump depends on these being well-formed, not just style. A husky pre-commit hook runs lint-staged (eslint --fix on staged *.ts files) and pre-push runs the test suite. There is a single main branch and no open pull request workflow established so far.
References
- document-content-model — the canonical
ContentBlock/ContentSection/ContentSlide/geometry/colour/alignment schemasreadDocx/readPptxreturn, imported here rather than defined locally. - odf.js — a sibling package doing the equivalent job for the OpenDocument Format (odt/ods/odp/odg/…), also built on
document-content-model. - documents.js — depends on this package for lossless OOXML handling and its cascade-resolved typed readers, adding PDF conversion and a read-and-write docx/pptx editor on top.
License
MIT
