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

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

Readme

ooxml.js

GitHub npm Release CI

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 install

Install as a dependency in another project:

pnpm add ooxml.js
# or
npm install ooxml.js

Usage

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 pkg

For 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 (PackageCompactPackage), 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 directly

Build, 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 identically

pnpm 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.ts defines XmlNode (text / cdata / comment / declaration / pi / element) as an ordered forest, matching XML's mixed-content model exactly. package.ts defines Package as a record of zip-entry path to Part (xml parts hold a parsed node forest; binary parts hold base64 bytes, keeping the whole Package a plain JSON value).
  • src/xml/parse.ts and build.ts convert between an XML string and the XmlNode[] forest, via fast-xml-parser in preserveOrder mode with entity re-encoding disabled, so element order, mixed content, and original entity encoding survive unchanged.
  • src/zip.ts — thin wrapper over fflate's synchronous zipSync/unzipSync, isomorphic and dependency-free.
  • src/package-io/read.ts and write.ts sit between the zip and XML layers: unzip a package into path -> bytes, classify each entry as XML or binary (looksLikeXml sniffs the leading non-whitespace byte for <), and parse/serialize accordingly.
  • src/codec.ts — the public round-trip surface: packageCodec/xmlCodec are z.codec() pairs, and decodePackage/encodePackage are the ergonomic wrappers around them.
  • src/compact.ts — the ooxml.js format: compactCodec (z.codec(PackageSchema, CompactPackageSchema, …)) maps Package ⇄ CompactPackage, with toCompact/fromCompact as the ergonomic wrappers. compactPackageCodec composes packageCodec and compactCodec into a direct bytes ⇄ CompactPackage codec (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 generic Package into ergonomic document/presentation/workbook models. docx/ and pptx/ share one block content model — ContentParagraph/ContentTable/ContentImageBlock/ContentPageBreak, discriminated as ContentBlock, imported from the sibling document-content-model package rather than defined here (see below) — instead of each keeping its own, disjoint shape: readDocx resolves the full WordprocessingML style cascade (docx/styles.ts: docDefaults → named-style basedOn chains → paragraph-mark run properties → character styles → direct formatting) into ordered sections of paragraphs/tables/page-breaks (document order preserved, including inside tables), plus comments, footnotes, and headers/footers; readPptx resolves the placeholder → layout → master → theme inheritance cascade (pptx/inherit.ts) into slides of positioned, styled shapes (geometry, run/paragraph formatting, embedded images, tables, speaker notes) in presentation order (p:sldIdLst, never slide filename order); readXlsx covers cell values and formulas, merged ranges and defined names. typed/shared/ holds the OOXML-specific primitives both docx/ and pptx/ build on: drawingml.ts (DrawingML a:xfrm geometry, theme/colour resolution, group-transform composition — including ColorTransform/applyColorTransforms, the shade/tint/lumMod/lumOff cascade maths, which stays here rather than in document-content-model since 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.xmlDocumentMetadata, shared verbatim across docx/pptx/xlsx), and source-path.ts (stamps a deterministic, document-order path like sections[0].blocks[2].runs[1] onto every ContentRun/ContentBlock/ContentShape, so a downstream consumer can trace a rendered item back to where it came from — see document-content-model's own sourcePath field). Geometry (Box/PageSize/Margins), colour (Color/ColorSchema), and alignment (Alignment) types are imported from document-content-model, not defined locally. src/image/sniff.ts (magic-byte PNG/JPEG detection) supports readPptx's picture-shape reading. None of this can be encoded back to a Package — round-tripping always goes through decodePackage/encodePackage, never through a typed view. typed/util.ts holds 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.
  • XmlNode uses a recursive structural guard, not z.lazy. z.lazy collapses to unknown for the element-children case in the Zod version this project pins, so XmlElementSchema validates children via z.custom<XmlNode>(isXmlNode), a hand-written recursive type guard in model/node.ts. Any change to XmlNode's shape must update isXmlNode in step. src/compact.ts's CompactXmlNode (isCompactXmlNode + z.custom) reuses the same pattern for the same reason; document-content-model's own ContentBlock (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. readDocx resolves cached field-result text rather than re-evaluating live PAGE/NUMPAGES fields, docx's own w:themeColor references aren't resolved, and readXlsx drops 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 generic Package.
  • XML entities stay raw in the lossless layer. parseXml runs with processEntities: false so encoded entities (e.g. &amp;) are preserved verbatim for round-trip fidelity; typed readers decode the five standard entities (decodeEntities in typed/util.ts) only in their own lossy projection, never in the core model.
  • No type assertions. eslint.config.ts runs @typescript-eslint/consistent-type-assertions with assertionStyle: "never", banning as and angle-bracket casts outright, with linterOptions.noInlineConfig: true so there is no eslint-disable escape hatch either — narrow with a guard or parse with Zod. An exception would have to be scoped structurally, as a files-matched override block in eslint.config.ts, not an inline comment.

Gotchas and quirks

  • readDocx/readPptx are 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, from word/numbering.xml) — only each paragraph's own numId/level membership is captured, so a consumer can group paragraphs into a list but can't render the list's own markers without separately reading word/numbering.xml; table cell border styling (w:tcBorders, a:tcPr line properties — only cell shading/fill is read); docx's own w:themeColor run-colour references (real-world runs overwhelmingly use direct w:val hex instead); live PAGE/NUMPAGES field 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 local a:xfrm/@rot rather 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:smoke depends on a fresh build. It runs tsdown && vitest run --project smoke, so it always rebuilds dist/ first — don't run it expecting to test a stale build.
  • --project matters for test/smoke.test.mjs. vitest.config.ts defines unit and smoke as separate projects; pnpm test/test:watch/test:smoke always pass the right --project flag. A bare vitest/vitest run with no --project filter runs both projects, and smoke fails loudly (Cannot find module '../dist/index.js') if dist/ hasn't been built yet — a clear failure pointing at the cause, not a silent false pass, but still worth knowing if you invoke vitest directly instead of through the npm scripts.
  • Binary-vs-XML part classification is a byte sniff, not an extension check. package-io/read.ts's looksLikeXml looks 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.isArray narrows unknown to any[], not unknown[]. lib.es5.d.ts types its parameter as any, so TypeScript can't do better even after the check succeeds — indexing straight into the result (e.g. value[0]) silently reintroduces any and trips @typescript-eslint/no-unsafe-assignment. compact.ts and xml/parse.ts each define a local isUnknownArray guard (value is unknown[]) for exactly this reason; reach for it instead of Array.isArray wherever 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) and cosmiconfig's TypeScript loader (used by semantic-release to read release.config.ts, via typescript.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's preset is angular, not conventionalcommits, unlike commit-analyzer's. [email protected] exports its changelog body under the key template, but the conventional-changelog-writer version @semantic-release/[email protected] bundles only reads options.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', no presetConfig at all) — not something introduced by this project's own config. commit-analyzer is unaffected because it only reads whatBump data from the same preset, no template rendering involved. Don't "fix the inconsistency" by switching release-notes-generator to conventionalcommits too 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 schemas readDocx/readPptx return, 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